From 20636904a538d4a7885981f97ebbaf1fd50d6b86 Mon Sep 17 00:00:00 2001 From: Damir Vazgird Date: Sat, 3 Oct 2026 22:23:02 -0700 Subject: [PATCH 1/3] docs: bump the herdr plugin manifest with each release Set integrations/herdr/herdr-plugin.toml to 0.1.4 and add it to the release-prepare checklist in releasing.md. Co-Authored-By: Claude Sonnet 5.5 --- integrations/herdr/herdr-plugin.toml | 2 +- wiki/guides/releasing.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/integrations/herdr/herdr-plugin.toml b/integrations/herdr/herdr-plugin.toml index 268b246..0dd50a2 100644 --- a/integrations/herdr/herdr-plugin.toml +++ b/integrations/herdr/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "wiki-reader" name = "Wiki Reader" -version = "0.1.0" +version = "0.1.4" min_herdr_version = "0.9.0" platforms = ["linux", "macos"] diff --git a/wiki/guides/releasing.md b/wiki/guides/releasing.md index ed138df..c463ec4 100644 --- a/wiki/guides/releasing.md +++ b/wiki/guides/releasing.md @@ -81,7 +81,7 @@ To go back to an older release, repeat the steps with that release's tarball (or Cut a tag only from a commit whose CI is already green on `main`. The release workflow builds and smoke-tests `--version` but does not re-run the full test suite. -1. **Prepare.** Branch `release/vX.Y.Z` (for example `release/v0.1.2`), bump `version` in the workspace `Cargo.toml` to the next `0.1.N` and refresh `Cargo.lock`, and update the roadmap note. Run `./scripts/check.sh`, open a PR, wait for green checks, and squash-merge. +1. **Prepare.** Branch `release/vX.Y.Z` (for example `release/v0.1.2`), bump `version` in the workspace `Cargo.toml` to the next `0.1.N` and refresh `Cargo.lock`, bump `version` in `integrations/herdr/herdr-plugin.toml` to match, and update the roadmap note. Run `./scripts/check.sh`, open a PR, wait for green checks, and squash-merge. 2. **Optional dry run.** In GitHub, Actions → Release → *Run workflow* on the branch or `main`. It builds all three targets, names the packages `dry-run`, and uploads them as workflow artifacts. It does **not** create or touch a GitHub Release (the attach step only runs on a tag push). Use this after changing `release.yml` or its pinned actions. 3. **Tag the merge commit** as `v` plus the `Cargo.toml` version and push the tag. The workflow fails if the tag and the crate version differ. From 6e33c483082d8b146d2dda22a1f8529c82b58172 Mon Sep 17 00:00:00 2001 From: Damir Vazgird Date: Sat, 3 Oct 2026 22:23:02 -0700 Subject: [PATCH 2/3] P3-19, P3-20, P3-21, P3-22: lite build and viewer rework P3-19: new wiki-reader-media crate (palette and fit maths always built; decode and Mermaid/SVG raster behind `raster`), a default-on `media` feature forwarded render -> binary, ADR-0023 (proposed), RenderedDoc::media inventory, check.sh [all|default|lite] and CI for both builds with a lite dependency-tree assertion, lite install docs and benchmarks. P3-20: content-sized modal panels, fit/actual (a), styled table cells. P3-21: Tab/Shift+Tab carousel over the media inventory. P3-22: v cycles image/text/source for the open diagram. The lite and viewer changes share render.rs, images.rs and the viewer modules, so they land together. scripts/check.sh is green for default and lite. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 14 + Cargo.lock | 14 +- Cargo.toml | 4 +- crates/wiki-reader-media/Cargo.toml | 27 + .../fonts/NotoSans.ttf | Bin .../fonts/OFL.txt | 0 .../fonts/README.md | 2 +- crates/wiki-reader-media/src/lib.rs | 58 ++ crates/wiki-reader-media/src/palette.rs | 44 ++ .../src/raster.rs} | 100 ++-- crates/wiki-reader-render/Cargo.toml | 10 +- crates/wiki-reader-render/src/diagrams.rs | 5 +- crates/wiki-reader-render/src/images.rs | 69 +-- crates/wiki-reader-render/src/lib.rs | 95 ++- crates/wiki-reader-render/src/render.rs | 179 +++++- crates/wiki-reader/Cargo.toml | 12 +- crates/wiki-reader/src/tui/app/draw.rs | 48 +- crates/wiki-reader/src/tui/app/events.rs | 2 + crates/wiki-reader/src/tui/app/image_tests.rs | 18 + crates/wiki-reader/src/tui/app/mod.rs | 58 +- crates/wiki-reader/src/tui/app/tests.rs | 2 + .../wiki-reader/src/tui/app/viewer_state.rs | 11 +- crates/wiki-reader/src/tui/image_viewer.rs | 553 +++++++++++++----- crates/wiki-reader/src/tui/images.rs | 30 +- crates/wiki-reader/src/tui/images_lite.rs | 42 ++ crates/wiki-reader/src/tui/keymap.rs | 75 ++- crates/wiki-reader/src/tui/mod.rs | 5 + crates/wiki-reader/src/tui/modal_viewer.rs | 63 +- crates/wiki-reader/src/tui/options_ui.rs | 24 +- crates/wiki-reader/src/tui/rendered_doc.rs | 6 + crates/wiki-reader/src/tui/table_viewer.rs | 184 ++++-- scripts/check.sh | 58 +- wiki/architecture/benchmarks.md | 41 +- wiki/architecture/overview.md | 3 +- .../0023-lite-build-is-a-cargo-feature.md | 37 ++ wiki/decisions/README.md | 1 + wiki/guides/configuration.md | 6 +- wiki/guides/development.md | 11 +- wiki/product/ui-spec.md | 29 +- 39 files changed, 1497 insertions(+), 443 deletions(-) create mode 100644 crates/wiki-reader-media/Cargo.toml rename crates/{wiki-reader-render => wiki-reader-media}/fonts/NotoSans.ttf (100%) rename crates/{wiki-reader-render => wiki-reader-media}/fonts/OFL.txt (100%) rename crates/{wiki-reader-render => wiki-reader-media}/fonts/README.md (73%) create mode 100644 crates/wiki-reader-media/src/lib.rs create mode 100644 crates/wiki-reader-media/src/palette.rs rename crates/{wiki-reader-render/src/mermaid_raster.rs => wiki-reader-media/src/raster.rs} (87%) create mode 100644 crates/wiki-reader/src/tui/images_lite.rs create mode 100644 wiki/decisions/0023-lite-build-is-a-cargo-feature.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4e8b36..c557a9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,20 @@ jobs: - name: Test run: cargo test --locked + - name: Clippy (lite) + run: cargo clippy --locked --all-targets --no-default-features -p wiki-reader -p wiki-reader-render -p wiki-reader-media -- -D warnings + + - name: Test (lite) + run: cargo test --locked --no-default-features -p wiki-reader -p wiki-reader-render -p wiki-reader-media + + - name: Lite tree has no image deps (ADR-0023) + run: | + tree=$(cargo tree --locked --no-default-features -p wiki-reader -e normal) + if echo "$tree" | grep -E '(^|[^A-Za-z0-9_-])(image|resvg|mermaid-rs-renderer|ratatui-image) v'; then + echo "lite build must not pull image, resvg, mermaid-rs-renderer or ratatui-image (ADR-0023)" >&2 + exit 1 + fi + - name: Link check run: cargo run --locked --quiet -p wiki-reader-tools --bin link-check diff --git a/Cargo.lock b/Cargo.lock index ae4d9c0..9ed1799 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3030,18 +3030,26 @@ dependencies = [ ] [[package]] -name = "wiki-reader-render" +name = "wiki-reader-media" version = "0.1.4" dependencies = [ "image", - "insta", "mermaid-rs-renderer", + "resvg", + "wiki-reader-core", +] + +[[package]] +name = "wiki-reader-render" +version = "0.1.4" +dependencies = [ + "insta", "mermaid-text", "pulldown-cmark", - "resvg", "tempfile", "unicode-width", "wiki-reader-core", + "wiki-reader-media", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d3da55a..9c60308 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,9 @@ mermaid-rs-renderer = { version = "0.3.1", default-features = false } resvg = { version = "0.48.1", default-features = false, features = ["text"] } signal-hook = "0.3.18" wiki-reader-core = { path = "crates/wiki-reader-core", version = "0.1.4" } -wiki-reader-render = { path = "crates/wiki-reader-render", version = "0.1.4" } +# Heavy media deps are opt-in per crate: the lite build drops them (ADR-0023). +wiki-reader-media = { path = "crates/wiki-reader-media", version = "0.1.4", default-features = false } +wiki-reader-render = { path = "crates/wiki-reader-render", version = "0.1.4", default-features = false } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/wiki-reader-media/Cargo.toml b/crates/wiki-reader-media/Cargo.toml new file mode 100644 index 0000000..f34cacf --- /dev/null +++ b/crates/wiki-reader-media/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "wiki-reader-media" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Image decode and Mermaid/SVG rasterisation for wiki-reader" +rust-version.workspace = true +repository.workspace = true +homepage.workspace = true +readme = "../../README.md" + +[features] +default = ["raster"] +# Heavy deps (ADR-0023): off in the lite build, leaving only the palette and fit maths. +raster = ["dep:image", "dep:resvg", "dep:mermaid-rs-renderer"] + +[dependencies] +image = { workspace = true, optional = true } +mermaid-rs-renderer = { workspace = true, optional = true } +resvg = { workspace = true, optional = true } +wiki-reader-core = { workspace = true } + +[lints] +workspace = true + +[lib] +path = "src/lib.rs" diff --git a/crates/wiki-reader-render/fonts/NotoSans.ttf b/crates/wiki-reader-media/fonts/NotoSans.ttf similarity index 100% rename from crates/wiki-reader-render/fonts/NotoSans.ttf rename to crates/wiki-reader-media/fonts/NotoSans.ttf diff --git a/crates/wiki-reader-render/fonts/OFL.txt b/crates/wiki-reader-media/fonts/OFL.txt similarity index 100% rename from crates/wiki-reader-render/fonts/OFL.txt rename to crates/wiki-reader-media/fonts/OFL.txt diff --git a/crates/wiki-reader-render/fonts/README.md b/crates/wiki-reader-media/fonts/README.md similarity index 73% rename from crates/wiki-reader-render/fonts/README.md rename to crates/wiki-reader-media/fonts/README.md index cce7d2a..aa59f3d 100644 --- a/crates/wiki-reader-render/fonts/README.md +++ b/crates/wiki-reader-media/fonts/README.md @@ -5,4 +5,4 @@ - SHA-256: `b85c38ecea8a7cfb39c24e395a4007474fa5a4fc864f6ee33309eb4948d232d5` - License: SIL Open Font License 1.1; see `OFL.txt`. -Used by `mermaid_raster` so Mermaid and SVG rasterisation never depends on system fonts. +Used by the `raster` module of `wiki-reader-media` so Mermaid and SVG rasterisation never depends on system fonts. diff --git a/crates/wiki-reader-media/src/lib.rs b/crates/wiki-reader-media/src/lib.rs new file mode 100644 index 0000000..e6d53cc --- /dev/null +++ b/crates/wiki-reader-media/src/lib.rs @@ -0,0 +1,58 @@ +//! Media helpers (P3-19): the diagram palette and fit maths are always built; image decode and +//! Mermaid/SVG rasterisation sit behind the default `raster` feature (the lite build drops it). +//! This crate must not depend on `wiki-reader-render`. + +mod palette; + +pub use palette::DiagramPalette; + +#[cfg(feature = "raster")] +mod raster; +#[cfg(feature = "raster")] +pub use raster::{ + RasterError, RasterImage, decode_file, image_dimensions, mermaid_svg_bytes, mermaid_to_svg, + rasterise_svg, rasterise_svg_scaled, render_mermaid, render_mermaid_for_pane, svg_natural_size, +}; + +/// Minimum scale of natural diagram pixels into the slot before the text tier is preferred. +pub const MIN_LEGIBLE_SCALE: f64 = 0.55; + +/// Fit scale for `px_w × px_h` into `max_cols` × `max_rows` at `cell_px`. Never upscales. +#[must_use] +pub fn fit_scale(px_w: u32, px_h: u32, cell_px: (u16, u16), max_cols: u16, max_rows: u16) -> f64 { + let cell_w = f64::from(cell_px.0.max(1)); + let cell_h = f64::from(cell_px.1.max(1)); + let w = f64::from(px_w.max(1)); + let h = f64::from(px_h.max(1)); + let max_cols = max_cols.max(1); + (f64::from(max_cols) * cell_w / w) + .min(f64::from(max_rows.max(1)) * cell_h / h) + .min(1.0) +} + +/// True when fitting `px_w`×`px_h` into `max_cols` at `cell_px` keeps scale ≥ [`MIN_LEGIBLE_SCALE`]. +#[must_use] +pub fn is_legible(px_w: u32, px_h: u32, cell_px: (u16, u16), max_cols: u16, max_rows: u16) -> bool { + fit_scale(px_w, px_h, cell_px, max_cols, max_rows) >= MIN_LEGIBLE_SCALE +} + +/// True for a `.svg` path (case-insensitive). +#[must_use] +pub fn is_svg_path(path: &std::path::Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("svg")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legibility_gate_rejects_wide_natural_size() { + // 2000 px into 80×8 = 640 px pane → scale 0.32 < 0.55. + assert!(!is_legible(2000, 400, (8, 17), 80, 30)); + // Compact diagram stays legible. + assert!(is_legible(400, 200, (8, 17), 80, 30)); + } +} diff --git a/crates/wiki-reader-media/src/palette.rs b/crates/wiki-reader-media/src/palette.rs new file mode 100644 index 0000000..258a5ba --- /dev/null +++ b/crates/wiki-reader-media/src/palette.rs @@ -0,0 +1,44 @@ +//! Diagram colours: plain RGB, no heavy deps. The raster module adds the theme overlay. + +/// Colours a Mermaid diagram is drawn with, so the picture matches the active theme. +/// +/// Plain RGB so the renderer stays terminal-free; the TUI fills it from its theme tokens. It is +/// part of every diagram cache key, so changing preset re-measures and re-rasterises. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DiagramPalette { + /// Card background, also used to fill the slot's padding so the picture is opaque. + pub bg: (u8, u8, u8), + /// Labels and titles. + pub text: (u8, u8, u8), + /// Edges and axes. + pub line: (u8, u8, u8), + /// Node fill. + pub node_fill: (u8, u8, u8), + /// Node outline. + pub node_border: (u8, u8, u8), + /// Subgraph / cluster fill. + pub cluster_fill: (u8, u8, u8), + /// Subgraph / cluster outline. + pub cluster_border: (u8, u8, u8), + /// Sequence / state note fill (its text uses [`text`](Self::text)). + pub note_fill: (u8, u8, u8), + /// Sequence / state note outline. + pub note_border: (u8, u8, u8), +} + +impl Default for DiagramPalette { + /// Dark card, matching the default (dark) theme's code background. + fn default() -> Self { + Self { + bg: (30, 32, 36), + text: (226, 229, 236), + line: (140, 148, 164), + node_fill: (44, 48, 58), + node_border: (110, 120, 140), + cluster_fill: (38, 41, 50), + cluster_border: (80, 88, 104), + note_fill: (58, 54, 40), + note_border: (150, 130, 80), + } + } +} diff --git a/crates/wiki-reader-render/src/mermaid_raster.rs b/crates/wiki-reader-media/src/raster.rs similarity index 87% rename from crates/wiki-reader-render/src/mermaid_raster.rs rename to crates/wiki-reader-media/src/raster.rs index 40a62a7..5bf746d 100644 --- a/crates/wiki-reader-render/src/mermaid_raster.rs +++ b/crates/wiki-reader-media/src/raster.rs @@ -1,4 +1,5 @@ -//! Mermaid → SVG → RGBA (ADR-0004 image tier). Deterministic embedded font; no external refs. +//! Mermaid → SVG → RGBA (ADR-0004 image tier) and bitmap decode. Deterministic embedded font; no +//! external refs. use std::fmt; @@ -6,57 +7,12 @@ use image::RgbaImage; use mermaid_rs_renderer::{RenderOptions, render_with_options}; use resvg::{tiny_skia, usvg}; -use crate::images::fit_scale; +use crate::{DiagramPalette, is_legible}; const FONT: &[u8] = include_bytes!("../fonts/NotoSans.ttf"); /// Private family so mermaid-rs-renderer uses fallback metrics instead of a system font. const LAYOUT_FONT_FAMILY: &str = "WikiReaderEmbeddedNotoSans"; const RASTER_FONT_FAMILY: &str = "Noto Sans"; -/// Minimum scale of natural diagram pixels into the slot before the text tier is preferred. -pub const MIN_LEGIBLE_SCALE: f64 = 0.55; - -/// Colours a Mermaid diagram is drawn with, so the picture matches the active theme. -/// -/// Plain RGB so the renderer stays terminal-free; the TUI fills it from its theme tokens. It is -/// part of every diagram cache key, so changing preset re-measures and re-rasterises. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct DiagramPalette { - /// Card background, also used to fill the slot's padding so the picture is opaque. - pub bg: (u8, u8, u8), - /// Labels and titles. - pub text: (u8, u8, u8), - /// Edges and axes. - pub line: (u8, u8, u8), - /// Node fill. - pub node_fill: (u8, u8, u8), - /// Node outline. - pub node_border: (u8, u8, u8), - /// Subgraph / cluster fill. - pub cluster_fill: (u8, u8, u8), - /// Subgraph / cluster outline. - pub cluster_border: (u8, u8, u8), - /// Sequence / state note fill (its text uses [`text`](Self::text)). - pub note_fill: (u8, u8, u8), - /// Sequence / state note outline. - pub note_border: (u8, u8, u8), -} - -impl Default for DiagramPalette { - /// Dark card, matching the default (dark) theme's code background. - fn default() -> Self { - Self { - bg: (30, 32, 36), - text: (226, 229, 236), - line: (140, 148, 164), - node_fill: (44, 48, 58), - node_border: (110, 120, 140), - cluster_fill: (38, 41, 50), - cluster_border: (80, 88, 104), - note_fill: (58, 54, 40), - note_border: (150, 130, 80), - } - } -} /// Mid-tone categorical slices that keep light labels readable on a dark card. Mermaid's own /// dark pie colours (`#0b0000`, `#010029`, …) vanish into a dark background. @@ -122,7 +78,7 @@ pub enum RasterError { Parse(String), /// `resvg` / usvg failed. Raster(String), - /// Natural size scaled into the pane is below [`MIN_LEGIBLE_SCALE`]. + /// Natural size scaled into the pane is below [`crate::MIN_LEGIBLE_SCALE`]. TooWide, /// Decoded pixel count exceeds the ADR-0017 cap. TooManyPixels, @@ -148,6 +104,40 @@ pub struct RasterImage { pub px_h: u32, } +/// ADR-0017 decode allocation cap: RGBA at the pixel cap, doubled for decoder scratch. +const MAX_DECODE_ALLOC: u64 = wiki_reader_core::images::MAX_IMAGE_PIXELS * 4 * 2; + +/// Decode a bitmap file under the ADR-0017 allocation and pixel caps. +/// +/// # Errors +/// +/// Returns the decoder's message, or `"image too large"` past the pixel cap. +pub fn decode_file(path: &std::path::Path) -> Result { + let mut reader = image::ImageReader::open(path) + .map_err(|e| e.to_string())? + .with_guessed_format() + .map_err(|e| e.to_string())?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_DECODE_ALLOC); + reader.limits(limits); + let decoded = reader.decode().map_err(|e| e.to_string())?; + if u64::from(decoded.width()) * u64::from(decoded.height()) + > wiki_reader_core::images::MAX_IMAGE_PIXELS + { + return Err("image too large".into()); + } + Ok(decoded) +} + +/// Header-only pixel size of a bitmap file (the pixel cap is the caller's check, before decode). +/// +/// # Errors +/// +/// Returns the decoder's message when the header cannot be read. +pub fn image_dimensions(path: &std::path::Path) -> Result<(u32, u32), String> { + image::image_dimensions(path).map_err(|e| e.to_string()) +} + /// Usvg options shared by Mermaid and local SVG: embedded font only, no external image refs. fn usvg_options() -> usvg::Options<'static> { let mut options = usvg::Options::default(); @@ -262,12 +252,6 @@ pub fn render_mermaid(src: &str, palette: &DiagramPalette) -> Result bool { - fit_scale(px_w, px_h, cell_px, max_cols, max_rows) >= MIN_LEGIBLE_SCALE -} - /// Rasterise Mermaid and reject results that would be illegible in the pane. /// /// # Errors @@ -301,14 +285,6 @@ mod tests { assert_eq!(a.image.as_raw(), b.image.as_raw()); } - #[test] - fn legibility_gate_rejects_wide_natural_size() { - // 2000 px into 80×8 = 640 px pane → scale 0.32 < 0.55. - assert!(!is_legible(2000, 400, (8, 17), 80, 30)); - // Compact diagram stays legible. - assert!(is_legible(400, 200, (8, 17), 80, 30)); - } - #[test] fn svg_external_href_is_ignored() { let svg = r##" diff --git a/crates/wiki-reader-render/Cargo.toml b/crates/wiki-reader-render/Cargo.toml index d19dced..df17d76 100644 --- a/crates/wiki-reader-render/Cargo.toml +++ b/crates/wiki-reader-render/Cargo.toml @@ -9,14 +9,18 @@ repository.workspace = true homepage.workspace = true readme = "../../README.md" +[features] +default = ["media"] +# ponytail: media is a non-optional dep (DiagramPalette is needed by lite too); the feature only +# turns on its heavy `raster` half. +media = ["wiki-reader-media/raster"] + [dependencies] -image = { workspace = true } -mermaid-rs-renderer = { workspace = true } mermaid-text = { workspace = true } pulldown-cmark = { workspace = true } -resvg = { workspace = true } unicode-width = { workspace = true } wiki-reader-core = { workspace = true } +wiki-reader-media = { workspace = true } [dev-dependencies] insta = { workspace = true } diff --git a/crates/wiki-reader-render/src/diagrams.rs b/crates/wiki-reader-render/src/diagrams.rs index d5baf0d..9fd6616 100644 --- a/crates/wiki-reader-render/src/diagrams.rs +++ b/crates/wiki-reader-render/src/diagrams.rs @@ -357,8 +357,9 @@ mod tests { let (b, _) = diagram_lines(src, DiagramTier::Text, 72); let hits_after = CACHE_HITS.load(std::sync::atomic::Ordering::Relaxed); assert_eq!(a, b); - assert_eq!(hits_before, 0); - assert_eq!(hits_after, 1); + // ponytail: CACHE_HITS is process-global and other tests render concurrently, so only + // assert growth. Ceiling: a per-call hit flag would make this exact. + assert!(hits_after > hits_before, "{hits_before} -> {hits_after}"); } #[test] diff --git a/crates/wiki-reader-render/src/images.rs b/crates/wiki-reader-render/src/images.rs index 82ab09b..70c7482 100644 --- a/crates/wiki-reader-render/src/images.rs +++ b/crates/wiki-reader-render/src/images.rs @@ -4,9 +4,9 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use wiki_reader_core::images::{ImageReject, MAX_IMAGE_PIXELS, resolve_local_image}; +use wiki_reader_core::images::{ImageReject, resolve_local_image}; -use crate::mermaid_raster::{self, DiagramPalette, RasterError}; +use wiki_reader_media::DiagramPalette; /// Tallest slot, in display rows. Keeps one image from filling several screens. pub const MAX_SLOT_ROWS: u16 = 30; @@ -116,19 +116,6 @@ pub(crate) enum ImagePlan { Placeholder(ImageReject), } -/// Fit scale for `px_w × px_h` into `max_cols` × `max_rows` at `cell_px`. Never upscales. -#[must_use] -pub fn fit_scale(px_w: u32, px_h: u32, cell_px: (u16, u16), max_cols: u16, max_rows: u16) -> f64 { - let cell_w = f64::from(cell_px.0.max(1)); - let cell_h = f64::from(cell_px.1.max(1)); - let w = f64::from(px_w.max(1)); - let h = f64::from(px_h.max(1)); - let max_cols = max_cols.max(1); - (f64::from(max_cols) * cell_w / w) - .min(f64::from(max_rows.max(1)) * cell_h / h) - .min(1.0) -} - /// Columns × rows for an image of `px_w × px_h` pixels: natural size, shrunk to fit /// `max_cols` and `max_rows` while keeping the aspect ratio. Never upscales. #[must_use] @@ -146,16 +133,35 @@ pub fn slot_geometry( let h = f64::from(px_h.max(1)); let max_cols = max_cols.max(1); let max_rows = max_rows.max(1); - let scale = fit_scale(px_w, px_h, cell_px, max_cols, max_rows); + let scale = wiki_reader_media::fit_scale(px_w, px_h, cell_px, max_cols, max_rows); let cols = (w * scale / cell_w).ceil().clamp(1.0, f64::from(max_cols)); let rows = (h * scale / cell_h).ceil().clamp(1.0, f64::from(max_rows)); (cols as u16, rows as u16) } -fn is_svg(path: &Path) -> bool { - path.extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| e.eq_ignore_ascii_case("svg")) +/// Pixel size without decoding; the ADR-0017 pixel cap is enforced here, before any decode. +#[cfg(feature = "media")] +fn natural_size(path: &Path) -> Result<(u32, u32), ImageReject> { + use wiki_reader_core::images::MAX_IMAGE_PIXELS; + use wiki_reader_media::{RasterError, image_dimensions, is_svg_path, svg_natural_size}; + if is_svg_path(path) { + let bytes = std::fs::read(path).map_err(|_| ImageReject::Unreadable)?; + return svg_natural_size(&bytes).map_err(|e| match e { + RasterError::TooManyPixels => ImageReject::TooManyPixels, + _ => ImageReject::Unreadable, + }); + } + let (px_w, px_h) = image_dimensions(path).map_err(|_| ImageReject::Unreadable)?; + if u64::from(px_w) * u64::from(px_h) > MAX_IMAGE_PIXELS { + return Err(ImageReject::TooManyPixels); + } + Ok((px_w, px_h)) +} + +/// Lite build: no decoder, so every local image stays a text placeholder. +#[cfg(not(feature = "media"))] +fn natural_size(_: &Path) -> Result<(u32, u32), ImageReject> { + Err(ImageReject::NoGraphics) } /// Decide how a block-level image `dest` renders from the page at `page_rel`. @@ -179,26 +185,9 @@ pub(crate) fn plan_image( let Some(cell_px) = cell_px else { return ImagePlan::Placeholder(ImageReject::NoGraphics); }; - let (px_w, px_h) = if is_svg(&local.path) { - let Ok(bytes) = std::fs::read(&local.path) else { - return ImagePlan::Placeholder(ImageReject::Unreadable); - }; - match mermaid_raster::svg_natural_size(&bytes) { - Ok(size) => size, - Err(RasterError::TooManyPixels) => { - return ImagePlan::Placeholder(ImageReject::TooManyPixels); - } - Err(_) => return ImagePlan::Placeholder(ImageReject::Unreadable), - } - } else { - // Header only: the pixel cap is enforced before any decode. - let Ok((px_w, px_h)) = image::image_dimensions(&local.path) else { - return ImagePlan::Placeholder(ImageReject::Unreadable); - }; - if u64::from(px_w) * u64::from(px_h) > MAX_IMAGE_PIXELS { - return ImagePlan::Placeholder(ImageReject::TooManyPixels); - } - (px_w, px_h) + let (px_w, px_h) = match natural_size(&local.path) { + Ok(size) => size, + Err(reason) => return ImagePlan::Placeholder(reason), }; let (cols, rows) = slot_geometry(px_w, px_h, cell_px, max_cols, max_rows); ImagePlan::Slot { diff --git a/crates/wiki-reader-render/src/lib.rs b/crates/wiki-reader-render/src/lib.rs index ffd7f9c..361a009 100644 --- a/crates/wiki-reader-render/src/lib.rs +++ b/crates/wiki-reader-render/src/lib.rs @@ -3,7 +3,6 @@ mod diagrams; mod images; mod link_span; -mod mermaid_raster; mod render; pub use diagrams::{ @@ -12,17 +11,20 @@ pub use diagrams::{ }; pub use images::{ DiagramRequest, DiagramSize, DiagramSizeCache, DiagramTextReason, ImageSlot, MAX_SLOT_ROWS, - SlotSource, empty_diagram_size_cache, fit_scale, slot_geometry, + SlotSource, empty_diagram_size_cache, slot_geometry, }; pub use link_span::{LinkClass, LinkId, LinkSpan}; -pub use mermaid_raster::{ - DiagramPalette, MIN_LEGIBLE_SCALE, RasterError, RasterImage, is_legible, mermaid_svg_bytes, - mermaid_to_svg, rasterise_svg, rasterise_svg_scaled, render_mermaid, render_mermaid_for_pane, - svg_natural_size, -}; pub use render::{ - BlockAction, BlockActionKind, DocTable, RenderOpts, RenderedDoc, StyleKind, StyledLine, - StyledSpan, render, render_with, + BlockAction, BlockActionKind, DocCell, DocTable, MediaOccurrence, MediaOccurrenceKind, + RenderOpts, RenderedDoc, StyleKind, StyledLine, StyledSpan, render, render_with, +}; +pub use wiki_reader_media::{ + DiagramPalette, MIN_LEGIBLE_SCALE, fit_scale, is_legible, is_svg_path, +}; +#[cfg(feature = "media")] +pub use wiki_reader_media::{ + RasterError, RasterImage, decode_file, mermaid_svg_bytes, mermaid_to_svg, rasterise_svg, + rasterise_svg_scaled, render_mermaid, render_mermaid_for_pane, svg_natural_size, }; #[cfg(test)] @@ -30,6 +32,7 @@ mod tests { use super::*; use std::collections::HashMap; use std::path::Path; + #[cfg(feature = "media")] use std::sync::Arc; use unicode_width::UnicodeWidthStr; use wiki_reader_core::provider::{CollectionProvider, FsProvider, PageKey}; @@ -41,8 +44,8 @@ mod tests { } } - fn render_src(src: &str, width: u16) -> RenderedDoc { - let index = wiki_reader_core::Index { + fn empty_index() -> wiki_reader_core::Index { + wiki_reader_core::Index { collection_id: "t".into(), pages: HashMap::default(), edges: vec![], @@ -51,8 +54,11 @@ mod tests { by_id: HashMap::default(), by_path: HashMap::default(), diagnostics: vec![], - }; - render(src, None, &empty_key(), &index, width) + } + } + + fn render_src(src: &str, width: u16) -> RenderedDoc { + render(src, None, &empty_key(), &empty_index(), width) } #[test] @@ -827,12 +833,13 @@ mod tests { let doc = render_src(src, 40); assert_eq!(doc.tables.len(), 1); let t = &doc.tables[0]; - assert_eq!(t.header, ["Name", "Note"]); - assert_eq!( - t.rows, - [["a", "x"], ["b", ""]], - "links flattened, short rows padded" - ); + let text = |r: &[DocCell]| r.iter().map(DocCell::text).collect::>(); + assert_eq!(text(&t.header), ["Name", "Note"]); + assert_eq!(text(&t.rows[0]), ["a", "x"]); + assert_eq!(text(&t.rows[1]), ["b", ""], "short rows padded"); + // The link keeps its style, so the viewer can colour it; plain text stays plain. + assert_eq!(t.rows[0][1].spans[0].kind, StyleKind::Link); + assert_ne!(t.rows[0][0].spans[0].kind, StyleKind::Link); assert_eq!(t.source_line, 3); let top = usize::try_from(t.line).unwrap(); assert!(doc.lines[top].starts_with('┌'), "{:?}", doc.lines[top]); @@ -1370,6 +1377,7 @@ mod tests { } #[test] + #[cfg(feature = "media")] fn block_images_reserve_slots_in_document_order() { let doc = render_images_fixture(Some((8, 17)), 80); let names: Vec<_> = doc @@ -1435,6 +1443,7 @@ mod tests { } #[test] + #[cfg(feature = "media")] fn herdr_plugin_pane_names_itself_in_the_no_graphics_reason() { let plugin = render_images_fixture_with( RenderOpts { @@ -1480,6 +1489,7 @@ mod tests { } #[test] + #[cfg(feature = "media")] fn herdr_plugin_pane_names_itself_in_the_diagram_tier_reason() { let src = "```mermaid\nflowchart LR\n A --> B\n```\n"; let key = empty_key(); @@ -1530,6 +1540,7 @@ mod tests { } #[test] + #[cfg(feature = "media")] fn inline_list_and_quote_images_stay_text() { let doc = render_images_fixture(Some((8, 17)), 80); assert_eq!( @@ -1548,6 +1559,7 @@ mod tests { } #[test] + #[cfg(feature = "media")] fn image_slots_snapshot_by_width() { for w in [40u16, 60, 80, 120] { let doc = render_images_fixture(Some((8, 17)), w); @@ -1572,6 +1584,46 @@ mod tests { } } + #[test] + fn media_inventory_is_document_ordered_and_stable_through_relayout() { + let src = + "![a](x.png)\n\n```mermaid\ngraph LR; A-->B\n```\n\n```mermaid\ngraph LR; A-->B\n```\n"; + let wide = render_src(src, 80).media; + let narrow = render_src(src, 40).media; + assert_eq!(wide, narrow, "ids and kinds survive a width change"); + assert!(matches!( + wide[0].kind, + MediaOccurrenceKind::Image { slot: false, .. } + )); + assert!(matches!(wide[1].kind, MediaOccurrenceKind::Diagram { .. })); + // Identical fences are separate occurrences. + assert_ne!(wide[1].id, wide[2].id); + assert_eq!(wide.len(), 3); + } + + #[test] + #[cfg(not(feature = "media"))] + fn lite_image_mode_falls_back_to_text_with_a_reason() { + let opts = RenderOpts { + diagram_mode: wiki_reader_core::config::DiagramMode::Image, + graphics: true, + cell_px: Some((8, 17)), + ..RenderOpts::default() + }; + let key = empty_key(); + let index = empty_index(); + let doc = render_with( + "```mermaid\ngraph LR; A-->B\n```\n", + None, + &key, + &index, + 80, + &opts, + ); + assert!(doc.image_slots.is_empty()); + assert!(doc.lines.join("\n").contains("lite build: no image tier")); + } + #[test] fn mermaid_fence_registers_expand_before_copy() { let doc = render_src("```mermaid\nflowchart LR\n A --> B\n```\n", 60); @@ -1624,6 +1676,7 @@ mod tests { } #[test] + #[cfg(feature = "media")] fn mermaid_image_tier_is_text_before_size_cache_and_slot_after() { let body = "flowchart LR\n A[Build] --> B[Deploy]\n"; let src = format!("```mermaid\n{body}```\n"); @@ -1689,6 +1742,7 @@ mod tests { } #[test] + #[cfg(feature = "media")] fn mermaid_size_cache_hit_across_widths_requests_once() { let body = "flowchart LR\n A[Build] --> B[Deploy]\n"; let src = format!("```mermaid\n{body}```\n"); @@ -1730,6 +1784,7 @@ mod tests { } #[test] + #[cfg(feature = "media")] fn mermaid_failed_measure_shows_parse_reason() { // Valid Mermaid so the text tier succeeds; the Failed cache entry supplies the header. let body = "flowchart LR\n A[Build] --> B[Deploy]\n"; @@ -1768,6 +1823,7 @@ mod tests { } #[test] + #[cfg(feature = "media")] fn mermaid_illegible_natural_size_shows_too_wide() { let body = "flowchart LR\n A[Build] --> B[Deploy]\n"; let src = format!("```mermaid\n{body}```\n"); @@ -1809,6 +1865,7 @@ mod tests { } #[test] + #[cfg(feature = "media")] fn mermaid_slots_snapshot_by_width() { let body = "flowchart LR\n A[Build] --> B[Deploy]\n"; let src = format!("```mermaid\n{body}```\n"); diff --git a/crates/wiki-reader-render/src/render.rs b/crates/wiki-reader-render/src/render.rs index 743dc9b..5cc4505 100644 --- a/crates/wiki-reader-render/src/render.rs +++ b/crates/wiki-reader-render/src/render.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::fmt::Write as _; +use std::hash::{Hash, Hasher}; use std::path::PathBuf; use std::sync::Arc; @@ -23,8 +24,8 @@ use crate::images::{ DiagramRequest, DiagramSize, DiagramSizeCache, DiagramTextReason, ImagePlan, ImageSlot, SlotSource, empty_diagram_size_cache, placeholder_text, plan_image, slot_geometry, }; -use crate::mermaid_raster::DiagramPalette; use crate::{LinkClass, LinkId, LinkSpan}; +use wiki_reader_media::DiagramPalette; /// Semantic style for a span (TUI maps to theme colours). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -129,11 +130,57 @@ pub struct RenderedDoc { pub image_slots: Vec, /// Mermaid fences waiting on an off-thread size measure (image tier, cache miss). pub diagram_requests: Vec, + /// Every block image and Mermaid fence in document order, whatever tier painted it. + pub media: Vec, pub word_count: u32, pub updated: String, } -/// One table's cell grid (plain cell text, links flattened), for the table viewer. +/// One block image or Mermaid fence in the document (P3-21 inventory; tier-independent). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MediaOccurrence { + /// Occurrence identity: hash of (kind, dest/source, n-th repeat of that pair). Not a content + /// hash: two identical fences get different ids. Survives relayout and edits elsewhere. + pub id: u64, + /// 0-based display line where the block starts (matches `ImageSlot::line` and the + /// `ExpandDiagram` action's line), so a click or `Enter` maps back to its occurrence. + pub line: u32, + pub kind: MediaOccurrenceKind, +} + +/// What a [`MediaOccurrence`] is. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MediaOccurrenceKind { + /// Block-level image; `slot` is true when rows were reserved for a picture. + Image { + dest: String, + alt: String, + slot: bool, + }, + /// Mermaid fence; `tier` is what was painted (`Image` only when a slot was reserved). + Diagram { + source: String, + hash: u64, + tier: DiagramTier, + }, +} + +/// One table cell: its styled runs (bold, link, code, …), so the viewer keeps inline styling. +/// [`Self::text`] is the plain text for filter, sort and copy. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DocCell { + pub spans: Vec, +} + +impl DocCell { + /// Plain cell text (trimmed; links flattened to their label). + #[must_use] + pub fn text(&self) -> String { + self.spans.iter().map(|s| s.text.as_str()).collect() + } +} + +/// One table's cell grid, for the table viewer. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DocTable { /// Source line (1-based) of the header row. @@ -143,9 +190,9 @@ pub struct DocTable { /// Display rows the table occupies. pub height: u32, /// Header cells. - pub header: Vec, + pub header: Vec, /// Body rows; every row has `header.len()` cells. - pub rows: Vec>, + pub rows: Vec>, } /// Kind of a Tab-cycle block action (BA / P2-03). @@ -349,6 +396,7 @@ pub fn render_with( source_map, headings: state.headings, image_slots: state.image_slots, + media: state.media, diagram_requests: state.diagram_requests, word_count, updated, @@ -426,6 +474,9 @@ struct LayoutState<'a> { list_marker_pending: bool, in_table: bool, table_row: Vec, + /// Styled twins of `table_row` / `table_rows` for the viewer grid. + table_row_cells: Vec, + table_cells: Vec>, /// Source line for the row currently being collected. table_row_src: u32, /// (source line, cells) per header/body row. @@ -450,6 +501,7 @@ struct LayoutState<'a> { max_slot_rows: u16, image_slots: Vec, diagram_requests: Vec, + media: Vec, } /// A link whose display columns are only known after the table is laid out. @@ -505,6 +557,8 @@ impl<'a> LayoutState<'a> { list_marker_pending: false, in_table: false, table_row: Vec::new(), + table_row_cells: Vec::new(), + table_cells: Vec::new(), table_row_src: 1, table_rows: Vec::new(), table_header_done: false, @@ -526,9 +580,33 @@ impl<'a> LayoutState<'a> { max_slot_rows: crate::MAX_SLOT_ROWS, image_slots: Vec::new(), diagram_requests: Vec::new(), + media: Vec::new(), } } + /// Append to the media inventory with a stable occurrence id. + fn push_media(&mut self, kind: MediaOccurrenceKind) { + let (tag, key) = match &kind { + MediaOccurrenceKind::Image { dest, .. } => (0u8, dest), + MediaOccurrenceKind::Diagram { source, .. } => (1u8, source), + }; + let nth = self + .media + .iter() + .filter(|m| match &m.kind { + MediaOccurrenceKind::Image { dest, .. } => tag == 0 && dest == key, + MediaOccurrenceKind::Diagram { source, .. } => tag == 1 && source == key, + }) + .count(); + let mut h = std::collections::hash_map::DefaultHasher::new(); + (tag, key, nth).hash(&mut h); + self.media.push(MediaOccurrence { + id: h.finish(), + line: u32::try_from(self.styled.len()).unwrap_or(0), + kind, + }); + } + /// `Start(Paragraph) Start(Image) … End(Image) End(Paragraph)` at `events[i]`, outside /// quotes, lists and tables (slots are not indented). fn image_only_paragraph( @@ -592,6 +670,11 @@ impl<'a> LayoutState<'a> { } other => other, }; + self.push_media(MediaOccurrenceKind::Image { + dest: dest.to_owned(), + alt: alt.to_owned(), + slot: matches!(plan, ImagePlan::Slot { .. }), + }); match plan { ImagePlan::Slot { source, cols, rows } => { let line = u32::try_from(self.styled.len()).unwrap_or(0); @@ -636,7 +719,8 @@ impl<'a> LayoutState<'a> { let env = DiagramEnv { tmux: self.tmux, herdr: self.herdr, - kitty_graphics: self.graphics && self.cell_px.is_some(), + // ponytail: lite has no image tier, whatever the caller claims. + kitty_graphics: cfg!(feature = "media") && self.graphics && self.cell_px.is_some(), }; let tier = select_tier(self.diagram_mode, &env); let hash = content_hash(body); @@ -646,13 +730,8 @@ impl<'a> LayoutState<'a> { match self.diagram_sizes.get(hash, palette) { Some(DiagramSize::Natural { px_w, px_h }) => { let cell_px = self.cell_px.unwrap_or((8, 17)); - if crate::mermaid_raster::is_legible( - px_w, - px_h, - cell_px, - width, - self.max_slot_rows, - ) { + if wiki_reader_media::is_legible(px_w, px_h, cell_px, width, self.max_slot_rows) + { let (cols, rows) = slot_geometry(px_w, px_h, cell_px, width, self.max_slot_rows); Some((cols, rows)) @@ -679,6 +758,17 @@ impl<'a> LayoutState<'a> { None }; + self.push_media(MediaOccurrenceKind::Diagram { + source: body.to_owned(), + hash, + tier: if image_plan.is_some() { + DiagramTier::Image + } else if tier == DiagramTier::Source { + DiagramTier::Source + } else { + DiagramTier::Text + }, + }); let start_line = u32::try_from(self.styled.len()).unwrap_or(0); if let Some((cols, rows)) = image_plan { self.ensure_block_gap(close_src); @@ -733,14 +823,13 @@ impl<'a> LayoutState<'a> { wiki_reader_core::config::DiagramMode::Image ) && !env.tmux => { - Some( - if self.herdr_plugin_pane { - ImageReject::NoGraphicsHerdrPlugin - } else { - ImageReject::NoGraphics - } - .to_string(), - ) + Some(if cfg!(not(feature = "media")) { + "lite build: no image tier".to_owned() + } else if self.herdr_plugin_pane { + ImageReject::NoGraphicsHerdrPlugin.to_string() + } else { + ImageReject::NoGraphics.to_string() + }) } _ => None, }; @@ -959,11 +1048,13 @@ impl<'a> LayoutState<'a> { self.mark_block(src); self.in_table = true; self.table_rows.clear(); + self.table_cells.clear(); self.table_links.clear(); self.table_header_done = false; } Tag::TableHead | Tag::TableRow => { self.table_row.clear(); + self.table_row_cells.clear(); self.table_row_src = src; } Tag::TableCell => { @@ -1082,6 +1173,8 @@ impl<'a> LayoutState<'a> { if !self.table_row.is_empty() { self.table_rows .push((self.table_row_src, std::mem::take(&mut self.table_row))); + self.table_cells + .push(std::mem::take(&mut self.table_row_cells)); self.table_header_done = true; } } @@ -1089,12 +1182,15 @@ impl<'a> LayoutState<'a> { if !self.table_row.is_empty() { self.table_rows .push((self.table_row_src, std::mem::take(&mut self.table_row))); + self.table_cells + .push(std::mem::take(&mut self.table_row_cells)); } } TagEnd::TableCell => { let _ = self.style_stack.pop(); - let cell = self.drain_cur_text(); - self.table_row.push(cell); + let cell = self.drain_cur_cell(); + self.table_row.push(cell.text()); + self.table_row_cells.push(cell); } _ => {} } @@ -1675,10 +1771,24 @@ impl<'a> LayoutState<'a> { }); } - fn drain_cur_text(&mut self) -> String { - let s: String = self.cur.iter().map(|sp| sp.text.as_str()).collect(); - self.cur.clear(); - s.trim().to_owned() + /// The collected cell runs with surrounding whitespace trimmed away. + fn drain_cur_cell(&mut self) -> DocCell { + let mut spans = std::mem::take(&mut self.cur); + while let Some(f) = spans.first_mut() { + f.text = f.text.trim_start().to_owned(); + if !f.text.is_empty() { + break; + } + spans.remove(0); + } + while let Some(l) = spans.last_mut() { + l.text = l.text.trim_end().to_owned(); + if !l.text.is_empty() { + break; + } + spans.pop(); + } + DocCell { spans } } /// Lay the table out, then record its cell grid and an expand action. @@ -1686,27 +1796,32 @@ impl<'a> LayoutState<'a> { let start = self.styled.len(); let has_header = self.table_header_done; let grid = self.table_rows.clone(); + let cells = std::mem::take(&mut self.table_cells); self.layout_table(); let Some((source_line, first)) = grid.first().cloned() else { return; }; let cols = grid.iter().map(|(_, c)| c.len()).max().unwrap_or(1).max(1); - let pad = |mut row: Vec| { - row.resize(cols, String::new()); + let pad = |mut row: Vec| { + row.resize(cols, DocCell::default()); row }; - let mut rows = grid.into_iter().map(|(_, c)| pad(c)); + let mut rows = cells.into_iter().map(pad); // ponytail: a table without a header row (can't happen with GFM) gets an empty header. let header = if has_header { rows.next().unwrap_or_default() } else { - vec![String::new(); cols] + vec![DocCell::default(); cols] }; - let rows: Vec> = rows.collect(); + let rows: Vec> = rows.collect(); let fingerprint = format!( "{}\n{}", first.join("\t"), - rows.first().map_or(String::new(), |r| r.join("\t")) + rows.first().map_or(String::new(), |r| r + .iter() + .map(DocCell::text) + .collect::>() + .join("\t")) ); let line = u32::try_from(start).unwrap_or(0); let width = self.styled.get(start).map_or(1, |l| { diff --git a/crates/wiki-reader/Cargo.toml b/crates/wiki-reader/Cargo.toml index 71776e2..b037c69 100644 --- a/crates/wiki-reader/Cargo.toml +++ b/crates/wiki-reader/Cargo.toml @@ -9,6 +9,12 @@ repository.workspace = true homepage.workspace = true readme = "../../README.md" +[features] +default = ["media"] +# Image / Mermaid-raster / terminal-graphics stack. `--no-default-features` is the lite build +# (ADR-0023): text placeholders and the text/source diagram tiers only. +media = ["wiki-reader-render/media", "dep:image", "dep:ratatui-image"] + [[bin]] name = "wiki-reader" path = "src/main.rs" @@ -20,16 +26,18 @@ path = "examples/keylog.rs" [[example]] name = "image-protocol" path = "examples/image_protocol.rs" +required-features = ["media"] [[example]] name = "mermaid-image" path = "examples/mermaid_image.rs" +required-features = ["media"] [dependencies] clap = { workspace = true } -image = { workspace = true } +image = { workspace = true, optional = true } ratatui = { workspace = true } -ratatui-image = { workspace = true } +ratatui-image = { workspace = true, optional = true } shell-words = { workspace = true } signal-hook = { workspace = true } syntect = { workspace = true } diff --git a/crates/wiki-reader/src/tui/app/draw.rs b/crates/wiki-reader/src/tui/app/draw.rs index 7d0aabe..11f0d93 100644 --- a/crates/wiki-reader/src/tui/app/draw.rs +++ b/crates/wiki-reader/src/tui/app/draw.rs @@ -4,6 +4,7 @@ use ratatui::Frame; use super::App; use crate::tui::focus::FocusPane; +#[cfg(feature = "media")] use crate::tui::images::Occlusion; use crate::tui::layout; use crate::tui::options_ui::{self, OptionLine, OptionsOverlay}; @@ -103,32 +104,35 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) { &mut app.hit_map, ); - // Pictures go over the viewer text and under popups (their `Clear` covers them). A narrow - // terminal's nav overlay hides pictures until it closes (terminal protocols are not cells). - // Popups draw after the pictures, and a Kitty picture's one-time upload rides in its first - // cell, so the pictures need to know where the popups will land (from the last frame; if - // the set of open popups just changed the panels are unknown, which counts as covered). let open_popups = u8::from(app.search.is_some()) | u8::from(app.help.is_some()) << 1 | u8::from(app.options.is_some()) << 2 | u8::from(app.modal.is_some()) << 3; - let popup_rects = std::mem::take(&mut app.last_popups.1); - let occlusion = if open_popups == 0 { - Occlusion::None - } else if app.last_popups.0 != open_popups || popup_rects.is_empty() { - Occlusion::All - } else { - Occlusion::Rects(&popup_rects) - }; - if !regions.nav_overlay { - app.images.draw( - frame, - app.doc.image_slots(), - app.scroll, - app.viewer_geom, - &theme, - occlusion, - ); + #[cfg(feature = "media")] + { + // Pictures go over the viewer text and under popups (their `Clear` covers them). A narrow + // terminal's nav overlay hides pictures until it closes (terminal protocols are not cells). + // Popups draw after the pictures, and a Kitty picture's one-time upload rides in its first + // cell, so the pictures need to know where the popups will land (from the last frame; if + // the set of open popups just changed the panels are unknown, which counts as covered). + let popup_rects = std::mem::take(&mut app.last_popups.1); + let occlusion = if open_popups == 0 { + Occlusion::None + } else if app.last_popups.0 != open_popups || popup_rects.is_empty() { + Occlusion::All + } else { + Occlusion::Rects(&popup_rects) + }; + if !regions.nav_overlay { + app.images.draw( + frame, + app.doc.image_slots(), + app.scroll, + app.viewer_geom, + &theme, + occlusion, + ); + } } let total = u32::try_from(app.doc.lines().len().max(1)).unwrap_or(1); diff --git a/crates/wiki-reader/src/tui/app/events.rs b/crates/wiki-reader/src/tui/app/events.rs index ae4cde4..f9e86df 100644 --- a/crates/wiki-reader/src/tui/app/events.rs +++ b/crates/wiki-reader/src/tui/app/events.rs @@ -23,6 +23,7 @@ use super::App; use super::draw::draw; use crate::tui::action::Action; use crate::tui::hit::{Hit, HitMap}; +#[cfg(feature = "media")] use crate::tui::images; use crate::tui::keymap; use wiki_reader_core::nav::{NavStop, NodeId}; @@ -56,6 +57,7 @@ pub fn run(root: &Path, config: Option<&Path>) -> io::Result<()> { }; // The graphics probe reads stdin, so it runs before mouse capture, the keyboard-enhancement // query and the first event read (ADR-0004). Config `diagrams = text|source` skips it entirely. + #[cfg(feature = "media")] if images::should_probe(app.diagram_mode_for_probe()) && app.graphics_allowed() && let Some(picker) = images::detect_picker(&images::GraphicsEnv::from_process()) diff --git a/crates/wiki-reader/src/tui/app/image_tests.rs b/crates/wiki-reader/src/tui/app/image_tests.rs index 054822d..612bd91 100644 --- a/crates/wiki-reader/src/tui/app/image_tests.rs +++ b/crates/wiki-reader/src/tui/app/image_tests.rs @@ -573,6 +573,24 @@ fn enter_on_a_picture_opens_it_zooms_within_caps_and_esc_closes() { modal_press(&mut app, KeyCode::Down); settle_modal(&mut term, &mut app); + // Carousel (P3-21): every block image of the page, opened one first; sizing is per item. + assert!(any_text(&term, "1/9") && any_text(&term, "fit 100%")); + modal_press(&mut app, KeyCode::Char('a')); + settle_modal(&mut term, &mut app); + assert!(any_text(&term, "actual 100%"), "a toggles actual size"); + modal_press(&mut app, KeyCode::Tab); + settle_modal(&mut term, &mut app); + assert!(any_text(&term, "2/9") && any_text(&term, "fit 100%")); + // The remote image is in the inventory too, as a note instead of a picture. + for _ in 0..4 { + modal_press(&mut app, KeyCode::Tab); + } + draw_to(&mut term, &mut app); + assert!(any_text(&term, "6/9") && any_text(&term, "logo.png: no preview")); + modal_press(&mut app, KeyCode::BackTab); + settle_modal(&mut term, &mut app); + assert!(any_text(&term, "5/9")); + modal_press(&mut app, KeyCode::Esc); assert!(app.modal.is_none(), "Esc closes and drops the viewer"); assert_eq!(app.input_mode, crate::tui::keymap::InputMode::Normal); diff --git a/crates/wiki-reader/src/tui/app/mod.rs b/crates/wiki-reader/src/tui/app/mod.rs index f77369c..01a47be 100644 --- a/crates/wiki-reader/src/tui/app/mod.rs +++ b/crates/wiki-reader/src/tui/app/mod.rs @@ -38,6 +38,7 @@ mod nav_ui; mod viewer_state; #[cfg(test)] +#[cfg(feature = "media")] mod image_tests; #[cfg(test)] mod tests; @@ -527,17 +528,20 @@ impl App { } /// `diagrams = "text" | "source"` or `images.enabled = false` opts out of graphics, including the startup probe. + #[cfg(feature = "media")] pub(crate) fn graphics_allowed(&self) -> bool { self.images_enabled && crate::tui::images::should_probe(self.diagram_mode) } /// Config mode visible to the startup probe (ADR-0004 step 1). + #[cfg(feature = "media")] pub(crate) fn diagram_mode_for_probe(&self) -> wiki_reader_core::config::DiagramMode { self.diagram_mode } /// Enable image drawing with a confirmed protocol. Call before the first draw: the first /// layout pass re-renders the page, which then reserves rows for images. + #[cfg(feature = "media")] pub(crate) fn enable_graphics(&mut self, picker: ratatui_image::picker::Picker) { let sizes = self.images.diagram_sizes(); self.images = crate::tui::images::ImageManager::enabled_with_sizes(picker, sizes); @@ -1042,14 +1046,62 @@ impl App { self.message.clear(); } - /// Open a picture or diagram in the modal viewer (source view on the text tier). - pub(crate) fn open_image(&mut self, source: wiki_reader_render::SlotSource, alt: &str) { - let viewer = super::image_viewer::ImageViewer::new(source, alt, self.images.picker()); + /// Open the picture or diagram whose block starts at display `line` in the modal viewer, + /// with every picture of the page in the Tab carousel (source view on the text tier). + #[cfg(feature = "media")] + pub(crate) fn open_media(&mut self, line: u32) { + use super::image_viewer::{ImageViewer, ViewerItem}; + use wiki_reader_render::{DiagramTier, MediaOccurrenceKind as Kind, SlotSource}; + let PageDoc::Rendered(doc) = &self.doc else { + return; + }; + // Inventory order is slot order: each picture or image-tier diagram owns the next slot. + let mut slots = doc.image_slots().iter().map(|s| s.source.clone()); + let mut start = 0; + let items: Vec = doc + .media() + .iter() + .enumerate() + .map(|(i, m)| { + if m.line == line { + start = i; + } + let (alt, src, note) = match &m.kind { + Kind::Image { dest, alt, slot } => ( + alt.clone(), + slot.then(|| slots.next()).flatten(), + format!("{dest}: no preview (not loaded inline)"), + ), + Kind::Diagram { source, hash, tier } => { + if *tier == DiagramTier::Image { + slots.next(); + } + let src = SlotSource::Mermaid { + hash: *hash, + palette: self.theme.diagram, + source: source.clone(), + }; + ("diagram".into(), Some(src), String::new()) + } + }; + ViewerItem { alt, src, note } + }) + .collect(); + if items.is_empty() { + return; + } + let viewer = ImageViewer::new(items, start, self.images.picker()); self.modal = Some(Box::new(viewer)); self.input_mode = InputMode::Modal; self.message.clear(); } + /// Lite build: no image viewer. + #[cfg(not(feature = "media"))] + pub(crate) fn open_media(&mut self, _: u32) { + self.message = "image viewer is not in this lite build".into(); + } + fn modal_key(&mut self, key: ratatui::crossterm::event::KeyEvent) { let Some(modal) = self.modal.as_mut() else { return; diff --git a/crates/wiki-reader/src/tui/app/tests.rs b/crates/wiki-reader/src/tui/app/tests.rs index 1a4b453..5c3dc90 100644 --- a/crates/wiki-reader/src/tui/app/tests.rs +++ b/crates/wiki-reader/src/tui/app/tests.rs @@ -3549,6 +3549,7 @@ fn help_overlay_open_close_and_activate() { } #[test] +#[cfg(feature = "media")] fn options_overlay_applies_choices_and_persists() { use wiki_reader_core::config::ThemeName; let root = fixture(); @@ -3603,6 +3604,7 @@ fn options_overlay_applies_choices_and_persists() { } #[test] +#[cfg(feature = "media")] fn options_overlay_snapshot() { let root = fixture(); let mut app = App::new(&root).unwrap(); diff --git a/crates/wiki-reader/src/tui/app/viewer_state.rs b/crates/wiki-reader/src/tui/app/viewer_state.rs index b231119..77318e5 100644 --- a/crates/wiki-reader/src/tui/app/viewer_state.rs +++ b/crates/wiki-reader/src/tui/app/viewer_state.rs @@ -359,9 +359,9 @@ impl App { .image_slots() .iter() .find(|s| (s.line..s.line + u32::from(s.rows)).contains(&line)) - .cloned() + .map(|s| s.line) { - self.open_image(slot.source, &slot.alt); + self.open_media(slot); } } @@ -391,12 +391,7 @@ impl App { } } wiki_reader_render::BlockActionKind::ExpandDiagram => { - let source = wiki_reader_render::SlotSource::Mermaid { - hash: wiki_reader_render::content_hash(&action.payload), - palette: self.theme.diagram, - source: action.payload, - }; - self.open_image(source, "diagram"); + self.open_media(action.line); } wiki_reader_render::BlockActionKind::ToggleFrontmatter => { if !self.expanded_blocks.remove(&id) { diff --git a/crates/wiki-reader/src/tui/image_viewer.rs b/crates/wiki-reader/src/tui/image_viewer.rs index ac6af36..bfe3857 100644 --- a/crates/wiki-reader/src/tui/image_viewer.rs +++ b/crates/wiki-reader/src/tui/image_viewer.rs @@ -1,4 +1,10 @@ -//! Image and diagram viewer (P3-15): a [`ModalContent`] over one picture. +//! Image and diagram viewer (P3-15, P3-20..22): a [`ModalContent`] over the document's pictures. +//! +//! The viewer holds the whole media inventory and shows one item at a time; `Tab` / `Shift+Tab` +//! step through them (P3-21). A diagram cycles image / text / source with `v` (P3-22, session +//! only); a view that cannot be shown (no graphics protocol) is skipped. The window is sized to +//! the picture, capped by the shell (P3-20); `a` flips fit / actual size on top of the zoom. +//! This module only exists with the `media` feature, so the lite build never has an image view. //! //! Zoom decision (plan, locked): SVG and Mermaid **re-rasterise** from source at the zoomed size //! (sharp); raster files are **scaled**. Both happen on a worker thread that lives as long as the @@ -13,12 +19,13 @@ clippy::cast_precision_loss )] +use std::fmt::Write as _; use std::sync::mpsc::{self, Receiver, Sender}; use image::imageops::FilterType; use image::{DynamicImage, RgbaImage}; use ratatui::Frame; -use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::layout::Rect; use ratatui::text::Line; use ratatui::widgets::Paragraph; @@ -26,25 +33,37 @@ use ratatui_image::StatefulImage; use ratatui_image::picker::Picker; use ratatui_image::protocol::StatefulProtocol; use wiki_reader_core::images::{MAX_IMAGE_FILE_BYTES, MAX_IMAGE_PIXELS}; -use wiki_reader_render::{SlotSource, mermaid_svg_bytes, rasterise_svg_scaled, svg_natural_size}; +use wiki_reader_render::{ + DiagramTier, SlotSource, decode_file, diagram_lines, is_svg_path, mermaid_svg_bytes, + rasterise_svg_scaled, svg_natural_size, +}; -use super::images::{decode_file, is_svg_path}; use super::modal_viewer::{ModalContent, ModalEvent}; +use super::text_col::line_width; use super::theme::Theme; -/// Zoom steps, percent of the fit size (100 = whole picture in the window). +/// Zoom steps, percent of the base size (100 = the fit size, or natural pixels in actual mode). const ZOOMS: [u32; 7] = [100, 150, 200, 300, 400, 600, 800]; -const HINT: &str = "←↑↓→ pan +/- zoom 0 fit g/G top/end Esc close"; -const TEXT_HINT: &str = "↑↓ scroll Esc close (no graphics protocol: source view)"; - -/// Pixel size of the picture at `zoom_pct`: the fit size (never above natural) times the zoom, -/// then shrunk if needed so `w × h` stays within [`MAX_IMAGE_PIXELS`]. +/// Fit mode enlarges a small picture up to this factor (the inline slot never exceeds natural +/// size, so opening a picture always shows it larger). +// ponytail: fixed 2×; upgrade: a config knob if dogfood wants more. +const FIT_UPSCALE: f64 = 2.0; + +/// Pixel size of the picture at `zoom_pct`, then shrunk if needed so `w × h` stays within +/// [`MAX_IMAGE_PIXELS`]. The base scale is **fit** (the window, at most [`FIT_UPSCALE`]× +/// natural) or **actual** (1 picture pixel = 1 terminal pixel, whatever the window). #[must_use] -pub fn zoom_dims(nat: (u32, u32), vp: (u32, u32), zoom_pct: u32) -> (u32, u32) { +pub fn zoom_dims(nat: (u32, u32), vp: (u32, u32), zoom_pct: u32, actual: bool) -> (u32, u32) { let (nw, nh) = (f64::from(nat.0.max(1)), f64::from(nat.1.max(1))); - let fit = (f64::from(vp.0) / nw).min(f64::from(vp.1) / nh).min(1.0); + let base = if actual { + 1.0 + } else { + (f64::from(vp.0) / nw) + .min(f64::from(vp.1) / nh) + .min(FIT_UPSCALE) + }; let cap = (MAX_IMAGE_PIXELS as f64 / (nw * nh)).sqrt() * 0.999; - let scale = (fit * f64::from(zoom_pct) / 100.0).min(cap); + let scale = (base * f64::from(zoom_pct) / 100.0).min(cap); let dim = |n: f64| (n * scale).ceil().max(1.0) as u32; (dim(nw), dim(nh)) } @@ -52,8 +71,8 @@ pub fn zoom_dims(nat: (u32, u32), vp: (u32, u32), zoom_pct: u32) -> (u32, u32) { /// What a cropped picture was cut from: window offset, zoomed width, size in cells. type ShownId = (u32, u32, u32, u16, u16); -/// One zoom request: percent and window size in pixels. -type ReqKey = (u32, (u32, u32)); +/// One zoom request: percent, window size in pixels, actual-size mode. +type ReqKey = (u32, (u32, u32), bool); struct Zoomed { img: RgbaImage, @@ -102,7 +121,7 @@ fn load(source: &SlotSource) -> Result { } fn zoom_to(nat: &Natural, key: ReqKey) -> Result { - let (w, h) = zoom_dims(nat.size(), key.1, key.0); + let (w, h) = zoom_dims(nat.size(), key.1, key.0, key.2); match nat { Natural::Bitmap(img) => Ok(img.resize_exact(w, h, FilterType::Triangle).to_rgba8()), Natural::Svg(bytes, (nw, _)) => { @@ -133,19 +152,21 @@ fn work(source: &SlotSource, reqs: &Receiver, out: &Sender, results: Receiver>, /// Index into [`ZOOMS`]. zoom: usize, + /// Base scale: natural pixels instead of fit-to-window. + actual: bool, /// Window centre as a fraction of the zoomed picture, so zoom keeps what you look at. centre: (f64, f64), zoomed: Option, asked: Option, error: Option, - /// Window size in pixels, from the last draw (pan step). + /// Body size in pixels, from the last draw (pan step, crop). vp: (u32, u32), /// The cropped picture on screen, and what it was cut from. shown: Option<(ShownId, StatefulProtocol)>, @@ -154,6 +175,30 @@ struct Gfx { } impl Gfx { + fn new(picker: Picker, source: SlotSource) -> Self { + let pad = match &source { + SlotSource::Mermaid { palette, .. } => Some(palette.bg), + SlotSource::File { .. } => None, + }; + let (reqs, req_rx) = mpsc::channel(); + let (out, results) = mpsc::channel(); + std::thread::spawn(move || work(&source, &req_rx, &out)); + Self { + picker, + reqs, + results, + zoom: 0, + actual: false, + centre: (0.5, 0.5), + zoomed: None, + asked: None, + error: None, + vp: (640, 340), + shown: None, + pad, + } + } + /// Top-left of the window in the zoomed picture, clamped inside it. fn offset(&self) -> (u32, u32) { let Some(z) = &self.zoomed else { @@ -187,63 +232,203 @@ impl Gfx { fn set_zoom(&mut self, zoom: usize) { self.zoom = zoom.min(ZOOMS.len() - 1); } + + /// Ask for the zoomed picture if the zoom, mode or window `vp` (pixels) changed, and take + /// finished ones. + fn sync(&mut self, vp: (u32, u32)) { + let key = (ZOOMS[self.zoom], vp, self.actual); + if self.asked != Some(key) { + self.asked = Some(key); + // A closed worker shows up as an error on the next read; nothing to do here. + let _ = self.reqs.send(key); + } + while let Ok(result) = self.results.try_recv() { + match result { + Ok(z) => { + self.zoomed = Some(z); + self.error = None; + } + Err(e) => self.error = Some(e), + } + } + } +} + +/// One picture or diagram of the document (P3-21 inventory entry). +pub struct ViewerItem { + pub alt: String, + /// What to draw; `None` for a picture the layout could not load (see `note`). + pub src: Option, + /// Shown when there is no source view: why the picture is not available. + pub note: String, +} + +/// What the viewer shows for the current item. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum View { + Image, + /// Mermaid text art at the window width. + Text, + /// Mermaid source, or the note for a picture with nothing to draw. + Source, +} + +impl View { + fn name(self) -> &'static str { + match self { + Self::Image => "image", + Self::Text => "text", + Self::Source => "source", + } + } +} + +fn source_lines(item: &ViewerItem) -> Vec { + match &item.src { + Some(SlotSource::Mermaid { source, .. }) => source.lines().map(str::to_owned).collect(), + Some(SlotSource::File { path, .. }) => { + vec![format!("{}: no graphics protocol", path.display())] + } + None => vec![item.note.clone()], + } } /// Open viewer state. pub struct ImageViewer { - label: &'static str, - alt: String, + items: Vec, + cur: usize, + picker: Option, + view: View, + /// `Some` exactly in [`View::Image`]; dropping it stops the worker. gfx: Option, - /// Source view: Mermaid text, or the reason there is nothing to show. + /// Lines of the text views (and the fallback when the picture cannot render). text: Vec, + /// Width the [`View::Text`] art was laid out for. + text_w: u16, scroll: usize, } impl ImageViewer { - /// `picker` is `None` on the text tier, which shows the source instead. + /// Show `items[start]` first. `picker` is `None` without a graphics protocol, which leaves + /// diagrams on their text / source views. `items` must not be empty. #[must_use] - pub fn new(source: SlotSource, alt: &str, picker: Option) -> Self { - let (label, text, pad) = match &source { - SlotSource::Mermaid { - source, palette, .. - } => ( - "DIAGRAM", - source.lines().map(str::to_owned).collect(), - Some(palette.bg), - ), - SlotSource::File { path, .. } => ( - "IMAGE", - vec![format!("{}: no graphics protocol", path.display())], - None, - ), + pub fn new(items: Vec, start: usize, picker: Option) -> Self { + let mut v = Self { + cur: start.min(items.len().saturating_sub(1)), + items, + picker, + view: View::Source, + gfx: None, + text: Vec::new(), + text_w: 0, + scroll: 0, }; - let gfx = picker.map(|picker| { - let (reqs, req_rx) = mpsc::channel(); - let (out, results) = mpsc::channel(); - std::thread::spawn(move || work(&source, &req_rx, &out)); - Gfx { - picker, - reqs, - results, - zoom: 0, - centre: (0.5, 0.5), - zoomed: None, - asked: None, - error: None, - vp: (640, 340), - shown: None, - pad, - } + v.show(v.cur); + v + } + + fn item(&self) -> &ViewerItem { + &self.items[self.cur] + } + + fn is_diagram(&self) -> bool { + matches!(self.item().src, Some(SlotSource::Mermaid { .. })) + } + + /// Views this item can show here: no image without a protocol (or a source), no text art + /// for a plain picture. + fn views(&self) -> Vec { + let mut v = Vec::new(); + if self.picker.is_some() && self.item().src.is_some() { + v.push(View::Image); + } + if self.is_diagram() { + v.push(View::Text); + } + v.push(View::Source); + v + } + + fn show(&mut self, idx: usize) { + self.cur = idx; + let first = self.views()[0]; + // A diagram without a protocol opens on its source, as the inline tier left it. + self.set_view(if first == View::Text { + View::Source + } else { + first }); - Self { - label, - alt: alt.to_owned(), - gfx, - text, - scroll: 0, + } + + fn set_view(&mut self, view: View) { + self.view = view; + self.scroll = 0; + self.text_w = 0; + self.text = source_lines(self.item()); + self.gfx = match (view, &self.picker, &self.item().src) { + (View::Image, Some(p), Some(src)) => Some(Gfx::new(p.clone(), src.clone())), + _ => None, + }; + } + + /// Step through the document's pictures, wrapping. + fn step(&mut self, forward: bool) { + let n = self.items.len(); + if n > 1 { + self.show(if forward { + (self.cur + 1) % n + } else { + (self.cur + n - 1) % n + }); + } + } + + /// Everything but item / view switching: pan and zoom the picture, or scroll the text. + fn pan_or_scroll(&mut self, code: KeyCode) { + let Some(g) = self.gfx.as_mut() else { + match code { + KeyCode::Up | KeyCode::Char('k') => self.scroll_text(-1), + KeyCode::Down | KeyCode::Char('j') => self.scroll_text(1), + KeyCode::PageUp => self.scroll_text(-10), + KeyCode::PageDown => self.scroll_text(10), + KeyCode::Home | KeyCode::Char('g') => self.scroll = 0, + KeyCode::End | KeyCode::Char('G') => self.scroll_text(isize::MAX), + _ => {} + } + return; + }; + // ponytail: keys only; mouse is the wheel (pans vertically). Upgrade: drag to pan. + let (step_x, step_y) = (i64::from(g.vp.0 / 8).max(1), i64::from(g.vp.1 / 8).max(1)); + match code { + KeyCode::Left | KeyCode::Char('h') => g.pan(-step_x, 0), + KeyCode::Right | KeyCode::Char('l') => g.pan(step_x, 0), + KeyCode::Up | KeyCode::Char('k') => g.pan(0, -step_y), + KeyCode::Down | KeyCode::Char('j') => g.pan(0, step_y), + KeyCode::PageUp => g.pan(0, -step_y * 7), + KeyCode::PageDown => g.pan(0, step_y * 7), + KeyCode::Home | KeyCode::Char('g') => g.centre.1 = 0.0, + KeyCode::End | KeyCode::Char('G') => g.centre.1 = 1.0, + KeyCode::Char('+' | '=') => g.set_zoom(g.zoom + 1), + KeyCode::Char('-' | '_') => g.set_zoom(g.zoom.saturating_sub(1)), + KeyCode::Char('a') => { + g.actual = !g.actual; + g.centre = (0.5, 0.5); + } + KeyCode::Char('0') => { + g.set_zoom(0); + g.actual = false; + g.centre = (0.5, 0.5); + } + _ => {} } } + fn cycle_view(&mut self) { + let views = self.views(); + let at = views.iter().position(|&v| v == self.view).unwrap_or(0); + self.set_view(views[(at + 1) % views.len()]); + } + /// Zoom percent. #[cfg(test)] #[must_use] @@ -251,26 +436,6 @@ impl ImageViewer { self.gfx.as_ref().map_or(100, |g| ZOOMS[g.zoom]) } - /// Ask for the zoomed picture if the window or zoom changed, and take finished ones. - fn sync(g: &mut Gfx, vp: (u32, u32)) { - g.vp = vp; - let key = (ZOOMS[g.zoom], vp); - if g.asked != Some(key) { - g.asked = Some(key); - // A closed worker shows up as an error on the next read; nothing to do here. - let _ = g.reqs.send(key); - } - while let Ok(result) = g.results.try_recv() { - match result { - Ok(z) => { - g.zoomed = Some(z); - g.error = None; - } - Err(e) => g.error = Some(e), - } - } - } - fn draw_text(&self, frame: &mut Frame<'_>, body: Rect, theme: &Theme, note: Option<&str>) { let mut lines: Vec = note.map(Line::from).into_iter().collect(); lines.extend(self.text.iter().map(|l| Line::from(l.as_str()))); @@ -287,31 +452,86 @@ impl ImageViewer { impl ModalContent for ImageViewer { fn label(&self) -> &'static str { - self.label + if self.is_diagram() { + "DIAGRAM" + } else { + "IMAGE" + } + } + + fn want(&mut self, cap: (u16, u16)) -> (u16, u16) { + if let Some(g) = self.gfx.as_mut() { + let font = g.picker.font_size(); + let (fw, fh) = (u32::from(font.width.max(1)), u32::from(font.height.max(1))); + g.sync((u32::from(cap.0) * fw, u32::from(cap.1) * fh)); + let cells = |px: u32, f: u32| u16::try_from(px.div_ceil(f)).unwrap_or(u16::MAX); + return match (&g.zoomed, &g.error) { + (Some(z), None) => (cells(z.img.width(), fw), cells(z.img.height(), fh)), + (None, None) => (0, 1), // "rendering…" + (_, Some(_)) => cap, + }; + } + if self.view == View::Text && self.text_w != cap.0 { + self.text_w = cap.0; + if let Some(SlotSource::Mermaid { source, .. }) = &self.item().src { + self.text = diagram_lines(source, DiagramTier::Text, cap.0).0; + } + } + let w = self.text.iter().map(|l| line_width(l)).max().unwrap_or(0); + (w, u16::try_from(self.text.len()).unwrap_or(u16::MAX)) } fn title(&self) -> String { - let name = if self.alt.is_empty() { - self.label.to_lowercase() + let item = self.item(); + let mut parts = vec![if item.alt.is_empty() { + self.label().to_lowercase() } else { - self.alt.clone() - }; + item.alt.clone() + }]; + if self.items.len() > 1 { + parts.push(format!("{}/{}", self.cur + 1, self.items.len())); + } match &self.gfx { - Some(g) => match &g.zoomed { - Some(z) => format!( - "{name} · {}% · {}×{} px", - ZOOMS[g.zoom], - z.img.width(), - z.img.height() - ), - None => format!("{name} · {}%", ZOOMS[g.zoom]), - }, - None => name, + Some(g) => { + parts.push(format!( + "{} {}%", + if g.actual { "actual" } else { "fit" }, + ZOOMS[g.zoom] + )); + if let Some(z) = &g.zoomed { + parts.push(format!("{}×{} px", z.img.width(), z.img.height())); + } + } + None if self.is_diagram() => parts.push(format!("{} view", self.view.name())), + None => {} } + parts.join(" · ") } fn hint(&self) -> String { - (if self.gfx.is_some() { HINT } else { TEXT_HINT }).into() + let mut h = match &self.gfx { + Some(g) => format!( + "←↑↓→ pan +/- zoom 0 fit a {} g/G top/end", + if g.actual { "fit" } else { "actual" } + ), + None => "↑↓ scroll".into(), + }; + if self.items.len() > 1 { + h.push_str(" Tab/⇧Tab item"); + } + if self.views().len() > 1 { + h.push_str(" v image/text/source"); + } + h.push_str(" Esc close"); + if self.gfx.is_none() { + let why = if self.picker.is_none() { + "; no graphics protocol" + } else { + "" + }; + let _ = write!(h, " ({} view{why})", self.view.name()); + } + h } fn busy(&self) -> bool { @@ -323,39 +543,16 @@ impl ModalContent for ImageViewer { } fn key(&mut self, key: KeyEvent) -> ModalEvent { - if matches!(key.code, KeyCode::Char('q')) { - return ModalEvent::Close; - } - let Some(g) = self.gfx.as_mut() else { - match key.code { - KeyCode::Up | KeyCode::Char('k') => self.scroll_text(-1), - KeyCode::Down | KeyCode::Char('j') => self.scroll_text(1), - KeyCode::PageUp => self.scroll_text(-10), - KeyCode::PageDown => self.scroll_text(10), - KeyCode::Home | KeyCode::Char('g') => self.scroll = 0, - KeyCode::End | KeyCode::Char('G') => self.scroll_text(isize::MAX), - _ => {} - } - return ModalEvent::Stay; - }; - // ponytail: keys only; mouse is the wheel (pans vertically). Upgrade: drag to pan. - let (step_x, step_y) = (i64::from(g.vp.0 / 8).max(1), i64::from(g.vp.1 / 8).max(1)); match key.code { - KeyCode::Left | KeyCode::Char('h') => g.pan(-step_x, 0), - KeyCode::Right | KeyCode::Char('l') => g.pan(step_x, 0), - KeyCode::Up | KeyCode::Char('k') => g.pan(0, -step_y), - KeyCode::Down | KeyCode::Char('j') => g.pan(0, step_y), - KeyCode::PageUp => g.pan(0, -step_y * 7), - KeyCode::PageDown => g.pan(0, step_y * 7), - KeyCode::Home | KeyCode::Char('g') => g.centre.1 = 0.0, - KeyCode::End | KeyCode::Char('G') => g.centre.1 = 1.0, - KeyCode::Char('+' | '=') => g.set_zoom(g.zoom + 1), - KeyCode::Char('-' | '_') => g.set_zoom(g.zoom.saturating_sub(1)), - KeyCode::Char('0') => { - g.set_zoom(0); - g.centre = (0.5, 0.5); + KeyCode::Char('q') => return ModalEvent::Close, + KeyCode::BackTab => self.step(false), + KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => self.step(false), + KeyCode::Tab => self.step(true), + KeyCode::Char('v') => self.cycle_view(), + _ => { + self.pan_or_scroll(key.code); + return ModalEvent::Stay; } - _ => {} } ModalEvent::Stay } @@ -367,7 +564,7 @@ impl ModalContent for ImageViewer { }; let font = g.picker.font_size(); let (fw, fh) = (u32::from(font.width.max(1)), u32::from(font.height.max(1))); - Self::sync(g, (u32::from(body.width) * fw, u32::from(body.height) * fh)); + g.vp = (u32::from(body.width) * fw, u32::from(body.height) * fh); if let Some(err) = g.error.clone() { // The diagram source is the next best thing to the picture. self.draw_text(frame, body, theme, Some(&format!("cannot render: {err}"))); @@ -418,7 +615,6 @@ impl ModalContent for ImageViewer { #[cfg(test)] mod tests { use super::*; - use ratatui::crossterm::event::KeyModifiers; use std::time::{Duration, Instant}; use wiki_reader_render::DiagramPalette; @@ -434,9 +630,21 @@ mod tests { } } + fn item(id: u64, src: Option) -> ViewerItem { + ViewerItem { + alt: format!("item{id}"), + src, + note: "pic.png: no preview".into(), + } + } + + fn one(picker: Option) -> ImageViewer { + ImageViewer::new(vec![item(1, Some(mermaid()))], 0, picker) + } + #[test] fn zoom_clamps_at_both_ends() { - let mut v = ImageViewer::new(mermaid(), "d", Some(Picker::halfblocks())); + let mut v = one(Some(Picker::halfblocks())); press(&mut v, KeyCode::Char('-')); assert_eq!(v.zoom_pct(), 100, "cannot go below fit"); for _ in 0..20 { @@ -448,32 +656,38 @@ mod tests { } #[test] - fn pixel_cap_holds_at_every_zoom() { + fn pixel_cap_holds_at_every_zoom_and_mode() { // A picture already near the cap, in a huge window, at the top zoom. for nat in [(4000, 4000), (16_000, 1000), (100, 100), (1, 1)] { for zoom in ZOOMS { - let (w, h) = zoom_dims(nat, (20_000, 20_000), zoom); - assert!( - u64::from(w) * u64::from(h) <= MAX_IMAGE_PIXELS, - "{nat:?} {zoom}" - ); + for actual in [false, true] { + let (w, h) = zoom_dims(nat, (20_000, 20_000), zoom, actual); + assert!( + u64::from(w) * u64::from(h) <= MAX_IMAGE_PIXELS, + "{nat:?} {zoom} {actual}" + ); + } } } - // 100 % never upscales past natural. - assert_eq!(zoom_dims((100, 50), (4000, 4000), 100), (100, 50)); - // Fits the window at 100 %. - let (w, h) = zoom_dims((1000, 500), (200, 200), 100); + // Fit enlarges a small picture (2×) and shrinks a big one into the window; + // actual size is natural pixels whatever the window. + assert_eq!(zoom_dims((100, 50), (4000, 4000), 100, false), (200, 100)); + assert_eq!(zoom_dims((100, 50), (4000, 4000), 100, true), (100, 50)); + assert_eq!(zoom_dims((100, 50), (40, 40), 100, true), (100, 50)); + let (w, h) = zoom_dims((1000, 500), (200, 200), 100, false); assert!(w <= 200 && h <= 200); + // Zoom multiplies the base of either mode. + assert_eq!(zoom_dims((100, 50), (4000, 4000), 200, true), (200, 100)); } #[test] fn pan_clamps_inside_the_picture() { - let mut v = ImageViewer::new(mermaid(), "d", Some(Picker::halfblocks())); + let mut v = one(Some(Picker::halfblocks())); let g = v.gfx.as_mut().unwrap(); g.vp = (100, 100); g.zoomed = Some(Zoomed { img: RgbaImage::new(300, 200), - key: (200, (100, 100)), + key: (200, (100, 100), false), }); g.pan(10_000, 10_000); assert_eq!(g.offset(), (200, 100), "right/bottom edge"); @@ -485,7 +699,7 @@ mod tests { #[test] fn text_tier_shows_source_and_scrolls() { - let mut v = ImageViewer::new(mermaid(), "d", None); + let mut v = one(None); assert!(!v.busy()); press(&mut v, KeyCode::Down); assert_eq!(v.scroll, 1); @@ -493,19 +707,63 @@ mod tests { press(&mut v, KeyCode::Up); assert_eq!(v.scroll, 0); assert!(v.hint().contains("source view")); + // The window wants the source's size, not the whole screen. + assert_eq!(v.want((90, 30)), (24, 2)); + } + + #[test] + fn tab_steps_the_inventory_and_v_cycles_only_available_views() { + let items = vec![ + item(1, Some(mermaid())), + item(2, None), + item(3, Some(mermaid())), + ]; + let mut v = ImageViewer::new(items, 2, Some(Picker::halfblocks())); + assert_eq!((v.cur, v.view), (2, View::Image), "opened one shows first"); + press(&mut v, KeyCode::Tab); + assert_eq!(v.cur, 0, "wraps forward"); + v.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + assert_eq!(v.cur, 2, "Shift+Tab wraps back"); + v.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT)); + assert_eq!(v.cur, 1); + // A picture with nothing to draw has one view; `v` is inert and the note shows. + assert_eq!((v.view, v.views().len()), (View::Source, 1)); + press(&mut v, KeyCode::Char('v')); + assert_eq!(v.view, View::Source); + assert!(v.gfx.is_none() && v.text == ["pic.png: no preview"]); + // A diagram cycles image → text → source → image, and the text art is drawn. + press(&mut v, KeyCode::Tab); + let seen: Vec<_> = (0..4) + .map(|_| { + let at = v.view; + press(&mut v, KeyCode::Char('v')); + at + }) + .collect(); + assert_eq!(seen, [View::Image, View::Text, View::Source, View::Image]); + // Without a graphics protocol the image view is skipped: opens on source, then text. + let mut v = one(None); + assert_eq!( + (v.view, v.views()), + (View::Source, vec![View::Text, View::Source]) + ); + press(&mut v, KeyCode::Char('v')); + assert_eq!(v.view, View::Text); + v.want((80, 20)); + assert!(v.text.iter().any(|l| l.contains("Build")), "{:?}", v.text); } #[test] fn worker_rerasterises_svg_sharper_at_higher_zoom() { - let mut v = ImageViewer::new(mermaid(), "d", Some(Picker::halfblocks())); + let mut v = one(Some(Picker::halfblocks())); let g = v.gfx.as_mut().unwrap(); let wait = |g: &mut Gfx, vp| { - ImageViewer::sync(g, vp); + g.sync(vp); let deadline = Instant::now() + Duration::from_secs(10); while g.zoomed.as_ref().map(|z| z.key) != g.asked { assert!(Instant::now() < deadline, "worker timed out"); std::thread::sleep(Duration::from_millis(10)); - ImageViewer::sync(g, vp); + g.sync(vp); } g.zoomed.as_ref().unwrap().img.width() }; @@ -514,5 +772,10 @@ mod tests { g.set_zoom(2); let big = wait(g, vp); assert!(big > fit, "{big} vs {fit}"); + // Actual size is the natural SVG size, smaller than the enlarged fit. + g.set_zoom(0); + g.actual = true; + let actual = wait(g, vp); + assert!(actual < fit, "{actual} vs {fit}"); } } diff --git a/crates/wiki-reader/src/tui/images.rs b/crates/wiki-reader/src/tui/images.rs index 78d5f97..1c83ac3 100644 --- a/crates/wiki-reader/src/tui/images.rs +++ b/crates/wiki-reader/src/tui/images.rs @@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; use image::imageops::FilterType; -use image::{DynamicImage, ImageReader, Limits, RgbaImage}; +use image::{DynamicImage, RgbaImage}; use ratatui::Frame; use ratatui::layout::Rect; use ratatui::text::Span; @@ -17,10 +17,10 @@ use ratatui_image::picker::cap_parser::QueryStdioOptions; use ratatui_image::picker::{Picker, ProtocolType}; use ratatui_image::protocol::StatefulProtocol; use ratatui_image::{CropOptions, Resize, StatefulImage}; -use wiki_reader_core::images::MAX_IMAGE_PIXELS; use wiki_reader_render::{ DiagramPalette, DiagramRequest, DiagramSize, DiagramSizeCache, DiagramTextReason, ImageSlot, - SlotSource, is_legible, mermaid_to_svg, rasterise_svg, render_mermaid, svg_natural_size, + SlotSource, decode_file, is_legible, is_svg_path, mermaid_to_svg, rasterise_svg, + render_mermaid, svg_natural_size, }; use crate::tui::theme::Theme; @@ -33,8 +33,6 @@ const QUERY_TIMEOUT_ENV: &str = "WIKI_READER_IMAGE_QUERY_TIMEOUT_MS"; /// Prepared pictures kept for scroll-back, in RGBA bytes. Visible pictures are never evicted, so /// the cache can exceed this by at most one screenful. const READY_BYTE_BUDGET: u64 = 64 * 1024 * 1024; -/// Decode allocation cap: four bytes per pixel at the pixel cap, doubled for the decoder's copy. -const MAX_DECODE_ALLOC: u64 = MAX_IMAGE_PIXELS * 4 * 2; // ── Detection ──────────────────────────────────────────────────────────────────────────────── @@ -311,28 +309,6 @@ fn scale_and_pad( DynamicImage::ImageRgba8(canvas) } -/// Decode a bitmap file under the ADR-0017 allocation and pixel caps. -pub(crate) fn decode_file(path: &std::path::Path) -> Result { - let mut reader = ImageReader::open(path) - .map_err(|e| e.to_string())? - .with_guessed_format() - .map_err(|e| e.to_string())?; - let mut limits = Limits::default(); - limits.max_alloc = Some(MAX_DECODE_ALLOC); - reader.limits(limits); - let decoded = reader.decode().map_err(|e| e.to_string())?; - if u64::from(decoded.width()) * u64::from(decoded.height()) > MAX_IMAGE_PIXELS { - return Err("image too large".into()); - } - Ok(decoded) -} - -pub(crate) fn is_svg_path(path: &std::path::Path) -> bool { - path.extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| e.eq_ignore_ascii_case("svg")) -} - /// Decode / rasterise `key` and pad to the slot's pixel size. fn prepare( key: &SlotKey, diff --git a/crates/wiki-reader/src/tui/images_lite.rs b/crates/wiki-reader/src/tui/images_lite.rs new file mode 100644 index 0000000..497ee98 --- /dev/null +++ b/crates/wiki-reader/src/tui/images_lite.rs @@ -0,0 +1,42 @@ +//! Lite build (ADR-0023): the `ImageManager` surface the app calls, with no graphics, decode +//! worker or probe. Every image stays a text placeholder; diagrams stay on the text tier. +#![allow(clippy::unused_self)] // same signatures as the real manager +// ponytail: stub kept method-for-method with `images.rs`; a trait would add a seam for no gain. + +use std::sync::Arc; + +use wiki_reader_render::{DiagramRequest, DiagramSizeCache, ImageSlot}; + +pub struct ImageManager { + sizes: Arc, +} + +impl ImageManager { + pub fn disabled() -> Self { + Self { + sizes: Arc::new(DiagramSizeCache::new()), + } + } + + pub fn cell_px(&self) -> Option<(u16, u16)> { + None + } + + pub fn diagram_sizes(&self) -> Arc { + Arc::clone(&self.sizes) + } + + pub fn queue_diagram_requests(&mut self, _: &[DiagramRequest]) {} + + pub fn retain_for(&mut self, _: &[ImageSlot]) {} + + pub fn poll(&mut self) {} + + pub fn take_diagram_relayout(&mut self) -> bool { + false + } + + pub fn has_pending(&self) -> bool { + false + } +} diff --git a/crates/wiki-reader/src/tui/keymap.rs b/crates/wiki-reader/src/tui/keymap.rs index 8ea2d59..614e1c6 100644 --- a/crates/wiki-reader/src/tui/keymap.rs +++ b/crates/wiki-reader/src/tui/keymap.rs @@ -50,6 +50,8 @@ pub enum BindingScope { Overlay, /// Multi-key chords (display-only in help; special-cased in map). Chord, + /// Table and image / diagram viewers (display-only in help; the modal reads its own keys). + Viewers, } impl BindingScope { @@ -62,6 +64,7 @@ impl BindingScope { Self::Viewer => "View", Self::Chord => "Chords", Self::Overlay => "Search overlay", + Self::Viewers => "Table and image viewers", } } } @@ -677,6 +680,76 @@ pub static BINDINGS: &[Binding] = &[ help: "Page results", matcher: None, }, + Binding { + keys: "↑↓←→ / hjkl", + scope: BindingScope::Viewers, + action: None, + help: "Move the table cell cursor, or pan a picture", + matcher: None, + }, + Binding { + keys: "/", + scope: BindingScope::Viewers, + action: None, + help: "Table: filter rows", + matcher: None, + }, + Binding { + keys: "s", + scope: BindingScope::Viewers, + action: None, + help: "Table: sort by the current column", + matcher: None, + }, + Binding { + keys: "y / Y", + scope: BindingScope::Viewers, + action: None, + help: "Table: copy cell / row", + matcher: None, + }, + Binding { + keys: "+ / -", + scope: BindingScope::Viewers, + action: None, + help: "Picture: zoom in / out", + matcher: None, + }, + Binding { + keys: "0", + scope: BindingScope::Viewers, + action: None, + help: "Picture: fit the window, reset zoom", + matcher: None, + }, + Binding { + keys: "a", + scope: BindingScope::Viewers, + action: None, + help: "Picture: toggle fit / actual size", + matcher: None, + }, + Binding { + keys: "Tab / Shift+Tab", + scope: BindingScope::Viewers, + action: None, + help: "Picture: next / previous image or diagram", + matcher: None, + }, + Binding { + keys: "v", + scope: BindingScope::Viewers, + action: None, + help: "Diagram: cycle image / text / source", + matcher: None, + }, + Binding { + keys: "Esc / q", + scope: BindingScope::Viewers, + action: None, + help: "Close the viewer", + matcher: None, + }, ]; /// True when CONTROL and ALT are absent (SHIFT alone is fine for capitals). @@ -1112,7 +1185,7 @@ mod tests { let focus = match b.scope { BindingScope::Nav => FocusPane::Nav, BindingScope::Viewer | BindingScope::Global => FocusPane::Viewer, - BindingScope::Overlay | BindingScope::Chord => continue, + BindingScope::Overlay | BindingScope::Chord | BindingScope::Viewers => continue, }; let (got, _) = map_with_overrides(key, focus, InputMode::Normal, Chord::None, None); assert_eq!( diff --git a/crates/wiki-reader/src/tui/mod.rs b/crates/wiki-reader/src/tui/mod.rs index aef719a..312d929 100644 --- a/crates/wiki-reader/src/tui/mod.rs +++ b/crates/wiki-reader/src/tui/mod.rs @@ -10,7 +10,12 @@ pub mod focus; pub mod help_ui; pub mod highlight; pub mod hit; +#[cfg(feature = "media")] pub mod image_viewer; +#[cfg(feature = "media")] +pub mod images; +#[cfg(not(feature = "media"))] +#[path = "images_lite.rs"] pub mod images; pub mod keymap; pub mod layout; diff --git a/crates/wiki-reader/src/tui/modal_viewer.rs b/crates/wiki-reader/src/tui/modal_viewer.rs index bebc417..bf1cdcd 100644 --- a/crates/wiki-reader/src/tui/modal_viewer.rs +++ b/crates/wiki-reader/src/tui/modal_viewer.rs @@ -39,6 +39,12 @@ pub trait ModalContent { fn busy(&self) -> bool { false } + /// Body size (columns, rows) the content wants, given the most the window can offer. + /// The shell sizes the panel to it, never beyond `cap`; the default fills the cap. + /// Called before every draw, so content may start background work from here. + fn want(&mut self, cap: (u16, u16)) -> (u16, u16) { + cap + } /// Text on the top border. fn title(&self) -> String; /// Footer line: key hints, or a prompt while [`Self::editing`]. @@ -61,6 +67,27 @@ pub fn handle_key(content: &mut dyn ModalContent, key: KeyEvent) -> ModalEvent { content.key(key) } +/// Narrowest body: room for the key hint's first words and the title. +const MIN_BODY_W: u16 = 40; + +/// The panel for `content` inside `cap`: border (2 cols, 2 rows) and footer (1 row) around the +/// body it asks for, centred, never larger than `cap` and never narrower than [`MIN_BODY_W`]. +fn fit(cap: Rect, content: &mut dyn ModalContent) -> Rect { + let (max_w, max_h) = (cap.width.saturating_sub(2), cap.height.saturating_sub(3)); + if max_w == 0 || max_h == 0 { + return cap; + } + let (w, h) = content.want((max_w, max_h)); + let w = w.max(MIN_BODY_W).min(max_w) + 2; + let h = h.max(1).min(max_h) + 3; + Rect { + x: cap.x + (cap.width - w) / 2, + y: cap.y + (cap.height - h) / 2, + width: w, + height: h, + } +} + /// Draw the panel over `area` and let `content` fill it. Call after every other layer. pub fn draw( frame: &mut Frame<'_>, @@ -71,11 +98,12 @@ pub fn draw( ) { hits.push(area, Hit::ModalDismiss); // Leave the header and status rows visible around a roomy panel; tiny terminals get all. - let rect = if area.width >= 30 && area.height >= 10 { + let cap = if area.width >= 30 && area.height >= 10 { area.inner(Margin::new(2, 1)) } else { area }; + let rect = fit(cap, content); frame.render_widget(Clear, rect); hits.occlude(rect); let title = Line::from(Span::styled( @@ -131,6 +159,39 @@ mod tests { fn draw(&mut self, _: &mut Frame<'_>, _: Rect, _: &Theme) {} } + #[test] + fn panel_is_content_sized_and_capped() { + struct Want((u16, u16)); + impl ModalContent for Want { + fn label(&self) -> &'static str { + "TEST" + } + fn want(&mut self, _: (u16, u16)) -> (u16, u16) { + self.0 + } + fn title(&self) -> String { + String::new() + } + fn hint(&self) -> String { + String::new() + } + fn key(&mut self, _: KeyEvent) -> ModalEvent { + ModalEvent::Stay + } + fn draw(&mut self, _: &mut Frame<'_>, _: Rect, _: &Theme) {} + } + let cap = Rect::new(2, 1, 96, 38); + // Small content: centred, with border + footer around the body (and the width floor). + let r = fit(cap, &mut Want((10, 5))); + assert_eq!((r.width, r.height), (MIN_BODY_W + 2, 5 + 3)); + assert_eq!( + (r.x, r.y), + (2 + (96 - r.width) / 2, 1 + (38 - r.height) / 2) + ); + // Huge content is capped at today's size. + assert_eq!(fit(cap, &mut Want((999, 999))), cap); + } + #[test] fn esc_closes_unless_content_is_editing() { let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); diff --git a/crates/wiki-reader/src/tui/options_ui.rs b/crates/wiki-reader/src/tui/options_ui.rs index b18d4c2..e926d8e 100644 --- a/crates/wiki-reader/src/tui/options_ui.rs +++ b/crates/wiki-reader/src/tui/options_ui.rs @@ -4,9 +4,12 @@ use wiki_reader_core::config::{CopyPathMode, DiagramMode, LabelMode, NavPosition, ThemeName}; /// Image slot heights offered in the "Max image rows" group (config accepts 1–60). +#[cfg_attr(not(feature = "media"), allow(dead_code))] // only the hidden Images group uses it pub const MAX_ROW_STEPS: [u16; 6] = [10, 20, 30, 40, 50, 60]; /// One selectable row. Radio groups pick one value; `ImagesEnabled` is an on/off toggle. +// ponytail: the image variants stay in lite (never built there) so the app match has no cfg arms. +#[cfg_attr(not(feature = "media"), allow(dead_code))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OptionChoice { Theme(ThemeName), @@ -89,11 +92,15 @@ pub fn lines() -> Vec { .map(OptionChoice::Diagrams) .into(), ); - group("Images", vec![OptionChoice::ImagesEnabled]); - group( - "Max image rows", - MAX_ROW_STEPS.map(OptionChoice::ImagesMaxRows).into(), - ); + // Lite build (ADR-0023) has no images, so their options are hidden. + #[cfg(feature = "media")] + { + group("Images", vec![OptionChoice::ImagesEnabled]); + group( + "Max image rows", + MAX_ROW_STEPS.map(OptionChoice::ImagesMaxRows).into(), + ); + } group( "Copy path (y)", [CopyPathMode::Relative, CopyPathMode::Absolute] @@ -186,7 +193,12 @@ mod tests { #[test] fn choices_cover_every_value_once() { let all = choices(); - assert_eq!(all.len(), 3 + 2 + 2 + 4 + 1 + MAX_ROW_STEPS.len() + 2); + let images = if cfg!(feature = "media") { + 1 + MAX_ROW_STEPS.len() + } else { + 0 + }; + assert_eq!(all.len(), 3 + 2 + 2 + 4 + images + 2); for (i, a) in all.iter().enumerate() { assert!(!all[i + 1..].contains(a), "duplicate {a:?}"); } diff --git a/crates/wiki-reader/src/tui/rendered_doc.rs b/crates/wiki-reader/src/tui/rendered_doc.rs index 555b248..7c0e372 100644 --- a/crates/wiki-reader/src/tui/rendered_doc.rs +++ b/crates/wiki-reader/src/tui/rendered_doc.rs @@ -75,6 +75,12 @@ impl RenderedViewerDoc { &self.inner.image_slots } + /// Every block image and Mermaid fence, document order (the viewer carousel). + #[must_use] + pub fn media(&self) -> &[wiki_reader_render::MediaOccurrence] { + &self.inner.media + } + /// Mermaid fences waiting on an off-thread size measure. #[must_use] pub fn diagram_requests(&self) -> &[wiki_reader_render::DiagramRequest] { diff --git a/crates/wiki-reader/src/tui/table_viewer.rs b/crates/wiki-reader/src/tui/table_viewer.rs index 1e0a605..4371c88 100644 --- a/crates/wiki-reader/src/tui/table_viewer.rs +++ b/crates/wiki-reader/src/tui/table_viewer.rs @@ -1,7 +1,9 @@ //! Table viewer (P3-14): a [`ModalContent`] over one [`DocTable`]. //! //! Two-axis scroll with a fixed header row and first column, row filter, column sort, and -//! cell / row copy (tab-separated, like the inline table copy from P2-R38). +//! cell / row copy (tab-separated, like the inline table copy from P2-R38). Cells keep their +//! inline styling (bold, link colour, code) from [`DocCell`]; filter, sort and copy use the +//! plain text. use std::cmp::Ordering; @@ -11,10 +13,9 @@ use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; -use wiki_reader_render::DocTable; +use wiki_reader_render::{DocCell, DocTable, StyleKind}; use super::modal_viewer::{ModalContent, ModalEvent}; -use super::regions::footer::ellipsis; use super::text_col::line_width; use super::theme::Theme; @@ -22,11 +23,15 @@ use super::theme::Theme; // ponytail: no in-cell wrap; upgrade: wrap the cursor row or show the cell in the footer. const MAX_COL_W: usize = 40; const SEP: &str = " │ "; +/// Display columns of [`SEP`] (its `len()` is bytes). +const SEP_W: usize = 3; const HINT: &str = "↑↓←→ move / filter s sort y cell Y row Esc close"; /// Open table viewer state. pub struct TableViewer { table: DocTable, + /// Plain text of the body cells, for width, filter, sort and copy. + text: Vec>, /// Body row indexes after filter and sort. view: Vec, filter: String, @@ -47,11 +52,14 @@ pub struct TableViewer { impl TableViewer { #[must_use] pub fn new(table: DocTable) -> Self { - let widths = (0..table.header.len()) + let plain = |r: &[DocCell]| r.iter().map(DocCell::text).collect::>(); + let head = plain(&table.header); + let text: Vec> = table.rows.iter().map(|r| plain(r)).collect(); + let widths = (0..head.len()) .map(|k| { - let cells = table.rows.iter().map(|r| show(&r[k])); + let cells = text.iter().map(|r| show(&r[k])); let w = cells - .chain(std::iter::once(show(&table.header[k]))) + .chain(std::iter::once(show(&head[k]))) .map(|c| usize::from(line_width(&c))) .max() .unwrap_or(1); @@ -70,6 +78,7 @@ impl TableViewer { left: 1, widths, page: 10, + text, table, }; v.refresh(); @@ -79,16 +88,16 @@ impl TableViewer { /// Re-run filter and sort over the body rows. fn refresh(&mut self) { let needle = self.filter.to_lowercase(); - self.view = (0..self.table.rows.len()) + self.view = (0..self.text.len()) .filter(|&i| { needle.is_empty() - || self.table.rows[i] + || self.text[i] .iter() .any(|c| c.to_lowercase().contains(&needle)) }) .collect(); if let Some((c, desc)) = self.sort { - let rows = &self.table.rows; + let rows = &self.text; // Stable, so equal keys keep document order. self.view.sort_by(|&a, &b| { let o = cmp_cells(&rows[a][c], &rows[b][c]); @@ -99,7 +108,7 @@ impl TableViewer { } fn current_row(&self) -> Option<&Vec> { - self.view.get(self.row).map(|&i| &self.table.rows[i]) + self.view.get(self.row).map(|&i| &self.text[i]) } fn move_row(&mut self, delta: isize) { @@ -114,13 +123,13 @@ impl TableViewer { /// Last column fully visible when scrolled to `self.left` in `avail` cells. fn last_full(&self, avail: usize) -> usize { - let mut used = self.widths[0] + SEP.len(); + let mut used = self.widths[0] + SEP_W; let mut last = 0; for k in self.left..self.widths.len() { if used + self.widths[k] > avail && k > self.left { break; } - used += self.widths[k] + 3; + used += self.widths[k] + SEP_W; last = k; } last @@ -140,15 +149,17 @@ impl TableViewer { } } - fn cells_line<'a>( + /// One table line from `cells`, left to right; column 0 stays, then from `self.left`. + /// `base` is the row style; `arrow` marks the sorted column (header only). + fn cells_line( &self, - cells: &[String], + cells: &[DocCell], avail: usize, - style: Style, + base: Style, cursor_row: bool, theme: &Theme, arrow: Option, - ) -> Line<'a> { + ) -> Line<'static> { let mut spans = Vec::new(); let mut used = 0; let ncols = self.widths.len(); @@ -157,32 +168,96 @@ impl TableViewer { if used >= avail { break; } - let mut text = show(&cells[k]); - if arrow.is_some() && self.sort.is_some_and(|(c, _)| c == k) { - text = format!("{text} {}", arrow.unwrap_or(' ')); - } - let text = ellipsis(&text, self.widths[k]); - let pad = self.widths[k].saturating_sub(usize::from(line_width(&text))); - let mut cell_style = style; + let arrow = arrow.filter(|_| self.sort.is_some_and(|(c, _)| c == k)); if cursor_row && k == self.col { - cell_style = Style::default().fg(theme.on_peach).bg(theme.peach); + let cursor = Style::default().fg(theme.on_peach).bg(theme.peach); + spans.extend(cell_spans(&cells[k], self.widths[k], arrow, cursor, None)); + } else { + spans.extend(cell_spans( + &cells[k], + self.widths[k], + arrow, + base, + Some(theme), + )); } - spans.push(Span::styled( - format!("{text}{}", " ".repeat(pad)), - cell_style, - )); spans.push(Span::styled(SEP, theme.muted())); - used += self.widths[k] + SEP.len(); + used += self.widths[k] + SEP_W; } Line::from(spans) } } +/// One cell as spans of exactly `w` columns: runs cut with `…` when too long, the sort `arrow` +/// after them when it fits, then padding. `theme` is `None` to paint the whole cell in `base`. +fn cell_spans( + cell: &DocCell, + w: usize, + arrow: Option, + base: Style, + theme: Option<&Theme>, +) -> Vec> { + let text_w: usize = cell + .spans + .iter() + .map(|s| usize::from(line_width(&s.text))) + .sum(); + let arrow = arrow.filter(|_| text_w + 2 <= w); + let cut = text_w + if arrow.is_some() { 2 } else { 0 } > w; + let room = if cut { w.saturating_sub(1) } else { w }; + // ponytail: every link takes the internal link colour; upgrade: carry `LinkClass` on the span. + let mut out = Vec::new(); + let mut used = 0; + 'runs: for run in &cell.spans { + let style = match (theme, run.kind) { + (Some(t), k) + if !matches!( + k, + StyleKind::Plain | StyleKind::Table | StyleKind::TableHeader + ) => + { + base.patch(t.style_kind(k)) + } + _ => base, + }; + let mut piece = String::new(); + for ch in show(&run.text).chars() { + let cw = usize::from(line_width(ch.encode_utf8(&mut [0; 4]))); + if used + cw > room { + out.push(Span::styled(piece, style)); + break 'runs; + } + used += cw; + piece.push(ch); + } + out.push(Span::styled(piece, style)); + } + if cut { + out.push(Span::styled("…", base)); + used += 1; + } + if let Some(a) = arrow { + out.push(Span::styled(format!(" {a}"), base)); + used += 2; + } + out.push(Span::styled(" ".repeat(w.saturating_sub(used)), base)); + out +} + impl ModalContent for TableViewer { fn label(&self) -> &'static str { "TABLE" } + fn want(&mut self, _: (u16, u16)) -> (u16, u16) { + // All columns and all body rows (not the filtered count, so the panel holds still while + // typing a filter); one more row for the header, and for "no rows match". + let w = self.widths.iter().map(|w| w + SEP_W).sum::(); + let h = 1 + self.text.len().max(1); + let to_u16 = |n: usize| u16::try_from(n).unwrap_or(u16::MAX); + (to_u16(w), to_u16(h)) + } + fn title(&self) -> String { format!( "Table (line {}) · row {}/{} · col {}/{}", @@ -326,8 +401,17 @@ fn cmp_cells(a: &str, b: &str) -> Ordering { mod tests { use super::*; + fn cell(s: &str) -> DocCell { + DocCell { + spans: vec![wiki_reader_render::StyledSpan { + text: s.to_owned(), + kind: StyleKind::Plain, + }], + } + } + fn viewer() -> TableViewer { - let row = |a: &str, b: &str| vec![a.to_owned(), b.to_owned()]; + let row = |a: &str, b: &str| vec![cell(a), cell(b)]; TableViewer::new(DocTable { source_line: 1, line: 0, @@ -346,10 +430,7 @@ mod tests { } fn names(v: &TableViewer) -> Vec<&str> { - v.view - .iter() - .map(|&i| v.table.rows[i][0].as_str()) - .collect() + v.view.iter().map(|&i| v.text[i][0].as_str()).collect() } #[test] @@ -384,11 +465,44 @@ mod tests { assert_eq!(press(&mut v, "Y"), ModalEvent::Copy("alice\t9".into())); } + #[test] + fn cells_keep_bold_header_and_link_colour_and_the_panel_fits_the_grid() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let link = DocCell { + spans: vec![wiki_reader_render::StyledSpan { + text: "docs".into(), + kind: StyleKind::Link, + }], + }; + let mut v = TableViewer::new(DocTable { + source_line: 1, + line: 0, + height: 4, + header: vec![cell("name"), cell("ref")], + rows: vec![vec![cell("a"), link]], + }); + let theme = Theme::default(); + let mut term = Terminal::new(TestBackend::new(30, 3)).unwrap(); + term.draw(|f| v.draw(f, f.area(), &theme)).unwrap(); + let buf = term.backend().buffer(); + assert!(buf[(0, 0)].modifier.contains(Modifier::BOLD), "bold header"); + let row = (0..30).map(|x| buf[(x, 1)].symbol()).collect::(); + let x = u16::try_from(row.find("docs").unwrap()).unwrap(); + assert_eq!(buf[(x, 1)].fg, theme.link, "link colour in a body cell"); + // Content-sized: both columns plus separators (widths include the 2-col sort arrow room). + assert_eq!(v.want((90, 30)), (6 + 3 + 6 + 3, 2)); + } + #[test] fn scrolling_right_keeps_header_and_first_column() { use ratatui::Terminal; use ratatui::backend::TestBackend; - let cells = |p: &str| (0..8).map(|k| format!("{p}-col{k}")).collect::>(); + let cells = |p: &str| { + (0..8) + .map(|k| cell(&format!("{p}-col{k}"))) + .collect::>() + }; let mut v = TableViewer::new(DocTable { source_line: 1, line: 0, diff --git a/scripts/check.sh b/scripts/check.sh index 98395c7..fc51639 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -1,22 +1,50 @@ #!/usr/bin/env bash +# Usage: scripts/check.sh [all|default|lite] (no args = all) +# default fmt, clippy, tests, link-check, rumdl, ADR-0006 tree (default features) +# lite clippy, tests, and the no-image-deps tree check with --no-default-features (ADR-0023) set -euo pipefail cd "$(dirname "$0")/.." -command -v rumdl >/dev/null || { - echo "rumdl not found; install it with: uv tool install rumdl" >&2 - exit 1 -} +mode=${1:-all} +case $mode in +all | default | lite) ;; +*) + echo "usage: $0 [all|default|lite]" >&2 + exit 2 + ;; +esac -cargo fmt --check -cargo clippy --locked --all-targets -- -D warnings -cargo test --locked -cargo run --locked --quiet -p wiki-reader-tools --bin link-check -rumdl fmt --check . -rumdl check . +# Crates whose behaviour changes with the `media` feature. +lite_pkgs=(-p wiki-reader -p wiki-reader-render -p wiki-reader-media) -tree=$(cargo tree -p wiki-reader-core -e normal) -echo "$tree" -if echo "$tree" | grep -E '(^| )(ratatui|crossterm)( |$)'; then - echo "wiki-reader-core must not depend on ratatui or crossterm (ADR-0006)" >&2 - exit 1 +if [[ $mode != lite ]]; then + command -v rumdl >/dev/null || { + echo "rumdl not found; install it with: uv tool install rumdl" >&2 + exit 1 + } + + cargo fmt --check + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked + cargo run --locked --quiet -p wiki-reader-tools --bin link-check + rumdl fmt --check . + rumdl check . + + tree=$(cargo tree -p wiki-reader-core -e normal) + echo "$tree" + if echo "$tree" | grep -E '(^| )(ratatui|crossterm)( |$)'; then + echo "wiki-reader-core must not depend on ratatui or crossterm (ADR-0006)" >&2 + exit 1 + fi +fi + +if [[ $mode != default ]]; then + cargo clippy --locked --all-targets --no-default-features "${lite_pkgs[@]}" -- -D warnings + cargo test --locked --no-default-features "${lite_pkgs[@]}" + + tree=$(cargo tree --locked --no-default-features -p wiki-reader -e normal) + if echo "$tree" | grep -E '(^|[^A-Za-z0-9_-])(image|resvg|mermaid-rs-renderer|ratatui-image) v'; then + echo "lite build must not pull image, resvg, mermaid-rs-renderer or ratatui-image (ADR-0023)" >&2 + exit 1 + fi fi diff --git a/wiki/architecture/benchmarks.md b/wiki/architecture/benchmarks.md index a845341..0fd192c 100644 --- a/wiki/architecture/benchmarks.md +++ b/wiki/architecture/benchmarks.md @@ -14,18 +14,47 @@ Installed binary, memory, start-up and crate count were measured on 2026-10-02 ( | | wiki-reader | wiki-reader lite (P3-19) | markdown-reader | treemd 0.9.1 | glow 3.0.0 | |---|---|---|---|---|---| -| Installed binary | 16.7 MiB | — | 13.1 MiB | not measured | not measured | -| Release download (macOS arm64 tarball) | 6.7 MiB (v0.1.1) | — | 6.0 MiB | 4.4 MiB | 6.1 MiB | -| Peak memory at start | 14.0 MiB | — | 12.3 MiB | not measured | not measured | -| Time to first output (warm) | ≈ 35 ms | — | ≈ 20 ms | not measured | not measured | -| Crates built (unique, normal deps, incl. workspace) | 229 | — | not measured | not measured | Go, n/a | +| Installed binary | 16.7 MiB (16.6 MiB re-measured, see below) | 8.4 MiB | 13.1 MiB | not measured | not measured | +| Release download (macOS arm64 tarball) | 6.7 MiB (v0.1.1) | no lite download ([ADR-0023](../decisions/0023-lite-build-is-a-cargo-feature.md)) | 6.0 MiB | 4.4 MiB | 6.1 MiB | +| Peak memory at start | 14.0 MiB | not measured | 12.3 MiB | not measured | not measured | +| Time to first output (warm) | ≈ 35 ms | not measured | ≈ 20 ms | not measured | not measured | +| Crates built (unique, normal deps, incl. workspace) | 229 (230 now, with `wiki-reader-media`) | 142 | not measured | not measured | Go, n/a | What this says: wiki-reader's binary is about 27 % larger than markdown-reader's on disk (16.7 against 13.1 MiB) and uses about 14 % more memory at start (14.0 against 12.3 MiB). Both start in well under a tenth of a second once warm; the first run after a fresh build took 0.57 s for wiki-reader, so treat start-up as indicative only. The release downloads are about 12 % apart (6.7 against 6.0 MiB). -Where the extra size comes from: the Mermaid image tier (`mermaid-rs-renderer`, `resvg`, an embedded font) was +6.7 MiB in P3-12c, the largest single step. Everything since alpha.5 (themes, nav position, the options window with `toml_edit`, both viewers) added about 0.3 MiB in total. Subtracting the image step gives roughly 10 MiB, which is below markdown-reader's 13.1 MiB. That figure is an estimate, not a build: a build without the image tier is [P3-19](../roadmap/phase-3-alpha.md) (the lite column above). markdown-reader also draws Mermaid as terminal images (Kitty, Sixel, iTerm2), so the two binaries carry comparable image stacks and the comparison is like for like on that feature. +Where the extra size comes from: the Mermaid image tier (`mermaid-rs-renderer`, `resvg`, an embedded font) was +6.7 MiB in P3-12c, the largest single step. Everything since alpha.5 (themes, nav position, the options window with `toml_edit`, both viewers) added about 0.3 MiB in total. Subtracting the image step gives roughly 10 MiB, which is below markdown-reader's 13.1 MiB. The lite column is now a build, not an estimate: [P3-19](../roadmap/phase-3-alpha.md) measured 8.4 MiB, below markdown-reader (see the next section). markdown-reader also draws Mermaid as terminal images (Kitty, Sixel, iTerm2), so the two binaries carry comparable image stacks and the comparison is like for like on that feature. Not measured: treemd and glow memory and start-up (not installed; only the published archive sizes were read), md-tui and Frogmouth (no comparable download; Frogmouth is Python), and memory while viewing a large Mermaid diagram or many images. Treat the table as one machine and one collection, not a benchmark. +## Lite build (P3-19) + +Measured 2026-10-03 on macOS arm64 (Apple M2 Max, 12 cores, rustc 1.98.1), default `release` profile, the same checkout for both builds: base commit `0517adc` plus the uncommitted P3-19 working tree (the commit will differ). The lite build is `--no-default-features`, which drops `image`, `resvg`, `mermaid-rs-renderer` and `ratatui-image` (asserted by `scripts/check.sh` and CI). + +Method, per variant, each in its own empty `CARGO_TARGET_DIR`, nothing else running: + +```bash +cargo build --locked --release -p wiki-reader # full +cargo build --locked --release -p wiki-reader --no-default-features # lite +``` + +- **Clean:** the first build into an empty target directory (all dependencies compiled), one run each. +- **Warm:** `cargo clean --release -p wiki-reader` then the same build, dependencies already cached, three runs each. A second row also cleans `wiki-reader-render`, `wiki-reader-media` and `wiki-reader-core` first. (`cargo clean -p` without `--release` removes the dev profile only and times a no-op.) +- **Size:** `ls -l` on `target/release/wiki-reader`, not stripped. + +| | full | lite | lite vs full | +|---|---|---|---| +| Binary | 17,417,296 B (16.6 MiB) | 8,856,384 B (8.4 MiB) | −49 % | +| Clean release build | 50.7 s | 19.9 s | −61 % | +| Warm, `wiki-reader` only | 2.9–3.2 s (≈ 3.0) | 2.6–2.7 s (≈ 2.6) | −13 % | +| Warm, all four workspace crates | 6.4–6.7 s (≈ 6.6) | 5.4–5.7 s (≈ 5.5) | −17 % | +| Crates in the normal dependency tree | 230 | 142 | −88 | + +The earlier rough estimate for a build without the image tier was about 10 MiB; the measured lite binary is 8.4 MiB, below markdown-reader's 13.1 MiB. The clean-build win is large because the four dependencies and their trees are most of the compile time; warm builds barely differ because only workspace code is rebuilt. The lite binary contains no `resvg`, `mermaid_rs_renderer` or `ratatui_image` symbols or strings. + +**Boundary.** A plain `cfg` boundary was enough, no trait: `wiki-reader-media` has a `raster` feature (decode, SVG and Mermaid raster, the embedded font), `wiki-reader-render` forwards it as `media` and falls back to placeholders and the text tier when it is off, and the `wiki-reader` TUI gates its `ratatui-image` probe, decode worker, drawing and viewer on `media`. The spike found no place where a second implementation behind a trait would be needed. + +Caveats: a single clean run per variant, one machine, and an uncommitted tree. + ## Related - [Prior art & libraries](prior-art-and-libs.md) diff --git a/wiki/architecture/overview.md b/wiki/architecture/overview.md index b6b89d3..94c43be 100644 --- a/wiki/architecture/overview.md +++ b/wiki/architecture/overview.md @@ -38,7 +38,8 @@ wiki-reader/ │ │ ├── nav/ # link resolution, NavTree build (titles, folding, order), prev/next │ │ ├── watch/ # notify-debouncer-mini → markdown dirty flag │ │ └── config/ -│ ├── wiki-reader-render/ # markdown → RenderedDoc (lines + link spans + source map) +│ ├── wiki-reader-render/ # markdown → RenderedDoc (lines + link spans + source map + media inventory) +│ ├── wiki-reader-media/ # image decode, Mermaid/SVG raster (feature `raster`; off in lite, ADR-0023) │ └── wiki-reader/ # binary: TUI app │ └── tui/ # app state, navigator, layout, regions, hit map, keymap, theme ├── wiki/ # this collection (dogfood) diff --git a/wiki/decisions/0023-lite-build-is-a-cargo-feature.md b/wiki/decisions/0023-lite-build-is-a-cargo-feature.md new file mode 100644 index 0000000..9bd3cf1 --- /dev/null +++ b/wiki/decisions/0023-lite-build-is-a-cargo-feature.md @@ -0,0 +1,37 @@ +--- +id: WR-ADR-0023 +title: "ADR-0023: The lite build is a cargo feature, not a separate artifact" +summary: Images, SVG and Mermaid rasterisation sit behind a default-on `media` cargo feature; lite is `cargo install --no-default-features`, with no `-lite` release tarball. +status: proposed +updated: 2026-10-03 +related: [0004-diagram-rendering, 0006-reader-first, 0017-static-local-images-only] +--- + +# ADR-0023: The lite build is a cargo feature, not a separate artifact + +**Status:** Proposed · **Date:** 2026-10-03 + +## Context + +The image stack (`image`, `resvg`, `mermaid-rs-renderer`, `ratatui-image`, an embedded font) is the largest single step in binary size and build time; see [benchmarks](../architecture/benchmarks.md). Some readers never use a graphics terminal and only want text, source and placeholders. The text tier (`mermaid-text`) already works without any of it ([ADR-0004](0004-diagram-rendering.md)). + +## Decision + +- **One feature, default on.** `wiki-reader`, `wiki-reader-render` and `wiki-reader-media` each have a `media` (or `raster`) feature. The binary's `media` forwards to `wiki-reader-render/media` and enables `image` and `ratatui-image`. Lite is `--no-default-features`. +- **Distribution.** Lite is `cargo install --locked --no-default-features --git … wiki-reader`. There is **no `-lite` release tarball**. Revisit only if someone asks for a prebuilt lite binary. +- **A `cfg` boundary, not a trait.** The seam is small: render asks the media crate for pixel sizes and the TUI owns the decode worker, so a plain `cfg` split is enough. Terminal-protocol probing and drawing stay in the TUI. +- **Crates.** `wiki-reader-media` holds the Mermaid and SVG rasteriser, bitmap decode and the embedded font behind its `raster` feature. The palette and fit maths are always built, because render's types name them. Media never depends on render. +- **Lite behaviour.** Every image is a text placeholder. `diagrams = "image"` falls back to the text tier with the visible reason `lite build: no image tier`. The Images and Max image rows option groups are hidden. The image viewer is absent. +- **Guard.** `scripts/check.sh` and CI run clippy and tests for both builds and fail if `cargo tree --no-default-features -p wiki-reader -e normal` lists `image`, `resvg`, `mermaid-rs-renderer` or `ratatui-image`. + +## Consequences + +- Default behaviour and snapshots are unchanged; the lite build has its own, smaller set of tests. +- CI cost goes up by one clippy and one test run. +- `RenderedDoc` carries a tier-independent media inventory (images and Mermaid fences, with occurrence ids that survive relayout) so later viewer work does not lose text-tier diagrams or placeholders. +- Cargo features are additive: building the whole workspace (`cargo build --workspace`) unifies to the default. Only a `-p wiki-reader --no-default-features` build is lite. + +## Related + +- [ADR-0004](0004-diagram-rendering.md) +- [ADR-0017](0017-static-local-images-only.md) diff --git a/wiki/decisions/README.md b/wiki/decisions/README.md index ed7a299..97090d4 100644 --- a/wiki/decisions/README.md +++ b/wiki/decisions/README.md @@ -28,6 +28,7 @@ Architecture decision records. Accepted ADRs are immutable — supersede, don't | [0020](0020-nav-label-modes.md) | Titles or actual filesystem names | accepted (chrome label scope superseded by [0021](0021-side-nav-only-label-mode.md)) | | [0021](0021-side-nav-only-label-mode.md) | Nav label mode applies only to the side nav | accepted | | [0022](0022-herdr-launcher-and-page-publishing.md) | herdr launcher opens an ordinary pane; page publishing is on by default | accepted | +| [0023](0023-lite-build-is-a-cargo-feature.md) | The lite build is a cargo feature, not a separate artifact | proposed | ## Related diff --git a/wiki/guides/configuration.md b/wiki/guides/configuration.md index a3ad5c4..3dacb96 100644 --- a/wiki/guides/configuration.md +++ b/wiki/guides/configuration.md @@ -29,7 +29,7 @@ The collection file is untrusted, since it comes with the content you are readin | `nav.position` | `"left"`, `"right"` | `"left"` | Side nav on the left or right edge. Nav width stays session-only. | | `nav.labels` | `"title"`, `"filename"` | `"title"` | Side-nav page titles or actual filenames including extensions; folders keep on-disk names. Header/footer labels stay title-based. | | `diagrams` | `"auto"`, `"image"`, `"text"`, `"source"` | `"auto"` | Mermaid tier; see [ADR-0004](../decisions/0004-diagram-rendering.md). tmux always uses text. | -| `images.enabled` | `true`, `false` | `true` | `false` skips the terminal graphics probe and shows text placeholders. | +| `images.enabled` | `true`, `false` | `true` | `false` skips the terminal graphics probe and shows text placeholders. No effect in the [lite build](#lite-build). | | `images.max_slot_rows` | integer `1`–`60` | `30` | Tallest picture or diagram slot, in rows. | | `herdr.publish` | `true`, `false` | `true` | Inside a herdr pane, show the page being read in herdr's sidebar (title and a `page` token, renewed while open and cleared on exit). Display-only; never reports agent state. Plugin popups have no pane of their own and never publish. See [ADR-0022](../decisions/0022-herdr-launcher-and-page-publishing.md). | | `copy.path` | `"relative"`, `"absolute"` | `"relative"` | What `y` ("Copy file path" in Help) copies: with the nav focused, the selected row's file or folder path; with the viewer focused, the open page's path. Relative to the collection root, or absolute. Also a row in the options window. | @@ -55,6 +55,10 @@ publish = false Legacy `nav.labels = "title+filename"` is read as `"title"` with one warning per config load, even if several config layers use it. Loading does not rewrite files; choose a supported value in Options or edit the config to stop the warning. See [ADR-0020](../decisions/0020-nav-label-modes.md). +## Lite build + +`cargo install --locked --no-default-features --git https://github.com/luckgrid/wiki-reader wiki-reader` builds without the image stack ([ADR-0023](../decisions/0023-lite-build-is-a-cargo-feature.md)). `images.enabled` and `images.max_slot_rows` are still read but have no effect: every image is a text placeholder, no graphics probe runs, and the options window hides the Images and Max image rows groups. `diagrams = "image"` renders the text tier with the header `lite build: no image tier`; `auto` and `text` look the same as on a terminal without graphics. Everything else is unchanged. + ## Options window `,` or `c` (or the layout footer ⚙) opens the options window, and the same keys, or `Esc`, close it. Each setting is a group of radio rows: `↑` / `↓` move, `Enter` applies. It edits `theme`, `nav.position`, `nav.labels`, `diagrams`, `images.enabled`, `images.max_slot_rows` and `copy.path`. Each change applies at once and is saved to one key of the file, per [ADR-0018](../decisions/0018-config-write-path.md): diff --git a/wiki/guides/development.md b/wiki/guides/development.md index 66e674b..3d6de82 100644 --- a/wiki/guides/development.md +++ b/wiki/guides/development.md @@ -26,7 +26,7 @@ Supported path today — install the binary from git: cargo install --locked --git https://github.com/luckgrid/wiki-reader wiki-reader ``` -Tagged releases attach platform tarballs (macOS arm64 / x86_64, Linux x86_64) plus SHA256 checksums. To upgrade, replace or roll back an installed version, see [Releasing and upgrading](releasing.md), which also covers how maintainers cut, dry-run, verify and fix a release. The tag name must be `v` plus the workspace `Cargo.toml` version (for example `v0.1.0-alpha.5.1`). +Tagged releases attach platform tarballs (macOS arm64 / x86_64, Linux x86_64) plus SHA256 checksums. To upgrade, replace or roll back an installed version, see [Releasing and upgrading](releasing.md), which also covers how maintainers cut, dry-run, verify and fix a release. The tag name must be `v` plus the workspace `Cargo.toml` version (for example `v0.1.N`). crates.io metadata is prepared on the publishable crates; do not `cargo publish` until that is an intentional follow-up. `wiki-reader-tools` stays `publish = false`. @@ -35,7 +35,8 @@ crates.io metadata is prepared on the publishable crates; do not `cargo publish` Local mirror of CI (run before every push): ```bash -./scripts/check.sh +./scripts/check.sh # default + lite +./scripts/check.sh default # or: lite ``` Optional: install the repo pre-push hook so those checks run automatically: @@ -50,10 +51,13 @@ Same steps individually: cargo fmt --check cargo clippy --locked --all-targets -- -D warnings cargo test --locked +cargo clippy --locked --all-targets --no-default-features -p wiki-reader -p wiki-reader-render -p wiki-reader-media -- -D warnings # lite +cargo test --locked --no-default-features -p wiki-reader -p wiki-reader-render -p wiki-reader-media cargo run --locked --quiet -p wiki-reader-tools --bin link-check rumdl fmt --check . rumdl check . cargo tree -p wiki-reader-core -e normal # must not list ratatui or crossterm (ADR-0006) +cargo tree --locked --no-default-features -p wiki-reader -e normal # must not list image, resvg, mermaid-rs-renderer, ratatui-image (ADR-0023) ``` Quickstart: @@ -75,7 +79,8 @@ cargo run -p wiki-reader --example keylog | Crate | Responsibility | |-------|----------------| | `wiki-reader-core` | Provider, parse, index, nav, watch, config. **No** `ratatui` / `crossterm`. | -| `wiki-reader-render` | Markdown → `RenderedDoc` (lines, link spans, source map). | +| `wiki-reader-render` | Markdown → `RenderedDoc` (lines, link spans, source map, media inventory). | +| `wiki-reader-media` | Image decode and Mermaid/SVG rasterisation behind `raster`; never depends on render. | | `wiki-reader` | Binary TUI: app state, layout, hit map, keymap. | See [architecture overview](../architecture/overview.md) and [ADR-0006](../decisions/0006-reader-first.md). diff --git a/wiki/product/ui-spec.md b/wiki/product/ui-spec.md index c62940e..ab89a06 100644 --- a/wiki/product/ui-spec.md +++ b/wiki/product/ui-spec.md @@ -30,6 +30,7 @@ Layout, side nav, header/footers, focus and cursor model, keyboard and mouse beh │ ││ │ │ ││ Start at Architecture Overview, follow Token │ │ ││ Projection, then return via back when history │ +│ ││ exists. Enter a picture for Tab carousel / a fit. │ │ │├────────────┬─────────────┬─────────────────────────┤ │ ││ ‹ Overview │ │ Architecture Overview › │ └────────────────────────┘└────────────┴─────────────┴─────────────────────────┘ @@ -64,7 +65,7 @@ P3-18 places ⚙ at the full-width layout footer's far right, below both panes, The layout footer reserves space for `?` / ⚙ before laying out status fields and messages. The View footer independently divides its width between prev/next. Labels truncate with `…`; unavailable links remain absent. Controls must not overlap, including at 40/60 columns and with Nav on either side. Collision policy: reserve the active tab first, ellipsizing its filename to the available label width (maximum 18 columns), then add other tabs in order only if each whole cell and its divider fits. Hide all tabs when fewer than seven columns are available. Prev and next cells are sized to their labels, each capped so that the two cells and their dividers fit in the interior; ellipsize labels and hide a cell if its cap cannot fit padding, an arrow and one label column. Hits occupy only the label row of each bar, never the border or seam rows. Short-terminal policy: retain the one-row layout header/footer whenever height permits; give the View's top bar its three rows first, then its bottom bar three more. Draw a bar only when all of its rows fit; an edge without a bar keeps just the pane's border row, with no controls or hits. Article rows are the remainder after the edges (six chrome rows when both bars fit). P3-01 sticky headings are explicitly deferred by operator decision; no additional sticky row is reserved. -The ASCII diagram documents the bar layout; screenshots will be refreshed in P3-23 after the UI settles. +The ASCII diagram documents the bar layout. Screenshot PNGs are refreshed in P3-23 once the operator retakes them in Ghostty (Screen Recording permission); the ASCII and carousel keys ship with the docs refresh even when the PNGs wait. ## Header (full width, 1 padded row) @@ -136,11 +137,16 @@ Connected `‹ Prev title` and `Next title ›` controls sit in their own cells ## Modal viewers -Table and image/diagram viewers share one modal shell (`Esc` dismisses, wheel scrolls, click outside dismisses, drawn after a `Clear` so they sit above Kitty pictures). Entry points are block actions in the View Tab cycle plus `Enter` on the focused table or image slot. +Table and image/diagram viewers share one modal shell (`Esc` dismisses, wheel scrolls, click outside dismisses, drawn after a `Clear` so they sit above Kitty pictures). Entry points are block actions in the View Tab cycle plus `Enter` on the focused table or image slot. The panel is **content-sized**: it wraps its content (at least 40 body columns), is centred, and never exceeds the full-size panel (the area inset by two columns and one row; tiny terminals get all of it). A wide table or picture therefore keeps today's width. The modal's keys are listed in Help under *Table and image viewers*, and the hint bar along the panel's bottom edge shows the ones that apply. -**Table viewer.** Opens from the expand-table block action or `Enter` on any row of a table. Keys: arrows / `hjkl` move the cell cursor; `PgUp` / `PgDn` page; `g` / `G` top / bottom; `/` filter rows (`Enter` keep, `Esc` clear); `s` cycles sort asc / desc / off on the current column; `y` copies the cell; `Y` copies the row (tab-separated). The header row and first column stay fixed while the body scrolls. +**Table viewer.** Opens from the expand-table block action or `Enter` on any row of a table. Keys: arrows / `hjkl` move the cell cursor; `PgUp` / `PgDn` page; `g` / `G` top / bottom; `/` filter rows (`Enter` keep, `Esc` clear); `s` cycles sort asc / desc / off on the current column; `y` copies the cell; `Y` copies the row (tab-separated). The header row and first column stay fixed while the body scrolls. Cells keep their inline styling (bold, link colour, code) and the header stays bold; filter, sort and copy use the plain text. The panel is as wide as all columns and as tall as all rows, up to the cap. -**Image and diagram viewer.** Opens from the expand-diagram block action (`Enter` on the Mermaid fence line) or `Enter` on any row of a picture slot. Keys: arrows / `hjkl` pan by an eighth of the window; `PgUp` / `PgDn` and `g` / `G` as in the table viewer; `+` / `-` zoom (100–800 % of fit); `0` resets to fit. The wheel pans vertically. SVG and Mermaid re-rasterise at the zoomed size; raster files scale from the decoded bitmap. Text-tier Mermaid opens as a scrollable source view. Planned: fit / actual-size toggle and content-sized windows (P3-20); Tab / Shift+Tab carousel (P3-21); diagram image / text / source cycle (P3-22). +**Image and diagram viewer.** Opens from the expand-diagram block action (`Enter` on the Mermaid fence line) or `Enter` on any row of a picture slot. Keys: arrows / `hjkl` pan by an eighth of the window; `PgUp` / `PgDn` and `g` / `G` as in the table viewer; `+` / `-` zoom (100–800 % of the base size); `0` fits the window and resets zoom; `a` toggles **fit** and **actual size**; `Tab` / `Shift+Tab` step to the next / previous image or diagram of the page, wrapping; `v` cycles a diagram's views. The wheel pans vertically. SVG and Mermaid re-rasterise at the zoomed size; raster files scale from the decoded bitmap. + +- **Sizing.** *Fit* scales the picture to the largest panel, enlarging small pictures up to 2× natural size, so an opened picture is always larger than its inline slot (which never exceeds natural size). *Actual size* is natural pixels (one picture pixel per terminal pixel; SVG and Mermaid at their natural SVG size) and pans when it exceeds the panel. Zoom multiplies either base. The 16-megapixel cap applies to every size. The panel wraps the picture, so a small picture gets a small panel and a wide one keeps the full width. +- **Carousel.** The inventory is every block image and Mermaid fence of the page in document order, whatever tier the page painted it with. A picture that could not be loaded (remote, missing, too large) is an entry that shows why. The one opened shows first; each item starts at fit, zoom 100 %. Arrows stay pan. +- **Diagram views.** `v` cycles *image* → *text* (terminal text art at the panel width) → *source*, for the open diagram only; nothing is saved. *Image* is skipped when the terminal has no graphics protocol (the viewer then opens on *source*, as the inline tier did). The lite build has no image viewer at all (`image viewer is not in this lite build`). +- **Hint bar.** `←↑↓→ pan +/- zoom 0 fit a actual g/G top/end Tab/⇧Tab item v image/text/source Esc close`; `Tab/⇧Tab` shows only with more than one item, `v` only with more than one view, and the text views show `↑↓ scroll … (source view)`. ## Layout footer / status bar (full width, 1 row) @@ -248,6 +254,21 @@ Generated from `BINDINGS` in `crates/wiki-reader/src/tui/keymap.rs`, the same ta | `↑ / ↓` | Move selection | | `Home / End` | Jump to first / last result | | `PgUp / PgDn` | Page results | + +### Table and image viewers + +| Key | Action | +|-----|--------| +| `↑↓←→ / hjkl` | Move the table cell cursor, or pan a picture | +| `/` | Table: filter rows | +| `s` | Table: sort by the current column | +| `y / Y` | Table: copy cell / row | +| `+ / -` | Picture: zoom in / out | +| `0` | Picture: fit the window, reset zoom | +| `a` | Picture: toggle fit / actual size | +| `Tab / Shift+Tab` | Picture: next / previous image or diagram | +| `v` | Diagram: cycle image / text / source | +| `Esc / q` | Close the viewer | ### Notes From 96cc7bbf014a3dfefbde1d8ff3c136fa86ccff12 Mon Sep 17 00:00:00 2001 From: Damir Vazgird Date: Sat, 3 Oct 2026 22:23:02 -0700 Subject: [PATCH 3/3] P3-23: roadmap status, README ASCII and carousel keys Record the single-release decision, keep P3-19..P3-23 at `doing` and the phase `active` until review and dogfood, restore the compact dogfood-log table, and note the operator-gated PNG retake. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 22 +++++++++++++--------- assets/README.md | 4 +++- wiki/roadmap/README.md | 2 +- wiki/roadmap/dogfood-log.md | 8 ++++++++ wiki/roadmap/phase-3-alpha.md | 31 ++++++++++++++++--------------- 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index c865008..74bff85 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A terminal wiki reader for markdown collections. It browses like a documentation │ ││ │ │ ││ Start at Architecture Overview, follow Token │ │ ││ Projection, then return via back when history │ +│ ││ exists. Enter a picture for Tab carousel / a fit. │ │ │├────────────┬─────────────┬─────────────────────────┤ │ ││ ‹ Overview │ │ Architecture Overview › │ └────────────────────────┘└────────────┴─────────────┴─────────────────────────┘ @@ -29,7 +30,7 @@ A terminal wiki reader for markdown collections. It browses like a documentation Help (`?`), search (`/`) and options (`,` or `c`) are popups over the same layout. Click `?` / ⚙ at the full-width layout footer's bottom right, below both panes, for Help / Options; the header retains ◫ / ✕ for nav / quit. -Nav rows show page titles by default (`nav.labels = "filename"` switches to filenames); folders always show the folder name. Press `?` in the app for every key. +Nav rows show page titles by default (`nav.labels = "filename"` switches to filenames); folders always show the folder name. Press `?` in the app for every key. In the image / diagram viewer: `Tab` / `Shift+Tab` step through pictures on the page, `a` toggles fit / actual size, `v` cycles a diagram's image / text / source view. ## Why not an existing reader? @@ -45,7 +46,7 @@ Other tools that shaped it, and what each contributed, are listed in [Prior art ## Status -Phase 2 is feature-complete on a dogfood hold (clock 2026-09-29 → ≈ 2026-10-13); Phase 3 alpha polish is active. See the [roadmap](wiki/roadmap/README.md) for what shipped and what's next, and the [dogfood log](wiki/roadmap/dogfood-log.md) for dated bites. Prereleases and binaries are on [GitHub Releases](https://github.com/luckgrid/wiki-reader/releases). +Phase 2 is feature-complete on a dogfood hold (clock 2026-09-29 → ≈ 2026-10-13). Phase 3 alpha polish is implemented on a branch for v0.1.5 (lite build, viewer sizing, carousel, diagram views); the phase stays active until review, the screenshot retake and installed-binary dogfood. See the [roadmap](wiki/roadmap/README.md) and the [dogfood log](wiki/roadmap/dogfood-log.md). Prereleases and binaries are on [GitHub Releases](https://github.com/luckgrid/wiki-reader/releases). ## Install @@ -55,6 +56,12 @@ From git (supported today): cargo install --locked --git https://github.com/luckgrid/wiki-reader wiki-reader ``` +Lite build (no images, SVG or Mermaid pictures; diagrams stay text, source or a placeholder; smaller binary, faster build; see [ADR-0023](wiki/decisions/0023-lite-build-is-a-cargo-feature.md)). It is a cargo feature only; there is no `-lite` release download: + +```bash +cargo install --locked --no-default-features --git https://github.com/luckgrid/wiki-reader wiki-reader +``` + Release binaries (macOS arm64 / x86_64, Linux x86_64) ship on `v*` tags under [GitHub Releases](https://github.com/luckgrid/wiki-reader/releases). Download the matching `.tar.gz`, verify the checksum, and put `wiki-reader` on your `PATH`: ```bash @@ -104,17 +111,14 @@ such as overlays and popups show text only.) ## Checks ```bash -cargo fmt --check -cargo clippy --locked --all-targets -- -D warnings -cargo test --locked -cargo run --locked --quiet -p wiki-reader-tools --bin link-check -rumdl fmt --check . -rumdl check . +./scripts/check.sh # default and lite builds; `default` or `lite` runs one half ``` +That runs `cargo fmt --check`, clippy and tests for both feature sets, link-check, `rumdl` and the dependency-tree guards. + ## Workspace -Cargo workspace with `wiki-reader-core` (terminal-free), `wiki-reader-render`, and the `wiki-reader` TUI binary. Docs live in `wiki/`. See [guides/development.md](wiki/guides/development.md). +Cargo workspace with `wiki-reader-core` (terminal-free), `wiki-reader-render`, `wiki-reader-media` (image decode and Mermaid/SVG rasterisation), and the `wiki-reader` TUI binary. Docs live in `wiki/`. See [guides/development.md](wiki/guides/development.md). ## License diff --git a/assets/README.md b/assets/README.md index 41087d2..6ae1461 100644 --- a/assets/README.md +++ b/assets/README.md @@ -2,4 +2,6 @@ The documentation screenshot of the app browsing its own wiki, used by the root [README](../README.md). They are not test fixtures: they are large and change with the UI. Fixtures for image tests live under `fixtures/` and stay tiny. -`wiki/assets/` holds an identical copy of it so wiki pages can show it from inside the collection root (images outside it are not rendered, [ADR-0017](../wiki/decisions/0017-static-local-images-only.md)), plus the help and search screenshots, which only the wiki shows. Update both copies of `wiki-reader.png` when retaking it. A full retake (new theme, footer icons, tab/footer borders) waits for [P3-23](../wiki/roadmap/phase-3-alpha.md) so screenshots are not taken twice. +`wiki/assets/` holds an identical copy of it so wiki pages can show it from inside the collection root (images outside it are not rendered, [ADR-0017](../wiki/decisions/0017-static-local-images-only.md)), plus the help and search screenshots, which only the wiki shows. Update both copies of `wiki-reader.png` when retaking it. + +P3-23 refreshed the README / ui-spec ASCII diagram and documented the carousel keys. The PNG retake (`assets/wiki-reader.png`, `wiki/assets/wiki-reader.png`, and ideally help/search) still needs an operator pass in Ghostty with Screen Recording permission — the agent session could not write a display capture. diff --git a/wiki/roadmap/README.md b/wiki/roadmap/README.md index 0eb8240..11dea12 100644 --- a/wiki/roadmap/README.md +++ b/wiki/roadmap/README.md @@ -11,7 +11,7 @@ Dated dogfood bites live in the [dogfood log](dogfood-log.md). | Phase 0 | done | Try what exists | Findings shaped [ADR-0005](../decisions/0005-navigation-model.md) and [ADR-0006](../decisions/0006-reader-first.md) | | Phase 1 | done | Reader shell (~1–2 weeks) | [phase-1-reader-shell.md](phase-1-reader-shell.md) | | Phase 2 | feature complete; dogfood hold to ≈ 2026-10-13 | Wiki navigation MVP (~2 weeks) | Side nav, breadcrumbs, links, back/forward, prev/next, backlinks, heading jumps, tabs, search, help, column cursor + drag-select, soft-wrapped raw view, `$EDITOR`, config, session restore. Fixes only during the hold — [phase-2-mvp.md](phase-2-mvp.md) | -| Phase 3 | active | Alpha polish | Themes, nav position, options window, table and image/diagram viewers, Mermaid image tier. Chrome pass (nav labels, footer buttons, bordered bars) done, shipped in v0.1.3. Next: lite build, viewer polish — [phase-3-alpha.md](phase-3-alpha.md) | +| Phase 3 | active | Alpha polish | Themes, nav position, options window, table and image/diagram viewers, Mermaid image tier, chrome pass (v0.1.3), herdr launcher and page publishing (v0.1.4). Lite build, viewer sizing, carousel and diagram toggle are implemented on a branch for v0.1.5 — [phase-3-alpha.md](phase-3-alpha.md) | | Phase 4 | planned | Beta: widget sidebar and agent surface | [phase-4-beta.md](phase-4-beta.md) | ## Phase 0 (done) diff --git a/wiki/roadmap/dogfood-log.md b/wiki/roadmap/dogfood-log.md index 43884ff..dff1f03 100644 --- a/wiki/roadmap/dogfood-log.md +++ b/wiki/roadmap/dogfood-log.md @@ -145,6 +145,14 @@ Phase 2 stays `active` through two weeks of real use on a real collection. Clock | 2026-10-03 | decision | P3-10 operator retest on herdr 0.9.0 (plain pane `w30:p18`, `target/debug/wiki-reader wiki`): `pane get` shows the page title and `tokens.page` following each page change (Decisions, ADR-0005, ADR-0006, ADR-0007), an unchanged page is not re-sent, `agent_status` stays `unknown`, and quitting leaves no title or token. Publishing works end to end. Not exercised: which sidebar element shows the title versus the token, the opt-out, a pane that hosts an agent, and TTL expiry after an unclean kill. Both fields stay on; ADR-0022 already records the agent-pane risk | P3-10 | | 2026-10-03 | decision | Prepare v0.1.4 to put P2-60 (diagrams switched under the options window), the herdr launcher with text-only plugin panes (P3-09) and herdr page publishing (P3-10, [ADR-0022](../decisions/0022-herdr-launcher-and-page-publishing.md)) in the installed dogfood binary. Require green CI on the release merge commit before tagging. Phase 2 stays on fixes-only hold until the ≈ 2026-10-13 verdict; the adoption clock does not restart | P2-60, P3-09, P3-10, P3-08 | | 2026-10-03 | release | [v0.1.4](https://github.com/luckgrid/wiki-reader/releases/tag/v0.1.4) published as a prerelease from #131 merge commit `5646e05` after its Ubuntu/macOS CI passed. All three release builds passed; three tarballs and three checksums present. macOS arm64 download checksum and binary version verified; Cargo dogfood install upgraded from v0.1.3 to v0.1.4 (`~/.cargo/bin/wiki-reader`, the only copy on `PATH`; `--version` reports 0.1.4), so the herdr `open` action's `exec wiki-reader` now runs a build that publishes. Operator verified P2-60, the launcher and page publishing in real herdr panes before the release. Clock continues without restarting | P2-60, P3-09, P3-10, P3-08 | +| 2026-10-03 | decision | Post-v0.1.4 docs drift: roadmap/README/phase-3 intro, development tag example, herdr plugin manifest bumped to 0.1.4 and added to the release checklist. Fold the unexercised P3-10 checks into the next dogfood session (sidebar title vs token, `publish = false`, agent-hosting pane, TTL after unclean kill); record each as a dated line | P3-10 | +| 2026-10-03 | decision | P3-19 lite build landed in the working tree (unreleased): new `wiki-reader-media` crate (palette and fit maths always, decode/Mermaid/SVG raster behind `raster`), `media` feature forwarded render → binary, `--no-default-features` drops `image`, `resvg`, `mermaid-rs-renderer` and `ratatui-image`; plain `cfg` boundary, no trait. [ADR-0023](../decisions/0023-lite-build-is-a-cargo-feature.md): cargo feature only, no `-lite` artifact. Measured on macOS arm64 at `0517adc` + working tree: binary 16.6 → 8.4 MiB, clean release build 50.7 → 19.9 s, warm (`wiki-reader` only) ≈ 3.0 → 2.6 s ([benchmarks](../architecture/benchmarks.md)). `RenderedDoc::media` added as the tier-independent inventory for P3-21. Default snapshots unchanged; manual lite pass and the separate lite release still to do | P3-19 | +| 2026-10-03 | decision | Release shape (operator): land P3-19 and Batch C as separate PRs from one branch and cut a single **v0.1.5** carrying both, rather than shipping lite alone first. Tasks stay `doing` and [ADR-0023](../decisions/0023-lite-build-is-a-cargo-feature.md) stays `proposed` until the operator reviews and a manual Ghostty/herdr pass is done. Release steps follow [releasing](../guides/releasing.md) (bump Cargo and herdr-plugin.toml, green main merge, tag, verify 3 tarballs, `cargo install --no-default-features` from the tag) | P3-19, P3-20, P3-21, P3-22, P3-08 | +| 2026-10-03 | decision | P3-20 viewer sizing landed in the working tree (unreleased): modal panels wrap their content within today's cap (`want`), fit enlarges small pictures up to 2× and `a` toggles actual (natural-pixel) size, `DocTable` cells carry styled runs so the table viewer keeps bold header, borders and link colour. Existing flatten test updated; `scripts/check.sh` green | P3-20 | +| 2026-10-03 | decision | P3-21 carousel landed in the working tree (unreleased): `Tab` / `Shift+Tab` through every image and diagram of the page, opened one first, arrows still pan; `MediaOccurrence::line` maps `Enter` / expand-diagram to the occurrence. Hint bar, Help section and ui-spec updated | P3-21 | +| 2026-10-03 | decision | P3-22 diagram state toggle landed in the working tree (unreleased): `v` cycles image / text / source for the open diagram, skipping image without a graphics protocol; session-only. Batch C landed as one working-tree batch; release split still available (P3-20 as v0.1.6, P3-21/22 as v0.1.7) pending operator cut after merge effort is known. Manual Ghostty/herdr pass still to do | P3-20, P3-21, P3-22 | +| 2026-10-03 | decision | P3-23 docs refresh (partial): README / ui-spec ASCII redrawn with carousel/fit callout; carousel keys in README prose, Help and ui-spec. PNG retake of `assets/wiki-reader.png` and `wiki/assets/` copies blocked (no Screen Recording permission in the agent session); operator must retake in Ghostty | P3-23 | +| 2026-10-03 | decision | Phase 3 status: still `active`. P3-19 to P3-23 are implemented on a branch (`doing`) and wait for review, the manual terminal pass, the PNG screenshot retake (operator-gated) and the v0.1.5 dogfood. The exit decision against the [exit criteria](phase-3-alpha.md#exit-criteria) is written after that dogfood, not before | P3-19…P3-23 | ## Related diff --git a/wiki/roadmap/phase-3-alpha.md b/wiki/roadmap/phase-3-alpha.md index b757281..d6d4265 100644 --- a/wiki/roadmap/phase-3-alpha.md +++ b/wiki/roadmap/phase-3-alpha.md @@ -10,7 +10,7 @@ nav_order: 3 # Phase 3 — Alpha polish -Phase 1 is closed and Phase 2 is feature complete (dogfood hold until ≈ 2026-10-13, fixes only; see [phase-2-mvp.md](phase-2-mvp.md)). Phase 3 was activated on 2026-10-01. Images, diagrams, themes, nav position, the options window and the table/image viewers have shipped; the chrome pass (P3-16 nav labels, P3-18 footer buttons, P3-17 bordered bars) is done and shipped in v0.1.3; next are the herdr integration spike, the lite install as its own release, then viewer polish. Dogfood bites live in the [dogfood log](dogfood-log.md). +Phase 1 is closed and Phase 2 is feature complete (dogfood hold until ≈ 2026-10-13, fixes only; see [phase-2-mvp.md](phase-2-mvp.md)). Phase 3 was activated on 2026-10-01. Batches A (chrome, v0.1.3) and the herdr work (v0.1.4) have shipped. The lite build (P3-19), the viewers (P3-20/21/22) and the docs refresh (P3-23) are implemented on a branch awaiting review, a manual terminal pass and the v0.1.5 release; the phase stays active until the exit decision below. Dogfood bites live in the [dogfood log](dogfood-log.md). ## Exit criteria @@ -25,23 +25,24 @@ End with a recorded decision, not an automatic status change: ## Proposed order -**Done:** P3-08 install; P3-06 Help; P3-12a–d images and diagrams; P3-07 themes / P3-11 layout; P3-13 options window; P3-14 table viewer; P3-15 image and diagram viewer; P3-16 nav label modes (closes P3-03); P3-18 footer buttons; P3-17 chrome pass (bordered bars). The chrome pass shipped in v0.1.3. +**Done:** P3-08 install; P3-06 Help; P3-12a–d images and diagrams; P3-07 themes / P3-11 layout; P3-13 options window; P3-14 table viewer; P3-15 image and diagram viewer; P3-16 nav label modes (closes P3-03); P3-18 footer buttons; P3-17 chrome pass (bordered bars). The chrome pass shipped in v0.1.3; herdr in v0.1.4. P3-19 to P3-23 are implemented but `doing` until the operator approves them. 1. **P3-16** — Nav label modes: drop `title+filename`; `filename` shows real file-system names (folds into P3-03). *Done.* 2. **P3-18** — Footer buttons: ⚙ moves from the header to the footer's bottom right, with a `?` help button to its left (part of P3-02). *Done.* 3. **P3-17** — Chrome pass: tab titles end in `.md`; the View's tab bar and prev/next bar are fully bordered three-row boxes with `│` dividers; the search bar in the nav matches the tab bar; selected labels are bold in the focus colour. P3-18 lands first so footer geometry changes once. *Done.* -4. **P3-09 / P3-10** — Run the half-day herdr spike first; confirm or defer each row with real-install evidence before implementation. +4. **P3-09 / P3-10** — herdr spike and implementation. *Done in v0.1.4.* 5. **P3-19** — Lite build: move image/SVG/Mermaid-raster into `wiki-reader-media` behind a default-on cargo feature (before the viewer rework so that code lands once). -6. **P3-20** — Viewer sizing: content-sized windows capped at today's size; fit / actual-size toggle. -7. **P3-21** — Image/diagram carousel (Tab / Shift+Tab). -8. **P3-22** — Diagram state toggle (image / text / source, session-only). -9. **P3-23** — Docs refresh once the UI settles (screenshots, README ASCII, carousel keys). +6. **P3-20** — Viewer sizing: content-sized windows capped at today's size; fit / actual-size toggle. *Implemented, awaiting review.* +7. **P3-21** — Image/diagram carousel (Tab / Shift+Tab). *Implemented, awaiting review.* +8. **P3-22** — Diagram state toggle (image / text / source, session-only). *Implemented, awaiting review.* +9. **P3-23** — Docs refresh once the UI settles (screenshots, README ASCII, carousel keys). *ASCII and keys done; PNG retake is operator-gated.* ### Batches and dependencies - **A — chrome:** P3-16 (medium), P3-18 (small), P3-17 (medium). Shipped in v0.1.3. Approved layout (revised after operator wireframe, 2026-10-03): fully bordered three-row tab and prev/next bars and a matching search bar, thin glyphs in each pane's own border style, bold focus-colour selected labels and muted inactive text, no fills; reserve the separate layout footer's right side for `?` / ⚙ before status text. Specify collision and short-terminal behavior before P3-17 coding. Snapshots at 40/60/80/120 columns, the full gate and Ghostty/herdr manual checks close the batch. -- **B — lite:** P3-19 (large). Spike before extraction; compare full/lite binary size and clean/warm build time on the same commit, profile and machine. Record methodology and results in [benchmarks](../architecture/benchmarks.md). Land extraction without behavior changes, then feature gating, then CI/docs. Ship lite in its own release before the viewers. A small `cfg` boundary is sufficient unless the spike justifies a trait; keep terminal protocols in the TUI. Design a tier-independent document media inventory during this seam work so text/source diagrams and placeholders are not lost by the carousel. -- **C — viewers:** P3-20 (medium–large), P3-21 (medium after inventory), P3-22 (medium). Release after the separate lite release; use the next available `v0.1.N` tag. P3-20 must define natural-pixel actual size, small-image enlargement, per-item carousel sizing and existing pixel caps. Table inline styling needs richer data: `DocTable` currently flattens styles/links into strings. P3-21 needs occurrence identity separate from content hashes/rendered rows, and stable ordering through relayout; P3-22 skips unavailable image states in lite or unsupported terminals. +- **B — lite:** P3-19 (large). Spike before extraction; compare full/lite binary size and clean/warm build time on the same commit, profile and machine. Record methodology and results in [benchmarks](../architecture/benchmarks.md). Land extraction without behavior changes, then feature gating, then CI/docs. Lite was meant to ship in its own release before the viewers; see the operator decision under Batch C. A small `cfg` boundary is sufficient unless the spike justifies a trait; keep terminal protocols in the TUI. Design a tier-independent document media inventory during this seam work so text/source diagrams and placeholders are not lost by the carousel. +- **C — viewers:** P3-20 (medium–large), P3-21 (medium after inventory), P3-22 (medium). Use the next available `v0.1.N` tag. P3-20 must define natural-pixel actual size, small-image enlargement, per-item carousel sizing and existing pixel caps. Table inline styling needs richer data: `DocTable` currently flattens styles/links into strings. P3-21 needs occurrence identity separate from content hashes/rendered rows, and stable ordering through relayout; P3-22 skips unavailable image states in lite or unsupported terminals. + - **Operator decision (2026-10-03):** land P3-19 and Batch C as separate PRs from one branch and cut a single release (v0.1.5) carrying both, instead of shipping lite alone first. The shared files (`image_viewer.rs`, `render.rs`, `images.rs`) made a lite-only release costly to split out. Note: P3-20 was smaller than its medium–large estimate (one shell hook, a base-scale parameter and a cell type). - **D — close-out:** P3-23 (small), then the exit decision. Behavioral docs change with each PR; only final screenshots and ASCII assets wait until the UI settles. Release numbers are intentions, not reservations: herdr work (if ready first), intervening fixes and the separately released Batch B consume the next `v0.1.N` number. Tag only a green main merge commit, following [releasing](../guides/releasing.md). @@ -50,7 +51,7 @@ P3-01 is explicitly deferred by operator decision (2026-10-03) before P3-17 sett ### Verification and housekeeping -Per PR: `scripts/check.sh`, intentional snapshot review, task IDs in commit messages and dated dogfood evidence. P3-19 must add actual default/lite clippy and test commands to the script and CI: the current script ignores command-line arguments, so passing `--no-default-features` alone does not work. Gate image examples for `--all-targets`, forward features across crates, and assert the lite binary dependency tree excludes `image`, `resvg`, `mermaid-rs-renderer` and `ratatui-image` even with workspace feature unification. Default snapshots must not change during extraction. +Per PR: `scripts/check.sh`, intentional snapshot review, task IDs in commit messages and dated dogfood evidence. P3-19 taught `scripts/check.sh [all|default|lite]` and CI to run both builds and assert the lite tree excludes `image`, `resvg`, `mermaid-rs-renderer` and `ratatui-image`. Gate image examples for `--all-targets`, forward features across crates. Default snapshots must not change during extraction. Per batch: manual passes in Ghostty and herdr, plus iTerm2/tmux for media or fallback changes, using the repo root, `fixtures/mermaid`, `fixtures/images` and `wiki/`. Per release: verify three tarballs/checksums, a downloaded binary and the Cargo-installed dogfood binary's version; add a new release heading in the dogfood log. At the ≈ 2026-10-13 Phase 2 review, record the adoption verdict; only a positive verdict changes Phase 2 to done and updates both roadmap status rows. @@ -80,11 +81,11 @@ Per batch: manual passes in Ghostty and herdr, plus iTerm2/tmux for media or fal | P3-16 | Nav label modes: drop `title+filename`; `filename` shows real file-system names | U3 | done | Implemented 2026-10-03 (v0.1.3): title/filename only; on-disk folder names; filenames including extensions and README/index landings; curated side-nav navigation respects filename mode. Operator clarification: filename mode affects only the side-nav tree; header breadcrumbs and View footer labels stay title-based ([ADR-0021](../decisions/0021-side-nav-only-label-mode.md)). Legacy title+filename maps to title with one diagnostic per config load across layers. [ADR-0020](../decisions/0020-nav-label-modes.md) supersedes label decisions without editing accepted ADRs. Config/options/spec/content-model docs and regression snapshots updated at 40/60/80/120 columns; `scripts/check.sh` green. Operator manually confirmed filename labels in the side nav and title-based header breadcrumbs and View footer labels. This satisfies the P3-16 portion of the Batch A manual gate; the remaining Ghostty/herdr pass covers P3-18/P3-17 only | | P3-17 | Chrome pass: tab titles end in `.md`; fully bordered three-row tab and prev/next bars with `│` dividers; search bar matches the tab bar; selected labels bold in the focus colour | U1 / U2 / U4 | done | Implemented on `feat/p3-17-chrome` (#124, v0.1.3): filename tab labels including extensions; bordered three-row bars (pane border, label row, seam) with a cell per tab and per prev/next link, built by the shared `regions/bar.rs`; every glyph is thin and uses its pane's border style (the earlier heavy `━` underline and label-coloured rules are gone); selected labels use `border_focus` (peach in the herdr presets; `peach` itself is a fill and fails contrast as text on the light preset), muted otherwise; the nav search is a bordered bar whose rows line up with the tab bar. Supersedes the initial boxes, filled bars and heavy-underline bars after operator reviews. Collision and short-terminal policy in ui-spec.md: a bar is drawn only when its three rows fit; six chrome rows when both fit. `?` / ⚙ stay in the separate layout footer. P3-01 explicitly deferred; history buttons remain separate. Operator reviewed the restyle in a real terminal and approved it 2026-10-03 | | P3-18 | Footer buttons: ⚙ moves from the header to the footer's bottom right, with a `?` help button to its left | U2 / U8 | done | Implemented in #123 (v0.1.3): layout footer `?` / ⚙ below both panes reserve space before status fields/messages, not prev/next; header retains ◫ / ✕ with bounded breadcrumb hits. Help icon row, generated keymap, behavioral docs and README diagram updated. Regression tests cover both nav sides, 40/60/80/120 widths, tiny layout-footer geometry and narrow overlay dismissal; intentional snapshots reviewed. Operator confirmed the corrected layout-footer terminal check passed; automated verification recorded in the dogfood log. Batch A's final Ghostty/herdr pass remains after P3-17. Advances P3-02; compact connected chrome remains P3-17 | -| P3-19 | Lite build: move image, SVG and Mermaid-raster code into its own crate (`wiki-reader-media`) behind a default-on cargo feature | U9 | todo | dogfood 2026-10. `cargo install --no-default-features` gives a build with no image, resvg, mermaid-rs-renderer or ratatui-image. Diagrams render as text through mermaid-text. `diagrams = image` falls back to text with a reason; the Images options group is hidden. Spike first: measure a build without the four heavy dependencies. Then record the cargo-feature-only distribution decision in a new ADR, add a CI check for both builds, document the lite install in README and configuration.md, and fill the lite column in [benchmarks](../architecture/benchmarks.md). No separate `-lite` release artifact; revisit only if a prebuilt lite binary is requested | -| P3-20 | Viewer sizing | U6 / U7 | todo | dogfood 2026-10. Table and image/diagram windows size to their content and stay capped at today's size. Wide pictures keep today's width. Add a fit / actual-size toggle on top of the existing zoom. Table viewer keeps in-view table styles (bold header, borders, link colour). Opening a picture should show it larger than its inline slot | -| P3-21 | Image/diagram carousel | U7 | todo | dogfood 2026-10. Tab / Shift+Tab step through every image and diagram in the document. The picture you opened shows first. Hints go in the modal hint bar, the Help window and ui-spec.md. Arrows stay pan | -| P3-22 | Diagram state toggle | U7 | todo | dogfood 2026-10. A key cycles image / text / source for the open diagram. Session-only. Builds on P3-21 | -| P3-23 | Docs refresh once the UI settles | — | todo | dogfood 2026-10. Retake `assets/wiki-reader.png` and the `wiki/assets/` copies (new theme, footer icons, borders). Redraw the README ASCII diagram. Add the carousel keys. Done last so screenshots are not retaken twice | +| P3-19 | Lite build: move image, SVG and Mermaid-raster code into its own crate (`wiki-reader-media`) behind a default-on cargo feature | U9 | doing | Implemented 2026-10-03: `wiki-reader-media` (palette and fit maths always; decode, Mermaid/SVG raster, embedded font behind `raster`), `media` feature forwarded render → binary, [ADR-0023](../decisions/0023-lite-build-is-a-cargo-feature.md) (cargo feature only, no `-lite` artifact). `cargo install --no-default-features` drops `image`, `resvg`, `mermaid-rs-renderer` and `ratatui-image`; diagrams stay on `mermaid-text`; `diagrams = image` falls back to text with reason `lite build: no image tier`; Images options group hidden. `scripts/check.sh [all\|default\|lite]` and CI run both builds and assert the lite tree. Lite column in [benchmarks](../architecture/benchmarks.md): 16.6 → 8.4 MiB, clean release 50.7 → 19.9 s. Plain `cfg` boundary (no trait); ratatui-image probe/draw stay in the TUI. `RenderedDoc::media` is the tier-independent inventory for P3-21. Default snapshots unchanged. Intended release **v0.1.5** (operator tags a green main merge) | +| P3-20 | Viewer sizing | U6 / U7 | doing | Implemented 2026-10-03 (unreleased): `ModalContent::want(cap)` lets the shell size the panel to its content, centred, never above today's inset-by-(2,1) panel and at least 40 body columns; wide tables and pictures hit the cap, so they keep today's width. Image sizing: *fit* enlarges up to 2× natural so an opened picture beats its inline slot (which never exceeds natural); *actual* is natural pixels (`a` toggles; `0` returns to fit); zoom multiplies either base and the 16 MP cap applies to all (`zoom_dims`). `DocTable` cells are now `DocCell` (styled `StyledSpan` runs plus `text()`), so the table viewer keeps the bold header, the `│` column borders and link colour; filter, sort and copy use plain text. The existing link-flatten test now asserts the span kind. Shortcut: link class colours collapse to the single link colour (`ponytail:` in the viewer). Tests: panel fit, `zoom_dims` maths, styled cells | +| P3-21 | Image/diagram carousel | U7 | doing | Implemented 2026-10-03 (unreleased): `Tab` / `Shift+Tab` step (wrapping) through `RenderedDoc::media`, the P3-19 inventory, snapshotted into the viewer at open, so relayout while open cannot reorder it. `MediaOccurrence` gained `line` so `Enter` on a slot or the expand-diagram action finds its occurrence (ids are occurrence hashes, not content hashes, so duplicates stay distinct). Pictures that failed to load are entries showing why. Each item gets a fresh worker and starts at fit, zoom 100 %; arrows stay pan. Hint bar, Help (new display-only *Table and image viewers* section; generated keymap block regenerated) and ui-spec updated. Test: opens 1/9 on `fixtures/images`, Tab/Shift+Tab wrap, per-item sizing reset, a note entry | +| P3-22 | Diagram state toggle | U7 | doing | Implemented 2026-10-03 (unreleased): `v` cycles image → text (`mermaid-text` art at the panel width) → source for the open diagram, per item, nothing persisted. States are computed per item: *image* needs a graphics protocol and a source, so terminals without one open on source and skip it. Lite has no image viewer (`image_viewer` is `media`-only, as before), so there is nothing to toggle there; `open_media` keeps the lite message. Hint bar and ui-spec updated. Gap: lite text/source diagram expansion is still unavailable (pre-existing from P3-19) | +| P3-23 | Docs refresh once the UI settles | — | doing | Implemented 2026-10-03: README / ui-spec ASCII redrawn (carousel / fit callout); carousel keys in README prose, Help keymap and ui-spec. PNG retake of `assets/wiki-reader.png` and `wiki/assets/` copies is **operator-gated** (Screen Recording); `assets/README.md` records the gap. Exit decision waits on that retake plus the v0.1.5+ release dogfood | ## Related