edamame 0.1.2

A fast TUI Markdown editor and viewer
Documentation
# Images, diagrams, and export

> Part of the edamame contributor deep-dives. Index and project-wide conventions: [`AGENTS.md`](../../AGENTS.md). Sibling docs live in [`docs/dev/`](.).


- **The accepted image-format list is a security boundary, and Cargo feature unification is what breaks it.** `Cargo.toml` declares `image` with exactly the formats edamame accepts (`png`, `jpeg`, `gif`, `bmp`, `webp`; SVG goes through `usvg`), and `loader::decode` refuses anything else as an ordinary `Unsupported` error. That list only holds if *no other crate in the graph* asks `image` for more, because Cargo unions features graph-wide: `ratatui-image`'s `image-defaults` feature forwards `image`'s full defaults, silently re-widening the decode surface (and dragging in the AVIF *encoder* for a program that encodes nothing). So `ratatui-image` is declared `default-features = false, features = ["crossterm"]`, and a new dependency wanting `image` must be checked the same way: `cargo tree -e features -i image`. Widening the list is a deliberate decision recorded in `docs/editing.md`, never a side effect.
- **Advisory policy is a checked-in file, not CI flags.** `.cargo/audit.toml` denies informational advisories (unmaintained / unsound / yanked) alongside vulnerabilities and carries the reasoned `ignore` list, so `cargo audit` from the repo root gives the same verdict as CI. An advisory belongs in `ignore` only when the fix is genuinely unreachable (upstream pin, or our own deliberate `=` pin); each entry records why it is acceptable and what would let us delete it. If a bump can clear it, take the bump.
- **The media prompts are per *document*, not per launch — and one method owns that.** Under the default `images.enabled = "ask"` (and its diagrams / remote twins) the prompt is the *only* thing that sets `session_images_enabled`, and `effective_images_enabled` is false until it does, so a question never asked means images never decode. All three prompts are built from the open document (`Ask` **and** this document has an image / diagram / remote URL), so every path replacing the document's *contents* must re-evaluate them. That is `App::on_document_contents_swapped` (marks `images_dirty`, queues the three), called by `load_file_into_editor`, `reload_buffer_from_disk` and `apply_diff_resolution` — the last being the *default* external-change path, since a clean buffer with `diff_on_change` goes to diff review rather than a reload (issue #30). One method rather than three copies, because the copies are how the diff path was missed. A new contents-replacing path owes the call, placed **after** `refresh_parsed`, since the prompts read `editor.parsed`.
  - Each `queue_*_prompt` helper carries its own gates, so every queue site — the document swap and the three settings-overlay `apply_*` handlers — inherits them: `media_renderable()` (below truecolor nothing is asked, since `Always` / `Never` would persist a choice made where the result can't be seen), an answer already given this session, and a prompt already on the stack (idempotent). The session-answer gate is what forces `session_remote_declined` to exist: images and diagrams record a decline in their `Option<bool>` by construction, while `session_allow_remote` is a bare bool, so without a companion flag a dismissed remote prompt is indistinguishable from "never asked". The two remote flags are mutually exclusive (the `Yes` / `Always` arms clear the decline), and a persisted policy change supersedes both (`apply_remote_policy_change`).
- **A new document means a new `EditorState`, and everything App-level it needs must be re-applied — `app::configure_new_editor` is where that lives.** `load_file_into_editor` builds a whole new `EditorState` (and `ImageCache`) per document, so anything wired onto the startup editor in `App::new` or `spawn_event_threads` is *gone* for every file opened by link, back-navigation or `$EDITOR` return. It has drifted twice, both invisible until a second document was opened: the encoder worker's `ResizeRequest` sender (attached once in `spawn_event_threads`; without one `get_protocol_pair` returns `None` and `paint_images` draws the `[Image: alt]` placeholder while the images cache perfectly in the background — `App::resize_tx` keeps the clone the swap site re-attaches), and `cursor_blink` (applied only in `App::new`, so `cursor_blink = false` started blinking again after the first link follow). A new setting an `EditorState` reads out of `Config` belongs in `configure_new_editor`; the `resize_tx` re-attach stays separate only because `App::new` has no sender yet when it builds its editor.
- **Decode happens off the UI thread.** `image::loader` runs in a worker; results arrive as `AppEvent::ImageReady`. URL fetches use `ureq` (rustls, no system OpenSSL). Failures are memoised so a reparse won't re-issue a doomed request every frame.
- **ratatui-image encode uses a second worker.** `ResizeRequest` / `ResizeResponse` route through `AppEvent::ProtocolReady` and a pending-request FIFO; failures still pop the queue so a placeholder stays visible until a later frame re-enqueues. `ThreadProtocol::resize_encode` *moves* the inner `StatefulProtocol` to the worker, so `render()` draws nothing until the response lands — and `ProtocolPair::native_ready` latches on the first successful encode and is never cleared. `paint_native` must therefore gate the native render on `native.protocol_type().is_some()` as well as `native_ready`, falling back to the scratch otherwise; gating on `native_ready` alone leaves a hole where `clear_visible_reserved_rect` just blanked the rect.
- **A native transmission that is still on screen must not be re-sent.** iTerm2 and Sixel deliver the full raw png on every render — `Iterm2::render` puts the whole base64 payload in one cell's `symbol` — and `Buffer::diff` sets `invalidated = max(symbol.width(), invalidated) - 1` per cell, where that symbol's display width is the payload length. `invalidated` therefore stays positive for the rest of the buffer and **every** later cell is re-emitted, including a second image's payload cell: one image at the top of a document retransmits every image below it on every frame, which at the cursor-blink cadence reads as a ~2 Hz flicker. Kitty is immune (transmit once, paint cheap placeholder rows), hence the symptom being iTerm2-only.
  - `paint_native` records each transmission as an `image::cache::NativePaint { rect, generation, frame }` and, when the *immediately preceding* frame left that same encoding at that same rect, marks the whole rect `skip` so ratatui emits nothing. Two things keep the record honest: the one-frame adjacency rule (any scratch / suppressed / off-screen frame invalidates it — the scratch paths clear it explicitly, the others rely on the frame gap, pinned by `a_suppressed_frame_forces_the_next_native_frame_to_retransmit`), and `ImageCache::invalidate_native_paints()` on the two events that repaint the screen behind our back: `App::on_resize` and the `terminal.clear()` after the external editor returns. `ProtocolPair::native_generation` rides along as defense in depth only.
  - **The skip marking makes the frame buffer lie** — it records blanks over rows still showing an image — and what keeps that from stranding a ghost is that ratatui's `impl PartialEq for Cell` compares the `skip` flag alongside symbol and style, so the first frame that stops skipping diffs unequal and emits the blanks. Without it, an image scrolling away into empty space would never be erased (`skipped_rect_still_diffs_against_the_same_cells_unskipped`). The frame counter is bumped in `App::draw_frame`, **not** `paint_images`: Raw and Diff draw frames without painting images, and counting only painted frames would make a pre-Raw transmission look adjacent to the first frame back in Rendered. A new path that repaints the terminal outside the paint pass must call `invalidate_native_paints`.
- **`Capabilities::halfblocks_picker` must be forced to `ProtocolType::Halfblocks`.** Both ratatui-image constructors are wrong: `Picker::halfblocks()` hardcodes `font_size` to (10, 20), so an image changes aspect ratio whenever it crosses the native↔halfblocks boundary; `Picker::from_fontsize()` keeps the probed size but *infers a protocol from the environment*, returning an **iTerm2** picker whenever `$TERM_PROGRAM`/`$LC_TERMINAL` names iTerm2, WezTerm, VS Code, Warp, Hyper, Tabby, rio or mintty (or tmux under one of those). `terminal::capabilities::halfblocks_from` takes the font size from the first and stamps the protocol from the second; `image::render_halfblocks_scratch` re-forces it locally. Getting this wrong is catastrophic and silent: the scratch then holds one cell carrying a whole base64-PNG escape surrounded by `skip` cells, which `paint_halfblocks_partial` cannot clip by row — the image flashes only on frames where row 0 is copied, re-transmits on every scroll frame, and punches through the modal dim sweep.
- **iTerm2 answers the Kitty capability query but can't do unicode placeholders.** ratatui-image's Kitty backend renders *exclusively* through that extension, and iTerm2 3.5+ implements the graphics protocol without it — so the probe lands on `Kitty`, the image is transmitted, never placed, and the reserved rows stay blank. `detect_image_protocol` overrides `Kitty` → `Iterm2` when `iterm2_hint_is_trustworthy()`, factored into the pure `resolve_protocol(probed, iterm2)` so it is testable without live probing; only `Kitty` is overridden. **The override is suppressed inside tmux**, because the env hint it rests on is exactly what tmux makes stale (`TERM_PROGRAM` / `LC_TERMINAL` are not in tmux's default `update-environment`, so a session created from iTerm2 and reattached from Ghostty still advertises iTerm2), while the stdio probe asks the live terminal through passthrough. The case this gives up — tmux inside iTerm2 — falls back to blank image rows, the pre-override status quo and recoverable via halfblocks, unlike a wrong-protocol pin on Ghostty.
- **Mermaid diagrams use synthetic cache keys.** `diagram::mermaid` renders Mermaid → SVG → PNG via `mermaid-rs-renderer` + `resvg`/`usvg`, keyed `diagram-mermaid-<sha256>` so a rendered diagram survives reparses. Pin `mermaid-rs-renderer` exactly (`=0.2.2`) — pre-1.0, known panic bugs, rapid churn; `resvg`/`usvg` versions must match what it transitively depends on so cargo doesn't compile two copies.
- **A revealed image block reserves one row per *source* line, not its image's rows.** A `Block::ImageBlock` reserves as many rows as the decoded image occupies, which has nothing to do with how many lines its source was written on — so the raw-source reveal was clipped by a short-wide diagram (issue #24), padded out by a tall one, and a one-line `![alt](url)` left floating in a screenful of blanks. **Both are the same bug and take the same fix; the gate is `is_image_block`, not `is_mermaid_block`.** `EditorState::image_reveal` stashes an `ImageReveal { ordinal, url, rows }` for the block the cursor rests in, and `refresh_parsed`'s row-override consults it **first** — ahead of both the decode cache and the images-disabled collapse to 1 row — so the document reflows.
  - The transition is resolved once per frame by `sync_image_reveal` from `App::prepare_viewport`, because the reveal is time-driven and `RAW_REVEAL_DELAY` elapses with no event of its own; the call is a no-op when the target hasn't moved. **A sync returning `true` owes `ensure_cursor_visible`**: the reflow moves rendered rows under a cursor that never moved, and `ensure_cursor_visible` otherwise runs only off a motion or an edit — so entering a fence from below near the fold grows the block and carries the cursor off screen, while the image direction shrinks it and can strand the cursor above `scroll` (`revealing_a_diagram_at_the_fold_keeps_the_cursor_on_screen`). The `dims` given to that call were measured *before* the reflow, so a reveal changing the line count can paint one frame with a stale gutter width; the `needs_draw` it sets re-measures next frame. It holds the current reservation while `parsed_dirty`, since a diagram's synthetic URL hashes its source and an ordinary image's *is* source text, so mid-typing the parse names a URL the next reparse retires — and an in-line edit can't change the line count anyway.
  - **The row count is `rendered_view::revealed_source_line_count`, not the raw line count**, because a block's *extended* byte range absorbs the blank line following a paragraph — and the promoted `![alt](url)` paragraph with it — while a fence's range stops at its closing ```. That blank is a virtual block with a rendered row of its own, so counting it reserves one row too many. It otherwise counts through the same split the reveal paints with (`raw_source_lines`), so reserved rows and painted lines can't disagree.
  - **The block is named by ordinal *and* URL, and `ImageRowOverride` takes both** — a URL alone doesn't identify a block, so a document using one image twice would see every copy collapse to the revealed block's line count (`revealing_one_image_leaves_a_duplicate_of_it_alone`). The ordinal counts `Block::ImageBlock`s in document order (`Renderer::image_block_seq` and the index into `ParsedDoc::image_blocks`, both walking the same top-level list). The URL is a staleness check for the `parsed_dirty` hold: a reservation whose block has stopped being an image matches nothing, instead of the ordinal sliding onto the next image. `sub_lines_in_block`'s mermaid branch clamps to `block_own - 1`, which is what turned the short reservation into a stuck cursor; with the expansion in place that clamp is a no-op.
- **HTML export has two modes.** `export::html` produces a linked or self-contained file (base64 `data:` URIs for local images); the bundled stylesheet is `config/export/default.css`. `export::custom` shells out to a user-defined command (pandoc, weasyprint, …), hosting the intermediate HTML via `tempfile` and cleaning up on drop.
- **One modal, one palette entry, and the format is a field inside it.** The palette shows a single `Export…` row (`Action::ExportHtml`, relabelled — there is no per-converter action); the modal's **Format list** offers HTML plus every valid `[[export.custom]]` converter. A custom export converts the HTML `export::html` produces, so it shares the whole form — the same options, overwrite confirmation and `AppEvent::ExportDone` handshake — and only the output extension, which `spawn_*` runs, and the success-phase button label differ. `ui::ExportFormat` (a `{label, open_result}` pair) is the widget's whole per-format surface; the App adapter holds a parallel `Vec<ExportJob>` in the same order and runs `jobs[state.format_idx]`, so `ui::export_modal` never branches on the backend.
  - **The jobs own *clones* of their `CustomExportEntry`, never indices.** `App::open_export_modal` builds the jobs (HTML first, then each converter whose `config_problem()` is `None`) once, at open time. Returning from the external editor reloads config wholesale, so an index captured when the modal opened could name a different converter — or none — by the time `[ Export ]` is pressed; a cloned job can't drift.
  - **`CustomExportEntry`'s fields all default, and the validator enforces them instead.** Required fields meant one typo inside an export entry failed the whole `config.toml` parse — and the loader answers a parse failure with `Config::default()`, so it silently reset theme, keybindings and everything else for that launch. `readers::validate_custom_exports` *reports* an unusable entry (empty name / command / extension, or a path separator in the extension) with an `InvalidValue` warning naming its index. It is also what makes the shipped reference config's commented `[[export.custom]]` block pass `shipped_reference_config_examples_are_all_uncommentable`, which uncomments one line at a time.
  - **A flagged entry is ignored, not deleted.** The validator does *not* `retain` the vector: the loader's result is written back on the next `Config::save` (a bookkeeping save fires within seconds of launch), so removing the entry would erase the user's `[[export.custom]]` block from their `config.toml` — the very lines the warning asks them to fix. The entry stays in `config.export.custom`; `CustomExportEntry::config_problem` (the shared predicate the validator and the modal both consult) is what keeps it *out of the Format list*, so a format that cannot run is never offered while the file on disk is untouched. `App::open_export_modal` filters on it when building the jobs. `config_problem` reads the extension through `output_extension` (trim + one leading dot), the same normalization `ExportJob::extension` writes into the filename, so a validated `".pdf"` and the resulting `guide.pdf` agree.
  - **The intermediate HTML is written into the output directory, not `/tmp`.** `export::custom` renders to a temp file beside `target` (falling back to `source_dir`, then cwd), because converters resolve a relative `src="images/…"` against the input file's own location — a `/tmp` intermediate silently drops every non-inlined image, which is the default. It also stamps `target`'s pre-run `(mtime, len)`: "the converter produced a file" means `target` was written *this run*, so the stdout-capture fallback (`!wrote_target`) can't report a stale file from a previous export as a fresh success on the overwrite path. Length is in the stamp because mtime granularity is a filesystem property — on a 1-second one a same-tick rewrite reads as unchanged, and the fallback would then overwrite real output with the converter's own stdout.
  - **The `{html}` / `{out}` arguments are absolutized (`custom::absolutize`) before substitution.** The command runs with its cwd set to the document's folder (for the HTML's own relative asset URLs), so a *relative* `{out}` — which is what `file_path` holds when the document was opened as `edamame docs/guide.md` — would resolve against `…/docs` and die with `…/docs/docs/guide.pdf: No such file or directory`. `std::path::absolute` normalizes without I/O (the target need not exist yet); the whole runner then uses the absolute forms, so the mtime probe, the write and the returned path agree on one location.