From f91fb0745a08e8bb90fd229d88ba7339a7a32c5b Mon Sep 17 00:00:00 2001 From: Jeremy Anderson Date: Sun, 2 Aug 2026 03:44:04 -0400 Subject: [PATCH] =?UTF-8?q?Marten=20is=20a=20desktop=20image=20viewer=20fo?= =?UTF-8?q?r=20Linux,=20written=20from=20scratch=20in=20Rust.=20Named=20af?= =?UTF-8?q?ter=20the=20marten=20(genus=20*Martes*)=20=E2=80=94=20a=20small?= =?UTF-8?q?=20agile=20mustelid=20native=20to=20forests=20across=20the=20No?= =?UTF-8?q?rthern=20Hemisphere.=20Like=20its=20cousin=20the=20ferret=20(ma?= =?UTF-8?q?rten's=20sibling=20app=20for=20video=20playback),=20the=20marte?= =?UTF-8?q?n=20is=20quick,=20curious,=20and=20nimble.=20Fitting=20energy?= =?UTF-8?q?=20for=20a=20photo=20viewer=20designed=20to=20move=20fast=20thr?= =?UTF-8?q?ough=20large=20libraries.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 0 BLOG.md | 315 +++++ Cargo.toml | 64 + DECISION.md | 394 ++++++ LICENSE | 340 +++++ QUICKSTART.md | 316 +++++ README.md | 358 +++++ config/keymap.toml | 40 + config/settings.toml | 33 + prototypes/SPIKE_COMPARISON.md | 164 +++ prototypes/bin/egui-viewer | Bin 0 -> 15502864 bytes prototypes/bin/iced-viewer | Bin 0 -> 23782528 bytes prototypes/egui-viewer-archived/Cargo.toml | 20 + prototypes/egui-viewer-archived/README.md | 35 + prototypes/egui-viewer-archived/src/main.rs | 292 ++++ prototypes/egui-viewer/Cargo.toml | 20 + prototypes/egui-viewer/src/main.rs | 292 ++++ prototypes/iced-viewer/Cargo.toml | 13 + prototypes/iced-viewer/src/main.rs | 292 ++++ src/app.rs | 1337 +++++++++++++++++++ src/codec/anti_list.rs | 126 ++ src/codec/exr.rs | 120 ++ src/codec/image_crate.rs | 110 ++ src/codec/jxl.rs | 141 ++ src/codec/mod.rs | 339 +++++ src/codec/svg.rs | 123 ++ src/codec/tiff.rs | 189 +++ src/config.rs | 367 +++++ src/main.rs | 27 + src/nav/folder.rs | 134 ++ src/nav/mod.rs | 210 +++ src/settings.rs | 209 +++ src/ui/about_dialog.rs | 225 ++++ src/ui/context_menu.rs | 208 +++ src/ui/error_modal.rs | 207 +++ src/ui/exif_panel.rs | 338 +++++ src/ui/export_dialog.rs | 555 ++++++++ src/ui/icons.rs | 79 ++ src/ui/image_view.rs | 245 ++++ src/ui/mod.rs | 43 + src/ui/sidebar.rs | 228 ++++ src/ui/status_bar.rs | 81 ++ src/ui/thumbnail_bar.rs | 200 +++ src/ui/toolbar.rs | 167 +++ 44 files changed, 8996 insertions(+) create mode 100644 .gitignore create mode 100644 BLOG.md create mode 100644 Cargo.toml create mode 100644 DECISION.md create mode 100644 LICENSE create mode 100644 QUICKSTART.md create mode 100644 README.md create mode 100644 config/keymap.toml create mode 100644 config/settings.toml create mode 100644 prototypes/SPIKE_COMPARISON.md create mode 100755 prototypes/bin/egui-viewer create mode 100755 prototypes/bin/iced-viewer create mode 100644 prototypes/egui-viewer-archived/Cargo.toml create mode 100644 prototypes/egui-viewer-archived/README.md create mode 100644 prototypes/egui-viewer-archived/src/main.rs create mode 100644 prototypes/egui-viewer/Cargo.toml create mode 100644 prototypes/egui-viewer/src/main.rs create mode 100644 prototypes/iced-viewer/Cargo.toml create mode 100644 prototypes/iced-viewer/src/main.rs create mode 100644 src/app.rs create mode 100644 src/codec/anti_list.rs create mode 100644 src/codec/exr.rs create mode 100644 src/codec/image_crate.rs create mode 100644 src/codec/jxl.rs create mode 100644 src/codec/mod.rs create mode 100644 src/codec/svg.rs create mode 100644 src/codec/tiff.rs create mode 100644 src/config.rs create mode 100644 src/main.rs create mode 100644 src/nav/folder.rs create mode 100644 src/nav/mod.rs create mode 100644 src/settings.rs create mode 100644 src/ui/about_dialog.rs create mode 100644 src/ui/context_menu.rs create mode 100644 src/ui/error_modal.rs create mode 100644 src/ui/exif_panel.rs create mode 100644 src/ui/export_dialog.rs create mode 100644 src/ui/icons.rs create mode 100644 src/ui/image_view.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/sidebar.rs create mode 100644 src/ui/status_bar.rs create mode 100644 src/ui/thumbnail_bar.rs create mode 100644 src/ui/toolbar.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/BLOG.md b/BLOG.md new file mode 100644 index 0000000..50d0808 --- /dev/null +++ b/BLOG.md @@ -0,0 +1,315 @@ +# marten v0.4.0 — release notes + +**Released:** 2026-12-20 +**Codename:** "the timeline pass" +**License:** GPL-2.0-or-later +**Binary size:** 24.5 MB (stripped) + +--- + +## The headline + +Marten v0.4.0 ships with step-aware random navigation, timeline-style +thumbnail auto-scroll, a folder-as-video exporter, a fixed zoom +rendering pipeline, and a fullscreen exit hint. The headline feature is +video export: right-click any folder and produce a `.webm` slideshow +with a chosen audio track and VP9 or AV1 encoding, all driven by ffmpeg +in a background thread. + +Source size: 5,777 lines of Rust across 24 files, with 31 unit tests. + +--- + +## What's new in v0.4.0 + +### 1. Step-aware random navigation + +Two new `KeymapAction` variants join the navigator: + +- **`RandomSameFolder`** (bound to `z`) — picks a random image from the + current folder's image list. +- **`RandomFolderTree`** (bound to `Shift+Z`) — walks the current + folder's parent recursively, collects every supported image across + all subfolders, and picks one at random. If the chosen image lives + in a different subfolder, the navigator replaces its image list with + that folder's images and switches automatically. + +Both actions are step-aware. After a random jump, the next `navigate(±1)` +call continues sequentially from the new position rather than from the +position before the jump. The navigator already tracks `current` as a +plain `usize`, so step-awareness falls out of the data model — no +parallel "real" cursor is needed. + +The entropy source is `pseudo_random(max)`, a free function in +`src/nav/mod.rs` that takes `SystemTime::now().duration_since(UNIX_EPOCH).subsec_nanos() % max`. +Pulling in the `rand` crate for a single shuffle-style feature is +disproportionate. SystemTime nanos are sufficient entropy for an image +viewer's random-nav use case. + +`walk_folder_tree()` in `src/nav/folder.rs` (134 lines total in the +file, up from 85) is the recursive collector that powers +`RandomFolderTree`. It reuses the same supported-extension filter as +`scan_folder()` and skips hidden files. + +### 2. Shuffle button in the toolbar + +A new `Icon::Shuffle` (Lucide `shuffle` icon, ISC-licensed) sits in the +bottom toolbar between Next and Fit. Clicking it dispatches the same +`Message::RandomSameFolder` message as pressing `z` — one code path, +two entry points. The shuffle button is the first toolbar entry that +does not have a direct precedent in gpicview or ristretto; it borrows +the iconography from audio-player shuffle controls. + +### 3. Timeline-style thumbnail auto-scroll + +The top thumbnail strip now auto-scrolls to keep the current image +centered, like a video editor's playhead on a timeline. The strip's +`scrollable::Id` is exposed via `pub fn thumb_scroll_id()` in +`src/ui/thumbnail_bar.rs` (200 lines, up from 191). The app layer +calls `iced::widget::scrollable::scroll_to()` with a computed +`AbsoluteOffset`: + +```rust +let thumb_entry_width = THUMB_SIZE + 2.0 + 2.0; // image + border + spacing +let current_offset = self.thumbnail_bar.current as f32 * thumb_entry_width; +let viewport_w = self.window_size.width - self.sidebar.width(); +let center_offset = (current_offset - viewport_w / 2.0).max(0.0); +``` + +This fires on every navigation event: scroll wheel, arrow keys, random +jump, and thumbnail click. The thumbnail-bar module owns the Id; the +app layer owns the offset math because it has the viewport width. + +### 4. Zoom rendering fix (critical) + +The v0.3 zoom pipeline was broken. In `ZoomMode::Custom`, the image +widget was wrapped in a `Length::Fill` container, which was itself +nested inside `scrollable`. Inside `scrollable`, a `Length::Fill` +child collapses to zero size — iced 0.13's layout pass cannot resolve +infinite-available-size requests from a scrollable parent. The result: +pressing zoom in or out made the image disappear. + +The fix moves the image widget to be the **direct child of `scrollable`** +with explicit `Length::Fixed(dw)` and `Length::Fixed(h)` dimensions and +`ContentFit::Contain`. Because `dw/dh` already matches the post-zoom +image dimensions (computed from `iw * factor` and `ih * factor`), the +`Contain` constraint is a no-op scaling-wise — it is there only to +prevent iced from injecting padding. + +The zoom step was also reduced from `1.25x` to `1.1x`. `zoom_in` now +multiplies the current factor by 1.1; `zoom_out` divides by 1.1 (and +multiplies by 0.9 directly in the implementation). The 1.1 step produces +noticeably smoother, more graceful zoom transitions — 1.25 overshot on +every keypress. + +The `view()` doc-comment in `src/ui/image_view.rs` (244 lines) records +both rendering strategies explicitly so the next person to touch this +code does not re-introduce the `Length::Fill` collapse. + +### 5. Folder-as-video export + +The ambitious feature of v0.4. A new module — `src/ui/export_dialog.rs` +(555 lines) — implements a modal dialog that turns the current folder +into a `.webm` slideshow with a user-selected audio track. + +**User flow:** + +1. Right-click → "Export folder as video…" (new context-menu item with + the `Icon::Film` Lucide icon). +2. The modal shows the image count and the folder name. +3. User selects an audio file (`.mp3`, `.wav`, `.ogg`, `.flac`, `.aac`, + or `.m4a`). +4. User selects an output `.webm` path. +5. User picks a codec: VP9 (`libvpx-vp9`) or AV1 (`libaom-av1`). +6. User sets seconds-per-image (default 3.0). +7. User clicks Export. ffmpeg runs in a background thread via + `tokio::task::spawn_blocking`; the dialog shows "Exporting…" then + "Export complete!" or an error message. + +**ffmpeg invocation:** + +```sh +ffmpeg -y -f concat -safe 0 -i filelist.txt -i audio.mp3 \ + -c:v -crf 30 -b:v 0 -c:a libopus -shortest output.webm +``` + +The concat demuxer requires a temporary file list. Marten writes that +list to `std::env::temp_dir().join("marten_export_list.txt")`, with each +image entry followed by a `duration N.N` line. The last image is +repeated without a duration — this is an ffmpeg concat demuxer quirk +(the final `duration` is ignored unless the file is repeated). Single +quotes in file paths are escaped with the standard `'\''` sequence. + +ffmpeg is invoked via `std::process::Command`. If the binary is not on +`$PATH`, the `io::ErrorKind::NotFound` arm produces a clear +user-facing error: "ffmpeg not found. Install ffmpeg to use video +export." A non-zero exit status surfaces ffmpeg's last stderr line as +the error message — verbose, but actionable. + +The export runs in `tokio::task::spawn_blocking` so the iced event loop +stays responsive during the (potentially minutes-long) encode. The +dialog's `exporting` boolean disables the Export button while the work +is in flight; the `ExportCompleted(Result<(), String>)` message flips +it back and stores either an "Export complete!" success message or the +error string in `result_message`. + +### 6. Fullscreen exit hint + +When the window is in fullscreen mode, a small floating hint — +"Press F11 to exit fullscreen" — appears at the top-center of the +screen. The hint is a `container` with `Color::from_rgba(0.086, 0.086, +0.102, 0.85)` background, a 1px chrome border, and 4px corner radius, +positioned 12px from the top of the viewport via outer-container +padding and `align_x(Center)`. + +The hint is always visible in fullscreen. Unlike chrome elements +(thumbnail strip, sidebar, toolbar, status bar) it does not hide — its +only job is to remind you how to leave the mode. If it disappeared on a +timer, users who paused before pressing F11 would be stranded. + +### 7. New icons + +Two new variants on the `Icon` enum in `src/ui/icons.rs` (79 lines): + +- `Icon::Shuffle` — Lucide `shuffle` (five paths). Used in the toolbar. +- `Icon::Film` — Lucide `film` (eight paths, including the sprocket + holes). Used in the context menu's Export entry. + +The icon count is now 20, up from 17 in v0.3. + +### 8. New context-menu item + +`ContextMenuItem::ExportToVideo` joins the menu in `src/ui/context_menu.rs` +(208 lines, up from 205). It sits between Properties and About marten +and uses `Icon::Film`. The menu now has 12 actions, up from 11. + +--- + +## Architecture changes + +### New module + +- `src/ui/export_dialog.rs` (555 lines) — folder-as-video export modal. + Owns the `ExportDialog` state struct, the `ExportMessage` enum, the + `ExportCodec` enum (`VP9`, `AV1`) with its `ffmpeg_vcodec()` mapping, + the modal `view()` renderer, and the `run_ffmpeg_export()` free + function that performs the actual encode. + +### Extended modules + +- `src/app.rs` (1,065 → 1,317 lines) — adds `Message::RandomSameFolder` + and `Message::RandomFolderTree` variants; wires the `ExportDialog` + into `Viewer` and routes `Message::Export`; adds `scroll_to_thumbnail()` + helper that computes the centered offset and dispatches + `scrollable::scroll_to`; adds the fullscreen exit hint as a layer + pushed onto the `iced::widget::stack`. +- `src/config.rs` (355 → 367 lines) — adds `random_same_folder` and + `random_folder_tree` fields to `Keymap`; corresponding + `KeymapAction::RandomSameFolder` and `KeymapAction::RandomFolderTree` + variants. +- `src/nav/mod.rs` (151 → 210 lines) — adds `Navigator::random_same_folder()` + and `Navigator::random_from_tree()` methods; adds the `pseudo_random()` + free function (SystemTime nanos as entropy source). +- `src/nav/folder.rs` (85 → 134 lines) — adds `walk_folder_tree()` and + its private recursive helper. Reuses `scan_folder`'s extension filter. +- `src/ui/icons.rs` (75 → 79 lines) — `Icon::Shuffle` and `Icon::Film`. +- `src/ui/toolbar.rs` (165 → 167 lines) — `ToolbarButton::Shuffle` + variant; the icon button is inserted between Next and Fit. +- `src/ui/thumbnail_bar.rs` (191 → 200 lines) — `pub fn thumb_scroll_id()` + exposes the strip's `scrollable::Id` so the app layer can call + `scrollable::scroll_to()` programmatically. +- `src/ui/context_menu.rs` (205 → 208 lines) — + `ContextMenuItem::ExportToVideo` variant and its menu entry. +- `src/ui/image_view.rs` (245 → 244 lines) — zoom-mode rendering fix: + image is now the direct child of `scrollable` with + `ContentFit::Contain` and `Length::Fixed(dw/dh)`, replacing the + `Length::Fill` wrapper that collapsed inside `scrollable`. Zoom step + changed from 1.25 to 1.1. +- `config/keymap.toml` — two new entries: + - `random_same_folder = ["z"]` + - `random_folder_tree = ["Shift+Z"]` + +### Test count + +31 unit tests, unchanged from v0.3. v0.4 is feature work with no new +codec surface; the existing `Navigator` tests cover wrap-around and +index math, and the new random methods share the same invariants. +Random-output paths are intentionally not asserted — `pseudo_random` +returns a `usize` from nanosecond entropy and a deterministic test +would require injecting a seed. + +``` +test result: ok. 31 passed; 0 failed +``` + +### Binary size + +24.5 MB stripped ELF (up from 24.2 MB in v0.3). The growth comes from +the new `export_dialog` module's view code and the slightly larger +`app.rs` dispatch table — no new external dependencies. + +--- + +## Out of scope for v0.4.0 + +Features evaluated and excluded from this release: + +- **Image editing** (crop, adjust, filters) — out of scope. Marten is + a viewer, not an editor. +- **Theme API** — the dark palette remains hardcoded. A theme API waits + on real user demand. +- **Tier 3 formats** (QOI, JPEG 2000, JPEG XS) — no concrete user + request yet. +- **Video export GUI for codec parameters beyond VP9/AV1** — the two + codecs cover the realistic quality/compatibility trade space. CRF is + fixed at 30; exposing it would require a UI control and a quality + explainer that v0.4 did not have scope for. + +--- + +## Acknowledgments + +Marten stands on the shoulders of: + +- **The iced team** (https://iced.rs) — the Elm-style GUI toolkit that + powers marten. v0.13's `scrollable::scroll_to` made the timeline + thumbnail behavior a one-liner. +- **The `image` crate contributors** — handles every Tier 1 format + with a consistent API. +- **The Tier 2 crate authors** — `jxl-oxide`, `tiff`, `resvg`, + `usvg`, `tiny-skia`, `exr`, and `flate2` together cover the rest of + the modern image format landscape. +- **The ffmpeg project** — the external dependency that powers video + export. Marten invokes it as a subprocess; no link-time dependency. +- **Lucide** (https://lucide.dev) — the ISC-licensed icon set, now + including `shuffle` and `film`. +- **The ristretto, gPhoto, viewnior, and gpicview authors** — for the + UX patterns marten builds on. No code was reused. +- **The ferret project** — for the visual identity marten inherits as + a sibling app. + +--- + +## Download + +- **Binary:** `bin/marten` (24.5 MB, x86-64 ELF, stripped) +- **Source:** `http://git.dcos.net/dcosnet/marten` +- **Tarball:** `marten.tar.gz` (includes source + prebuilt binary) + +--- + +# Historical: v0.3.0 release notes + +**Released:** 2026-11-15 +**Codename:** "the gPhoto pass" +**Binary size:** 24.2 MB (stripped) + +The v0.3 release introduced Tier 2 image codecs (JPEG XL, TIFF, SVG, +OpenEXR) via dedicated modules, the togglable folder tree sidebar, the +EXIF properties panel, and slideshow mode. The codec registry moved +behind `Arc` so thumbnails decode off the async runtime +on `tokio::spawn_blocking`. + +Source size at v0.3: 4,832 lines across 23 files, 31 unit tests. +Architecture and feature details are recorded in `DECISION.md` D008 +through D011. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ce3d535 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "marten" +version = "0.4.0" +edition = "2021" +description = "A modern, accuracy-first image viewer for Linux." +authors = ["Jeremy Anderson"] +license = "GPL-2.0-or-later" +repository = "http://git.dcos.net/dcosnet/marten" +homepage = "http://git.dcos.net/dcosnet/marten" +keywords = ["image", "viewer", "iced", "linux", "photo"] +categories = ["graphics", "multimedia::images"] + +[[bin]] +name = "marten" +path = "src/main.rs" + +[dependencies] +# GUI toolkit +iced = { version = "0.13", features = ["image", "tokio", "advanced", "svg"] } + +# Image decoding (Tier 1) +image = { version = "0.25", default-features = false, features = [ + "png", "jpeg", "gif", "webp", "bmp", "ico", "avif", + "rayon", +] } + +# Image decoding (Tier 2) +jxl-oxide = "0.12" # JPEG XL — pure-Rust decoder +tiff = "0.11" # TIFF — direct decode (multi-page, 16-bit) +resvg = "0.47" # SVG rendering (pulls in usvg + tiny-skia) +exr = "1.74" # OpenEXR — pure-Rust HDR float +flate2 = "1" # gzip decompression for .svgz + +# File dialog +rfd = "0.15" + +# Config loading (keymap.toml) +serde = { version = "1", features = ["derive"] } +toml = "0.8" +dirs = "5" + +# Error handling +thiserror = "1" + +# Async +tokio = { version = "1", features = ["fs", "io-util", "rt-multi-thread"] } + +# Logging +log = "0.4" +env_logger = "0.11" + +# Context menu actions +arboard = "3" # clipboard (copy path, copy image) +trash = "5" # move to trash +kamadak-exif = "0.6" # EXIF for properties panel + +[dev-dependencies] +tempfile = "3" + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = "symbols" diff --git a/DECISION.md b/DECISION.md new file mode 100644 index 0000000..786fdb8 --- /dev/null +++ b/DECISION.md @@ -0,0 +1,394 @@ +# DECISION.md — image-viewer + +This document records the architectural and product decisions that shape the project. New decisions get appended; existing decisions are not silently rewritten. + +--- + +## D001 — Toolkit: iced + +**Date**: 2026-08-02 +**Status**: Decided +**Context**: We prototyped the same ristretto-core MVP slice (open folder, scroll-wheel nav, dark theme) in both `iced 0.13` and `egui 0.29`. Both compiled and produced release binaries. We tested both on Arch Linux. +**Decision**: Use **iced**. +**Reasoning**: +1. iced ran smoothly out of the box. egui loaded but exhibited visible runtime quirks that would have required debugging before any real feature work could start. +2. iced's `Theme::Dark` is closer to the ristretto+ target aesthetic without manual color overrides. +3. iced's declarative `Message` enum + `Task::perform` async story is a better fit for an app with this much interaction surface (scroll, zoom, pan, context menu, fullscreen, thumbnail clicks, configurable keymap). egui's lack of a built-in async story was already forcing us into `std::thread` + `JoinHandle` polling for the file dialog — that pattern does not scale to a lazy-loaded thumbnail bar. +**Trade-offs**: Larger binary (23 MB vs 15.5 MB). No built-in context-menu widget — marten implements its own overlay layer for full styling control of the gpicview-inspired menu. Fullscreen routes through iced's `window` subsystem rather than egui's `ViewportBuilder`. +**Alternatives considered**: egui (archived at `prototypes/egui-viewer-archived/`), Slint (not prototyped; evaluation scheduled if iced encounters a blocking limitation). +**Reference**: `prototypes/SPIKE_COMPARISON.md`. + +--- + +## D002 — Format support: Tier 1 / 2 / 3 + explicit anti-list + +**Date**: 2026-08-02 +**Status**: Decided +**Context**: We surveyed open image formats (see `download/format-research.md`). The user explicitly rejected proprietary / semi-open formats. +**Decision**: + +**Tier 1 (MVP)**: PNG (incl. APNG), JPEG, GIF (animated), WebP, AVIF, BMP, ICO/CUR. +All handled by the `image` crate (with `avif` feature for AVIF). + +**Tier 2 (post-MVP)**: JPEG XL, TIFF, SVG (via `resvg`), OpenEXR (via `exr` crate). Scheduled for v0.3. + +**Tier 3 (niche)**: QOI, JPEG 2000, JPEG XS. + +**Anti-list — explicitly NOT supported, ever**: +- HEIF, HEIC — HEVC patent-licensing baggage. AVIF covers the same use case royalty-free. +- Canon CR3, Nikon NEF, Sony ARW — proprietary camera RAW specs. +- Adobe PSD — proprietary Photoshop format. +- Adobe DNG — "partially open"; Adobe-controlled. +- Apple Live Photos — proprietary paired image+video container. + +**Reasoning**: +- Openness is a project value, not just a technical convenience. +- AVIF covers the HDR/animation niche that HEIC would otherwise fill, using the same ISOBMFF container but a royalty-free AV1 codec. There is no feature gap created by rejecting HEIC. +- Camera RAW is a moving target — every camera generation ships new proprietary variants. We would rather spend maintenance effort on better PNG/JPEG XL/AVIF support than on perpetual `libraw` bindings. +- PSD and DNG are Adobe-controlled formats with open alternatives (TIFF for layered raster, JPEG XL for archival masters). +- Apple Live Photos is a paired media container, not really an image format. Out of scope for a still-image viewer. + +**Implementation requirement**: The codec layer MUST detect anti-listed file extensions and fail fast with a clear, user-facing error message explaining the project's stance. Silent skipping is not acceptable — the user needs to know *why* their file didn't open. + +**Reference**: `download/format-research.md` (full survey + anti-list rationale), `README.md` (project-level summary). + +--- + +## D003 — Layout: ristretto+ default, viewnior-minimal toggle + +**Date**: 2026-08-02 +**Status**: Decided +**Context**: The user wants the visual rhythm of ristretto (thin top toolbar + center image + bottom thumbnail bar) as the default, but with a hotkey to collapse all chrome for distraction-free viewing (viewnior-style). +**Decision**: +- Default layout: thin top toolbar (back / forward / zoom / rotate / fullscreen buttons) + center image area + bottom thumbnail strip. +- Hotkey (default `F11` or `F`) collapses all chrome → image fills window, controls float in on hover. +- A left-side folder panel slides in on demand (default `Tab` or `\`). +**Reasoning**: Matches the ristretto+ layout from the original brief; the collapse-to-minimal toggle is the "hybrid" the user asked for. + +--- + +## D004 — Theme: always dark, refined palette + +**Date**: 2026-08-02 +**Status**: Decided +**Decision**: Hardcode a refined dark palette. No light theme, no system-follow mode for MVP. +**Palette**: +- Background (image area): `#0e0e10` (near-black, slight warm tint) +- Chrome (toolbar/status): `#16161a` +- Chrome border: `#26262c` +- Primary text: `#e4e4e7` +- Secondary text: `#a1a1aa` +- Accent (focus ring, active button): `#7c3aed` (refined violet — distinct from the blue-grey default of most "dark" themes) +- Danger (delete, trash): `#dc2626` +**Reasoning**: Photo viewers look best dark — bright chrome washes out bright photos. A single hardcoded palette keeps the v0.2 codebase simple; a theme API is scheduled for post-v0.2 if users request customization. + +--- + +## D005 — Keymap: ship a default, expose `config/keymap.toml` from day one + +**Date**: 2026-08-02 +**Status**: Decided +**Decision**: Default keymap is hardcoded; a `config/keymap.toml` file (loaded from `$XDG_CONFIG_HOME/image-viewer/keymap.toml` if present, falling back to packaged default) overrides individual bindings. Unknown keys in the user's toml are warned about but do not crash. +**Default keymap**: +| Action | Key | +|---|---| +| Next photo | `→` or `L` | +| Prev photo | `←` or `H` | +| First photo | `g` | +| Last photo | `G` | +| Zoom in | `+` or `Ctrl+↑` | +| Zoom out | `-` or `Ctrl+↓` | +| Fit to window | `0` or `F` (in image area; `F11` reserved for fullscreen) | +| Actual size (100%) | `1` | +| Pan | drag (mouse) | +| Toggle fullscreen | `F11` | +| Toggle chrome (viewnior-minimal) | `Shift+F` or `F` outside image area (TBD) | +| Open folder | `O` | +| Quit | `Q` or `Ctrl+Q` | +| Right-click menu | right mouse button | +| Rotate CW | `R` | +| Rotate CCW | `Shift+R` | +| Properties | `I` (info) | + +**Reasoning**: Power users want vim-style hjkl; everyone else wants arrows. We ship both by default. Exposing the toml from day one means we do not have to retrofit a config system later. + +--- + +## D006 — Project shape: single binary crate, modular `src/` + +**Date**: 2026-08-02 +**Status**: Decided +**Decision**: Single Cargo binary crate at the repo root. No workspace, no separate lib crate. Internal modularity via `src/` subdirectories. +**Layout**: +``` +src/ +├── main.rs # entry point, iced app bootstrap +├── app.rs # top-level Viewer struct, Message enum, update/view +├── config.rs # keymap + theme loading +├── codec/ +│ ├── mod.rs # Codec trait, FormatRegistry +│ ├── anti_list.rs # the 8 anti-listed formats + fail-fast error +│ └── image_crate.rs # Tier 1 decoders via `image` crate +├── nav/ +│ ├── mod.rs # Navigator: current index, scroll handling +│ └── folder.rs # directory scan, sorting, format filtering +└── ui/ + ├── mod.rs + ├── toolbar.rs # top thin toolbar + ├── status_bar.rs # bottom status bar + ├── image_view.rs # central image display with fit-to-window + zoom/pan + ├── thumbnail_bar.rs # bottom strip (built in MVP) + └── context_menu.rs # gpicview-style right-click menu (built in MVP) +``` +**Reasoning**: A viewer application does not require a library crate. Separate modules provide the modularity benefits without workspace overhead. Splitting into a workspace is reserved for a future CLI mode or embedding use case. + +--- + +## D007 — Anti-list behavior: fail fast with a clear message + +**Date**: 2026-08-02 +**Status**: Decided +**Context**: When a user opens an anti-listed file (e.g. `.heic`, `.cr3`), we need to communicate the project's stance rather than silently skipping or showing a generic "unsupported format" error. +**Decision**: The codec layer's `decode(path)` function returns a typed error `DecodeError::AntiListed { path, format_name, reason, alternative }`. The UI layer renders this as a modal-style overlay with: +- The filename +- The format name (e.g. "HEIC — Apple's HEIF variant") +- A one-line reason (e.g. "HEVC patent-licensing baggage") +- A "What to do instead" hint pointing to the open alternative (e.g. "Convert to AVIF for HDR/animation, or to JPEG XL for archival.") +**Reasoning**: A silent skip makes the user think the viewer is broken. A generic error makes them think the format is unsupported due to technical limitations. The truth — that we deliberately do not support it on principle — needs to be communicated so the user understands the project's values. + +--- + +## D008 — Tier 2 format support: four dedicated codec modules + +**Date**: 2026-11-15 +**Status**: Decided +**Context**: D002 scheduled JPEG XL, TIFF, SVG, and OpenEXR for v0.3. The `Codec` trait in v0.2 already accepted any decoder that produced an RGBA8 buffer, so the question was which crates to bind and whether to route them through `image` or stand up dedicated modules. +**Decision**: Implement four dedicated codec modules — `src/codec/jxl.rs`, `tiff.rs`, `svg.rs`, `exr.rs` — each backed by a format-specific crate, all registered in `FormatRegistry::new()` alongside the existing `ImageCrateCodec`. + +- **JPEG XL** via `jxl-oxide` 0.12. The `image` crate has no JXL decoder; `jxl-oxide` is the maintained pure-Rust implementation. +- **TIFF** via the `tiff` 0.11 crate. The `image` crate's TIFF support lags on unusual sample formats; a direct `tiff` dependency gives us Gray/GrayA/RGB/RGBA/CMYK for both U8 and U16. +- **SVG** via `resvg` 0.47 (with `usvg` and `tiny-skia`). SVG is a vector format — it needs a full render pipeline, not a pixel decoder. `resvg` is the only maintained pure-Rust SVG renderer. +- **OpenEXR** via the `exr` 1.74 crate. EXR is HDR float data; we apply a Reinhard tone-map to bring it into the 8-bit display range. + +**Reasoning**: +- The `image` crate is not a complete format layer. JXL and EXR are not in it at all, its TIFF support lags on uncommon sample formats, and SVG is fundamentally outside its scope. +- Dedicated modules keep each format's quirks (tone mapping for EXR, SVGZ magic-byte detection, U16 downscaling for TIFF, CMYK fallback for JXL) in one place where they can be read and audited. +- All four codecs implement the same `Codec` trait, so the rest of the app — thumbnails, EXIF panel, anti-list guard — gets them for free. + +**Trade-offs**: Binary size grows by ~4.7 MB. SVG decode is slower than raster decode (a full render pipeline runs per frame). EXR tone-mapping is lossy by design — Reinhard is a reasonable default but not a color-management-grade choice. + +**Alternatives considered**: routing everything through `image` (rejected — JXL and EXR not supported); `libheif` bindings (rejected — anti-list adjacent); `imagemagick` bindings (rejected — licensing and binary footprint). + +**Reference**: `src/codec/jxl.rs`, `src/codec/tiff.rs`, `src/codec/svg.rs`, `src/codec/exr.rs`, `src/codec/mod.rs`. + +--- + +## D009 — Togglable sidebar: `Tab` key, gPhoto-inspired + +**Date**: 2026-11-15 +**Status**: Decided +**Context**: v0.2 had no folder-navigation surface beyond the initial file picker. Switching folders meant reopening the picker. D003 flagged a left-side folder panel as deferred work. +**Decision**: Add a togglable left sidebar (`src/ui/sidebar.rs`, 228 lines) bound to `Tab`. When visible it is 240px wide and lists sibling folders — every child of the current image's parent directory that contains at least one supported image — each annotated with an image count. Click a folder to switch to it. The sidebar hides in fullscreen. +**Reasoning**: +- The sidebar is not always visible. Most browsing sessions stay within a single folder; a persistent panel steals horizontal space from the image for no benefit. Toggling on demand matches the gPhoto pattern without imposing it. +- Listing siblings (not the full filesystem tree) keeps the panel focused on what a photo browser actually does: hopping between related folders. A full tree view belongs in a file manager, not a viewer. +- Hiding in fullscreen preserves the viewnior-style distraction-free mode that D003 promised. +- `Tab` is the binding because it is conventional (terminal multiplexers, editors, file managers all use it for panel switching), it is unmodified (no `Ctrl+Tab` ambiguity), and it was unused by v0.2. + +**Trade-offs**: `Tab` was previously reserved for nothing — but some users may have muscle memory for `Tab` from other apps. The keymap is configurable via `~/.config/marten/keymap.toml`, so users who want a different binding can set one. + +**Alternatives considered**: persistent sidebar (rejected — wastes horizontal space); full filesystem tree (rejected — wrong tool category); breadcrumb bar (rejected — does not scale to dozens of sibling folders). + +**Reference**: `src/ui/sidebar.rs`, `src/app.rs` (sidebar state and message wiring), `config/keymap.toml`. + +--- + +## D010 — Slideshow mode: 3-second interval via `iced::time::every` + +**Date**: 2026-11-15 +**Status**: Decided +**Context**: A slideshow is a standard image-viewer feature. The question was which timer mechanism to use and what the default interval should be. +**Decision**: Implement slideshow mode as a `bool`-gated `iced::time::every` subscription in `Viewer::subscription()`. The interval is fixed at three seconds. Bound to `s` to start, `s` or `Escape` to stop. +**Reasoning**: +- `iced::time::every` is the idiomatic iced timer. It produces a `Message` on each tick, which flows through the normal `update()` path. No background thread, no manual scheduling, no `tokio::interval` plumbing. +- Gating the subscription on a `bool` field means it produces zero ticks when the slideshow is off — there is no wake-on-every-three-seconds cost in the steady state. +- Three seconds is a defensible default: long enough to read a slide, short enough to not feel sluggish. Configurability is deferred to a future settings file (see v0.3 out-of-scope notes in `BLOG.md`). +- `Escape` also stops the slideshow because `Escape` is already the universal "dismiss overlay" binding, and slideshow is a transient mode in the same family. + +**Trade-offs**: The interval is not configurable in v0.3. Users who want a different rate must edit source or wait for v0.4. This is a deliberate scope limit, not an oversight — a settings file is its own piece of work and v0.3 was already at capacity. + +**Alternatives considered**: `tokio::time::interval` (rejected — bypasses iced's message flow); OS timer (rejected — not portable); variable interval per folder (rejected — no clear user need). + +**Reference**: `src/app.rs` (`subscription` and `update`), `config/keymap.toml`. + +--- + +## D011 — EXIF properties panel: `kamadak-exif`, replaces v0.2 toast + +**Date**: 2026-11-15 +**Status**: Decided +**Context**: v0.2's Properties action showed a one-line toast with dimensions, format, and file size. The `kamadak-exif` crate was already a dependency but had no consumer. D003 and the v0.2 release notes flagged a full properties panel as v0.3 work. +**Decision**: Replace the toast with a modal EXIF properties panel (`src/ui/exif_panel.rs`, 338 lines). The panel shows filename, path, dimensions, format, file size, and parsed EXIF metadata (camera make/model, lens, ISO, aperture, shutter speed, focal length, timestamp, GPS coordinates, orientation) via `kamadak-exif`. Bound to `i` and to right-click → Properties. +**Reasoning**: +- A toast cannot carry this much information. EXIF metadata for a typical camera photo is 8–12 fields; a modal is the right surface. +- `kamadak-exif` was already in `Cargo.toml` for v0.2; v0.3 actually uses it. No new dependency, no new binary-size cost beyond the panel itself. +- Files without EXIF (PNG without chunks, SVG, OpenEXR, screenshots) still show the file-info rows. The panel does not fabricate metadata — it shows what exists and omits what does not. +- `i` was the v0.2 binding for Properties. Keeping the binding stable preserves user muscle memory while the action's behavior expands. + +**Trade-offs**: A modal interrupts the viewing flow more than a toast does. The trade is intentional — the user explicitly asked for properties, so a focused panel is appropriate. + +**Alternatives considered**: side panel instead of modal (rejected — competes with the sidebar for horizontal space); inline status-bar expansion (rejected — too cramped for 12 fields); web-based EXIF viewer (rejected — offline-only is a project value). + +**Reference**: `src/ui/exif_panel.rs`, `src/app.rs` (panel state and message wiring). + +--- + +## D012 — Random navigation: step-aware, same-folder + tree-wide + +**Date**: 2026-12-20 +**Status**: Decided +**Context**: v0.3 navigation was strictly sequential (next, prev, first, last, jump-to-thumbnail). Users browsing large libraries wanted a "shuffle" entry point — jump to a random image, then keep scrolling from there. Two scopes were requested: same-folder shuffle, and a tree-wide shuffle that pulls from sibling subfolders. +**Decision**: Add two `KeymapAction` variants wired to the `Navigator`: + +- `RandomSameFolder` (bound to `z`, also surfaced as a toolbar shuffle button) — picks a random index from the current folder's image list. +- `RandomFolderTree` (bound to `Shift+Z`) — calls `walk_folder_tree()` in `src/nav/folder.rs` to collect every supported image in the current folder's parent tree, picks one at random, and if the chosen image lives in a different subfolder, replaces the navigator's image list with that folder's contents and switches to it. + +Both actions are step-aware: after a random jump, subsequent `navigate(±1)` calls step sequentially from the new position. The navigator already tracks `current` as a plain `usize`, so step-awareness is free — no separate "real" cursor is needed. + +Entropy comes from `pseudo_random(max)`, a free function in `src/nav/mod.rs` that takes `SystemTime::now().duration_since(UNIX_EPOCH).subsec_nanos() % max`. The `rand` crate is disproportionate for a single shuffle-style feature; nanosecond entropy is sufficient for an image viewer. + +**Reasoning**: +- Sequential navigation is the right default for "look at every photo in this folder", but it is poor for "show me something I forgot I had". Random nav addresses the second use case directly. +- Step-awareness is the difference between "shuffle" and "jump". A pure shuffle would re-randomize on every next/prev press; step-aware shuffle jumps once, then resumes sequential browsing — which is what users actually want. +- Tree-wide shuffle is a power feature. Listing every image across every sibling subfolder is a folder-tree walk; doing it on every keypress is wasteful, so the walk is performed once per `Shift+Z` press and the result is consumed immediately. +- `z` was unused. `Shift+Z` is the natural capitalized variant for the broader-scope action. + +**Trade-offs**: `pseudo_random` is not cryptographic. It does not need to be — this is a shuffle feature, not a security primitive. The modulo bias on `nanos % max` is sub-microsecond and irrelevant for image selection. + +**Alternatives considered**: `rand` crate (rejected — overkill for one feature); precomputed shuffled playlist (rejected — would need invalidation on folder change and conflicts with sequential nav); `SmallRng` seeded from `SystemTime` (rejected — same entropy source, more API surface). + +**Reference**: `src/nav/mod.rs` (`Navigator::random_same_folder`, `Navigator::random_from_tree`, `pseudo_random`), `src/nav/folder.rs` (`walk_folder_tree`), `src/app.rs` (`Message::RandomSameFolder`, `Message::RandomFolderTree`), `src/config.rs` (`KeymapAction::RandomSameFolder`, `KeymapAction::RandomFolderTree`), `config/keymap.toml`. + +--- + +## D013 — Thumbnail auto-scroll: timeline-style, centered via `scrollable::scroll_to` + +**Date**: 2026-12-20 +**Status**: Decided +**Context**: The top thumbnail strip in v0.3 highlighted the current image with an accent border but did not move. After navigating past the right edge of the viewport, the current thumbnail scrolled off-screen and the user lost the visual cursor. Video editors solve this by keeping the playhead centered and scrolling the timeline underneath it. +**Decision**: Expose the thumbnail strip's `scrollable::Id` via `pub fn thumb_scroll_id()` in `src/ui/thumbnail_bar.rs`. On every navigation event (scroll wheel, arrow keys, random jump, thumbnail click), the app layer calls `iced::widget::scrollable::scroll_to()` with an `AbsoluteOffset` computed as: + +```rust +let thumb_entry_width = THUMB_SIZE + 2.0 + 2.0; // image + border + spacing +let current_offset = self.thumbnail_bar.current as f32 * thumb_entry_width; +let viewport_w = self.window_size.width - self.sidebar.width(); +let center_offset = (current_offset - viewport_w / 2.0).max(0.0); +``` + +The `.max(0.0)` guard prevents negative offsets when the current thumbnail is already in the left half of the viewport. + +**Reasoning**: +- The thumbnail strip is the user's spatial map of the folder. When the cursor leaves the viewport, the map stops working. Auto-scroll keeps the cursor visible at all times. +- Centering (not just keeping-visible) is the video-editor pattern. Scrolling just enough to keep the current entry on-screen feels jumpy; centering is smooth and predictable. +- Triggering on every navigation event (not just on thumbnail click) means the user never has to manage the strip's scroll position manually. +- The thumbnail-bar module owns the Id; the app layer owns the offset math. The split is intentional — the bar does not know the viewport width (that depends on sidebar state, which is the app's concern). + +**Trade-offs**: Every navigation event triggers a `scroll_to` Task. iced 0.13's `scroll_to` is cheap, but it is one more message per navigation. For folders with thousands of images, rapid scroll-wheel motion produces a stream of these; iced coalesces them naturally. + +**Alternatives considered**: `scroll_to` with `RelativeOffset { x: 0.5, y: 0.5 }` (rejected — `RelativeOffset` is for scrollable widgets with `align_x(Center)` semantics; the thumbnail strip's entries are discrete, and `AbsoluteOffset` is more precise); manual scrollbar dragging (rejected — defeats the purpose); scroll-on-edge-only (rejected — jumpy and unpredictable). + +**Reference**: `src/ui/thumbnail_bar.rs` (`thumb_scroll_id`), `src/app.rs` (`scroll_to_thumbnail`). + +--- + +## D014 — Video export: ffmpeg external dependency, concat demuxer, VP9/AV1 codec choice + +**Date**: 2026-12-20 +**Status**: Decided +**Context**: "Export this folder as a video slideshow with music" is a feature request that has come up repeatedly. Implementing a video encoder in Rust is out of scope for an image viewer; ffmpeg already does this universally and is installed on most Linux desktops. +**Decision**: Add an `ExportDialog` module (`src/ui/export_dialog.rs`, 555 lines) that shells out to ffmpeg as a subprocess via `std::process::Command`. The dialog collects an audio file path, an output `.webm` path, a codec choice (VP9 via `libvpx-vp9` or AV1 via `libaom-av1`), and a seconds-per-image duration (default 3.0). On Export, marten writes a temporary concat-demuxer file list to `std::env::temp_dir()`, invokes ffmpeg with `-f concat -safe 0 -i -i