316 lines
13 KiB
Markdown
316 lines
13 KiB
Markdown
# 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 <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 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<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.
|