marten/BLOG.md

13 KiB

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:

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:

ffmpeg -y -f concat -safe 0 -i filelist.txt -i audio.mp3 \
  -c:v <libvpx-vp9|libaom-av1> -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 authorsjxl-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<FormatRegistry> 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.