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 toz) — picks a random image from the current folder's image list.RandomFolderTree(bound toShift+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:
- Right-click → "Export folder as video…" (new context-menu item with
the
Icon::FilmLucide icon). - The modal shows the image count and the folder name.
- User selects an audio file (
.mp3,.wav,.ogg,.flac,.aac, or.m4a). - User selects an output
.webmpath. - User picks a codec: VP9 (
libvpx-vp9) or AV1 (libaom-av1). - User sets seconds-per-image (default 3.0).
- 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— Lucideshuffle(five paths). Used in the toolbar.Icon::Film— Lucidefilm(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 theExportDialogstate struct, theExportMessageenum, theExportCodecenum (VP9,AV1) with itsffmpeg_vcodec()mapping, the modalview()renderer, and therun_ffmpeg_export()free function that performs the actual encode.
Extended modules
src/app.rs(1,065 → 1,317 lines) — addsMessage::RandomSameFolderandMessage::RandomFolderTreevariants; wires theExportDialogintoViewerand routesMessage::Export; addsscroll_to_thumbnail()helper that computes the centered offset and dispatchesscrollable::scroll_to; adds the fullscreen exit hint as a layer pushed onto theiced::widget::stack.src/config.rs(355 → 367 lines) — addsrandom_same_folderandrandom_folder_treefields toKeymap; correspondingKeymapAction::RandomSameFolderandKeymapAction::RandomFolderTreevariants.src/nav/mod.rs(151 → 210 lines) — addsNavigator::random_same_folder()andNavigator::random_from_tree()methods; adds thepseudo_random()free function (SystemTime nanos as entropy source).src/nav/folder.rs(85 → 134 lines) — addswalk_folder_tree()and its private recursive helper. Reusesscan_folder's extension filter.src/ui/icons.rs(75 → 79 lines) —Icon::ShuffleandIcon::Film.src/ui/toolbar.rs(165 → 167 lines) —ToolbarButton::Shufflevariant; 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'sscrollable::Idso the app layer can callscrollable::scroll_to()programmatically.src/ui/context_menu.rs(205 → 208 lines) —ContextMenuItem::ExportToVideovariant and its menu entry.src/ui/image_view.rs(245 → 244 lines) — zoom-mode rendering fix: image is now the direct child ofscrollablewithContentFit::ContainandLength::Fixed(dw/dh), replacing theLength::Fillwrapper that collapsed insidescrollable. 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_tomade the timeline thumbnail behavior a one-liner. - The
imagecrate contributors — handles every Tier 1 format with a consistent API. - The Tier 2 crate authors —
jxl-oxide,tiff,resvg,usvg,tiny-skia,exr, andflate2together 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
shuffleandfilm. - 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.