marten/BLOG.md

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.