# 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.