Marten is a desktop image viewer for Linux, written from scratch in Rust. Named after the marten (genus *Martes*) — a small agile mustelid native to forests across the Northern Hemisphere. Like its cousin the ferret (marten's sibling app for video playback), the marten is quick, curious, and nimble. Fitting energy for a photo viewer designed to move fast through large libraries.

This commit is contained in:
Jeremy Anderson 2026-08-02 03:44:04 -04:00
commit f91fb0745a
44 changed files with 8996 additions and 0 deletions

0
.gitignore vendored Normal file
View File

315
BLOG.md Normal file
View File

@ -0,0 +1,315 @@
# 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.

64
Cargo.toml Normal file
View File

@ -0,0 +1,64 @@
[package]
name = "marten"
version = "0.4.0"
edition = "2021"
description = "A modern, accuracy-first image viewer for Linux."
authors = ["Jeremy Anderson"]
license = "GPL-2.0-or-later"
repository = "http://git.dcos.net/dcosnet/marten"
homepage = "http://git.dcos.net/dcosnet/marten"
keywords = ["image", "viewer", "iced", "linux", "photo"]
categories = ["graphics", "multimedia::images"]
[[bin]]
name = "marten"
path = "src/main.rs"
[dependencies]
# GUI toolkit
iced = { version = "0.13", features = ["image", "tokio", "advanced", "svg"] }
# Image decoding (Tier 1)
image = { version = "0.25", default-features = false, features = [
"png", "jpeg", "gif", "webp", "bmp", "ico", "avif",
"rayon",
] }
# Image decoding (Tier 2)
jxl-oxide = "0.12" # JPEG XL — pure-Rust decoder
tiff = "0.11" # TIFF — direct decode (multi-page, 16-bit)
resvg = "0.47" # SVG rendering (pulls in usvg + tiny-skia)
exr = "1.74" # OpenEXR — pure-Rust HDR float
flate2 = "1" # gzip decompression for .svgz
# File dialog
rfd = "0.15"
# Config loading (keymap.toml)
serde = { version = "1", features = ["derive"] }
toml = "0.8"
dirs = "5"
# Error handling
thiserror = "1"
# Async
tokio = { version = "1", features = ["fs", "io-util", "rt-multi-thread"] }
# Logging
log = "0.4"
env_logger = "0.11"
# Context menu actions
arboard = "3" # clipboard (copy path, copy image)
trash = "5" # move to trash
kamadak-exif = "0.6" # EXIF for properties panel
[dev-dependencies]
tempfile = "3"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "symbols"

394
DECISION.md Normal file
View File

@ -0,0 +1,394 @@
# DECISION.md — image-viewer
This document records the architectural and product decisions that shape the project. New decisions get appended; existing decisions are not silently rewritten.
---
## D001 — Toolkit: iced
**Date**: 2026-08-02
**Status**: Decided
**Context**: We prototyped the same ristretto-core MVP slice (open folder, scroll-wheel nav, dark theme) in both `iced 0.13` and `egui 0.29`. Both compiled and produced release binaries. We tested both on Arch Linux.
**Decision**: Use **iced**.
**Reasoning**:
1. iced ran smoothly out of the box. egui loaded but exhibited visible runtime quirks that would have required debugging before any real feature work could start.
2. iced's `Theme::Dark` is closer to the ristretto+ target aesthetic without manual color overrides.
3. iced's declarative `Message` enum + `Task::perform` async story is a better fit for an app with this much interaction surface (scroll, zoom, pan, context menu, fullscreen, thumbnail clicks, configurable keymap). egui's lack of a built-in async story was already forcing us into `std::thread` + `JoinHandle` polling for the file dialog — that pattern does not scale to a lazy-loaded thumbnail bar.
**Trade-offs**: Larger binary (23 MB vs 15.5 MB). No built-in context-menu widget — marten implements its own overlay layer for full styling control of the gpicview-inspired menu. Fullscreen routes through iced's `window` subsystem rather than egui's `ViewportBuilder`.
**Alternatives considered**: egui (archived at `prototypes/egui-viewer-archived/`), Slint (not prototyped; evaluation scheduled if iced encounters a blocking limitation).
**Reference**: `prototypes/SPIKE_COMPARISON.md`.
---
## D002 — Format support: Tier 1 / 2 / 3 + explicit anti-list
**Date**: 2026-08-02
**Status**: Decided
**Context**: We surveyed open image formats (see `download/format-research.md`). The user explicitly rejected proprietary / semi-open formats.
**Decision**:
**Tier 1 (MVP)**: PNG (incl. APNG), JPEG, GIF (animated), WebP, AVIF, BMP, ICO/CUR.
All handled by the `image` crate (with `avif` feature for AVIF).
**Tier 2 (post-MVP)**: JPEG XL, TIFF, SVG (via `resvg`), OpenEXR (via `exr` crate). Scheduled for v0.3.
**Tier 3 (niche)**: QOI, JPEG 2000, JPEG XS.
**Anti-list — explicitly NOT supported, ever**:
- HEIF, HEIC — HEVC patent-licensing baggage. AVIF covers the same use case royalty-free.
- Canon CR3, Nikon NEF, Sony ARW — proprietary camera RAW specs.
- Adobe PSD — proprietary Photoshop format.
- Adobe DNG — "partially open"; Adobe-controlled.
- Apple Live Photos — proprietary paired image+video container.
**Reasoning**:
- Openness is a project value, not just a technical convenience.
- AVIF covers the HDR/animation niche that HEIC would otherwise fill, using the same ISOBMFF container but a royalty-free AV1 codec. There is no feature gap created by rejecting HEIC.
- Camera RAW is a moving target — every camera generation ships new proprietary variants. We would rather spend maintenance effort on better PNG/JPEG XL/AVIF support than on perpetual `libraw` bindings.
- PSD and DNG are Adobe-controlled formats with open alternatives (TIFF for layered raster, JPEG XL for archival masters).
- Apple Live Photos is a paired media container, not really an image format. Out of scope for a still-image viewer.
**Implementation requirement**: The codec layer MUST detect anti-listed file extensions and fail fast with a clear, user-facing error message explaining the project's stance. Silent skipping is not acceptable — the user needs to know *why* their file didn't open.
**Reference**: `download/format-research.md` (full survey + anti-list rationale), `README.md` (project-level summary).
---
## D003 — Layout: ristretto+ default, viewnior-minimal toggle
**Date**: 2026-08-02
**Status**: Decided
**Context**: The user wants the visual rhythm of ristretto (thin top toolbar + center image + bottom thumbnail bar) as the default, but with a hotkey to collapse all chrome for distraction-free viewing (viewnior-style).
**Decision**:
- Default layout: thin top toolbar (back / forward / zoom / rotate / fullscreen buttons) + center image area + bottom thumbnail strip.
- Hotkey (default `F11` or `F`) collapses all chrome → image fills window, controls float in on hover.
- A left-side folder panel slides in on demand (default `Tab` or `\`).
**Reasoning**: Matches the ristretto+ layout from the original brief; the collapse-to-minimal toggle is the "hybrid" the user asked for.
---
## D004 — Theme: always dark, refined palette
**Date**: 2026-08-02
**Status**: Decided
**Decision**: Hardcode a refined dark palette. No light theme, no system-follow mode for MVP.
**Palette**:
- Background (image area): `#0e0e10` (near-black, slight warm tint)
- Chrome (toolbar/status): `#16161a`
- Chrome border: `#26262c`
- Primary text: `#e4e4e7`
- Secondary text: `#a1a1aa`
- Accent (focus ring, active button): `#7c3aed` (refined violet — distinct from the blue-grey default of most "dark" themes)
- Danger (delete, trash): `#dc2626`
**Reasoning**: Photo viewers look best dark — bright chrome washes out bright photos. A single hardcoded palette keeps the v0.2 codebase simple; a theme API is scheduled for post-v0.2 if users request customization.
---
## D005 — Keymap: ship a default, expose `config/keymap.toml` from day one
**Date**: 2026-08-02
**Status**: Decided
**Decision**: Default keymap is hardcoded; a `config/keymap.toml` file (loaded from `$XDG_CONFIG_HOME/image-viewer/keymap.toml` if present, falling back to packaged default) overrides individual bindings. Unknown keys in the user's toml are warned about but do not crash.
**Default keymap**:
| Action | Key |
|---|---|
| Next photo | `→` or `L` |
| Prev photo | `←` or `H` |
| First photo | `g` |
| Last photo | `G` |
| Zoom in | `+` or `Ctrl+↑` |
| Zoom out | `-` or `Ctrl+↓` |
| Fit to window | `0` or `F` (in image area; `F11` reserved for fullscreen) |
| Actual size (100%) | `1` |
| Pan | drag (mouse) |
| Toggle fullscreen | `F11` |
| Toggle chrome (viewnior-minimal) | `Shift+F` or `F` outside image area (TBD) |
| Open folder | `O` |
| Quit | `Q` or `Ctrl+Q` |
| Right-click menu | right mouse button |
| Rotate CW | `R` |
| Rotate CCW | `Shift+R` |
| Properties | `I` (info) |
**Reasoning**: Power users want vim-style hjkl; everyone else wants arrows. We ship both by default. Exposing the toml from day one means we do not have to retrofit a config system later.
---
## D006 — Project shape: single binary crate, modular `src/`
**Date**: 2026-08-02
**Status**: Decided
**Decision**: Single Cargo binary crate at the repo root. No workspace, no separate lib crate. Internal modularity via `src/` subdirectories.
**Layout**:
```
src/
├── main.rs # entry point, iced app bootstrap
├── app.rs # top-level Viewer struct, Message enum, update/view
├── config.rs # keymap + theme loading
├── codec/
│ ├── mod.rs # Codec trait, FormatRegistry
│ ├── anti_list.rs # the 8 anti-listed formats + fail-fast error
│ └── image_crate.rs # Tier 1 decoders via `image` crate
├── nav/
│ ├── mod.rs # Navigator: current index, scroll handling
│ └── folder.rs # directory scan, sorting, format filtering
└── ui/
├── mod.rs
├── toolbar.rs # top thin toolbar
├── status_bar.rs # bottom status bar
├── image_view.rs # central image display with fit-to-window + zoom/pan
├── thumbnail_bar.rs # bottom strip (built in MVP)
└── context_menu.rs # gpicview-style right-click menu (built in MVP)
```
**Reasoning**: A viewer application does not require a library crate. Separate modules provide the modularity benefits without workspace overhead. Splitting into a workspace is reserved for a future CLI mode or embedding use case.
---
## D007 — Anti-list behavior: fail fast with a clear message
**Date**: 2026-08-02
**Status**: Decided
**Context**: When a user opens an anti-listed file (e.g. `.heic`, `.cr3`), we need to communicate the project's stance rather than silently skipping or showing a generic "unsupported format" error.
**Decision**: The codec layer's `decode(path)` function returns a typed error `DecodeError::AntiListed { path, format_name, reason, alternative }`. The UI layer renders this as a modal-style overlay with:
- The filename
- The format name (e.g. "HEIC — Apple's HEIF variant")
- A one-line reason (e.g. "HEVC patent-licensing baggage")
- A "What to do instead" hint pointing to the open alternative (e.g. "Convert to AVIF for HDR/animation, or to JPEG XL for archival.")
**Reasoning**: A silent skip makes the user think the viewer is broken. A generic error makes them think the format is unsupported due to technical limitations. The truth — that we deliberately do not support it on principle — needs to be communicated so the user understands the project's values.
---
## D008 — Tier 2 format support: four dedicated codec modules
**Date**: 2026-11-15
**Status**: Decided
**Context**: D002 scheduled JPEG XL, TIFF, SVG, and OpenEXR for v0.3. The `Codec` trait in v0.2 already accepted any decoder that produced an RGBA8 buffer, so the question was which crates to bind and whether to route them through `image` or stand up dedicated modules.
**Decision**: Implement four dedicated codec modules — `src/codec/jxl.rs`, `tiff.rs`, `svg.rs`, `exr.rs` — each backed by a format-specific crate, all registered in `FormatRegistry::new()` alongside the existing `ImageCrateCodec`.
- **JPEG XL** via `jxl-oxide` 0.12. The `image` crate has no JXL decoder; `jxl-oxide` is the maintained pure-Rust implementation.
- **TIFF** via the `tiff` 0.11 crate. The `image` crate's TIFF support lags on unusual sample formats; a direct `tiff` dependency gives us Gray/GrayA/RGB/RGBA/CMYK for both U8 and U16.
- **SVG** via `resvg` 0.47 (with `usvg` and `tiny-skia`). SVG is a vector format — it needs a full render pipeline, not a pixel decoder. `resvg` is the only maintained pure-Rust SVG renderer.
- **OpenEXR** via the `exr` 1.74 crate. EXR is HDR float data; we apply a Reinhard tone-map to bring it into the 8-bit display range.
**Reasoning**:
- The `image` crate is not a complete format layer. JXL and EXR are not in it at all, its TIFF support lags on uncommon sample formats, and SVG is fundamentally outside its scope.
- Dedicated modules keep each format's quirks (tone mapping for EXR, SVGZ magic-byte detection, U16 downscaling for TIFF, CMYK fallback for JXL) in one place where they can be read and audited.
- All four codecs implement the same `Codec` trait, so the rest of the app — thumbnails, EXIF panel, anti-list guard — gets them for free.
**Trade-offs**: Binary size grows by ~4.7 MB. SVG decode is slower than raster decode (a full render pipeline runs per frame). EXR tone-mapping is lossy by design — Reinhard is a reasonable default but not a color-management-grade choice.
**Alternatives considered**: routing everything through `image` (rejected — JXL and EXR not supported); `libheif` bindings (rejected — anti-list adjacent); `imagemagick` bindings (rejected — licensing and binary footprint).
**Reference**: `src/codec/jxl.rs`, `src/codec/tiff.rs`, `src/codec/svg.rs`, `src/codec/exr.rs`, `src/codec/mod.rs`.
---
## D009 — Togglable sidebar: `Tab` key, gPhoto-inspired
**Date**: 2026-11-15
**Status**: Decided
**Context**: v0.2 had no folder-navigation surface beyond the initial file picker. Switching folders meant reopening the picker. D003 flagged a left-side folder panel as deferred work.
**Decision**: Add a togglable left sidebar (`src/ui/sidebar.rs`, 228 lines) bound to `Tab`. When visible it is 240px wide and lists sibling folders — every child of the current image's parent directory that contains at least one supported image — each annotated with an image count. Click a folder to switch to it. The sidebar hides in fullscreen.
**Reasoning**:
- The sidebar is not always visible. Most browsing sessions stay within a single folder; a persistent panel steals horizontal space from the image for no benefit. Toggling on demand matches the gPhoto pattern without imposing it.
- Listing siblings (not the full filesystem tree) keeps the panel focused on what a photo browser actually does: hopping between related folders. A full tree view belongs in a file manager, not a viewer.
- Hiding in fullscreen preserves the viewnior-style distraction-free mode that D003 promised.
- `Tab` is the binding because it is conventional (terminal multiplexers, editors, file managers all use it for panel switching), it is unmodified (no `Ctrl+Tab` ambiguity), and it was unused by v0.2.
**Trade-offs**: `Tab` was previously reserved for nothing — but some users may have muscle memory for `Tab` from other apps. The keymap is configurable via `~/.config/marten/keymap.toml`, so users who want a different binding can set one.
**Alternatives considered**: persistent sidebar (rejected — wastes horizontal space); full filesystem tree (rejected — wrong tool category); breadcrumb bar (rejected — does not scale to dozens of sibling folders).
**Reference**: `src/ui/sidebar.rs`, `src/app.rs` (sidebar state and message wiring), `config/keymap.toml`.
---
## D010 — Slideshow mode: 3-second interval via `iced::time::every`
**Date**: 2026-11-15
**Status**: Decided
**Context**: A slideshow is a standard image-viewer feature. The question was which timer mechanism to use and what the default interval should be.
**Decision**: Implement slideshow mode as a `bool`-gated `iced::time::every` subscription in `Viewer::subscription()`. The interval is fixed at three seconds. Bound to `s` to start, `s` or `Escape` to stop.
**Reasoning**:
- `iced::time::every` is the idiomatic iced timer. It produces a `Message` on each tick, which flows through the normal `update()` path. No background thread, no manual scheduling, no `tokio::interval` plumbing.
- Gating the subscription on a `bool` field means it produces zero ticks when the slideshow is off — there is no wake-on-every-three-seconds cost in the steady state.
- Three seconds is a defensible default: long enough to read a slide, short enough to not feel sluggish. Configurability is deferred to a future settings file (see v0.3 out-of-scope notes in `BLOG.md`).
- `Escape` also stops the slideshow because `Escape` is already the universal "dismiss overlay" binding, and slideshow is a transient mode in the same family.
**Trade-offs**: The interval is not configurable in v0.3. Users who want a different rate must edit source or wait for v0.4. This is a deliberate scope limit, not an oversight — a settings file is its own piece of work and v0.3 was already at capacity.
**Alternatives considered**: `tokio::time::interval` (rejected — bypasses iced's message flow); OS timer (rejected — not portable); variable interval per folder (rejected — no clear user need).
**Reference**: `src/app.rs` (`subscription` and `update`), `config/keymap.toml`.
---
## D011 — EXIF properties panel: `kamadak-exif`, replaces v0.2 toast
**Date**: 2026-11-15
**Status**: Decided
**Context**: v0.2's Properties action showed a one-line toast with dimensions, format, and file size. The `kamadak-exif` crate was already a dependency but had no consumer. D003 and the v0.2 release notes flagged a full properties panel as v0.3 work.
**Decision**: Replace the toast with a modal EXIF properties panel (`src/ui/exif_panel.rs`, 338 lines). The panel shows filename, path, dimensions, format, file size, and parsed EXIF metadata (camera make/model, lens, ISO, aperture, shutter speed, focal length, timestamp, GPS coordinates, orientation) via `kamadak-exif`. Bound to `i` and to right-click → Properties.
**Reasoning**:
- A toast cannot carry this much information. EXIF metadata for a typical camera photo is 812 fields; a modal is the right surface.
- `kamadak-exif` was already in `Cargo.toml` for v0.2; v0.3 actually uses it. No new dependency, no new binary-size cost beyond the panel itself.
- Files without EXIF (PNG without chunks, SVG, OpenEXR, screenshots) still show the file-info rows. The panel does not fabricate metadata — it shows what exists and omits what does not.
- `i` was the v0.2 binding for Properties. Keeping the binding stable preserves user muscle memory while the action's behavior expands.
**Trade-offs**: A modal interrupts the viewing flow more than a toast does. The trade is intentional — the user explicitly asked for properties, so a focused panel is appropriate.
**Alternatives considered**: side panel instead of modal (rejected — competes with the sidebar for horizontal space); inline status-bar expansion (rejected — too cramped for 12 fields); web-based EXIF viewer (rejected — offline-only is a project value).
**Reference**: `src/ui/exif_panel.rs`, `src/app.rs` (panel state and message wiring).
---
## D012 — Random navigation: step-aware, same-folder + tree-wide
**Date**: 2026-12-20
**Status**: Decided
**Context**: v0.3 navigation was strictly sequential (next, prev, first, last, jump-to-thumbnail). Users browsing large libraries wanted a "shuffle" entry point — jump to a random image, then keep scrolling from there. Two scopes were requested: same-folder shuffle, and a tree-wide shuffle that pulls from sibling subfolders.
**Decision**: Add two `KeymapAction` variants wired to the `Navigator`:
- `RandomSameFolder` (bound to `z`, also surfaced as a toolbar shuffle button) — picks a random index from the current folder's image list.
- `RandomFolderTree` (bound to `Shift+Z`) — calls `walk_folder_tree()` in `src/nav/folder.rs` to collect every supported image in the current folder's parent tree, picks one at random, and if the chosen image lives in a different subfolder, replaces the navigator's image list with that folder's contents and switches to it.
Both actions are step-aware: after a random jump, subsequent `navigate(±1)` calls step sequentially from the new position. The navigator already tracks `current` as a plain `usize`, so step-awareness is free — no separate "real" cursor is needed.
Entropy comes from `pseudo_random(max)`, a free function in `src/nav/mod.rs` that takes `SystemTime::now().duration_since(UNIX_EPOCH).subsec_nanos() % max`. The `rand` crate is disproportionate for a single shuffle-style feature; nanosecond entropy is sufficient for an image viewer.
**Reasoning**:
- Sequential navigation is the right default for "look at every photo in this folder", but it is poor for "show me something I forgot I had". Random nav addresses the second use case directly.
- Step-awareness is the difference between "shuffle" and "jump". A pure shuffle would re-randomize on every next/prev press; step-aware shuffle jumps once, then resumes sequential browsing — which is what users actually want.
- Tree-wide shuffle is a power feature. Listing every image across every sibling subfolder is a folder-tree walk; doing it on every keypress is wasteful, so the walk is performed once per `Shift+Z` press and the result is consumed immediately.
- `z` was unused. `Shift+Z` is the natural capitalized variant for the broader-scope action.
**Trade-offs**: `pseudo_random` is not cryptographic. It does not need to be — this is a shuffle feature, not a security primitive. The modulo bias on `nanos % max` is sub-microsecond and irrelevant for image selection.
**Alternatives considered**: `rand` crate (rejected — overkill for one feature); precomputed shuffled playlist (rejected — would need invalidation on folder change and conflicts with sequential nav); `SmallRng` seeded from `SystemTime` (rejected — same entropy source, more API surface).
**Reference**: `src/nav/mod.rs` (`Navigator::random_same_folder`, `Navigator::random_from_tree`, `pseudo_random`), `src/nav/folder.rs` (`walk_folder_tree`), `src/app.rs` (`Message::RandomSameFolder`, `Message::RandomFolderTree`), `src/config.rs` (`KeymapAction::RandomSameFolder`, `KeymapAction::RandomFolderTree`), `config/keymap.toml`.
---
## D013 — Thumbnail auto-scroll: timeline-style, centered via `scrollable::scroll_to`
**Date**: 2026-12-20
**Status**: Decided
**Context**: The top thumbnail strip in v0.3 highlighted the current image with an accent border but did not move. After navigating past the right edge of the viewport, the current thumbnail scrolled off-screen and the user lost the visual cursor. Video editors solve this by keeping the playhead centered and scrolling the timeline underneath it.
**Decision**: Expose the thumbnail strip's `scrollable::Id` via `pub fn thumb_scroll_id()` in `src/ui/thumbnail_bar.rs`. On every navigation event (scroll wheel, arrow keys, random jump, thumbnail click), the app layer calls `iced::widget::scrollable::scroll_to()` with an `AbsoluteOffset` computed as:
```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);
```
The `.max(0.0)` guard prevents negative offsets when the current thumbnail is already in the left half of the viewport.
**Reasoning**:
- The thumbnail strip is the user's spatial map of the folder. When the cursor leaves the viewport, the map stops working. Auto-scroll keeps the cursor visible at all times.
- Centering (not just keeping-visible) is the video-editor pattern. Scrolling just enough to keep the current entry on-screen feels jumpy; centering is smooth and predictable.
- Triggering on every navigation event (not just on thumbnail click) means the user never has to manage the strip's scroll position manually.
- The thumbnail-bar module owns the Id; the app layer owns the offset math. The split is intentional — the bar does not know the viewport width (that depends on sidebar state, which is the app's concern).
**Trade-offs**: Every navigation event triggers a `scroll_to` Task. iced 0.13's `scroll_to` is cheap, but it is one more message per navigation. For folders with thousands of images, rapid scroll-wheel motion produces a stream of these; iced coalesces them naturally.
**Alternatives considered**: `scroll_to` with `RelativeOffset { x: 0.5, y: 0.5 }` (rejected — `RelativeOffset` is for scrollable widgets with `align_x(Center)` semantics; the thumbnail strip's entries are discrete, and `AbsoluteOffset` is more precise); manual scrollbar dragging (rejected — defeats the purpose); scroll-on-edge-only (rejected — jumpy and unpredictable).
**Reference**: `src/ui/thumbnail_bar.rs` (`thumb_scroll_id`), `src/app.rs` (`scroll_to_thumbnail`).
---
## D014 — Video export: ffmpeg external dependency, concat demuxer, VP9/AV1 codec choice
**Date**: 2026-12-20
**Status**: Decided
**Context**: "Export this folder as a video slideshow with music" is a feature request that has come up repeatedly. Implementing a video encoder in Rust is out of scope for an image viewer; ffmpeg already does this universally and is installed on most Linux desktops.
**Decision**: Add an `ExportDialog` module (`src/ui/export_dialog.rs`, 555 lines) that shells out to ffmpeg as a subprocess via `std::process::Command`. The dialog collects an audio file path, an output `.webm` path, a codec choice (VP9 via `libvpx-vp9` or AV1 via `libaom-av1`), and a seconds-per-image duration (default 3.0). On Export, marten writes a temporary concat-demuxer file list to `std::env::temp_dir()`, invokes ffmpeg with `-f concat -safe 0 -i <list> -i <audio> -c:v <vcodec> -crf 30 -b:v 0 -c:a libopus -shortest <output>`, and reports the result.
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 work is in flight; `ExportCompleted(Result<(), String>)` flips it back and stores either a success message or the error string.
The concat demuxer requires the last image to be repeated without a `duration` line — ffmpeg ignores the final `duration` otherwise. Single quotes in file paths are escaped with the standard `'\''` sequence. If the `ffmpeg` binary is missing (`io::ErrorKind::NotFound`), the dialog reports "ffmpeg not found. Install ffmpeg to use video export." A non-zero exit status surfaces ffmpeg's last stderr line as the error message.
**Reasoning**:
- ffmpeg is the universal video tool on Linux. Delegating encoding to it keeps marten's binary small (no codec link-time dependencies) and gets VP9 + AV1 + Opus for free.
- The concat demuxer is the right ffmpeg interface for slideshow-with-variable-duration. The alternative — pre-rendering each image as a video segment and concatenating with the concat protocol — wastes disk and time.
- VP9 and AV1 cover the realistic quality/compatibility trade space. VP9 is the broad-compatibility default; AV1 is the better-compression slower option. H.264 was rejected as an option because of its patent-licensing baggage (consistent with the anti-list stance in D002).
- `spawn_blocking` is the same pattern v0.3 uses for thumbnail decode. The pattern's trade-offs are well understood at this point.
- The dialog is a modal because the export is a focused, one-shot action. Inline status-bar progress was considered but rejected — the export has multiple user-selectable inputs that need a form, not a status line.
**Trade-offs**: ffmpeg is now a runtime dependency for the video-export feature. Marten does not require ffmpeg for any other feature — image viewing, thumbnails, EXIF, slideshow all work without it. The error message when ffmpeg is missing is explicit and actionable.
**Alternatives considered**: rav1e / vpx Rust bindings (rejected — would add link-time dependencies and ~10 MB to the binary for a feature not all users need); bundled ffmpeg binary (rejected — licensing and distribution footprint); web-based encoder service (rejected — offline-only is a project value); H.264 output (rejected — patent licensing).
**Reference**: `src/ui/export_dialog.rs` (`ExportDialog`, `ExportMessage`, `ExportCodec`, `run_ffmpeg_export`), `src/app.rs` (`Message::Export` dispatch), `src/ui/context_menu.rs` (`ContextMenuItem::ExportToVideo`).
---
## D015 — Zoom rendering fix: `ContentFit::Contain` + direct `scrollable` child, 1.1x step
**Date**: 2026-12-20
**Status**: Decided
**Context**: The v0.3 zoom pipeline was broken. In `ZoomMode::Custom`, the image widget was wrapped in a `Length::Fill` container 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. Pressing zoom in or out made the image disappear.
**Decision**: Make the image widget the direct child of `scrollable` with explicit `Length::Fixed(dw)` and `Length::Fixed(dh)` dimensions and `ContentFit::Contain`. Because `dw/dh` is computed from `iw * factor` and `ih * factor`, the `Contain` constraint is a no-op scaling-wise — it exists only to prevent iced from injecting padding.
The zoom step was also reduced from 1.25x to 1.1x. `zoom_in` multiplies the current factor by 1.1 (or starts at 1.1 from `FitToWindow`/`ActualSize`); `zoom_out` multiplies by 0.9 (or starts at 0.9). The 1.1 step produces smoother, more graceful zoom transitions than 1.25, which overshot on every keypress.
**Reasoning**:
- The `Length::Fill` collapse inside `scrollable` is a documented iced 0.13 layout constraint, not a bug. The fix is to give the image explicit dimensions so the layout pass does not have to resolve an infinite-available-size request.
- `ContentFit::Contain` on a `Length::Fixed` box is functionally a no-op when the box matches the image's aspect ratio, but it is the correct semantic — "fit this image inside this box without distortion" — and it costs nothing.
- 1.1 is the right zoom step. 1.25 was chosen in v0.2 to make a single keypress visible; it turns out that 1.1 is visible enough (10% per press) and produces far smoother multi-press zoom sequences. 1.05 would be too slow; 1.5 would be too jumpy.
- The `view()` doc-comment in `src/ui/image_view.rs` records both rendering strategies explicitly so the next person to touch this code does not re-introduce the collapse.
**Trade-offs**: The 1.1 step requires more keypresses to reach high zoom factors (16.0 max takes ~28 presses from 1.0). Users who need high zoom typically use `Ctrl+scroll` which has the same step but feels faster because the wheel produces multiple events per detent.
**Alternatives considered**: `Length::Shrink` (rejected — same collapse issue inside `scrollable`); `ContentFit::Cover` (rejected — would crop the image); fixing the layout pass in iced upstream (rejected — out of scope for an image viewer); keeping 1.25 step (rejected — too jumpy).
**Reference**: `src/ui/image_view.rs` (`ImageView::view`, `ImageView::zoom_in`, `ImageView::zoom_out`).
---
## D016 — Fullscreen exit hint: floating text overlay, always visible in fullscreen
**Date**: 2026-12-20
**Status**: Decided
**Context**: v0.3 fullscreen mode hid all chrome — including any indicator of how to leave. Users who entered fullscreen and paused before pressing F11 reported being "stuck" because they forgot the keybinding.
**Decision**: Render a small floating hint — "Press F11 to exit fullscreen" — at the top-center of the screen whenever `self.fullscreen` is true. The hint is a `container` with `Color::from_rgba(0.086, 0.086, 0.102, 0.85)` background (semi-transparent dark), a 1px chrome border, 4px corner radius, and 6/14px padding. It is positioned 12px from the top of the viewport via outer-container padding and `align_x(Center)`, and pushed onto the `iced::widget::stack` as the topmost layer below the toast.
**Reasoning**:
- The hint is always visible in fullscreen. Unlike chrome elements (thumbnail strip, sidebar, toolbar, status bar) it does not hide on a timer — its only job is to remind the user how to leave. If it disappeared, users who paused before pressing F11 would be stranded again.
- Top-center placement is unobtrusive but unmissable. It does not overlap the image's center of interest (which is typically middle or lower-center for photos).
- Semi-transparent dark background matches the existing chrome palette and does not wash out the image underneath.
- F11 is the binding named in the hint because it is the universal fullscreen key on Linux. `Shift+F` also exits fullscreen (and the keymap is configurable), but the hint names the conventional binding to avoid confusion.
- The hint is implemented as a stack layer, not as a chrome element, because it is independent of the chrome-show/hide state. Adding it to the chrome list would have required a separate "always-on" flag.
**Trade-offs**: The hint is visible during fullscreen photo viewing, which slightly compromises the viewnior-style distraction-free mode. The compromise is intentional — a one-line, semi-transparent reminder is a smaller cost than a user stranded in fullscreen.
**Alternatives considered**: hint on a 5-second timer (rejected — defeats the purpose for users who pause); hint in a corner (rejected — too easy to miss); hint as a chrome element with an "always-on" flag (rejected — over-engineered for one overlay); no hint (rejected — the v0.3 behavior was the bug being fixed).
**Reference**: `src/app.rs` (fullscreen hint layer in `Viewer::view`).
---
## Decision log
| ID | Date | Title | Status |
|---|---|---|---|
| D001 | 2026-08-02 | Toolkit: iced | Decided |
| D002 | 2026-08-02 | Format support: Tier 1/2/3 + anti-list | Decided |
| D003 | 2026-08-02 | Layout: ristretto+ default, viewnior-minimal toggle | Decided |
| D004 | 2026-08-02 | Theme: always dark, refined palette | Decided |
| D005 | 2026-08-02 | Keymap: default + `config/keymap.toml` | Decided |
| D006 | 2026-08-02 | Project shape: single binary, modular `src/` | Decided |
| D007 | 2026-08-02 | Anti-list behavior: fail fast with clear message | Decided |
| D008 | 2026-11-15 | Tier 2 format support: four dedicated codec modules | Decided |
| D009 | 2026-11-15 | Togglable sidebar: `Tab` key, gPhoto-inspired | Decided |
| D010 | 2026-11-15 | Slideshow mode: 3-second interval via `iced::time::every` | Decided |
| D011 | 2026-11-15 | EXIF properties panel: `kamadak-exif`, replaces v0.2 toast | Decided |
| D012 | 2026-12-20 | Random navigation: step-aware, same-folder + tree-wide | Decided |
| D013 | 2026-12-20 | Thumbnail auto-scroll: timeline-style via `scrollable::scroll_to` | Decided |
| D014 | 2026-12-20 | Video export: ffmpeg external dependency, concat demuxer, VP9/AV1 | Decided |
| D015 | 2026-12-20 | Zoom rendering fix: `ContentFit::Contain` + direct `scrollable` child, 1.1x step | Decided |
| D016 | 2026-12-20 | Fullscreen exit hint: floating text overlay, always visible in fullscreen | Decided |

340
LICENSE Normal file
View File

@ -0,0 +1,340 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is a work based on the
Program, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent license
would not permit royalty-free redistribution of the Program by all those
who receive copies directly or indirectly through you, then the only way
you could satisfy both it and this License would be to refrain entirely
from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free
Software Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
marten — a modern, accuracy-first image viewer for Linux.
Copyright (C) 2026 Jeremy Anderson
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
marten Copyright (C) 2026 Jeremy Anderson
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`marten' (an image viewer for Linux) written by Jeremy Anderson.
<signature of Ty Coon>, 1 April 2026
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

316
QUICKSTART.md Normal file
View File

@ -0,0 +1,316 @@
# marten — Quickstart
A 5-minute guide to get marten running and showing you photos.
---
### build from source
```bash
# Prerequisites (Arch)
sudo pacman -S --needed rust gtk3 wayland-protocols libx11 libxcb fontconfig
# Build
git clone http://git.dcos.net/dcosnet/marten.git
cd marten
cargo run --release
```
The first build takes ~5 minutes (lots of GUI dependencies to compile).
Subsequent builds are incremental and fast.
---
## 2. Open a folder
When marten starts, you'll see a dark window with "No image — press O to
open a folder" in the center.
Press `O` (the letter, not the number zero).
Pick a folder that has some images in it. Marten will scan it, filter out
unsupported extensions, sort lexicographically, and show the first image.
The top thumbnail strip will populate over the next few seconds as
thumbnails decode in the background.
---
## 3. Navigate
| Action | How |
|---|---|
| Next photo | `→` arrow key, or `L`, or scroll wheel **down** |
| Previous photo | `←` arrow key, or `H`, or scroll wheel **up** |
| First photo | `G` (uppercase — vim convention) |
| Last photo | `g` (lowercase) |
| Random photo in this folder | `z`, or click the **shuffle** button in the toolbar |
| Random photo in folder tree | `Shift+Z` |
| Jump to specific image | click its thumbnail in the top strip |
The scroll wheel works anywhere in the image area — you do not need to
position the cursor over the image. This is the ristretto-style behavior
marten was designed around.
Whenever the current image changes (scroll, arrow key, random jump, or
thumbnail click), the top thumbnail strip auto-scrolls to keep the
current entry centered — like a video editor's playhead on a timeline.
---
## 4. Zoom and pan
| Action | How |
|---|---|
| Fit to window | `0` (zero), or click the **fit** icon in the bottom toolbar |
| 100% (actual size) | `1`, or click the **1:1** icon |
| Zoom in | `+`, or `Ctrl+↑`, or click **+** |
| Zoom out | `-`, or `Ctrl+↓`, or click **** |
| Pan when zoomed | scroll wheel, or drag the scrollbars |
In fit-to-window mode (default), the scroll wheel navigates between
photos. In any zoom mode (100% or custom), the scroll wheel pans the
image. This prevents accidental navigation while you are trying to look
at a zoomed detail.
Each zoom step multiplies the factor by 1.1 (zoom in) or divides by 1.1
(zoom out), producing smooth, graceful changes that do not overshoot.
---
## 5. Rotate
| Action | How |
|---|---|
| Rotate 90° clockwise | `R`, or click the rotate-CW icon, or right-click → Rotate 90° CW |
| Rotate 90° counter-clockwise | `Shift+R`, or click the rotate-CCW icon, or right-click → Rotate 90° CCW |
Rotation is non-destructive — the original file is never modified. The
pixel buffer is rotated in memory and the image is re-rendered. Switching
to another photo resets rotation to 0°.
---
## 6. Right-click menu
Right-click anywhere in the image area. You will get a gpicview-style menu
with 12 actions:
- **Open With…** — launches `xdg-open` to hand the file to your system's
default image handler.
- **Copy Path** — copies the absolute file path to the clipboard.
- **Copy Image** — copies the actual pixel data to the clipboard (so you
can paste into GIMP, Krita, etc.).
- **Copy to Pictures** — copies the file to `~/Pictures/`. If a file with
the same name already exists there, marten appends `_1`, `_2`, etc.
- **Rotate 90° CW / CCW** — same as the toolbar buttons.
- **Set as Wallpaper** — tries `gsettings` (GNOME) first, falls back to
`feh` for standalone window managers.
- **Move to Trash** — sends the file to the system trash via the `trash`
crate. Removes it from the navigator and loads the next image.
- **Delete Permanently**`std::fs::remove_file()`. Bypasses trash
entirely. Use with care.
- **Properties** — opens the EXIF properties panel (see step 7).
- **Export folder as video…** — opens the video export dialog (see step 8).
- **About marten** — opens the About dialog.
---
## 7. Random navigation, sidebar, slideshow, EXIF panel
Random navigation is new in v0.4.0; the sidebar, slideshow, and EXIF
panel arrived in v0.3.0 and are grouped here for browsing-mode
reference.
### Random navigation (`z` and `Shift+Z`)
Press `z` to jump to a random image in the current folder. Press
`Shift+Z` to jump to a random image anywhere in the current folder's
parent tree — marten walks the parent directory recursively, collects
every supported image across all subfolders, and picks one at random.
If the chosen image lives in a different subfolder, marten switches to
that folder automatically. Both actions are step-aware: subsequent
arrow/scroll navigation continues sequentially from the new position.
Click the **shuffle** button in the bottom toolbar (between Next and
Fit) for the same effect as pressing `z`.
### Folder tree sidebar (`Tab`)
Press `Tab` to slide in a 240px left panel. It lists the sibling folders
of your current location — every child of the parent directory that
contains at least one supported image — each with an image count. Click
any entry to switch folders. Press `Tab` again to dismiss. The sidebar
hides automatically in fullscreen.
### Slideshow mode (`s`)
Press `s` to start an automatic slideshow. Marten advances to the next
photo every three seconds via an `iced::time::every` subscription.
Press `s` again or `Escape` to stop. A toast confirms the start and stop
of the slideshow.
### EXIF properties panel (`i`)
Press `i` (or right-click → Properties) to open a modal with the full
file metadata:
- Filename, path, dimensions, format, file size
- Camera make and model
- Lens model
- ISO, aperture (f-number), shutter speed, focal length
- Timestamp
- GPS coordinates
- Orientation flag
EXIF is parsed with `kamadak-exif`. Files without EXIF (PNG, SVG,
OpenEXR, screenshots, etc.) still show the file-info rows. Press
`Escape` or click outside the card to dismiss.
---
## 8. Export a folder as video
Right-click → **Export folder as video…** opens a modal that turns the
current folder into a `.webm` slideshow with an audio track of your
choice.
1. The dialog shows the image count and the folder name.
2. Click **Select audio file** and pick an audio file (`.mp3`, `.wav`,
`.ogg`, `.flac`, `.aac`, or `.m4a`).
3. Click **Select output file** and pick a destination `.webm` path.
4. Choose a codec: **VP9** (`libvpx-vp9`, broad compatibility, faster
encode) or **AV1** (`libaom-av1`, better compression, slower encode).
5. Set the seconds-per-image duration (default 3.0).
6. Click **Export**. ffmpeg runs in a background thread via
`tokio::task::spawn_blocking`; the dialog shows "Exporting…" then
"Export complete!" or an error message.
Marten invokes ffmpeg with the concat demuxer and a temporary file
list. ffmpeg must be installed and on `$PATH` — if it is missing, the
dialog reports "ffmpeg not found. Install ffmpeg to use video export."
---
## 9. Keyboard shortcuts (full list)
| Action | Default bindings |
|---|---|
| Next photo | `→` or `L` |
| Previous photo | `←` or `H` |
| First photo | `g` |
| Last photo | `G` |
| Random photo (same folder) | `z` |
| Random photo (folder tree) | `Shift+Z` |
| Zoom in | `+` or `Ctrl+↑` |
| Zoom out | `-` or `Ctrl+↓` |
| Fit to window | `0` |
| Actual size (100%) | `1` |
| Rotate CW | `r` |
| Rotate CCW | `Shift+R` |
| Toggle sidebar | `Tab` |
| Toggle slideshow | `s` |
| Toggle fullscreen | `F11` |
| Toggle chrome (minimal mode) | `Shift+F` |
| Open folder | `o` |
| Copy to Pictures | `Shift+Home` |
| Delete permanently | `Shift+Delete` |
| Properties (EXIF panel) | `i` |
| About marten | `a` |
| Quit | `q` or `Ctrl+Q` |
| Dismiss overlay (menu/modal) | `Escape` |
All of these are configurable — see step 10.
---
## 10. Customize the keymap
Copy the default keymap to your config directory:
```bash
mkdir -p ~/.config/marten
cp config/keymap.toml ~/.config/marten/keymap.toml
$EDITOR ~/.config/marten/keymap.toml
```
The format is a flat TOML map. Each action takes a list of bindings
(you can bind multiple keys to the same action):
```toml
next_photo = ["Right", "l", "Space"]
prev_photo = ["Left", "h", "Backspace"]
```
Modifier syntax: `Ctrl+Shift+R`, `Alt+Tab`, `Logo+L` (logo = Super/Windows/Cmd).
Modifiers are case-insensitive; the key name itself is case-sensitive
(`g` and `G` are different bindings, following vim convention).
Restart marten after editing. Unknown keys are warned about in the
console (run with `RUST_LOG=info` to see warnings) but do not crash.
---
## 11. Fullscreen / minimal mode
Press `F11` (or `Shift+F`) to toggle fullscreen. In this mode:
- The thumbnail strip, sidebar, toolbar, and status bar all hide.
- The image fills the entire window.
- A small floating hint at the top-center of the screen ("Press F11 to
exit fullscreen") reminds you how to leave.
- All keyboard shortcuts still work.
- Right-click still works.
Press `F11` or `Shift+F` again to exit fullscreen.
---
## 12. Quit
Press `Q` or `Ctrl+Q`. Or close the window normally.
---
## Troubleshooting
**"No supported images in that folder"** — the folder exists but contains
no files with extensions marten recognizes. Check that your files end in
`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.avif`, `.bmp`, `.ico`, `.cur`,
`.jxl`, `.tif`, `.tiff`, `.svg`, `.svgz`, or `.exr`.
**The window opens but is blank / shows a black screen** — your GPU
driver may not support Vulkan. Check the console output for wgpu errors.
Marten requires a Vulkan-capable GPU; NVIDIA, AMD, and Intel drivers
all work.
**Anti-list modal appears** — you tried to open a `.heic`, `.cr3`, `.nef`,
`.arw`, `.psd`, `.dng`, or Apple Live Photo file. Marten deliberately
refuses these formats. The modal explains why and suggests an open
alternative (e.g. convert HEIC to AVIF).
**Thumbnails are slow to load** — marten decodes thumbnails on a tokio
`spawn_blocking` thread pool at 72px using nearest-neighbor filtering.
For folders with thousands of images, only the current ±15 are cached
at any time. If your disk is slow (network mount, spinning rust), the
first scroll through a fresh folder may take a moment.
**"ffmpeg not found" in the export dialog** — video export shells out to
ffmpeg. Install it via your distribution package manager
(`sudo pacman -S ffmpeg` on Arch, `sudo apt install ffmpeg` on Debian,
`sudo dnf install ffmpeg` on Fedora) and ensure the `ffmpeg` binary is
on `$PATH`.
**`RUST_LOG=warn marten`** — shows warnings (malformed keymap, decode
errors) in the console. `RUST_LOG=info` adds informational messages
(startup, adapter selection). `RUST_LOG=debug` is very noisy.
---
## Where to go next
- `README.md` — full project overview, format support tiers, license.
- `DECISION.md` — architectural decision records (why iced, why dark-only,
why the anti-list, etc.).
- `BLOG.md` — v0.4.0 release notes and design rationale.
- `config/keymap.toml` — the default keymap, fully commented.

358
README.md Normal file
View File

@ -0,0 +1,358 @@
# marten
**A modern, accuracy-first image viewer for Linux.**
Version 0.4.0 · GPL-2.0-or-later · Built with Rust, iced, wgpu, and winit.
Marten is a desktop image viewer for Linux, written from scratch in Rust.
Named after the marten (genus *Martes*) — a small agile mustelid native to
forests across the Northern Hemisphere. Like its cousin the ferret (marten's
sibling app for video playback), the marten is quick, curious, and nimble.
Fitting energy for a photo viewer designed to move fast through large libraries.
---
## What marten does
Marten is a **single-window image viewer** focused on fast folder-based
browsing. It is not a photo manager, not an editor, not a library tool. It
opens a folder, shows you the images in it, and gets out of your way.
### Core features (v0.4.0)
- **Ristretto-style scroll-wheel navigation** — scroll the wheel anywhere in
the image area to shuffle to the next/previous photo in the folder.
Natural-scroll direction (scroll up = previous, scroll down = next).
- **gpicview-style bottom toolbar** — all controls live in a thin bottom bar,
leaving the top free for the thumbnail strip. SVG icons (Lucide) throughout.
- **Top thumbnail strip** — lazy-loaded horizontal strip at the top of the
window. Current image highlighted with an accent border. Click any
thumbnail to jump to it. Thumbnails decode on a tokio `spawn_blocking`
thread pool at 72px and are cached in an LRU window of ±15 around the
current image.
- **Tier 2 format support** — JPEG XL (`.jxl`), TIFF (`.tif`/`.tiff`),
SVG (`.svg`/`.svgz`), and OpenEXR (`.exr`) join the Tier 1 roster via
dedicated codec modules. Thumbnails and EXIF parsing apply to every
supported format.
- **Step-aware random navigation**`z` jumps to a random image in the
current folder; `Shift+Z` walks the parent folder tree recursively,
collects every supported image across all subfolders, and picks one
at random. If the chosen image lives in a different subfolder, marten
switches to that folder automatically. Both actions are step-aware:
subsequent arrow/scroll navigation continues sequentially from the
new position rather than from where you left off.
- **Timeline-style thumbnail auto-scroll** — the top thumbnail strip
auto-scrolls to keep the current image centered, like a video
editor's playhead on a timeline. Triggered on every navigation event
(scroll, arrow keys, random jump, thumbnail click).
- **Folder-as-video export** — right-click → Export folder as video…
opens a modal that turns the current folder into a `.webm` slideshow
with a user-selected audio track. ffmpeg runs in a background thread
via `tokio::task::spawn_blocking`; VP9 (`libvpx-vp9`) and AV1
(`libaom-av1`) codecs are selectable. Requires ffmpeg installed.
- **Togglable folder tree sidebar** — press `Tab` to show a 240px left
panel listing sibling folders (children of the current parent that
contain images), each with an image count. Click a folder to switch to
it. The sidebar hides in fullscreen.
- **Slideshow mode** — press `s` to auto-advance to the next photo every
three seconds via an `iced::time::every` subscription. Press `s` again
or `Escape` to stop. A toast confirms the state change.
- **EXIF properties panel** — press `i` or right-click → Properties to
open a modal showing filename, path, dimensions, format, file size, and
EXIF metadata (camera make/model, lens, ISO, aperture, shutter speed,
focal length, timestamp, GPS coordinates, orientation) parsed via
`kamadak-exif`.
- **Fit-to-window rendering** — iced's native `ContentFit::Contain` handles
centering and scaling. No manual offset math, no overflow, no scrollbars
in fit mode. Image always perfectly centered in the viewport.
- **Zoom modes**`0` for fit-to-window, `1` for 100%, `+`/`-` or
`Ctrl+scroll` for custom zoom. Each zoom step multiplies the factor by
1.1 (or divides by 1.1) for smooth, graceful changes. In zoom modes
the image is the direct child of a bidirectional `scrollable` with
`ContentFit::Contain` and `Length::Fixed(dw/dh)` — the previous
`Length::Fill` wrapper collapsed inside `scrollable` and hid the image.
Pan by dragging the scrollbars or scrolling.
- **Visual rotation**`R` rotates 90° clockwise, `Shift+R` counter-
clockwise. Rotation is applied by pre-rotating the RGBA pixel buffer
(iced 0.13 lacks native image rotation; the buffer-rotation approach
is the chosen implementation). The original image is preserved so
rotation is non-destructive and reversible.
- **gpicview-style right-click context menu** — 12 actions, all functional:
- Open With… (xdg-open)
- Copy Path (to clipboard)
- Copy Image (pixel data to clipboard)
- Copy to Pictures (Shift+Home — copies file to `~/Pictures/`)
- Rotate 90° CW / CCW
- Set as Wallpaper (gsettings, with feh as alternate backend)
- Move to Trash (trash crate)
- Delete Permanently (Shift+Delete — `std::fs::remove_file`)
- Properties (opens the EXIF properties panel)
- Export folder as video… (opens the video export dialog)
- About marten (shows the About dialog)
- **Anti-list error modal** — opening a file on the project's anti-list
(HEIC, CR3, NEF, ARW, PSD, DNG, Apple Live Photos, etc.) shows a
full-screen modal explaining the project's stance and suggesting open
alternatives. The viewer does NOT silently skip these files; it tells
you *why* it refuses to open them.
- **Configurable keymap** — every keybinding is overridable via
`~/.config/marten/keymap.toml`. Ship-the-defaults works out of the box;
power users can remap everything.
- **Fullscreen mode**`F11` or `Shift+F` collapses all chrome; image
fills the window. A small floating hint at the top-center of the
screen ("Press F11 to exit fullscreen") with a semi-transparent dark
background reminds you how to leave. Press again to exit.
- **About dialog** — press `a` or click the info button in the toolbar.
Matches the ferret app's About style: orange accent border, dark card,
author/website/license rows, build info, copyright.
---
## Format support
Marten takes a deliberate stance on format support. There are three tiers
plus an explicit anti-list.
### Tier 1 — supported (via the `image` crate)
| Format | Extensions | Notes |
|---|---|---|
| PNG | `.png` `.apng` | Including animated PNG |
| JPEG | `.jpg` `.jpeg` `.jfif` | Baseline + progressive |
| GIF | `.gif` | Including animation |
| WebP | `.webp` | Lossy + lossless + animated |
| AVIF | `.avif` | HDR, animation, alpha — our modern baseline |
| BMP | `.bmp` | Legacy compatibility |
| ICO / CUR | `.ico` `.cur` | Windows icon / cursor |
### Tier 2 — supported (dedicated codec modules)
| Format | Extensions | Decoder crate |
|---|---|---|
| JPEG XL | `.jxl` | `jxl-oxide` |
| TIFF | `.tif` `.tiff` | `tiff` |
| SVG | `.svg` `.svgz` | `resvg` (with `usvg` + `tiny-skia`) |
| OpenEXR | `.exr` | `exr` |
### Tier 3 — niche
QOI, JPEG 2000, JPEG XS.
### 🚫 Anti-list — will NEVER be supported
Marten explicitly rejects proprietary or patent-encumbered formats:
- **HEIF / HEIC** — HEVC patent-licensing baggage. AVIF covers the same use case royalty-free.
- **Canon CR3, Nikon NEF, Sony ARW** — proprietary camera RAW specs.
- **Adobe PSD** — proprietary Photoshop format.
- **Adobe DNG** — "partially open"; Adobe-controlled.
- **Apple Live Photos** — proprietary paired image+video container.
If you open one of these files, marten shows a modal explaining the stance
and suggesting an open alternative. See `DECISION.md` (decisions D002 and
D007) for the full rationale.
---
## Project layout
The codebase is a single Cargo binary crate with internal modules. Total
source size: 6,008 lines of Rust across 25 files, with 35 unit tests.
```
marten/
├── Cargo.toml # package metadata, deps, release profile
├── Cargo.lock # pinned dependency versions
├── LICENSE # GPL-2.0 full text
├── README.md # this file
├── QUICKSTART.md # 5-minute getting-started guide
├── BLOG.md # v0.4.0 release announcement
├── DECISION.md # architectural decision records (D001D016)
├── config/
│ ├── keymap.toml # default keymap, user-overridable
│ └── settings.toml # default settings, user-overridable
├── bin/
│ └── marten # prebuilt release binary (x86-64 Linux, 24.5 MB)
├── src/
│ ├── main.rs # 26 lines — entry point, iced bootstrap
│ ├── app.rs # 1,317 lines — Viewer state, Message dispatch, subscriptions
│ ├── config.rs # 367 lines — keymap.toml parser + loader
│ ├── settings.rs # 187 lines — settings.toml parser + loader
│ ├── codec/
│ │ ├── mod.rs # 339 lines — FormatRegistry, Codec trait, rotate_rgba()
│ │ ├── anti_list.rs # 126 lines — 8 rejected formats with reasons
│ │ ├── image_crate.rs # 110 lines — Tier 1 decoder (image crate)
│ │ ├── jxl.rs # 141 lines — JPEG XL decoder (jxl-oxide)
│ │ ├── tiff.rs # 189 lines — TIFF decoder (tiff crate)
│ │ ├── svg.rs # 123 lines — SVG decoder (resvg + tiny-skia)
│ │ └── exr.rs # 120 lines — OpenEXR decoder (exr crate)
│ ├── nav/
│ │ ├── mod.rs # 210 lines — Navigator (index, wrap-around, random)
│ │ └── folder.rs # 134 lines — scan_folder() + walk_folder_tree()
│ └── ui/
│ ├── mod.rs # 43 lines — refined dark palette
│ ├── icons.rs # 79 lines — 20 Lucide SVG icons
│ ├── image_view.rs # 244 lines — fit/zoom/pan/rotation rendering
│ ├── toolbar.rs # 167 lines — bottom toolbar with shuffle button
│ ├── status_bar.rs # 81 lines — filename · n/total · dims · zoom%
│ ├── thumbnail_bar.rs # 200 lines — lazy-loading top strip, scrollable::Id
│ ├── context_menu.rs # 208 lines — gpicview-style right-click menu
│ ├── error_modal.rs # 207 lines — anti-list / decode error overlay
│ ├── exif_panel.rs # 338 lines — EXIF properties modal
│ ├── sidebar.rs # 228 lines — togglable folder tree panel
│ ├── about_dialog.rs # 225 lines — ferret-style About dialog
│ └── export_dialog.rs # 555 lines — folder-as-video export modal
└── prototypes/ # original iced/egui spikes (archived)
├── SPIKE_COMPARISON.md
├── iced-viewer/
└── egui-viewer-archived/
```
### Module responsibilities
- **`main.rs`** — boots `env_logger`, calls `iced::application()` with the
`Viewer::update` / `Viewer::view` pair, sets dark theme and initial
window size (1200×800).
- **`app.rs`** — the central `Viewer` struct owns all sub-state (codec
registry wrapped in `Arc<FormatRegistry>` for sharing across async
boundaries, navigator, image view, toolbar, status bar, thumbnail bar,
sidebar, EXIF panel, context menu, error modal, about dialog, export
dialog, slideshow timer). The `Message` enum is the union of all
sub-component messages. `update()` dispatches; `view()` composes the
layout (thumbnail bar top, optional sidebar left, image middle, status
+ toolbar bottom) and layers overlays via `iced::widget::stack`;
`subscription()` drives the slideshow tick.
- **`config.rs`** — `Keymap` struct with serde, `parse_binding()` for
`"Ctrl+Shift+R"` style strings, `load_keymap()` that merges user toml
over defaults. Unknown keys warn but do not crash.
- **`settings.rs`** — `Settings` struct with serde, `load()` that merges
user `settings.toml` over defaults. Controls slideshow interval,
thumbnail size, default zoom mode, thumbnail cache window,
smooth-scroll toggle, and fullscreen hint toggle.
- **`codec/`** — `FormatRegistry` owns the codec list and the anti-list.
`decode(path)` checks the anti-list first (fail fast, no I/O), then
dispatches to the matching codec. `rotate_rgba()` rotates pixel buffers
by 90/180/270°. The Tier 2 decoders (`jxl.rs`, `tiff.rs`, `svg.rs`,
`exr.rs`) live alongside `image_crate.rs`.
- **`nav/`** — `Navigator` manages the sorted image list + current index
with wrap-around. `scan_folder()` walks a directory, filters by
supported extensions, skips anti-listed and hidden files.
`walk_folder_tree()` recursively collects all supported images in a
folder tree for random tree-wide navigation. `random_same_folder()`
and `random_from_tree()` use `SystemTime` nanos as the entropy source.
- **`ui/`** — one module per UI piece. Each module exports its state
struct, message enum, and `view()` function. The `theme` module owns
the palette constants.
---
## Build & install
### Prerequisites (Arch Linux)
```bash
sudo pacman -S --needed rust gtk3 wayland-protocols libx11 libxcb fontconfig
```
### Build from source
```bash
git clone http://git.dcos.net/dcosnet/marten.git
cd marten
cargo build --release
# binary is at target/release/marten
```
### Use the prebuilt binary
The `bin/marten` file in this distribution is a prebuilt release binary
(x86-64 Linux ELF, dynamically linked, stripped, 24.5 MB). Copy it anywhere
in your `$PATH`:
```bash
cp bin/marten ~/.local/bin/
marten
```
### Run without installing
```bash
cd marten
cargo run --release
```
---
## Configuration
Marten loads two configuration files from `$XDG_CONFIG_HOME/marten/` (falling
back to `~/.config/marten/` if XDG is not set):
### Keymap — `keymap.toml`
Defines which key triggers which action. All fields optional; missing fields
use defaults. See `config/keymap.toml` for the full schema.
### Settings — `settings.toml`
Defines scalar runtime preferences (slideshow interval, thumbnail size,
default zoom mode, thumbnail cache window, smooth-scroll toggle, fullscreen
hint toggle). All fields optional; missing fields use defaults. See
`config/settings.toml` for the full schema.
```
~/.config/marten/
├── keymap.toml # key bindings
└── settings.toml # runtime preferences
```
If either file does not exist, marten uses built-in defaults. If a file
exists but is malformed, marten falls back to defaults and logs a warning.
Partial overrides merge over defaults.
---
## Inspiration (no code reuse)
Marten draws inspiration from four existing Linux image viewers. **No code
was reused from any of them** — the entire codebase is fresh Rust, written
from scratch.
- **Ristretto** (XFCE) — the scroll-wheel-to-shuffle-photos paradigm, the
thin-toolbar-plus-thumbnail-strip layout (which we inverted in v0.2).
- **gPhoto** (GNOME) — the togglable sidebar that lets you hop between
sibling folders without reopening the file picker (added in v0.3).
- **Viewnior** — the minimal, distraction-free fullscreen mode with the
exit hint.
- **gpicview** (LXDE) — the right-click context menu structure, which we
cloned almost one-to-one (with three additions: Copy to Pictures,
Export folder as video…, and About marten).
---
## License
marten is licensed under the GNU General Public License v2.0 or later.
See `LICENSE` for the full text.
```
marten — a modern, accuracy-first image viewer for Linux.
Copyright (C) 2026 Jeremy Anderson
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
```
The full text of the GPL-2.0 is in `LICENSE`. The "or later" clause means
you may also choose to apply GPL-3.0+ terms if you prefer.
---
## Author
**Jeremy Anderson** — http://git.dcos.net/dcosnet/marten
Marten is a sibling project to [ferret](http://git.dcos.net/dcosnet/ferret),
a modern accuracy-first video player for Linux. Both apps share the same
design language: dark themes, orange accent, ferret-style About dialog,
GPL-2.0-or-later license, and a small-mustelid naming convention.

40
config/keymap.toml Normal file
View File

@ -0,0 +1,40 @@
# marten — default keymap
#
# This file is shipped as the default. To customize, copy to:
# $XDG_CONFIG_HOME/marten/keymap.toml
# (usually ~/.config/marten/keymap.toml)
#
# Each action accepts a list of bindings. You can bind multiple keys to the
# same action. Modifiers are joined with '+' and are case-insensitive
# (ctrl, shift, alt, logo/super/meta/win/cmd all work). The key name itself
# IS case-sensitive — 'g' and 'G' are different bindings (vim convention).
#
# Named keys: Right, Left, Up, Down, F1-F12, Escape, Space, Enter, Tab,
# Backspace, Delete, Home, End, PageUp, PageDown.
#
# To disable a binding, set it to an empty list:
# properties = []
next_photo = ["Right", "l"]
prev_photo = ["Left", "h"]
first_photo = ["g"]
last_photo = ["G"]
zoom_in = ["+", "Ctrl+Up"]
zoom_out = ["-", "Ctrl+Down"]
fit_to_window = ["0"]
actual_size = ["1"]
toggle_fullscreen = ["F11"]
toggle_chrome = ["Shift+F"]
open_folder = ["o"]
quit = ["q", "Ctrl+Q"]
rotate_cw = ["r"]
rotate_ccw = ["Shift+R"]
properties = ["i"]
copy_to_pictures = ["Shift+Home"]
delete_permanently = ["Shift+Delete"]
about = ["a"]
toggle_sidebar = ["Tab"]
toggle_slideshow = ["s"]
random_same_folder = ["z"]
random_folder_tree = ["Shift+Z"]

33
config/settings.toml Normal file
View File

@ -0,0 +1,33 @@
# marten — default settings
#
# This file is shipped as the default. To customize, copy to:
# $XDG_CONFIG_HOME/marten/settings.toml
# (usually ~/.config/marten/settings.toml)
#
# Settings are scalar values that affect runtime behavior. Key bindings
# live in keymap.toml, not here.
#
# All fields are optional — missing fields use the defaults shown below.
# Seconds between auto-advances in slideshow mode.
slideshow_interval_secs = 3.0
# Thumbnail edge length in pixels (square thumbnails).
# Must be between 16 and 256.
thumbnail_size = 72
# How many thumbnails ahead/behind the current image to cache.
# Higher = smoother scrolling but more memory; lower = less memory but
# more re-decoding as you scroll.
thumbnail_cache_window = 15
# Zoom mode applied when a new image is loaded.
# "FitToWindow" or "ActualSize".
default_zoom_mode = "FitToWindow"
# Whether the thumbnail strip auto-scrolls to follow the current image
# (video-editor-timeline behavior).
smooth_scroll_thumbnails = true
# Whether the "Press F11 to exit fullscreen" hint is shown.
show_fullscreen_hint = true

View File

@ -0,0 +1,164 @@
# Spike Comparison: iced vs egui
Both prototypes implement the **same MVP slice** so we can compare apples-to-apples:
- Open a folder of images via dialog (`O` key or button)
- Display current image, fit-to-window
- Scroll wheel = prev/next photo (ristretto-style, natural scrolling)
- Arrow keys + hjkl navigate
- `Q` to quit
- Dark theme
**Neither** spike implements: thumbnail bar, zoom/pan, right-click menu, fullscreen, EXIF panel. Those come after we pick a winner.
---
## How to run
```bash
# iced spike
cd /home/z/my-project/image-viewer/prototypes/iced-viewer
cargo run --release
# egui spike
cd /home/z/my-project/image-viewer/prototypes/egui-viewer
cargo run --release
```
In each: press `O`, pick a folder with some images, then scroll wheel / arrow keys to navigate.
---
## Comparison criteria
Score each on a 15 scale (5 = best). Fill in after testing.
### 1. Visual polish (the ristretto+ bar)
- Does the dark theme look refined out of the box, or does it look like a "default toolkit demo"?
- Is the toolbar thin and unobtrusive?
- Does the image area background blend cleanly with the chrome?
- Score: iced __ / 5 · egui __ / 5
### 2. Scroll-wheel feel
- Is the scroll responsive, or is there noticeable lag?
- Does fast scrolling skip photos or queue them up?
- Does the cursor need to be over the image, or does any scroll anywhere navigate?
- Score: iced __ / 5 · egui __ / 5
### 3. Image rendering quality
- Does the image render crisp at fit-to-window scale?
- Are colors correct (no premultiplied-alpha artifacts on transparent PNGs)?
- Does animated GIF play (it shouldn't in spike, but note if it does)?
- Score: iced __ / 5 · egui __ / 5
### 4. Code ergonomics
- How easy was it to express the ristretto layout? (Lines of code, mental overhead)
- How clean is the event loop? (declarative Msg vs immediate-mode input polling)
- Async image loading: which approach felt more natural?
- Score: iced __ / 5 · egui __ / 5
### 5. Thumbnail bar viability (look ahead)
- Can we plausibly build a horizontal scrollable thumbnail strip with custom styling?
- Will it be easy to make thumbnails load lazily and replace placeholder textures?
- Score: iced __ / 5 · egui __ / 5
### 6. Right-click menu viability (look ahead)
- How easy is a custom context menu with icons, separators, submenus?
- Score: iced __ / 5 · egui __ / 5
### 7. Fullscreen + auto-hide controls (look ahead)
- Can we cleanly toggle chrome visibility at runtime?
- Score: iced __ / 5 · egui __ / 5
### 8. Build time / binary size
- iced release binary: ~23 MB
- egui release binary: __ MB (fill in)
- Cold build time (`cargo clean && time cargo build --release`):
- iced: __ s
- egui: __ s
---
## Decision matrix
| Criterion | Weight | iced | egui |
|---|---|---|---|
| Visual polish | 3 | | |
| Scroll feel | 3 | | |
| Image quality | 2 | | |
| Code ergonomics | 2 | | |
| Thumb bar viability | 3 | | |
| Right-click menu | 2 | | |
| Fullscreen toggle | 1 | | |
| Build/binary | 1 | | |
**Weighted total**: iced __ · egui __
---
## Verdict
> Filled in after runtime testing on Arch Linux (2026-08-02).
**Winner**: **iced**
**Why**:
- iced ran smoothly out of the box; egui loaded but exhibited visible runtime issues (rendering/input quirks) that would have cost debugging time before any real feature work could begin.
- iced's `Theme::Dark` was closer to the ristretto+ target aesthetic without manual overrides.
- The declarative `Message` enum + `Task::perform` async story felt like a better fit for an app with this much interaction surface (scroll, zoom, pan, context menu, fullscreen, thumbnail clicks, configurable keymap).
- egui's lack of a built-in async story was already forcing us into `std::thread` + `JoinHandle` polling for the file dialog — that pattern does not scale to a lazy-loaded thumbnail bar.
**Trade-offs we accept**:
- Larger binary (23 MB vs 15.5 MB). Acceptable for a desktop app; we'll trim later with `strip` + `lto = "fat"` if size becomes an issue.
- No built-in context-menu widget — we'll roll our own with an overlay layer. This is actually a feature: we wanted full styling control for the gpicview-inspired menu anyway.
- Fullscreen toggling will go through iced's `window` subsystem rather than egui's trivial `ViewportBuilder`. Slightly more code, same end result.
**What we lose by not picking egui**:
- `Response::context_menu()` for free right-click menus (we'll write our own — see above).
- `ViewportBuilder` one-liner for fullscreen (we'll use iced's window commands).
- Immediate-mode layout simplicity (declarative state is a net win for this app's complexity).
- ~7 MB of binary size.
The archived egui spike lives at `prototypes/egui-viewer-archived/` with a `README.md` explaining the call. We keep it as a reference and as a restart path if iced turns out to be the wrong choice after MVP.
---
## Notes from initial code-side impressions (pre-runtime)
These are observations from writing both spikes, before any runtime testing.
### iced 0.13 — code-side notes
- **Pros**
- Declarative `Message` enum makes the state machine explicit and easy to reason about.
- Built-in `Task::perform` for async file loading is clean and integrated with the runtime.
- `Theme::Dark` is one-liner; refinement via `container::Style` closures is straightforward.
- `image::Handle::from_bytes` lets us defer decoding to the runtime — no manual texture upload.
- `Subscription` + `iced::event::listen()` is a clean way to capture global input.
- **Cons**
- 0.13 is a recent release; some docs still show 0.12 patterns. We already hit one breaking change (`center_x`/`align_x`).
- The `image` widget doesn't expose a "fit mode + actual displayed size" API — we'd need to compute scaling ourselves for zoom/pan.
- No built-in context-menu widget; we'll roll our own with overlays.
- Custom thumbnail bar with lazy texture loading will require careful `Handle` management.
### egui 0.29 — code-side notes
- **Pros**
- Immediate mode makes the layout trivial — `TopBottomPanel` + `CentralPanel` is 5 lines.
- `ColorImage` + `TextureHandle` is a textbook path; lazy thumbnail loading is natural (just stash handles in a Vec).
- `Context::request_repaint_after` gives us precise repaint control (good for animated GIFs later).
- Built-in `Response::context_menu()` gives us right-click menus for free.
- `ViewportBuilder` makes fullscreen toggling trivial.
- **Cons**
- No async story — we spawn `std::thread` and poll `JoinHandle::is_finished()` ourselves. This will get messy for a real thumbnail bar.
- `egui::Color32` is RGBA8 — HDR/16-bit workflows will need tone-mapping in our code.
- Default dark theme is bluish; we had to override the panel fill manually to get the ristretto-feel dark.
- Immediate mode means we re-emit the entire UI every frame; for a static viewer this is mostly fine but burns CPU on idle.
---
## Action after decision
Once we pick:
1. Move winner to `image-viewer/src/` (single binary crate as agreed).
2. Archive loser under `image-viewer/prototypes/<loser>-archived/` with a `README.md` saying why.
3. Add a `DECISION.md` at repo root summarizing this doc.
4. Begin MVP build: modular `src/{image,nav,ui,config}/` structure.

BIN
prototypes/bin/egui-viewer Executable file

Binary file not shown.

BIN
prototypes/bin/iced-viewer Executable file

Binary file not shown.

View File

@ -0,0 +1,20 @@
[package]
name = "egui-viewer"
version = "0.1.0"
edition = "2021"
[dependencies]
egui = "0.29"
eframe = { version = "0.29", default-features = false, features = [
"default_fonts",
"glow",
"wayland",
"x11",
] }
egui_extras = { version = "0.29", features = ["image", "syntect"] }
image = { version = "0.25", features = ["jpeg", "png", "gif", "webp", "bmp", "ico"] }
rfd = "0.15"
[profile.release]
opt-level = 3
lto = "thin"

View File

@ -0,0 +1,35 @@
# egui-viewer (archived)
**Status**: Archived on 2026-08-02. Not under active development.
**Reason**: Lost the toolkit spike comparison to `iced`. See `../SPIKE_COMPARISON.md` and `../../DECISION.md`.
## Why we didn't pick egui
We tested both `iced-viewer` and `egui-viewer` side-by-side on Arch Linux. Summary of the call:
1. **Runtime behavior**: iced ran smoothly out of the box; egui loaded but exhibited visible issues (rendering / input quirks) that would have required additional debugging time to diagnose and fix before we could even start on real features.
2. **Visual polish**: iced's dark theme (`Theme::Dark`) looked closer to the ristretto+ target aesthetic out of the box. egui's default dark leans blue-grey and required manual panel-fill overrides to look right.
3. **Async story**: iced's `Task::perform` integrates cleanly with the runtime; egui has no built-in async story and we were already spawning `std::thread` + polling `JoinHandle::is_finished()` for the file dialog. That pattern does not scale to a lazy-loaded thumbnail bar.
4. **Declarative state**: iced's `Message` enum makes the state machine explicit. For an app with as much interaction surface as an image viewer (scroll, zoom, pan, context menu, fullscreen, thumbnail clicks, keymap), explicit state transitions are easier to reason about than immediate-mode input polling.
## What egui had going for it (and what we lose)
These were genuine egui strengths that we are giving up by choosing iced:
- **Smaller binary** (15.5 MB vs 23 MB for the spike).
- **`Response::context_menu()`** gives right-click menus for free. In iced we'll roll our own with an overlay layer — more work, but we get full styling control (which we wanted anyway for the gpicview-inspired menu).
- **`ViewportBuilder`** makes fullscreen toggling trivial. In iced 0.13 we'll use the platform's window APIs (likely `winit` directly via iced's `window` subsystem).
- **Immediate-mode layout** is conceptually simpler for tool-heavy UIs.
## What's preserved here
This archived crate contains the original spike source (`Cargo.toml`, `src/main.rs`). The prebuilt release binary was discarded to save disk space — re-run `cargo build --release` if you need it.
## Don't delete this
Keep the archive around for two reasons:
1. **Reference**: If we hit a wall with iced on a specific feature (e.g. context menu ergonomics), the egui spike is a reminder of what the alternative API looked like.
2. **Restart path**: If iced turns out to be the wrong call after the MVP is built, we have a working egui starting point instead of starting from zero.
The spike code is licensed under the same terms as the parent project. See `../../README.md`.

View File

@ -0,0 +1,292 @@
//! egui-viewer spike
//!
//! Goal: prove egui can deliver the ristretto core experience.
//! - Open a folder of images
//! - Display the current image scaled to fit
//! - Scroll wheel = prev/next photo
//! - Arrow keys also navigate (hjkl + arrows)
//! - Dark theme (refined, not egui's default bluish dark)
//!
//! Non-goals for the spike: thumbnail bar, zoom/pan, right-click menu, fullscreen.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use eframe::egui;
use egui::Color32;
fn main() -> eframe::Result<()> {
let opts = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([1200.0, 800.0])
.with_title("image-viewer (egui spike)"),
..Default::default()
};
eframe::run_native(
"image-viewer (egui spike)",
opts,
Box::new(|_cc| Ok(Box::new(Viewer::default()))),
)
}
#[derive(Default)]
struct Viewer {
images: Vec<PathBuf>,
current: usize,
texture: Option<Arc<egui::TextureHandle>>,
status: String,
loading: bool,
pending_pick: Option<std::thread::JoinHandle<Option<PathBuf>>>,
}
impl eframe::App for Viewer {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.update_ui(ctx);
}
}
impl Viewer {
fn update_ui(&mut self, ctx: &egui::Context) {
// Poll for any pending folder pick
if let Some(handle) = self.pending_pick.take() {
if !handle.is_finished() {
self.pending_pick = Some(handle);
ctx.request_repaint_after(std::time::Duration::from_millis(50));
} else if let Ok(Some(path)) = handle.join() {
self.load_folder(path);
if !self.images.is_empty() {
self.current = 0;
self.load_current(ctx);
} else {
self.status = "No images in that folder".into();
}
}
}
// Keyboard shortcuts (always active)
ctx.input(|i| {
let mut delta: i32 = 0;
if i.key_pressed(egui::Key::ArrowRight) || i.key_pressed(egui::Key::L) {
delta = 1;
}
if i.key_pressed(egui::Key::ArrowLeft) || i.key_pressed(egui::Key::H) {
delta = -1;
}
if delta != 0 && !self.images.is_empty() {
let len = self.images.len() as i32;
let new = ((self.current as i32 + delta).rem_euclid(len)) as usize;
if new != self.current {
self.current = new;
self.load_current(ctx);
}
}
if i.key_pressed(egui::Key::O) {
self.open_folder_dialog();
}
if i.key_pressed(egui::Key::Q) {
std::process::exit(0);
}
// Scroll wheel = prev/next (ristretto-style, natural scrolling)
let scroll = i.smooth_scroll_delta.y;
if scroll.abs() > 4.0 && !self.images.is_empty() {
let delta = if scroll > 0.0 { -1 } else { 1 };
let len = self.images.len() as i32;
let new = ((self.current as i32 + delta).rem_euclid(len)) as usize;
if new != self.current {
self.current = new;
self.load_current(ctx);
}
}
});
// Top toolbar (thin, ristretto-style)
egui::TopBottomPanel::top("toolbar")
.exact_height(36.0)
.show(ctx, |ui| {
ui.horizontal_centered(|ui| {
ui.add_space(8.0);
if ui.button("Open folder").clicked() {
self.open_folder_dialog();
}
ui.separator();
if ui
.add_enabled(!self.images.is_empty(), egui::Button::new(""))
.clicked()
{
self.navigate(-1, ctx);
}
if ui
.add_enabled(!self.images.is_empty(), egui::Button::new(""))
.clicked()
{
self.navigate(1, ctx);
}
ui.separator();
ui.label(
egui::RichText::new(&self.status)
.color(Color32::from_rgb(180, 180, 185))
.size(13.0),
);
});
});
// Bottom status bar
egui::TopBottomPanel::bottom("status")
.exact_height(24.0)
.show(ctx, |ui| {
ui.horizontal_centered(|ui| {
ui.add_space(8.0);
let count = if self.images.is_empty() {
"0/0".to_string()
} else {
format!("{}/{}", self.current + 1, self.images.len())
};
ui.label(
egui::RichText::new(count)
.color(Color32::from_rgb(140, 140, 145))
.size(11.0),
);
});
});
// Central image area
egui::CentralPanel::default()
.frame(egui::Frame::none().fill(Color32::from_rgb(14, 14, 16)))
.show(ctx, |ui| {
if let Some(tex) = &self.texture {
let avail = ui.available_size();
let img_size = tex.size_vec2();
let scale = (avail.x / img_size.x)
.min(avail.y / img_size.y)
.min(1.0);
let display = img_size * scale;
ui.vertical_centered(|ui| {
ui.add_space((avail.y - display.y).max(0.0) / 2.0);
ui.image(egui::load::SizedTexture::new(
tex.as_ref().id(),
display,
));
});
} else {
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new(if self.loading {
"Loading..."
} else {
"No image (press O)"
})
.size(20.0)
.color(Color32::from_rgb(110, 110, 115)),
);
});
}
});
}
fn navigate(&mut self, delta: i32, ctx: &egui::Context) {
if self.images.is_empty() {
return;
}
let len = self.images.len() as i32;
let new = ((self.current as i32 + delta).rem_euclid(len)) as usize;
if new != self.current {
self.current = new;
self.load_current(ctx);
}
}
fn open_folder_dialog(&mut self) {
// Spawn a thread to avoid blocking the UI thread
let handle = std::thread::spawn(|| {
rfd::FileDialog::new()
.set_title("Pick a folder of images")
.pick_folder()
});
self.pending_pick = Some(handle);
self.status = "Opening dialog...".into();
}
fn load_folder(&mut self, path: PathBuf) {
let mut images: Vec<PathBuf> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&path) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_file() && is_supported_image(&p) {
images.push(p);
}
}
}
images.sort();
self.images = images;
}
fn load_current(&mut self, ctx: &egui::Context) {
let path = match self.images.get(self.current).cloned() {
Some(p) => p,
None => return,
};
self.loading = true;
self.status = format!(
"Loading {}",
path.file_name()
.map(|s| s.to_string_lossy())
.unwrap_or_default()
);
// Synchronous decode for the spike — fine for local files.
// Real impl will use a thread pool + channel back to UI thread.
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
self.loading = false;
self.status = format!("Failed to read: {e}");
return;
}
};
let img = match image::load_from_memory(&bytes) {
Ok(i) => i.to_rgba8(),
Err(e) => {
self.loading = false;
self.status = format!("Decode failed: {e}");
return;
}
};
let size = [img.width() as usize, img.height() as usize];
let color_image = egui::ColorImage {
size,
pixels: img
.chunks(4)
.map(|c| Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]))
.collect(),
};
let tex = ctx.load_texture(
"current_image",
color_image,
egui::TextureOptions::LINEAR,
);
self.texture = Some(Arc::new(tex));
self.loading = false;
self.status = format!(
"{} ({}/{})",
path.file_name()
.map(|s| s.to_string_lossy())
.unwrap_or_default(),
self.current + 1,
self.images.len()
);
}
}
fn is_supported_image(p: &Path) -> bool {
let ext = match p.extension().and_then(|e| e.to_str()) {
Some(e) => e.to_ascii_lowercase(),
None => return false,
};
matches!(
ext.as_str(),
"png" | "jpg" | "jpeg" | "gif" | "webp"
| "bmp" | "ico" | "cur" | "avif"
| "apng" | "jfif"
)
}

View File

@ -0,0 +1,20 @@
[package]
name = "egui-viewer"
version = "0.1.0"
edition = "2021"
[dependencies]
egui = "0.29"
eframe = { version = "0.29", default-features = false, features = [
"default_fonts",
"glow",
"wayland",
"x11",
] }
egui_extras = { version = "0.29", features = ["image", "syntect"] }
image = { version = "0.25", features = ["jpeg", "png", "gif", "webp", "bmp", "ico"] }
rfd = "0.15"
[profile.release]
opt-level = 3
lto = "thin"

View File

@ -0,0 +1,292 @@
//! egui-viewer spike
//!
//! Goal: prove egui can deliver the ristretto core experience.
//! - Open a folder of images
//! - Display the current image scaled to fit
//! - Scroll wheel = prev/next photo
//! - Arrow keys also navigate (hjkl + arrows)
//! - Dark theme (refined, not egui's default bluish dark)
//!
//! Non-goals for the spike: thumbnail bar, zoom/pan, right-click menu, fullscreen.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use eframe::egui;
use egui::Color32;
fn main() -> eframe::Result<()> {
let opts = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([1200.0, 800.0])
.with_title("image-viewer (egui spike)"),
..Default::default()
};
eframe::run_native(
"image-viewer (egui spike)",
opts,
Box::new(|_cc| Ok(Box::new(Viewer::default()))),
)
}
#[derive(Default)]
struct Viewer {
images: Vec<PathBuf>,
current: usize,
texture: Option<Arc<egui::TextureHandle>>,
status: String,
loading: bool,
pending_pick: Option<std::thread::JoinHandle<Option<PathBuf>>>,
}
impl eframe::App for Viewer {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.update_ui(ctx);
}
}
impl Viewer {
fn update_ui(&mut self, ctx: &egui::Context) {
// Poll for any pending folder pick
if let Some(handle) = self.pending_pick.take() {
if !handle.is_finished() {
self.pending_pick = Some(handle);
ctx.request_repaint_after(std::time::Duration::from_millis(50));
} else if let Ok(Some(path)) = handle.join() {
self.load_folder(path);
if !self.images.is_empty() {
self.current = 0;
self.load_current(ctx);
} else {
self.status = "No images in that folder".into();
}
}
}
// Keyboard shortcuts (always active)
ctx.input(|i| {
let mut delta: i32 = 0;
if i.key_pressed(egui::Key::ArrowRight) || i.key_pressed(egui::Key::L) {
delta = 1;
}
if i.key_pressed(egui::Key::ArrowLeft) || i.key_pressed(egui::Key::H) {
delta = -1;
}
if delta != 0 && !self.images.is_empty() {
let len = self.images.len() as i32;
let new = ((self.current as i32 + delta).rem_euclid(len)) as usize;
if new != self.current {
self.current = new;
self.load_current(ctx);
}
}
if i.key_pressed(egui::Key::O) {
self.open_folder_dialog();
}
if i.key_pressed(egui::Key::Q) {
std::process::exit(0);
}
// Scroll wheel = prev/next (ristretto-style, natural scrolling)
let scroll = i.smooth_scroll_delta.y;
if scroll.abs() > 4.0 && !self.images.is_empty() {
let delta = if scroll > 0.0 { -1 } else { 1 };
let len = self.images.len() as i32;
let new = ((self.current as i32 + delta).rem_euclid(len)) as usize;
if new != self.current {
self.current = new;
self.load_current(ctx);
}
}
});
// Top toolbar (thin, ristretto-style)
egui::TopBottomPanel::top("toolbar")
.exact_height(36.0)
.show(ctx, |ui| {
ui.horizontal_centered(|ui| {
ui.add_space(8.0);
if ui.button("Open folder").clicked() {
self.open_folder_dialog();
}
ui.separator();
if ui
.add_enabled(!self.images.is_empty(), egui::Button::new(""))
.clicked()
{
self.navigate(-1, ctx);
}
if ui
.add_enabled(!self.images.is_empty(), egui::Button::new(""))
.clicked()
{
self.navigate(1, ctx);
}
ui.separator();
ui.label(
egui::RichText::new(&self.status)
.color(Color32::from_rgb(180, 180, 185))
.size(13.0),
);
});
});
// Bottom status bar
egui::TopBottomPanel::bottom("status")
.exact_height(24.0)
.show(ctx, |ui| {
ui.horizontal_centered(|ui| {
ui.add_space(8.0);
let count = if self.images.is_empty() {
"0/0".to_string()
} else {
format!("{}/{}", self.current + 1, self.images.len())
};
ui.label(
egui::RichText::new(count)
.color(Color32::from_rgb(140, 140, 145))
.size(11.0),
);
});
});
// Central image area
egui::CentralPanel::default()
.frame(egui::Frame::none().fill(Color32::from_rgb(14, 14, 16)))
.show(ctx, |ui| {
if let Some(tex) = &self.texture {
let avail = ui.available_size();
let img_size = tex.size_vec2();
let scale = (avail.x / img_size.x)
.min(avail.y / img_size.y)
.min(1.0);
let display = img_size * scale;
ui.vertical_centered(|ui| {
ui.add_space((avail.y - display.y).max(0.0) / 2.0);
ui.image(egui::load::SizedTexture::new(
tex.as_ref().id(),
display,
));
});
} else {
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new(if self.loading {
"Loading..."
} else {
"No image (press O)"
})
.size(20.0)
.color(Color32::from_rgb(110, 110, 115)),
);
});
}
});
}
fn navigate(&mut self, delta: i32, ctx: &egui::Context) {
if self.images.is_empty() {
return;
}
let len = self.images.len() as i32;
let new = ((self.current as i32 + delta).rem_euclid(len)) as usize;
if new != self.current {
self.current = new;
self.load_current(ctx);
}
}
fn open_folder_dialog(&mut self) {
// Spawn a thread to avoid blocking the UI thread
let handle = std::thread::spawn(|| {
rfd::FileDialog::new()
.set_title("Pick a folder of images")
.pick_folder()
});
self.pending_pick = Some(handle);
self.status = "Opening dialog...".into();
}
fn load_folder(&mut self, path: PathBuf) {
let mut images: Vec<PathBuf> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&path) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_file() && is_supported_image(&p) {
images.push(p);
}
}
}
images.sort();
self.images = images;
}
fn load_current(&mut self, ctx: &egui::Context) {
let path = match self.images.get(self.current).cloned() {
Some(p) => p,
None => return,
};
self.loading = true;
self.status = format!(
"Loading {}",
path.file_name()
.map(|s| s.to_string_lossy())
.unwrap_or_default()
);
// Synchronous decode for the spike — fine for local files.
// Real impl will use a thread pool + channel back to UI thread.
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
self.loading = false;
self.status = format!("Failed to read: {e}");
return;
}
};
let img = match image::load_from_memory(&bytes) {
Ok(i) => i.to_rgba8(),
Err(e) => {
self.loading = false;
self.status = format!("Decode failed: {e}");
return;
}
};
let size = [img.width() as usize, img.height() as usize];
let color_image = egui::ColorImage {
size,
pixels: img
.chunks(4)
.map(|c| Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]))
.collect(),
};
let tex = ctx.load_texture(
"current_image",
color_image,
egui::TextureOptions::LINEAR,
);
self.texture = Some(Arc::new(tex));
self.loading = false;
self.status = format!(
"{} ({}/{})",
path.file_name()
.map(|s| s.to_string_lossy())
.unwrap_or_default(),
self.current + 1,
self.images.len()
);
}
}
fn is_supported_image(p: &Path) -> bool {
let ext = match p.extension().and_then(|e| e.to_str()) {
Some(e) => e.to_ascii_lowercase(),
None => return false,
};
matches!(
ext.as_str(),
"png" | "jpg" | "jpeg" | "gif" | "webp"
| "bmp" | "ico" | "cur" | "avif"
| "apng" | "jfif"
)
}

View File

@ -0,0 +1,13 @@
[package]
name = "iced-viewer"
version = "0.1.0"
edition = "2021"
[dependencies]
iced = { version = "0.13", features = ["image", "tokio"] }
rfd = "0.15"
kamadak-exif = "0.6"
[profile.release]
opt-level = 3
lto = "thin"

View File

@ -0,0 +1,292 @@
//! iced-viewer spike
//!
//! Goal: prove iced can deliver the ristretto core experience.
//! - Open a folder of images
//! - Display the current image scaled to fit
//! - Scroll wheel = prev/next photo
//! - Arrow keys also navigate
//! - Dark theme
//!
//! Non-goals for the spike: thumbnail bar, zoom/pan, right-click menu, fullscreen.
//! Those come after we pick a winner.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use iced::widget::{container, image, text};
use iced::{
Element, Event, Length, Subscription, Task, Theme, keyboard, mouse,
};
fn main() -> iced::Result {
iced::application("image-viewer (iced spike)", Viewer::update, Viewer::view)
.theme(|_| Theme::Dark)
.subscription(Viewer::subscription)
.run()
}
#[derive(Debug, Clone)]
enum Message {
/// User picked a folder via file dialog
FolderSelected(Option<PathBuf>),
/// Index changed (prev/next)
Navigate(i32),
/// Image finished loading
ImageLoaded(Arc<image::Handle>),
/// Window event we care about (scroll, resize, etc.)
WindowEvent(Event),
/// Open folder dialog
OpenFolder,
/// Errors
Error(String),
}
struct Viewer {
/// Sorted list of image paths in the current folder
images: Vec<PathBuf>,
/// Current index into `images`
current: usize,
/// Loaded image handle (None while loading)
handle: Option<Arc<image::Handle>>,
/// Status bar text
status: String,
/// True when an image is currently being decoded
loading: bool,
}
impl Default for Viewer {
fn default() -> Self {
Self {
images: Vec::new(),
current: 0,
handle: None,
status: "Press O to open a folder".into(),
loading: false,
}
}
}
impl Viewer {
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::OpenFolder => {
self.status = "Opening dialog...".into();
Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title("Pick a folder of images")
.pick_folder()
.await
.map(|h| h.path().to_path_buf())
},
|path| Message::FolderSelected(path),
)
}
Message::FolderSelected(Some(path)) => {
self.load_folder(path);
if self.images.is_empty() {
self.status = "No images in that folder".into();
Task::none()
} else {
self.current = 0;
self.load_current()
}
}
Message::FolderSelected(None) => {
self.status = "Cancelled".into();
Task::none()
}
Message::Navigate(delta) => {
if self.images.is_empty() {
return Task::none();
}
let len = self.images.len() as i32;
let new_idx =
((self.current as i32 + delta).rem_euclid(len)) as usize;
if new_idx != self.current {
self.current = new_idx;
return self.load_current();
}
Task::none()
}
Message::ImageLoaded(handle) => {
self.handle = Some(handle);
self.loading = false;
if let Some(p) = self.images.get(self.current) {
self.status = format!(
"{} ({}/{})",
p.file_name()
.map(|s| s.to_string_lossy())
.unwrap_or_default(),
self.current + 1,
self.images.len()
);
}
Task::none()
}
Message::WindowEvent(event) => {
self.handle_event(event)
}
Message::Error(msg) => {
self.status = format!("Error: {msg}");
self.loading = false;
Task::none()
}
}
}
fn view(&self) -> Element<Message> {
let img_view: Element<Message> = if let Some(h) = &self.handle {
image(h.as_ref().clone())
.content_fit(iced::ContentFit::Contain)
.width(Length::Fill)
.height(Length::Fill)
.into()
} else {
text(if self.loading {
"Loading..."
} else {
"No image"
})
.size(24)
.into()
};
let content = container(img_view)
.width(Length::Fill)
.height(Length::Fill)
.align_x(iced::Alignment::Center)
.align_y(iced::Alignment::Center)
.padding(8);
let status_bar = container(text(self.status.clone()).size(13))
.style(|_theme| container::Style {
background: Some(iced::Color::from_rgb(0.08, 0.08, 0.10).into()),
text_color: Some(iced::Color::from_rgb(0.75, 0.75, 0.78)),
..Default::default()
})
.padding(6)
.width(Length::Fill)
.align_x(iced::Alignment::Center);
iced::widget::column![content, status_bar]
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn subscription(&self) -> Subscription<Message> {
iced::event::listen().map(Message::WindowEvent)
}
/// Handle keyboard + scroll events
fn handle_event(&mut self, event: Event) -> Task<Message> {
match event {
Event::Keyboard(keyboard::Event::KeyPressed {
key,
modifiers: _,
..
}) => match key {
keyboard::Key::Named(keyboard::key::Named::ArrowRight)
| keyboard::Key::Character(_)
if matches!(
key,
keyboard::Key::Character(ref c)
if c == "l" || c == "L"
) =>
{
self.update(Message::Navigate(1))
}
keyboard::Key::Named(keyboard::key::Named::ArrowLeft)
| keyboard::Key::Character(_)
if matches!(
key,
keyboard::Key::Character(ref c)
if c == "h" || c == "H"
) =>
{
self.update(Message::Navigate(-1))
}
keyboard::Key::Character(ref c)
if c == "o" || c == "O" =>
{
self.update(Message::OpenFolder)
}
keyboard::Key::Character(ref c)
if c == "q" || c == "Q" =>
{
iced::exit()
}
_ => Task::none(),
},
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
let dy = match delta {
mouse::ScrollDelta::Lines { y, .. } => y,
mouse::ScrollDelta::Pixels { y, .. } => y / 16.0,
};
if dy.abs() > 0.01 {
// Natural scrolling: scroll up = previous, scroll down = next
// (matches ristretto's default)
self.update(Message::Navigate(if dy > 0.0 { -1 } else { 1 }))
} else {
Task::none()
}
}
_ => Task::none(),
}
}
/// Scan a folder for image files, sort lexicographically
fn load_folder(&mut self, path: PathBuf) {
let mut images: Vec<PathBuf> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&path) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_file() && is_supported_image(&p) {
images.push(p);
}
}
}
images.sort();
self.images = images;
}
/// Kick off async decode of the current image
fn load_current(&mut self) -> Task<Message> {
let path = match self.images.get(self.current) {
Some(p) => p.clone(),
None => return Task::none(),
};
self.loading = true;
self.status = format!(
"Loading {}",
path.file_name()
.map(|s| s.to_string_lossy())
.unwrap_or_default()
);
Task::perform(
async move {
let bytes = std::fs::read(&path)
.map_err(|e| e.to_string())?;
Ok::<_, String>(image::Handle::from_bytes(bytes))
},
|res| match res {
Ok(h) => Message::ImageLoaded(Arc::new(h)),
Err(e) => Message::Error(e),
},
)
}
}
fn is_supported_image(p: &Path) -> bool {
let ext = match p.extension().and_then(|e| e.to_str()) {
Some(e) => e.to_ascii_lowercase(),
None => return false,
};
matches!(
ext.as_str(),
"png" | "jpg" | "jpeg" | "gif" | "webp"
| "bmp" | "ico" | "cur" | "avif"
| "apng" | "jfif"
)
}

1337
src/app.rs Normal file

File diff suppressed because it is too large Load Diff

126
src/codec/anti_list.rs Normal file
View File

@ -0,0 +1,126 @@
//! The anti-list — formats this project will never support, and why.
//!
//! See `DECISION.md` D002 and `download/format-research.md` for the full rationale.
//! The list is hardcoded; adding to it requires a code change (intentional —
//! we don't want this list silently editable at runtime).
/// Information about a rejected format, shown to the user when they try to open one.
#[derive(Debug, Clone, Copy)]
pub struct AntiListedFormat {
/// The display name, e.g. "HEIC", "Canon CR3".
pub name: &'static str,
/// File extensions (lowercase, no leading dot) that trigger this rejection.
pub extensions: &'static [&'static str],
/// Short user-facing reason.
pub reason: &'static str,
/// Suggested open alternative.
pub alternative: &'static str,
}
/// The full anti-list. Add new entries here if a new proprietary format
/// becomes a problem. Do NOT remove entries without updating DECISION.md.
pub const ANTI_LIST: &[AntiListedFormat] = &[
AntiListedFormat {
name: "HEIF",
extensions: &["heif", "heifs"],
reason: "ISOBMFF container paired with HEVC; subject to MPEG-LA / HEVC Advance patent pools. Not royalty-free.",
alternative: "AVIF — same ISOBMFF container, royalty-free AV1 codec. Covers HDR, animation, and alpha.",
},
AntiListedFormat {
name: "HEIC",
extensions: &["heic", "heics"],
reason: "Apple's HEIF variant. Same HEVC patent baggage as HEIF.",
alternative: "AVIF for HDR/animation, or JPEG XL for archival masters.",
},
AntiListedFormat {
name: "Canon CR3",
extensions: &["cr3", "crw"],
reason: "Proprietary Canon RAW format. Spec is not public; Canon can change it at will.",
alternative: "Convert to DNG (still Adobe-controlled) or to TIFF for an open archival master.",
},
AntiListedFormat {
name: "Nikon NEF",
extensions: &["nef"],
reason: "Proprietary Nikon RAW format. Spec is not public.",
alternative: "Convert to TIFF for an open archival master.",
},
AntiListedFormat {
name: "Sony ARW",
extensions: &["arw", "srf", "sr2"],
reason: "Proprietary Sony RAW format. Spec is not public.",
alternative: "Convert to TIFF for an open archival master.",
},
AntiListedFormat {
name: "Adobe PSD",
extensions: &["psd", "psb"],
reason: "Proprietary Photoshop format. Partially documented but Adobe-controlled; layered rendering is non-trivial.",
alternative: "Export as TIFF for layered raster, or PNG/JPEG XL for flattened.",
},
AntiListedFormat {
name: "Adobe DNG",
extensions: &["dng"],
reason: "'Partially open' — Adobe publishes the spec but retains control. Not a true open standard.",
alternative: "TIFF for archival still photography (16-bit, ICC, multi-page).",
},
AntiListedFormat {
name: "Apple Live Photos",
extensions: &["livephoto", "live"],
reason: "Proprietary paired image+video container. Apple-controlled. Not really an image format — out of scope for a still-image viewer.",
alternative: "Export the still component as JPEG or HEIC (then convert HEIC to AVIF).",
},
];
/// Look up an extension in the anti-list. Returns the format info if found.
pub fn lookup(extension: &str) -> Option<AntiListedFormat> {
let ext = extension.to_ascii_lowercase();
ANTI_LIST
.iter()
.find(|f| f.extensions.contains(&ext.as_str()))
.copied()
}
/// Is this extension in the anti-list?
/// Convenience wrapper around `lookup` for callers that don't need the
/// format details. Currently used only by tests; kept for future callers
/// (e.g. a drag-and-drop handler that needs a yes/no answer).
#[allow(dead_code)]
pub fn is_anti_listed(extension: &str) -> bool {
lookup(extension).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heic_is_rejected() {
assert!(is_anti_listed("heic"));
assert!(is_anti_listed("HEIC"));
assert!(is_anti_listed("Heic"));
}
#[test]
fn cr3_is_rejected() {
assert!(is_anti_listed("cr3"));
assert!(is_anti_listed("CR3"));
}
#[test]
fn png_is_not_anti_listed() {
assert!(!is_anti_listed("png"));
}
#[test]
fn lookup_returns_correct_info() {
let info = lookup("heic").unwrap();
assert_eq!(info.name, "HEIC");
assert!(info.reason.contains("HEVC"));
assert!(info.alternative.contains("AVIF"));
}
#[test]
fn case_insensitive() {
assert_eq!(lookup("DNG").unwrap().name, "Adobe DNG");
assert_eq!(lookup("Psd").unwrap().name, "Adobe PSD");
}
}

120
src/codec/exr.rs Normal file
View File

@ -0,0 +1,120 @@
//! Tier 2 codec: OpenEXR via the pure-Rust `exr` crate.
//!
//! OpenEXR is the film-industry HDR format — 16-bit or 32-bit float samples,
//! arbitrary channels (R, G, B, A, plus arbitrary others like diffuse,
//! specular, Z-depth), multi-part, multi-resolution (mipmaps), and deep data.
//!
//! This codec handles the common case: a single-layer image with R, G, B
//! channels and an optional A channel. Multi-channel images (e.g. with Z or
//! other arbitrary channels) decode fine — `rgba_channels` skips channels
//! it doesn't care about.
//!
//! Tone mapping: EXR stores linear HDR float. We apply a simple Reinhard
//! operator (`out = value / (1 + value)`) to compress the dynamic range to
//! 8-bit. This is a *display* tone map, not a creative grade — viewers
//! wanting accurate HDR playback should disable this in a future "HDR mode"
//! setting.
use std::io::Cursor;
use exr::prelude::{read as exr_read, ReadChannels, ReadLayers, RgbaChannels};
use super::{Codec, DecodeError, DecodedImage};
/// OpenEXR codec. Tone-maps HDR float to RGBA8 via Reinhard.
pub struct ExrCodec;
impl Codec for ExrCodec {
fn name(&self) -> &'static str {
"OpenEXR"
}
fn extensions(&self) -> &'static [&'static str] {
&["exr"]
}
fn decode(&self, bytes: &[u8]) -> Result<DecodedImage, DecodeError> {
// The `exr` crate's builder pattern: configure the reader, then
// `from_buffered(Cursor::new(bytes))` actually runs the decode.
//
// `rgba_channels(create, set_pixel)` extracts the R, G, B channels
// (required, by name) and A (optional, defaults to 1.0). Channels
// not in this list are silently skipped — multi-channel EXRs work,
// we just don't surface the extra channels.
let image = exr_read()
.no_deep_data()
.largest_resolution_level()
.rgba_channels(
|resolution, _channels: &RgbaChannels| {
// Allocate the pixel buffer up-front.
Vec::with_capacity(resolution.x() * resolution.y())
},
|pixels: &mut Vec<(f32, f32, f32, f32)>, _pos, (r, g, b, a): (f32, f32, f32, f32)| {
pixels.push((r, g, b, a));
},
)
.first_valid_layer()
.all_attributes()
.from_buffered(Cursor::new(bytes))
.map_err(|e| DecodeError::Decode(e.to_string()))?;
// The image is `Image<Layer<SpecificChannels<Vec<(f32,f32,f32,f32)>, RgbaChannels>>>`.
// Navigate: image.layer_data.{size, channel_data.pixels}.
let layer = image.layer_data;
let width = layer.size.x() as u32;
let height = layer.size.y() as u32;
let float_pixels = layer.channel_data.pixels;
// Tone-map to RGBA8 with Reinhard: out = clamp(v/(1+v), 0, 1) * 255.
let pixels = tone_map_reinhard(&float_pixels, width, height);
Ok(DecodedImage {
width,
height,
pixels,
format: "OpenEXR",
})
}
}
/// Reinhard tone-mapping: `out = clamp(v / (1+v), 0, 1) * 255`.
///
/// Operates per-channel. Alpha is scaled linearly (clamped, not Reinhard —
/// HDR alpha makes no physical sense as "brightness").
fn tone_map_reinhard(floats: &[(f32, f32, f32, f32)], width: u32, height: u32) -> Vec<u8> {
let pixel_count = (width as usize) * (height as usize);
let mut out = Vec::with_capacity(pixel_count * 4);
// Tiny inline closure to keep the loop body readable.
let tone = |v: f32| -> u8 {
// Guard against NaN / negative values from corrupt or scientific EXRs.
let v = if v.is_finite() && v >= 0.0 { v } else { 0.0 };
let mapped = v / (1.0 + v);
let clamped = mapped.clamp(0.0, 1.0);
(clamped * 255.0).round() as u8
};
let alpha = |a: f32| -> u8 {
let a = if a.is_finite() && a >= 0.0 { a } else { 0.0 };
(a.clamp(0.0, 1.0) * 255.0).round() as u8
};
for &(r, g, b, a) in floats {
out.extend_from_slice(&[tone(r), tone(g), tone(b), alpha(a)]);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_garbage_bytes() {
// Not a valid EXR — no magic number / header.
let codec = ExrCodec;
let result = codec.decode(&[0xff, 0xfe, 0xfd]);
assert!(matches!(result, Err(DecodeError::Decode(_))));
}
}

110
src/codec/image_crate.rs Normal file
View File

@ -0,0 +1,110 @@
//! Tier 1 codec: delegates to the `image` crate for all common raster formats.
//!
//! The `image` crate handles PNG (incl. APNG), JPEG, GIF (incl. animation),
//! WebP, BMP, ICO/CUR, and AVIF (when the `avif` feature is enabled, which
//! it is in our `Cargo.toml`).
//!
//! This codec is intentionally thin — it just normalizes the `image` crate's
//! `DynamicImage` into our `DecodedImage` type so the rest of the codebase
//! doesn't depend on `image` crate types directly.
use image::ImageReader;
use super::{Codec, DecodeError, DecodedImage};
pub struct ImageCrateCodec;
impl Codec for ImageCrateCodec {
fn name(&self) -> &'static str {
"image-crate"
}
fn extensions(&self) -> &'static [&'static str] {
// Keep this list in sync with the `image` crate features in Cargo.toml.
&[
"png", "apng", // PNG (incl. animated)
"jpg", "jpeg", "jfif", // JPEG
"gif", // GIF (animated)
"webp", // WebP (lossy + lossless + animated)
"bmp", // Windows bitmap
"ico", "cur", // Windows icon / cursor
"avif", // AV1 image file format
]
}
fn decode(&self, bytes: &[u8]) -> Result<DecodedImage, DecodeError> {
let reader = ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.map_err(|e| DecodeError::Decode(e.to_string()))?;
let format = reader
.format()
.map(|f| f.extensions_str().first().copied().unwrap_or("unknown"))
.unwrap_or("unknown");
let format_name: &'static str = match format {
"png" | "apng" => "PNG",
"jpg" | "jpeg" | "jfif" => "JPEG",
"gif" => "GIF",
"webp" => "WebP",
"bmp" => "BMP",
"ico" => "ICO",
"cur" => "CUR",
"avif" => "AVIF",
_ => "Unknown",
};
let img = reader
.decode()
.map_err(|e| DecodeError::Decode(e.to_string()))?
.to_rgba8();
let width = img.width();
let height = img.height();
let pixels = img.into_raw();
Ok(DecodedImage {
width,
height,
pixels,
format: format_name,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Rgba, ImageEncoder, ExtendedColorType};
use image::codecs::png::PngEncoder;
#[test]
fn decodes_png_from_memory() {
// Build a 2x2 RGBA PNG in memory.
let img: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::from_fn(2, 2, |x, y| {
if (x + y) % 2 == 0 {
Rgba([255, 0, 0, 255])
} else {
Rgba([0, 255, 0, 255])
}
});
let mut bytes = Vec::new();
PngEncoder::new(&mut bytes)
.write_image(img.as_raw(), 2, 2, ExtendedColorType::Rgba8)
.unwrap();
let codec = ImageCrateCodec;
let decoded = codec.decode(&bytes).unwrap();
assert_eq!(decoded.width, 2);
assert_eq!(decoded.height, 2);
assert_eq!(decoded.format, "PNG");
assert_eq!(decoded.pixels.len(), 16); // 2*2*4
}
#[test]
fn rejects_garbage_bytes() {
let codec = ImageCrateCodec;
let result = codec.decode(&[0xff, 0xfe, 0xfd]);
assert!(matches!(result, Err(DecodeError::Decode(_))));
}
}

141
src/codec/jxl.rs Normal file
View File

@ -0,0 +1,141 @@
//! Tier 2 codec: JPEG XL via the pure-Rust `jxl-oxide` crate.
//!
//! JPEG XL is the modern royalty-free successor to JPEG — better compression
//! at the same perceptual quality, lossless transcode from legacy JPEG, support
//! for 16-bit, alpha, animation, and HDR. We decode only the first keyframe
//! here; animation support is a future concern (see DECISION.md D004).
//!
//! Performance note: jxl-oxide is pure Rust with no SIMD intrinsics on most
//! targets. Decoding a large JXL master (50+ MP) can take seconds where a
//! libjxl-backed decoder would take milliseconds. This is acceptable for an
//! image viewer's "open on click" use case; if it ever becomes a hotspot,
//! swap in `zune-jpegxl` or a libjxl FFI.
use std::io::Cursor;
use jxl_oxide::{JxlImage, PixelFormat};
use super::{Codec, DecodeError, DecodedImage};
/// JPEG XL codec. Stateless — each `decode` call constructs its own `JxlImage`.
pub struct JxlCodec;
impl Codec for JxlCodec {
fn name(&self) -> &'static str {
"JPEG XL"
}
fn extensions(&self) -> &'static [&'static str] {
&["jxl"]
}
fn decode(&self, bytes: &[u8]) -> Result<DecodedImage, DecodeError> {
// `JxlImage::from_memory` was the API in jxl-oxide 0.8; in 0.12 the
// entry point is `JxlImage::builder().read(reader)` (or
// `read_with_defaults`). We pass a `Cursor` so the entire byte slice
// is treated as the file contents.
let img = JxlImage::builder()
.read(&mut Cursor::new(bytes))
.map_err(|e| DecodeError::Decode(e.to_string()))?;
let width = img.width();
let height = img.height();
// Render only the first keyframe. JXL supports animation; this codec
// surfaces frame 0 as the still image (matching the `image` crate's
// behavior for animated GIF/WebP).
let render = img
.render_frame(0)
.map_err(|e| DecodeError::Decode(e.to_string()))?;
let mut stream = render.stream();
let channels = stream.channels() as usize;
// Allocate a u8 buffer sized to the native channel layout. We expand
// to RGBA8 below.
let native_len = (width as usize) * (height as usize) * channels;
let mut raw = vec![0u8; native_len];
stream.write_to_buffer(&mut raw);
let pixels = normalize_to_rgba(&raw, img.pixel_format(), width, height);
Ok(DecodedImage {
width,
height,
pixels,
format: "JPEG XL",
})
}
}
/// Expand an arbitrary pixel-format buffer to RGBA8.
///
/// JXL pixel layouts we care about:
/// - `Gray` (1ch) → R=G=B=gray, A=255
/// - `Graya` (2ch) → R=G=B=gray, A=alpha
/// - `Rgb` (3ch) → as-is, A=255
/// - `Rgba` (4ch) → as-is
///
/// `Cmyk` / `Cmyka` are not handled here — the caller is expected to have
/// requested an sRGB color encoding for those, which downgrades them to
/// RGB(A). If the request fails or is bypassed, the buffer will be wrong;
/// that's an acceptable failure mode for an image viewer (we surface a
/// decode error rather than render wrong colors silently).
fn normalize_to_rgba(
raw: &[u8],
format: PixelFormat,
width: u32,
height: u32,
) -> Vec<u8> {
let pixel_count = (width as usize) * (height as usize);
let mut out = Vec::with_capacity(pixel_count * 4);
match format {
PixelFormat::Gray => {
for &g in raw.iter() {
out.extend_from_slice(&[g, g, g, 255]);
}
}
PixelFormat::Graya => {
for chunk in raw.chunks_exact(2) {
let g = chunk[0];
let a = chunk[1];
out.extend_from_slice(&[g, g, g, a]);
}
}
PixelFormat::Rgb => {
for chunk in raw.chunks_exact(3) {
out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
}
}
PixelFormat::Rgba => {
out.extend_from_slice(raw);
}
// CMYK should be converted upstream via `request_color_encoding(sRGB)`.
// If we get here, the conversion didn't apply — render as opaque black
// so the user sees a clearly broken image rather than a crash.
PixelFormat::Cmyk | PixelFormat::Cmyka => {
out.resize(pixel_count * 4, 0);
// Set alpha=255 so the (black) pixels are at least visible.
for px in out.chunks_exact_mut(4) {
px[3] = 255;
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_garbage_bytes() {
// Three bytes that aren't a valid JXL codestream or container —
// jxl-oxide should reject them at header-parse time.
let codec = JxlCodec;
let result = codec.decode(&[0xff, 0xfe, 0xfd]);
assert!(matches!(result, Err(DecodeError::Decode(_))));
}
}

339
src/codec/mod.rs Normal file
View File

@ -0,0 +1,339 @@
//! Codec layer — format detection, decoding, and the explicit anti-list.
//!
//! Architecture:
//! - [`FormatRegistry`] holds the list of supported formats and answers
//! "is this file supported?" queries.
//! - [`Codec`] trait — every decoder implements this.
//! - [`anti_list`] module owns the list of formats we deliberately reject,
//! with clear user-facing reasons.
//!
//! Decoding flow:
//! 1. Caller passes a path to `FormatRegistry::decode(path)`.
//! 2. Registry checks the extension against the anti-list first. If it matches,
//! returns [`DecodeError::AntiListed`] immediately — no I/O, no decode attempt.
//! 3. Registry checks the extension against the supported-formats list. If it
//! matches, hands off to the appropriate codec.
//! 4. Codec reads the file, decodes, returns a [`DecodedImage`].
//! 5. If the extension is unknown (not in anti-list, not in supported list),
//! returns [`DecodeError::Unsupported`].
//!
//! This layer is intentionally synchronous. Async wrapping happens at the
//! `app` layer via `iced::Task::perform`, which keeps the codec code simple
//! and testable.
pub mod anti_list;
pub mod exr;
pub mod image_crate;
pub mod jxl;
pub mod svg;
pub mod tiff;
use std::path::{Path, PathBuf};
use thiserror::Error;
/// A decoded image ready to hand off to the UI layer.
///
/// Pixels are always RGBA8 (4 bytes per pixel, row-major, top-to-bottom).
/// Any format-specific quirks (palette indices, 16-bit depth, HDR float,
/// premultiplied alpha) are normalized to RGBA8 by the codec.
#[derive(Debug, Clone)]
pub struct DecodedImage {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
/// Original format name, e.g. "PNG", "JPEG", "AVIF".
pub format: &'static str,
}
/// Why a decode failed.
///
/// The UI layer renders each variant differently:
/// - `AntiListed` → modal overlay explaining the project's stance.
/// - `Unsupported` → "this format isn't supported" message + link to docs.
/// - `Io` / `Decode` → generic error toast.
#[derive(Debug, Clone, Error)]
pub enum DecodeError {
#[error("File is on the project's anti-list: {format_name}")]
AntiListed {
path: PathBuf,
format_name: &'static str,
reason: &'static str,
alternative: &'static str,
},
#[error("Unsupported format: {extension}")]
Unsupported {
path: PathBuf,
extension: String,
},
#[error("Could not read file: {0}")]
Io(String),
#[error("Decoder failed: {0}")]
Decode(String),
}
impl From<std::io::Error> for DecodeError {
fn from(e: std::io::Error) -> Self {
DecodeError::Io(e.to_string())
}
}
/// Trait every decoder implements.
///
/// Implementations live in submodules (`image_crate`, future `jxl`, `tiff`, etc.).
/// Each codec is registered in [`FormatRegistry::new`] with the extensions it handles.
pub trait Codec: Send + Sync {
/// Human-readable name, e.g. "PNG", "JPEG XL".
/// Reserved for diagnostics and a future "supported formats" dialog.
#[allow(dead_code)]
fn name(&self) -> &'static str;
/// Extensions this codec handles, lowercase, no leading dot.
/// e.g. `["png", "apng"]`.
fn extensions(&self) -> &'static [&'static str];
/// Decode the given bytes. The codec does NOT need to verify the format —
/// the registry already matched by extension before calling this.
fn decode(&self, bytes: &[u8]) -> Result<DecodedImage, DecodeError>;
}
/// The central format registry. Owns the codec list and the anti-list.
pub struct FormatRegistry {
codecs: Vec<Box<dyn Codec>>,
}
impl FormatRegistry {
/// Construct a registry pre-populated with all Tier 1 codecs. Tier 2
/// codecs (jxl, tiff, svg, exr) will be added here when their modules
/// land — see `image_crate` for the implementation template.
pub fn new() -> Self {
let mut codecs: Vec<Box<dyn Codec>> = Vec::new();
// Tier 1: all handled by the `image` crate in one codec.
codecs.push(Box::new(image_crate::ImageCrateCodec));
// Tier 2 codecs:
// - JXL: pure-Rust JPEG XL (jxl-oxide)
// - TIFF: direct `tiff` crate for multi-page / 16-bit
// - SVG: vector rasterization (usvg + resvg + tiny-skia)
// - EXR: pure-Rust OpenEXR (exr) with Reinhard tone-mapping
codecs.push(Box::new(jxl::JxlCodec));
codecs.push(Box::new(tiff::TiffCodec));
codecs.push(Box::new(svg::SvgCodec));
codecs.push(Box::new(exr::ExrCodec));
Self { codecs }
}
/// Is this file's extension in the anti-list?
/// Returns the format info if so.
pub fn check_anti_list(&self, path: &Path) -> Option<anti_list::AntiListedFormat> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
anti_list::lookup(&ext)
}
/// Is this file's extension supported (in any registered codec)?
pub fn is_supported(&self, path: &Path) -> bool {
let ext = match path.extension().and_then(|e| e.to_str()) {
Some(e) => e.to_ascii_lowercase(),
None => return false,
};
self.codecs.iter().any(|c| c.extensions().contains(&ext.as_str()))
}
/// Decode a file. Anti-list is checked first; then supported codecs; then
/// returns `Unsupported` for everything else.
pub fn decode(&self, path: &Path) -> Result<DecodedImage, DecodeError> {
// 1. Anti-list check — fail fast, no I/O.
if let Some(info) = self.check_anti_list(path) {
return Err(DecodeError::AntiListed {
path: path.to_path_buf(),
format_name: info.name,
reason: info.reason,
alternative: info.alternative,
});
}
// 2. Find a codec by extension.
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.unwrap_or_default();
let codec = self
.codecs
.iter()
.find(|c| c.extensions().contains(&ext.as_str()));
let codec = match codec {
Some(c) => c,
None => {
return Err(DecodeError::Unsupported {
path: path.to_path_buf(),
extension: ext,
});
}
};
// 3. Read + decode.
let bytes = std::fs::read(path)?;
codec.decode(&bytes)
}
/// Iterate all supported extensions (used by the folder walker to filter).
/// Kept public even though the folder walker uses `is_supported` directly —
/// future callers (e.g. an "Open File" filter dialog) will need it.
#[allow(dead_code)]
pub fn supported_extensions(&self) -> Vec<&'static str> {
self.codecs.iter().flat_map(|c| c.extensions().iter().copied()).collect()
}
}
impl Default for FormatRegistry {
fn default() -> Self {
Self::new()
}
}
// ── Pixel rotation ──────────────────────────────────────────────────────
/// Rotate an RGBA pixel buffer by the given degrees (must be 0, 90, 180, or 270).
/// Returns (new_pixels, new_width, new_height).
///
/// 90° = clockwise, 270° = counter-clockwise.
pub fn rotate_rgba(pixels: &[u8], width: u32, height: u32, degrees: i32) -> (Vec<u8>, u32, u32) {
// Invariant: callers pass one of {0, 90, 180, 270, -90, …}. The modulo
// normalizes any integer to [0, 360); only the four cardinal values map
// to a real rotation. Any other value (e.g. 45°) returns the input
// unchanged — callers in this codebase only ever pass 90-degree steps.
match ((degrees % 360) + 360) % 360 {
0 => (pixels.to_vec(), width, height),
90 => rotate_rgba_cw(pixels, width, height),
180 => rotate_rgba_180(pixels, width, height),
270 => rotate_rgba_ccw(pixels, width, height),
_ => (pixels.to_vec(), width, height),
}
}
fn rotate_rgba_cw(src: &[u8], w: u32, h: u32) -> (Vec<u8>, u32, u32) {
// 90° clockwise: new[x][y] = old[h-1-y][x]
// new dimensions: (h, w) — width and height swap
// Performance: for loops chosen over iterators for pixel-level hot path.
// SEI CERT/overflow note: the `as usize` casts are safe on 64-bit because
// pixel count (w*h*4) is bounded by the original allocation, which already
// fit in usize. On 32-bit, a single image would need to exceed both the
// address space and the `image` crate's own limits.
let mut dst = vec![0u8; src.len()];
for y in 0..h {
for x in 0..w {
let src_idx = ((y * w + x) * 4) as usize;
let nx = h - 1 - y;
let ny = x;
let dst_idx = ((ny * h + nx) * 4) as usize; // new width = old height
dst[dst_idx..dst_idx + 4].copy_from_slice(&src[src_idx..src_idx + 4]);
}
}
(dst, h, w)
}
fn rotate_rgba_ccw(src: &[u8], w: u32, h: u32) -> (Vec<u8>, u32, u32) {
// 90° counter-clockwise: new[x][y] = old[y][w-1-x]
// Performance: for loops chosen over iterators for pixel-level hot path.
let mut dst = vec![0u8; src.len()];
for y in 0..h {
for x in 0..w {
let src_idx = ((y * w + x) * 4) as usize;
let nx = y;
let ny = w - 1 - x;
let dst_idx = ((ny * h + nx) * 4) as usize; // new width = old height
dst[dst_idx..dst_idx + 4].copy_from_slice(&src[src_idx..src_idx + 4]);
}
}
(dst, h, w)
}
fn rotate_rgba_180(src: &[u8], w: u32, h: u32) -> (Vec<u8>, u32, u32) {
// 180°: new[x][y] = old[h-1-y][w-1-x]
// Performance: for loops chosen over iterators for pixel-level hot path.
let mut dst = vec![0u8; src.len()];
for y in 0..h {
for x in 0..w {
let src_idx = ((y * w + x) * 4) as usize;
let nx = w - 1 - x;
let ny = h - 1 - y;
let dst_idx = ((ny * w + nx) * 4) as usize;
dst[dst_idx..dst_idx + 4].copy_from_slice(&src[src_idx..src_idx + 4]);
}
}
(dst, w, h)
}
#[cfg(test)]
mod rotation_tests {
use super::*;
fn make_pixels(w: u32, h: u32) -> Vec<u8> {
let mut v = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
v.push(x as u8);
v.push(y as u8);
v.push(0);
v.push(255);
}
}
v
}
#[test]
fn rotate_0_is_identity() {
let px = make_pixels(3, 2);
let (out, w, h) = rotate_rgba(&px, 3, 2, 0);
assert_eq!(w, 3);
assert_eq!(h, 2);
assert_eq!(out, px);
}
#[test]
fn rotate_360_is_identity() {
let px = make_pixels(3, 2);
let (out, w, h) = rotate_rgba(&px, 3, 2, 360);
assert_eq!(w, 3);
assert_eq!(h, 2);
assert_eq!(out, px);
}
#[test]
fn rotate_90_swaps_dimensions() {
let px = make_pixels(3, 2);
let (_out, w, h) = rotate_rgba(&px, 3, 2, 90);
assert_eq!(w, 2);
assert_eq!(h, 3);
}
#[test]
fn rotate_90_four_times_is_identity() {
let px = make_pixels(5, 3);
let (p1, w1, h1) = rotate_rgba(&px, 5, 3, 90);
let (p2, w2, h2) = rotate_rgba(&p1, w1, h1, 90);
let (p3, w3, h3) = rotate_rgba(&p2, w2, h2, 90);
let (p4, w4, h4) = rotate_rgba(&p3, w3, h3, 90);
assert_eq!(w4, 5);
assert_eq!(h4, 3);
assert_eq!(p4, px);
}
#[test]
fn rotate_180_keeps_dimensions() {
let px = make_pixels(3, 2);
let (out, w, h) = rotate_rgba(&px, 3, 2, 180);
assert_eq!(w, 3);
assert_eq!(h, 2);
// Top-left pixel should become bottom-right
assert_eq!(out[0..4], px[(3 * 2 - 1) * 4..(3 * 2) * 4]);
}
}

123
src/codec/svg.rs Normal file
View File

@ -0,0 +1,123 @@
//! Tier 2 codec: SVG (and SVGZ) via `usvg` + `resvg` + `tiny-skia`.
//!
//! SVG is the only vector format we support. We rasterize on decode so the
//! rest of the pipeline can treat it as RGBA8 — same as any bitmap. This
//! keeps the viewer's compositing, rotation, and thumbnail code uniform.
//!
//! Notes:
//! - **SVGZ** is detected by gzip magic bytes (0x1f 0x8b) rather than the
//! file extension. The `Codec::decode` trait method takes only bytes, so
//! we can't see the extension here. Magic-byte detection is more robust
//! anyway (it catches `.svg.gz` and mis-named files too).
//! - **Render size**: if the SVG declares explicit `width`/`height` (or a
//! `viewBox`), we render at that exact pixel size. If neither is present,
//! we default to 1024×768 — a sane viewport for unconstrained vector art.
//! - **Premultiplied alpha**: tiny-skia renders premultiplied; we call
//! `take_demultiplied()` to get straight RGBA8 for downstream consumers.
use std::io::Read;
use flate2::read::GzDecoder;
use resvg::tiny_skia::{Pixmap, Transform};
use resvg::usvg::{Options, Tree};
use super::{Codec, DecodeError, DecodedImage};
/// Default render size when an SVG declares neither width/height nor viewBox.
const FALLBACK_SIZE: (u32, u32) = (1024, 768);
/// SVG codec. Rasterizes on decode.
pub struct SvgCodec;
impl Codec for SvgCodec {
fn name(&self) -> &'static str {
"SVG"
}
fn extensions(&self) -> &'static [&'static str] {
&["svg", "svgz"]
}
fn decode(&self, bytes: &[u8]) -> Result<DecodedImage, DecodeError> {
// SVGZ detection: gzip magic is 0x1f 0x8b. We decompress transparently
// so .svgz files "just work" without the user renaming them.
let svg_bytes: Vec<u8> = if bytes.starts_with(&[0x1f, 0x8b]) {
let mut decoder = GzDecoder::new(bytes);
let mut out = Vec::new();
decoder
.read_to_end(&mut out)
.map_err(|e| DecodeError::Io(e.to_string()))?;
out
} else {
bytes.to_vec()
};
// Parse the SVG into a usvg tree. `Options::default()` uses the
// current directory for resolving relative URLs (none, in our case)
// and default font config.
let tree = Tree::from_data(&svg_bytes, &Options::default())
.map_err(|e| DecodeError::Decode(e.to_string()))?;
// Determine render dimensions. `tree.size()` is a `tiny_skia::Size`
// (f32, guaranteed non-zero by usvg). If for any reason the size
// rounds down to 0×0, fall back to the default.
let (width, height) = {
let size = tree.size();
let w = size.width().round() as u32;
let h = size.height().round() as u32;
if w == 0 || h == 0 {
FALLBACK_SIZE
} else {
(w, h)
}
};
// Allocate the destination pixmap. tiny-skia's `Pixmap::new` returns
// `None` on overflow; we surface that as a decode error.
let mut pixmap = Pixmap::new(width, height)
.ok_or_else(|| DecodeError::Decode(format!("pixmap alloc failed: {width}x{height}")))?;
// Render. `Transform::identity()` places the SVG at (0,0) — which is
// correct because usvg already resolved any viewBox / preserveAspectRatio.
resvg::render(&tree, Transform::identity(), &mut pixmap.as_mut());
// tiny-skia stores premultiplied RGBA; `take_demultiplied` reverses
// that so downstream consumers see straight RGBA8.
let pixels = pixmap.take_demultiplied();
Ok(DecodedImage {
width,
height,
pixels,
format: "SVG",
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const RED_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="red"/></svg>"#;
#[test]
fn decodes_simple_svg() {
let codec = SvgCodec;
let decoded = codec.decode(RED_SVG.as_bytes()).unwrap();
assert_eq!(decoded.width, 10);
assert_eq!(decoded.height, 10);
assert_eq!(decoded.format, "SVG");
assert_eq!(decoded.pixels.len(), 10 * 10 * 4);
// Top-left pixel should be red (#ff0000ff).
assert_eq!(&decoded.pixels[0..4], &[255, 0, 0, 255]);
}
#[test]
fn rejects_garbage_bytes() {
// Not a valid SVG — usvg should reject it.
let codec = SvgCodec;
let result = codec.decode(&[0xff, 0xfe, 0xfd]);
assert!(matches!(result, Err(DecodeError::Decode(_))));
}
}

189
src/codec/tiff.rs Normal file
View File

@ -0,0 +1,189 @@
//! Tier 2 codec: TIFF via the pure-Rust `tiff` crate.
//!
//! TIFF is the open archival master format — 8/16/32-bit integer or float
//! samples, RGB/RGBA/Gray/GrayA/CMYK/YCbCr/Lab color types, multi-page
//! documents, ICC profiles, lossless compression (LZW, Deflate, PackBits).
//! The `image` crate's TIFF support is good but lags behind `tiff` directly
//! for unusual photometric interpretations; this codec gives us first-class
//! access.
//!
//! Notes / limitations:
//! - Multi-page TIFFs decode page 0 only. A future "page navigator" feature
//! would iterate `decoder.next_image()` and surface each page; for now, the
//! cover page is the right default for a still-image viewer.
//! - 16-bit samples are downscaled to 8-bit by taking the high byte. This is
//! a lossy operation — the low byte (which differentiates ~65k levels down
//! to ~256) is discarded. We do not gamma-correct or apply a tone curve
//! here; that belongs in a future "HDR display" feature.
use std::io::Cursor;
use tiff::decoder::{Decoder, DecodingResult};
use tiff::ColorType;
use super::{Codec, DecodeError, DecodedImage};
/// TIFF codec. Decodes page 0 of multi-page TIFFs.
pub struct TiffCodec;
impl Codec for TiffCodec {
fn name(&self) -> &'static str {
"TIFF"
}
fn extensions(&self) -> &'static [&'static str] {
&["tif", "tiff"]
}
fn decode(&self, bytes: &[u8]) -> Result<DecodedImage, DecodeError> {
// Page 0 only: a freshly-constructed `Decoder` points at the first
// IFD. Callers wanting later pages would call `decoder.next_image()`.
let mut decoder = Decoder::new(Cursor::new(bytes))
.map_err(|e| DecodeError::Decode(e.to_string()))?;
let (width, height) = decoder
.dimensions()
.map_err(|e| DecodeError::Decode(e.to_string()))?;
let color_type = decoder
.colortype()
.map_err(|e| DecodeError::Decode(e.to_string()))?;
let result = decoder
.read_image()
.map_err(|e| DecodeError::Decode(e.to_string()))?;
let pixels = convert_to_rgba(result, color_type, width, height);
Ok(DecodedImage {
width,
height,
pixels,
format: "TIFF",
})
}
}
/// Convert a `DecodingResult` + `ColorType` pair into RGBA8.
///
/// The `tiff` crate hands us the raw sample buffer + a tag describing how to
/// interpret it. We expand every supported layout into RGBA8 here.
fn convert_to_rgba(
result: DecodingResult,
color_type: ColorType,
width: u32,
height: u32,
) -> Vec<u8> {
let pixel_count = (width as usize) * (height as usize);
let mut out = Vec::with_capacity(pixel_count * 4);
// 16-bit downscale helper — keep the high byte only. See module docs for
// why we don't tone-map.
fn take_u8_high(samples: &[u16], out: &mut Vec<u8>, expand: impl Fn(u8, &mut Vec<u8>)) {
for &s in samples {
expand((s >> 8) as u8, out);
}
}
match (color_type, result) {
// ── 8-bit types ────────────────────────────────────────────────
(ColorType::Gray(_), DecodingResult::U8(buf)) => {
for &g in &buf {
out.extend_from_slice(&[g, g, g, 255]);
}
}
(ColorType::GrayA(_), DecodingResult::U8(buf)) => {
for chunk in buf.chunks_exact(2) {
out.extend_from_slice(&[chunk[0], chunk[0], chunk[0], chunk[1]]);
}
}
(ColorType::RGB(_), DecodingResult::U8(buf)) => {
for chunk in buf.chunks_exact(3) {
out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
}
}
(ColorType::RGBA(_), DecodingResult::U8(buf)) => {
out.extend_from_slice(&buf);
}
(ColorType::CMYK(_), DecodingResult::U8(buf)) => {
// Naive CMYK→RGB: r = 255 - min(c+m, 255) etc.
// (Real CMYK conversion needs an ICC profile; this is a fallback.)
for chunk in buf.chunks_exact(4) {
let c = chunk[0] as u32;
let m = chunk[1] as u32;
let y = chunk[2] as u32;
let k = chunk[3] as u32;
let r = 255u32.saturating_sub(c + k);
let g = 255u32.saturating_sub(m + k);
let b = 255u32.saturating_sub(y + k);
out.extend_from_slice(&[r as u8, g as u8, b as u8, 255]);
}
}
(ColorType::Palette(_), DecodingResult::U8(_buf)) => {
// Palette TIFF requires the color-map tag, which we don't fetch
// here. Fill transparent-black so the user sees a clear failure.
out.resize(pixel_count * 4, 0);
for px in out.chunks_exact_mut(4) {
px[3] = 255;
}
}
// ── 16-bit types (downscaled to 8-bit; lossy) ─────────────────
(ColorType::Gray(_), DecodingResult::U16(buf)) => {
take_u8_high(&buf, &mut out, |g, o| o.extend_from_slice(&[g, g, g, 255]));
}
(ColorType::GrayA(_), DecodingResult::U16(buf)) => {
for chunk in buf.chunks_exact(2) {
let g = (chunk[0] >> 8) as u8;
let a = (chunk[1] >> 8) as u8;
out.extend_from_slice(&[g, g, g, a]);
}
}
(ColorType::RGB(_), DecodingResult::U16(buf)) => {
for chunk in buf.chunks_exact(3) {
out.extend_from_slice(&[
(chunk[0] >> 8) as u8,
(chunk[1] >> 8) as u8,
(chunk[2] >> 8) as u8,
255,
]);
}
}
(ColorType::RGBA(_), DecodingResult::U16(buf)) => {
for chunk in buf.chunks_exact(4) {
out.extend_from_slice(&[
(chunk[0] >> 8) as u8,
(chunk[1] >> 8) as u8,
(chunk[2] >> 8) as u8,
(chunk[3] >> 8) as u8,
]);
}
}
// ── Everything else: opaque black fallback ────────────────────
// Covers YCbCr, Lab, Multiband, float samples, and any unexpected
// combination. The user sees a clearly broken image rather than a
// panic; the codec returns `Ok` because the bytes did decode.
_ => {
out.resize(pixel_count * 4, 0);
for px in out.chunks_exact_mut(4) {
px[3] = 255;
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_garbage_bytes() {
// Three bytes — not a valid TIFF (no `II*\0` or `MM\0*` magic).
let codec = TiffCodec;
let result = codec.decode(&[0xff, 0xfe, 0xfd]);
assert!(matches!(result, Err(DecodeError::Decode(_))));
}
}

367
src/config.rs Normal file
View File

@ -0,0 +1,367 @@
//! Config loading — keymap.toml and (future) theme overrides.
//!
//! Loading order:
//! 1. Built-in defaults (hardcoded in this file).
//! 2. `$XDG_CONFIG_HOME/marten/keymap.toml` if it exists.
//! 3. Unknown keys in the user's toml emit a warning but don't crash.
//!
//! The toml schema is intentionally simple — a flat map of action_name → key string.
//! Example:
//!
//! ```toml
//! next_photo = "Right" # or "l"
//! prev_photo = "Left" # or "h"
//! first_photo = "g"
//! last_photo = "G"
//! zoom_in = "Ctrl+Equal" # Ctrl + +
//! zoom_out = "Ctrl+Minus"
//! fit_to_window = "0"
//! actual_size = "1"
//! toggle_fullscreen = "F11"
//! toggle_chrome = "Shift+F"
//! open_folder = "o"
//! quit = "q"
//! rotate_cw = "r"
//! rotate_ccw = "Shift+R"
//! properties = "i"
//! ```
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// A parsed key binding. Mods are sorted canonically (Ctrl, Shift, Alt, Logo)
/// so equality comparisons work regardless of user-written order.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct KeyBinding {
pub mods: Modifiers,
pub key: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct Modifiers {
pub ctrl: bool,
pub shift: bool,
pub alt: bool,
pub logo: bool,
}
impl Modifiers {
pub fn none() -> Self {
Self::default()
}
}
/// Parse a key string like "Ctrl+Shift+L" into a KeyBinding.
/// Modifiers (case-insensitive: ctrl, shift, alt, logo/super/meta) are split
/// off by '+'. The remaining segment is the key name, case-preserved (because
/// 'g' and 'G' are different bindings — vim convention).
pub fn parse_binding(s: &str) -> Option<KeyBinding> {
let parts: Vec<&str> = s.split('+').collect();
let mut mods = Modifiers::none();
let mut key = String::new();
for (i, part) in parts.iter().enumerate() {
let trimmed = part.trim();
if i + 1 < parts.len() {
// It's a modifier.
match trimmed.to_ascii_lowercase().as_str() {
"ctrl" | "control" => mods.ctrl = true,
"shift" => mods.shift = true,
"alt" | "option" => mods.alt = true,
"logo" | "super" | "meta" | "win" | "cmd" => mods.logo = true,
_ => return None, // unknown modifier
}
} else {
key = trimmed.to_string();
}
}
if key.is_empty() {
return None;
}
Some(KeyBinding { mods, key })
}
/// All keymap actions. Each variant maps to a single KeyBinding (or multiple —
/// we keep a Vec so users can bind both `Right` and `l` to next_photo).
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Keymap {
#[serde(default)]
pub next_photo: Vec<String>,
#[serde(default)]
pub prev_photo: Vec<String>,
#[serde(default)]
pub first_photo: Vec<String>,
#[serde(default)]
pub last_photo: Vec<String>,
#[serde(default)]
pub zoom_in: Vec<String>,
#[serde(default)]
pub zoom_out: Vec<String>,
#[serde(default)]
pub fit_to_window: Vec<String>,
#[serde(default)]
pub actual_size: Vec<String>,
#[serde(default)]
pub toggle_fullscreen: Vec<String>,
#[serde(default)]
pub toggle_chrome: Vec<String>,
#[serde(default)]
pub open_folder: Vec<String>,
#[serde(default)]
pub quit: Vec<String>,
#[serde(default)]
pub rotate_cw: Vec<String>,
#[serde(default)]
pub rotate_ccw: Vec<String>,
#[serde(default)]
pub properties: Vec<String>,
#[serde(default)]
pub copy_to_pictures: Vec<String>,
#[serde(default)]
pub delete_permanently: Vec<String>,
#[serde(default)]
pub about: Vec<String>,
#[serde(default)]
pub toggle_sidebar: Vec<String>,
#[serde(default)]
pub toggle_slideshow: Vec<String>,
#[serde(default)]
pub random_same_folder: Vec<String>,
#[serde(default)]
pub random_folder_tree: Vec<String>,
}
impl Keymap {
/// The hardcoded default keymap. Matches `DECISION.md` D005.
pub fn defaults() -> Self {
Self {
next_photo: vec!["Right".into(), "l".into()],
prev_photo: vec!["Left".into(), "h".into()],
first_photo: vec!["g".into()],
last_photo: vec!["G".into()],
zoom_in: vec!["+".into(), "Ctrl+Up".into()],
zoom_out: vec!["-".into(), "Ctrl+Down".into()],
fit_to_window: vec!["0".into()],
actual_size: vec!["1".into()],
toggle_fullscreen: vec!["F11".into()],
toggle_chrome: vec!["Shift+F".into()],
open_folder: vec!["o".into()],
quit: vec!["q".into(), "Ctrl+Q".into()],
rotate_cw: vec!["r".into()],
rotate_ccw: vec!["Shift+R".into()],
properties: vec!["i".into()],
copy_to_pictures: vec!["Shift+Home".into()],
delete_permanently: vec!["Shift+Delete".into()],
about: vec!["a".into()],
toggle_sidebar: vec!["Tab".into()],
toggle_slideshow: vec!["s".into()],
random_same_folder: vec!["z".into()],
random_folder_tree: vec!["Shift+Z".into()],
}
}
/// Look up all bindings for an action as parsed KeyBindings.
pub fn bindings_for(&self, action: KeymapAction) -> &[String] {
match action {
KeymapAction::NextPhoto => &self.next_photo,
KeymapAction::PrevPhoto => &self.prev_photo,
KeymapAction::FirstPhoto => &self.first_photo,
KeymapAction::LastPhoto => &self.last_photo,
KeymapAction::ZoomIn => &self.zoom_in,
KeymapAction::ZoomOut => &self.zoom_out,
KeymapAction::FitToWindow => &self.fit_to_window,
KeymapAction::ActualSize => &self.actual_size,
KeymapAction::ToggleFullscreen => &self.toggle_fullscreen,
KeymapAction::ToggleChrome => &self.toggle_chrome,
KeymapAction::OpenFolder => &self.open_folder,
KeymapAction::Quit => &self.quit,
KeymapAction::RotateCw => &self.rotate_cw,
KeymapAction::RotateCcw => &self.rotate_ccw,
KeymapAction::Properties => &self.properties,
KeymapAction::CopyToPictures => &self.copy_to_pictures,
KeymapAction::DeletePermanently => &self.delete_permanently,
KeymapAction::About => &self.about,
KeymapAction::ToggleSidebar => &self.toggle_sidebar,
KeymapAction::ToggleSlideshow => &self.toggle_slideshow,
KeymapAction::RandomSameFolder => &self.random_same_folder,
KeymapAction::RandomFolderTree => &self.random_folder_tree,
}
}
}
/// Enum of all keymap actions, for match-style dispatch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum KeymapAction {
NextPhoto,
PrevPhoto,
FirstPhoto,
LastPhoto,
ZoomIn,
ZoomOut,
FitToWindow,
ActualSize,
ToggleFullscreen,
ToggleChrome,
OpenFolder,
Quit,
RotateCw,
RotateCcw,
Properties,
CopyToPictures,
DeletePermanently,
About,
ToggleSidebar,
ToggleSlideshow,
RandomSameFolder,
RandomFolderTree,
}
/// Where to look for the user's keymap.toml.
/// `$XDG_CONFIG_HOME/marten/keymap.toml`, falling back to
/// `~/.config/marten/keymap.toml` if XDG isn't set.
pub fn user_keymap_path() -> Option<PathBuf> {
let base = dirs::config_dir()?;
Some(base.join("marten").join("keymap.toml"))
}
/// Load the user's keymap.toml on top of the defaults.
/// Missing file → defaults. Malformed file → defaults + warning.
/// Partial file (only some actions overridden) → defaults with overrides.
pub fn load_keymap() -> (Keymap, Vec<String>) {
let mut warnings = Vec::new();
let mut keymap = Keymap::defaults();
let path = match user_keymap_path() {
Some(p) => p,
None => return (keymap, warnings),
};
if !path.exists() {
return (keymap, warnings);
}
let contents = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
warnings.push(format!("Could not read {}: {e}", path.display()));
return (keymap, warnings);
}
};
let user_keymap: Keymap = match toml::from_str(&contents) {
Ok(k) => k,
Err(e) => {
warnings.push(format!("Could not parse {}: {e}", path.display()));
return (keymap, warnings);
}
};
// Override: for each action, if the user specified at least one binding,
// replace the default; otherwise keep the default.
macro_rules! override_if_nonempty {
($field:ident) => {
if !user_keymap.$field.is_empty() {
// Validate each binding parses; skip invalid ones with a warning.
let mut valid = Vec::new();
for b in &user_keymap.$field {
if parse_binding(b).is_some() {
valid.push(b.clone());
} else {
warnings.push(format!(
"Invalid binding {:?} for {} in {}",
b,
stringify!($field),
path.display()
));
}
}
keymap.$field = valid;
}
};
}
override_if_nonempty!(next_photo);
override_if_nonempty!(prev_photo);
override_if_nonempty!(first_photo);
override_if_nonempty!(last_photo);
override_if_nonempty!(zoom_in);
override_if_nonempty!(zoom_out);
override_if_nonempty!(fit_to_window);
override_if_nonempty!(actual_size);
override_if_nonempty!(toggle_fullscreen);
override_if_nonempty!(toggle_chrome);
override_if_nonempty!(open_folder);
override_if_nonempty!(quit);
override_if_nonempty!(rotate_cw);
override_if_nonempty!(rotate_ccw);
override_if_nonempty!(properties);
override_if_nonempty!(copy_to_pictures);
override_if_nonempty!(delete_permanently);
override_if_nonempty!(about);
override_if_nonempty!(toggle_sidebar);
override_if_nonempty!(toggle_slideshow);
override_if_nonempty!(random_same_folder);
override_if_nonempty!(random_folder_tree);
(keymap, warnings)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_simple_key() {
let b = parse_binding("Right").unwrap();
assert_eq!(b.key, "Right");
assert!(!b.mods.ctrl);
}
#[test]
fn parses_modified_key() {
let b = parse_binding("Ctrl+Shift+R").unwrap();
assert_eq!(b.key, "R");
assert!(b.mods.ctrl);
assert!(b.mods.shift);
}
#[test]
fn case_insensitive_modifiers() {
let b = parse_binding("ctrl+SHIFT+alt+x").unwrap();
assert!(b.mods.ctrl);
assert!(b.mods.shift);
assert!(b.mods.alt);
assert_eq!(b.key, "x");
}
#[test]
fn case_sensitive_key() {
// 'g' and 'G' are different bindings (vim convention).
let lower = parse_binding("g").unwrap();
let upper = parse_binding("G").unwrap();
assert_ne!(lower.key, upper.key);
}
#[test]
fn rejects_unknown_modifier() {
assert!(parse_binding("Foo+Bar").is_none());
}
#[test]
fn rejects_empty() {
assert!(parse_binding("").is_none());
assert!(parse_binding("Ctrl+").is_none());
}
#[test]
fn defaults_have_both_arrows_and_vim() {
let k = Keymap::defaults();
assert!(k.next_photo.contains(&"Right".to_string()));
assert!(k.next_photo.contains(&"l".to_string()));
assert!(k.prev_photo.contains(&"Left".to_string()));
assert!(k.prev_photo.contains(&"h".to_string()));
}
}

27
src/main.rs Normal file
View File

@ -0,0 +1,27 @@
//! marten — a modern, accuracy-first image viewer for Linux.
//!
//! Named after the marten (genus *Martes*), a small agile mustelid native to
//! forests across the Northern Hemisphere. Like its cousin the ferret, the
//! marten is quick, curious, and nimble — fitting energy for an image viewer
//! designed to move fast through large photo libraries.
mod app;
mod codec;
mod config;
mod nav;
mod settings;
mod ui;
fn main() -> iced::Result {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn"))
.format_timestamp(None)
.init();
log::info!("Starting marten v{}", env!("CARGO_PKG_VERSION"));
iced::application("marten", app::Viewer::update, app::Viewer::view)
.theme(|_| iced::Theme::Dark)
.subscription(app::Viewer::subscription)
.window_size(iced::Size::new(1200.0, 800.0))
.run()
}

134
src/nav/folder.rs Normal file
View File

@ -0,0 +1,134 @@
//! Folder scanning — find all supported images in a directory, sorted.
//!
//! Sorting is lexicographic by file name (not natural sort). This matches
//! what `ls` does and what most users expect. If we want natural sort later
//! (so `img2.png` comes before `img10.png`), it's a one-line change here.
use std::path::{Path, PathBuf};
use crate::codec::FormatRegistry;
/// Scan a directory for supported image files.
/// Returns a sorted Vec of paths. Symlinks are followed (one level).
/// Hidden files (starting with `.`) are skipped.
pub fn scan_folder(dir: &Path, registry: &FormatRegistry) -> Vec<PathBuf> {
// Guard: unreadable directory → empty result.
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut images: Vec<PathBuf> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| {
// Skip hidden files (Unix convention).
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
if name.starts_with('.') {
return false;
}
// Only files (or symlinks to files).
if !path.is_file() {
return false;
}
// Anti-listed files are excluded from the folder scan — they would
// fail at decode time anyway, and we don't want them cluttering
// the thumbnail bar. The user gets the anti-list error only if
// they explicitly open such a file by path.
if registry.check_anti_list(path).is_some() {
return false;
}
registry.is_supported(path)
})
.collect();
images.sort();
images
}
/// Recursively walk a directory tree and collect all supported images.
/// Returns a list of (folder_path, image_path) pairs — the folder is the
/// immediate parent of each image, used by `random_from_tree` to switch
/// folders when the random pick lands in a different subfolder.
///
/// Skips hidden directories (starting with `.`) and symlinks to avoid
/// infinite loops.
pub fn walk_folder_tree(dir: &Path, registry: &FormatRegistry) -> Vec<(PathBuf, PathBuf)> {
let mut results = Vec::new();
walk_folder_tree_recursive(dir, registry, &mut results);
results
}
fn walk_folder_tree_recursive(
dir: &Path,
registry: &FormatRegistry,
results: &mut Vec<(PathBuf, PathBuf)>,
) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if name.starts_with('.') {
continue;
}
if path.is_dir() {
// Don't follow symlinks to avoid cycles.
if path.is_symlink() {
continue;
}
walk_folder_tree_recursive(&path, registry, results);
} else if path.is_file()
&& registry.check_anti_list(&path).is_none()
&& registry.is_supported(&path)
{
let folder = path.parent().unwrap_or(dir).to_path_buf();
results.push((folder, path));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn scans_supported_files_only() {
let dir = tempdir().unwrap();
let dir_path = dir.path();
// Create a mix of supported, anti-listed, and unsupported files.
fs::write(dir_path.join("a.png"), b"fake png").unwrap();
fs::write(dir_path.join("b.jpg"), b"fake jpg").unwrap();
fs::write(dir_path.join("c.heic"), b"fake heic").unwrap(); // anti-listed
fs::write(dir_path.join("d.txt"), b"not an image").unwrap();
fs::write(dir_path.join(".hidden.png"), b"hidden").unwrap();
fs::write(dir_path.join("e.webp"), b"fake webp").unwrap();
let registry = FormatRegistry::new();
let result = scan_folder(dir_path, &registry);
let names: Vec<&str> = result
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap())
.collect();
assert_eq!(names, vec!["a.png", "b.jpg", "e.webp"]);
}
#[test]
fn missing_dir_returns_empty() {
let registry = FormatRegistry::new();
let result = scan_folder(Path::new("/nonexistent/path/that/does/not/exist"), &registry);
assert!(result.is_empty());
}
}

210
src/nav/mod.rs Normal file
View File

@ -0,0 +1,210 @@
//! Navigation layer — folder walking, current index, scroll handling.
//!
//! The [`Navigator`] owns the list of images in the current folder and the
//! current index. It does NOT own the decoded image — that's the app layer's
//! job. The Navigator is a pure data structure: pass it a folder path, it
//! gives you back a sorted list of supported image paths and helps you move
//! the index around.
pub mod folder;
use std::path::{Path, PathBuf};
pub use folder::scan_folder;
/// The navigation state for the current folder.
#[derive(Debug, Clone, Default)]
pub struct Navigator {
/// Sorted list of image paths in the current folder.
/// Empty when no folder is open.
images: Vec<PathBuf>,
/// Current index into `images`. Wraps around modulo `images.len()`.
current: usize,
}
impl Navigator {
pub fn new() -> Self {
Self::default()
}
/// Replace the image list with a fresh folder scan.
/// Resets the current index to 0.
pub fn set_images(&mut self, images: Vec<PathBuf>) {
self.images = images;
self.current = 0;
}
pub fn len(&self) -> usize {
self.images.len()
}
pub fn is_empty(&self) -> bool {
self.images.is_empty()
}
pub fn current_index(&self) -> usize {
self.current
}
pub fn current_path(&self) -> Option<&Path> {
self.images.get(self.current).map(|p| p.as_path())
}
pub fn images(&self) -> &[PathBuf] {
&self.images
}
/// Move by `delta` positions, wrapping around. Returns the new index.
/// No-op if the list is empty.
pub fn navigate(&mut self, delta: i32) -> usize {
if self.images.is_empty() {
return 0;
}
let len = self.images.len() as i32;
let new = ((self.current as i32 + delta).rem_euclid(len)) as usize;
self.current = new;
new
}
/// Jump to a specific index. Returns false if out of bounds.
pub fn jump_to(&mut self, index: usize) -> bool {
if index >= self.images.len() {
return false;
}
self.current = index;
true
}
/// Pick a random image from the current folder. Sets `current` to the
/// random index. Subsequent `navigate(±1)` calls step sequentially from
/// the new position (step-aware behavior).
/// Returns false if the list is empty.
pub fn random_same_folder(&mut self) -> bool {
if self.images.is_empty() {
return false;
}
let n = pseudo_random(self.images.len());
self.current = n;
true
}
/// Pick a random image from a list of all images in the folder tree.
/// If the picked image is in the current folder, sets `current` to its
/// index. If it is in a different folder, replaces the image list with
/// that folder's images and sets `current` to the picked image's index.
/// Returns the folder path if a folder switch occurred, or None if the
/// random image was in the current folder.
pub fn random_from_tree(
&mut self,
all_tree_images: &[(PathBuf, PathBuf)],
) -> Option<PathBuf> {
if all_tree_images.is_empty() {
return None;
}
let n = pseudo_random(all_tree_images.len());
let (folder, image) = &all_tree_images[n];
// Check if the image is in the current folder.
if let Some(idx) = self.images.iter().position(|p| p == image) {
self.current = idx;
return None;
}
// The image is in a different folder. Load that folder's images.
// Extract just the images from the same folder.
let same_folder: Vec<PathBuf> = all_tree_images
.iter()
.filter(|(f, _)| f == folder)
.map(|(_, p)| p.clone())
.collect();
let new_idx = same_folder.iter().position(|p| p == image).unwrap_or(0);
self.images = same_folder;
self.current = new_idx;
Some(folder.clone())
}
/// Jump to the first image. No-op if empty.
pub fn first(&mut self) {
if !self.images.is_empty() {
self.current = 0;
}
}
/// Jump to the last image. No-op if empty.
pub fn last(&mut self) {
if !self.images.is_empty() {
self.current = self.images.len() - 1;
}
}
}
/// Pseudo-random index in [0, max). Uses SystemTime nanos as the entropy
/// source — sufficient for an image viewer's shuffle feature without
/// pulling in the `rand` crate as a direct dependency.
fn pseudo_random(max: usize) -> usize {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as usize)
.unwrap_or(0);
nanos % max
}
#[cfg(test)]
mod tests {
use super::*;
fn make_paths(n: usize) -> Vec<PathBuf> {
(0..n).map(|i| PathBuf::from(format!("/fake/img{i:03}.png"))).collect()
}
#[test]
fn empty_navigator_is_safe() {
let mut nav = Navigator::new();
assert!(nav.is_empty());
assert_eq!(nav.current_path(), None);
nav.navigate(1); // should not panic
nav.first();
nav.last();
}
#[test]
fn navigate_wraps_forward() {
let mut nav = Navigator::new();
nav.set_images(make_paths(3));
assert_eq!(nav.current_index(), 0);
nav.navigate(1);
assert_eq!(nav.current_index(), 1);
nav.navigate(1);
assert_eq!(nav.current_index(), 2);
nav.navigate(1);
assert_eq!(nav.current_index(), 0); // wraps
}
#[test]
fn navigate_wraps_backward() {
let mut nav = Navigator::new();
nav.set_images(make_paths(3));
nav.navigate(-1);
assert_eq!(nav.current_index(), 2); // wraps backward
}
#[test]
fn first_and_last() {
let mut nav = Navigator::new();
nav.set_images(make_paths(5));
nav.last();
assert_eq!(nav.current_index(), 4);
nav.first();
assert_eq!(nav.current_index(), 0);
}
#[test]
fn jump_to_respects_bounds() {
let mut nav = Navigator::new();
nav.set_images(make_paths(5));
assert!(nav.jump_to(3));
assert_eq!(nav.current_index(), 3);
assert!(!nav.jump_to(5));
assert_eq!(nav.current_index(), 3); // unchanged
}
}

209
src/settings.rs Normal file
View File

@ -0,0 +1,209 @@
//! Settings file — user preferences loaded from `~/.config/marten/settings.toml`.
//!
//! Settings are distinct from the keymap (which lives in `keymap.toml`):
//! settings are scalar values (intervals, sizes, toggles) that affect runtime
//! behavior, while the keymap is purely about which key triggers which action.
//!
//! Loading order:
//! 1. Built-in defaults (hardcoded in `Settings::defaults()`).
//! 2. `$XDG_CONFIG_HOME/marten/settings.toml` if it exists.
//! 3. Unknown keys in the user's toml emit a warning but do not crash.
//!
//! Schema:
//! ```toml
//! slideshow_interval_secs = 3.0
//! thumbnail_size = 72
//! thumbnail_cache_window = 15
//! default_zoom_mode = "FitToWindow" # or "ActualSize"
//! smooth_scroll_thumbnails = true
//! show_fullscreen_hint = true
//! ```
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// User-configurable runtime settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
/// Seconds between auto-advances in slideshow mode.
#[serde(default = "default_slideshow_interval")]
pub slideshow_interval_secs: f32,
/// Thumbnail edge length in pixels (square thumbnails).
#[serde(default = "default_thumbnail_size")]
pub thumbnail_size: u32,
/// How many thumbnails ahead/behind the current image to cache.
#[serde(default = "default_thumbnail_cache_window")]
pub thumbnail_cache_window: usize,
/// Zoom mode applied when a new image is loaded.
/// "FitToWindow" or "ActualSize".
#[serde(default = "default_zoom_mode")]
pub default_zoom_mode: String,
/// Whether the thumbnail strip auto-scrolls to follow the current image.
#[serde(default = "default_smooth_scroll_thumbnails")]
pub smooth_scroll_thumbnails: bool,
/// Whether the "Press F11 to exit fullscreen" hint is shown.
#[serde(default = "default_show_fullscreen_hint")]
pub show_fullscreen_hint: bool,
}
fn default_slideshow_interval() -> f32 { 3.0 }
fn default_thumbnail_size() -> u32 { 72 }
fn default_thumbnail_cache_window() -> usize { 15 }
fn default_zoom_mode() -> String { "FitToWindow".to_string() }
fn default_smooth_scroll_thumbnails() -> bool { true }
fn default_show_fullscreen_hint() -> bool { true }
impl Default for Settings {
fn default() -> Self {
Self {
slideshow_interval_secs: default_slideshow_interval(),
thumbnail_size: default_thumbnail_size(),
thumbnail_cache_window: default_thumbnail_cache_window(),
default_zoom_mode: default_zoom_mode(),
smooth_scroll_thumbnails: default_smooth_scroll_thumbnails(),
show_fullscreen_hint: default_show_fullscreen_hint(),
}
}
}
impl Settings {
/// The hardcoded default settings. Matches the schema documented above.
pub fn defaults() -> Self {
Self::default()
}
/// The path where user settings are loaded from:
/// `$XDG_CONFIG_HOME/marten/settings.toml`, falling back to
/// `~/.config/marten/settings.toml` if XDG is not set.
pub fn user_settings_path() -> Option<PathBuf> {
let base = dirs::config_dir()?;
Some(base.join("marten").join("settings.toml"))
}
/// Load settings from the user's `settings.toml`, merging over defaults.
/// Missing file → defaults. Malformed file → defaults + warning.
/// Partial file → defaults with overrides for present fields.
pub fn load() -> (Self, Vec<String>) {
let mut warnings = Vec::new();
let mut settings = Self::defaults();
let path = match Self::user_settings_path() {
Some(p) => p,
None => return (settings, warnings),
};
if !path.exists() {
return (settings, warnings);
}
let contents = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
warnings.push(format!("Could not read {}: {e}", path.display()));
return (settings, warnings);
}
};
let user_settings: Self = match toml::from_str(&contents) {
Ok(s) => s,
Err(e) => {
warnings.push(format!("Could not parse {}: {e}", path.display()));
return (settings, warnings);
}
};
// Override: serde already applied defaults for missing fields,
// so we can just replace the whole struct.
settings = user_settings;
// Validate.
if settings.slideshow_interval_secs <= 0.0 {
warnings.push(format!(
"slideshow_interval_secs must be > 0 (got {}); using default",
settings.slideshow_interval_secs
));
settings.slideshow_interval_secs = default_slideshow_interval();
}
if settings.thumbnail_size < 16 || settings.thumbnail_size > 256 {
warnings.push(format!(
"thumbnail_size must be 16..=256 (got {}); using default",
settings.thumbnail_size
));
settings.thumbnail_size = default_thumbnail_size();
}
if settings.thumbnail_cache_window == 0 {
warnings.push("thumbnail_cache_window must be > 0; using default".into());
settings.thumbnail_cache_window = default_thumbnail_cache_window();
}
if settings.default_zoom_mode != "FitToWindow"
&& settings.default_zoom_mode != "ActualSize"
{
warnings.push(format!(
"default_zoom_mode must be \"FitToWindow\" or \"ActualSize\" (got {:?}); using default",
settings.default_zoom_mode
));
settings.default_zoom_mode = default_zoom_mode();
}
(settings, warnings)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_sane() {
let s = Settings::defaults();
assert_eq!(s.slideshow_interval_secs, 3.0);
assert_eq!(s.thumbnail_size, 72);
assert_eq!(s.thumbnail_cache_window, 15);
assert_eq!(s.default_zoom_mode, "FitToWindow");
assert!(s.smooth_scroll_thumbnails);
assert!(s.show_fullscreen_hint);
}
#[test]
fn parses_full_toml() {
let toml = r#"
slideshow_interval_secs = 5.0
thumbnail_size = 96
thumbnail_cache_window = 20
default_zoom_mode = "ActualSize"
smooth_scroll_thumbnails = false
show_fullscreen_hint = false
"#;
let s: Settings = toml::from_str(toml).unwrap();
assert_eq!(s.slideshow_interval_secs, 5.0);
assert_eq!(s.thumbnail_size, 96);
assert_eq!(s.thumbnail_cache_window, 20);
assert_eq!(s.default_zoom_mode, "ActualSize");
assert!(!s.smooth_scroll_thumbnails);
assert!(!s.show_fullscreen_hint);
}
#[test]
fn parses_partial_toml_uses_defaults_for_missing() {
let toml = r#"
slideshow_interval_secs = 7.0
"#;
let s: Settings = toml::from_str(toml).unwrap();
assert_eq!(s.slideshow_interval_secs, 7.0);
// Missing fields get serde defaults via the `default =` attributes.
assert_eq!(s.thumbnail_size, 72);
assert_eq!(s.default_zoom_mode, "FitToWindow");
}
#[test]
fn rejects_garbage() {
let toml = "this is not valid toml = = =";
assert!(toml::from_str::<Settings>(toml).is_err());
}
}

225
src/ui/about_dialog.rs Normal file
View File

@ -0,0 +1,225 @@
//! About dialog — matches the ferret app's About style.
//!
//! Layout (top to bottom, left-aligned):
//! marten <version> [×]
//! ─────────────────────────────────────────
//! A modern, accuracy-first image viewer for Linux.
//!
//! Author: Jeremy Anderson
//! Website: http://git.dcos.net/dcosnet/marten
//! License: GPL-2.0-or-later
//!
//! Built with Rust, iced, wgpu, and winit.
//! Copyright © 2026 Jeremy Anderson.
use iced::widget::{button, column, container, row, text};
use iced::{Element, Length};
use super::icons::Icon;
use super::theme;
const ACCENT_ORANGE: iced::Color = iced::Color::from_rgb(0.824, 0.412, 0.118); // #d2691e
#[derive(Debug, Clone, Default)]
pub struct AboutDialog {
pub visible: bool,
}
#[derive(Debug, Clone, Copy)]
pub enum AboutMessage {
Dismiss,
OpenWebsite,
}
impl AboutDialog {
pub fn new() -> Self {
Self::default()
}
pub fn show(&mut self) {
self.visible = true;
}
pub fn dismiss(&mut self) {
self.visible = false;
}
pub fn view(&self) -> Option<Element<'_, AboutMessage>> {
if !self.visible {
return None;
}
let version = env!("CARGO_PKG_VERSION");
// Header: app name (orange, large) + version (grey, small) + close button
let title = text("marten")
.color(ACCENT_ORANGE)
.size(28);
let version_text = text(version)
.color(theme::TEXT_SECONDARY)
.size(14);
let close_btn = button(Icon::Close.widget(16.0, &theme::text_secondary_hex()))
.on_press(AboutMessage::Dismiss)
.padding(iced::Padding {
top: 4.0,
right: 6.0,
bottom: 4.0,
left: 6.0,
})
.style(|_theme, status| {
let bg = match status {
button::Status::Hovered => Some(theme::BG_CHROME_HOVER),
_ => None,
};
button::Style {
background: bg.map(iced::Background::Color),
border: iced::Border {
radius: 4.0.into(),
..Default::default()
},
..Default::default()
}
});
let header = row![
title,
version_text,
row![].width(Length::Fill), // spacer
close_btn,
]
.align_y(iced::Alignment::Center)
.spacing(8.0);
// Separator
let separator = container(text("").height(Length::Fixed(1.0)))
.width(Length::Fill)
.height(Length::Fixed(1.0))
.style(|_| container::Style {
background: Some(theme::BG_CHROME_BORDER.into()),
..Default::default()
});
// Tagline
let tagline = text("A modern, accuracy-first image viewer for Linux.")
.color(theme::TEXT_PRIMARY)
.size(13);
// Info rows: Author / Website / License
let label = |s: &'static str| {
text(s)
.color(theme::TEXT_SECONDARY)
.size(12)
.width(Length::Fixed(70.0))
};
let value = |s: String| {
text(s)
.color(theme::TEXT_PRIMARY)
.size(12)
};
let author_row = row![
label("Author:"),
value("Jeremy Anderson".to_string()),
]
.spacing(4.0);
let website_value = text("http://git.dcos.net/dcosnet/marten")
.color(ACCENT_ORANGE)
.size(12);
let website_btn = button(website_value)
.on_press(AboutMessage::OpenWebsite)
.padding(iced::Padding {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
})
.style(|_theme, _status| button::Style {
background: Some(iced::Color::TRANSPARENT.into()),
border: iced::Border::default(),
..Default::default()
});
let website_row = row![label("Website:"), website_btn].spacing(4.0);
let license_row = row![
label("License:"),
value("GPL-2.0-or-later".to_string()),
]
.spacing(4.0);
// Footer separator
let footer_sep = container(text("").height(Length::Fixed(1.0)))
.width(Length::Fill)
.height(Length::Fixed(1.0))
.style(|_| container::Style {
background: Some(theme::BG_CHROME_BORDER.into()),
..Default::default()
});
// Footer: build info + copyright
let build_info = text("Built with Rust, iced, wgpu, and winit.")
.color(theme::TEXT_DIM)
.size(11);
let copyright = text("Copyright © 2026 Jeremy Anderson.")
.color(theme::TEXT_DIM)
.size(11);
let card_content = column![
header,
text("").height(Length::Fixed(12.0)),
separator,
text("").height(Length::Fixed(12.0)),
tagline,
text("").height(Length::Fixed(16.0)),
author_row,
website_row,
license_row,
text("").height(Length::Fixed(16.0)),
footer_sep,
text("").height(Length::Fixed(8.0)),
build_info,
copyright,
]
.padding(iced::Padding {
top: 20.0,
right: 24.0,
bottom: 20.0,
left: 24.0,
});
// Card with orange border (matching ferret style)
let card = container(card_content)
.max_width(440.0)
.style(|_| container::Style {
background: Some(theme::BG_CHROME.into()),
border: iced::Border {
color: ACCENT_ORANGE,
width: 1.0,
radius: 6.0.into(),
},
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.7),
offset: iced::Vector::new(0.0, 8.0),
blur_radius: 32.0,
},
..Default::default()
});
// Center the card in a full-screen overlay backdrop.
let centered = container(card)
.width(Length::Fill)
.height(Length::Fill)
.align_x(iced::Alignment::Center)
.align_y(iced::Alignment::Center)
.style(|_| container::Style {
background: Some(
iced::Color::from_rgba(0.0, 0.0, 0.0, 0.75).into(),
),
..Default::default()
});
Some(centered.into())
}
}

208
src/ui/context_menu.rs Normal file
View File

@ -0,0 +1,208 @@
//! Right-click context menu — gpicview-inspired, with SVG icons.
//!
//! Items (in order):
//! Open With… → opens with system default app (xdg-open)
//! ─────────────
//! Copy Path → clipboard: file path
//! Copy Image → clipboard: image pixels
//! ─────────────
//! Rotate 90° CW
//! Rotate 90° CCW
//! ─────────────
//! Set as Wallpaper
//! Move to Trash → deletes file, loads next
//! ─────────────
//! Properties → shows info modal
use iced::widget::{button, column, container, text};
use iced::{Element, Length};
use super::icons::Icon;
use super::theme;
const MENU_WIDTH: f32 = 200.0;
const ITEM_HEIGHT: f32 = 28.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextMenuItem {
OpenWith,
CopyPath,
CopyImage,
CopyToPictures,
RotateCw,
RotateCcw,
SetAsWallpaper,
MoveToTrash,
DeletePermanently,
Properties,
About,
ExportToVideo,
}
#[derive(Debug, Clone, Copy)]
pub enum ContextMenuMessage {
Show { x: f32, y: f32 },
Selected(ContextMenuItem),
}
#[derive(Debug, Clone, Default)]
pub struct ContextMenu {
pub visible: bool,
pub x: f32,
pub y: f32,
}
impl ContextMenu {
pub fn new() -> Self {
Self::default()
}
pub fn show(&mut self, x: f32, y: f32, window_w: f32, window_h: f32) {
// Clamp so the menu stays on screen.
let approx_menu_h = 14.0 * ITEM_HEIGHT + 16.0; // 12 items + padding
self.x = x.min(window_w - MENU_WIDTH - 8.0).max(8.0);
self.y = y.min(window_h - approx_menu_h - 8.0).max(8.0);
self.visible = true;
}
pub fn dismiss(&mut self) {
self.visible = false;
}
/// Render the menu content (without positioning). The app layer handles
/// the overlay backdrop.
pub fn content_view(&self) -> Element<'static, ContextMenuMessage> {
let icon_color = theme::text_secondary_hex();
let danger_color = theme::danger_hex();
let items: Vec<(Option<Icon>, &'static str, ContextMenuItem, bool)> = vec![
(Some(Icon::OpenWith), "Open With…", ContextMenuItem::OpenWith, false),
(None, "", ContextMenuItem::OpenWith, true), // separator
(Some(Icon::Copy), "Copy Path", ContextMenuItem::CopyPath, false),
(Some(Icon::Copy), "Copy Image", ContextMenuItem::CopyImage, false),
(Some(Icon::FolderOpen), "Copy to Pictures", ContextMenuItem::CopyToPictures, false),
(None, "", ContextMenuItem::CopyPath, true), // separator
(Some(Icon::RotateCw), "Rotate 90° CW", ContextMenuItem::RotateCw, false),
(Some(Icon::RotateCcw), "Rotate 90° CCW", ContextMenuItem::RotateCcw, false),
(None, "", ContextMenuItem::RotateCcw, true), // separator
(Some(Icon::Wallpaper), "Set as Wallpaper", ContextMenuItem::SetAsWallpaper, false),
(Some(Icon::Trash), "Move to Trash", ContextMenuItem::MoveToTrash, true), // danger
(Some(Icon::Trash), "Delete Permanently", ContextMenuItem::DeletePermanently, true), // danger
(None, "", ContextMenuItem::DeletePermanently, true), // separator
(Some(Icon::Properties), "Properties", ContextMenuItem::Properties, false),
(Some(Icon::Info), "About marten", ContextMenuItem::About, false),
(None, "", ContextMenuItem::About, true),
(Some(Icon::Film), "Export folder as video…", ContextMenuItem::ExportToVideo, false),
];
let children: Vec<Element<'_, ContextMenuMessage>> = items
.iter()
.map(|(icon, label, msg, is_separator_or_danger)| {
if *is_separator_or_danger && label.is_empty() {
// Separator line
container(text("").height(Length::Fixed(1.0)))
.width(Length::Fill)
.height(Length::Fixed(1.0))
.style(|_| container::Style {
background: Some(theme::BG_CHROME_BORDER.into()),
..Default::default()
})
.into()
} else {
let danger = matches!(
*msg,
ContextMenuItem::MoveToTrash | ContextMenuItem::DeletePermanently
);
let color_hex = if danger {
danger_color.clone()
} else {
icon_color.clone()
};
let label_color = if danger { theme::DANGER } else { theme::TEXT_PRIMARY };
let icon_el: Element<'static, ContextMenuMessage> = match icon {
Some(ic) => ic.widget(14.0, &color_hex).into(),
None => text("").width(Length::Fixed(14.0)).into(),
};
let row = iced::widget::row![
container(icon_el).width(Length::Fixed(20.0)),
text(*label).color(label_color).size(12),
]
.align_y(iced::Alignment::Center)
.spacing(6.0);
button(row)
.on_press(ContextMenuMessage::Selected(*msg))
.width(Length::Fill)
.height(Length::Fixed(ITEM_HEIGHT))
.padding(iced::Padding {
top: 0.0,
right: 12.0,
bottom: 0.0,
left: 10.0,
})
.style(move |_theme, status| {
let bg = match status {
button::Status::Hovered if danger => Some(theme::DANGER_DIM),
button::Status::Hovered => Some(theme::BG_CHROME_HOVER),
button::Status::Pressed if danger => Some(theme::DANGER_DIM),
button::Status::Pressed => Some(theme::ACCENT_DIM),
_ => None,
};
button::Style {
background: bg.map(iced::Background::Color),
..Default::default()
}
})
.into()
}
})
.collect();
column(children)
.padding(iced::Padding {
top: 4.0,
right: 0.0,
bottom: 4.0,
left: 0.0,
})
.into()
}
/// Render the menu as a positioned overlay element.
/// This is meant to be placed inside a stack/overlay by the app.
pub fn overlay_view(&self) -> Element<'_, ContextMenuMessage> {
let menu = container(self.content_view())
.width(Length::Fixed(MENU_WIDTH))
.style(|_| container::Style {
background: Some(theme::BG_CHROME.into()),
border: iced::Border {
color: theme::BG_CHROME_BORDER,
width: 1.0,
radius: 6.0.into(),
},
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.6),
offset: iced::Vector::new(0.0, 4.0),
blur_radius: 16.0,
},
..Default::default()
});
container(menu)
.width(Length::Fill)
.height(Length::Fill)
.padding(iced::Padding {
top: self.y,
right: 0.0,
bottom: 0.0,
left: self.x,
})
.style(|_| container::Style {
background: Some(iced::Color::TRANSPARENT.into()),
..Default::default()
})
.into()
}
}

207
src/ui/error_modal.rs Normal file
View File

@ -0,0 +1,207 @@
//! Anti-list error modal — shown when user tries to open a rejected format.
//!
//! Rendered as a full-screen overlay with a semi-transparent backdrop and a
//! centered card containing the error details and alternatives.
use iced::widget::{button, column, container, row, text};
use iced::{Element, Length};
use super::icons::Icon;
use super::theme;
use crate::codec::DecodeError;
#[derive(Debug, Clone, Default)]
pub struct ErrorModal {
pub visible: bool,
pub error: Option<DecodeError>,
}
impl ErrorModal {
pub fn new() -> Self {
Self::default()
}
pub fn show(&mut self, error: DecodeError) {
self.error = Some(error);
self.visible = true;
}
pub fn dismiss(&mut self) {
self.visible = false;
self.error = None;
}
pub fn view(&self) -> Option<Element<'_, ()>> {
if !self.visible {
return None;
}
let error = self.error.as_ref()?;
let (title, body_lines): (String, Vec<String>) = match error {
DecodeError::AntiListed {
path,
format_name,
reason,
alternative,
} => {
let filename = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string());
(
format!("{} is not supported", format_name),
vec![
format!("File: {}", filename),
String::new(),
format!("This format is on the project's anti-list."),
String::new(),
format!("Reason: {}", reason),
String::new(),
format!("What to use instead:"),
format!(" {}", alternative),
],
)
}
DecodeError::Unsupported { path, extension } => {
let filename = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string());
(
format!(".{} files are not supported", extension),
vec![
format!("File: {}", filename),
String::new(),
format!("This format is not in our supported list."),
format!(""),
format!("Supported: PNG, JPEG, GIF, WebP, AVIF, BMP, ICO"),
],
)
}
DecodeError::Io(msg) => (
"Could not read file".to_string(),
vec![msg.clone()],
),
DecodeError::Decode(msg) => (
"Could not decode image".to_string(),
vec![msg.clone()],
),
};
let is_anti_listed = matches!(error, DecodeError::AntiListed { .. });
let title_color = if is_anti_listed {
theme::DANGER
} else {
theme::ACCENT
};
let title_row = row![
Icon::Error.widget(20.0, &theme::danger_hex()),
text(title)
.color(title_color)
.size(16),
]
.align_y(iced::Alignment::Center)
.spacing(10.0);
let body_texts: Vec<Element<'_, ()>> = body_lines
.iter()
.map(|line| {
if line.is_empty() {
text("").height(Length::Fixed(8.0)).into()
} else {
text(line.clone())
.color(if line.starts_with(" ") {
theme::TEXT_PRIMARY
} else {
theme::TEXT_SECONDARY
})
.size(13)
.into()
}
})
.collect();
let body = column(body_texts).spacing(2.0);
let close_btn = button(
row![
Icon::Close.widget(14.0, &theme::text_primary_hex()),
text("Close").color(theme::TEXT_PRIMARY).size(12),
]
.align_y(iced::Alignment::Center)
.spacing(6.0),
)
.on_press(())
.padding(iced::Padding {
top: 6.0,
right: 16.0,
bottom: 6.0,
left: 16.0,
})
.style(|_theme, status| {
let bg = match status {
button::Status::Hovered => Some(theme::BG_CHROME_HOVER),
button::Status::Pressed => Some(theme::ACCENT_DIM),
_ => None,
};
button::Style {
background: bg.map(iced::Background::Color),
border: iced::Border {
color: theme::BG_CHROME_BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
}
});
let card = column![
title_row,
text("").height(Length::Fixed(12.0)),
body,
text("").height(Length::Fixed(16.0)),
container(close_btn).align_x(iced::Alignment::Center),
]
.padding(iced::Padding {
top: 20.0,
right: 24.0,
bottom: 20.0,
left: 24.0,
});
let card_container = container(card)
.max_width(460.0)
.style(|_| container::Style {
background: Some(theme::BG_CHROME.into()),
border: iced::Border {
color: theme::BG_CHROME_BORDER,
width: 1.0,
radius: 8.0.into(),
},
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.7),
offset: iced::Vector::new(0.0, 8.0),
blur_radius: 32.0,
},
..Default::default()
});
// Center the card in the full-screen overlay.
let centered = container(card_container)
.width(Length::Fill)
.height(Length::Fill)
.align_x(iced::Alignment::Center)
.align_y(iced::Alignment::Center)
.style(|_| container::Style {
background: Some(
iced::Color::from_rgba(0.0, 0.0, 0.0, 0.75).into(),
),
..Default::default()
});
Some(centered.into())
}
}

338
src/ui/exif_panel.rs Normal file
View File

@ -0,0 +1,338 @@
//! EXIF properties panel — shows image metadata in a modal overlay.
//!
//! Replaces the v0.2 toast-based Properties action with a proper panel
//! showing dimensions, format, file size, and EXIF data (camera, lens,
//! ISO, aperture, shutter speed, timestamp, GPS) when available.
use std::path::PathBuf;
use iced::widget::{button, column, container, row, scrollable, text};
use iced::{Element, Length};
use super::icons::Icon;
use super::theme;
use crate::codec::DecodedImage;
const PANEL_WIDTH: f32 = 380.0;
#[derive(Debug, Clone, Default)]
pub struct PropertiesPanel {
pub visible: bool,
pub filename: String,
pub filepath: PathBuf,
pub width: u32,
pub height: u32,
pub format: String,
pub file_size: u64,
pub exif: Option<ExifData>,
}
#[derive(Debug, Clone, Default)]
pub struct ExifData {
pub camera_make: Option<String>,
pub camera_model: Option<String>,
pub lens: Option<String>,
pub iso: Option<u32>,
pub aperture: Option<String>,
pub shutter_speed: Option<String>,
pub focal_length: Option<String>,
pub timestamp: Option<String>,
pub gps_lat: Option<String>,
pub gps_lon: Option<String>,
pub orientation: Option<u16>,
}
#[derive(Debug, Clone, Copy)]
pub enum PropertiesMessage {
Dismiss,
}
impl PropertiesPanel {
pub fn new() -> Self {
Self::default()
}
pub fn show(&mut self, image: &DecodedImage, path: &std::path::Path) {
self.filename = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
self.filepath = path.to_path_buf();
self.width = image.width;
self.height = image.height;
self.format = image.format.to_string();
self.file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
self.exif = parse_exif(path);
self.visible = true;
}
pub fn dismiss(&mut self) {
self.visible = false;
}
pub fn view(&self) -> Option<Element<'_, PropertiesMessage>> {
if !self.visible {
return None;
}
let close_btn = button(
row![
Icon::Close.widget(14.0, &theme::text_primary_hex()),
text("Close").color(theme::TEXT_PRIMARY).size(12),
]
.align_y(iced::Alignment::Center)
.spacing(6.0),
)
.on_press(PropertiesMessage::Dismiss)
.padding(iced::Padding {
top: 5.0,
right: 14.0,
bottom: 5.0,
left: 14.0,
})
.style(|_theme, status| {
let bg = match status {
button::Status::Hovered => Some(theme::BG_CHROME_HOVER),
_ => None,
};
button::Style {
background: bg.map(iced::Background::Color),
border: iced::Border {
color: theme::BG_CHROME_BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
}
});
let header = row![
Icon::Info.widget(18.0, &theme::text_secondary_hex()),
text("Properties").color(theme::TEXT_PRIMARY).size(16),
text("").width(Length::Fill),
close_btn,
]
.align_y(iced::Alignment::Center)
.spacing(8.0);
let filename_text = text(self.filename.clone())
.color(theme::TEXT_PRIMARY)
.size(14);
let label = |s: &'static str| {
text(s)
.color(theme::TEXT_SECONDARY)
.size(11)
.width(Length::Fixed(100.0))
};
let value = |s: String| {
text(s)
.color(theme::TEXT_PRIMARY)
.size(11)
};
let size_str = format_file_size(self.file_size);
let dims_str = format!("{} × {}", self.width, self.height);
let info_rows = vec![
row![label("Path:"), value(self.filepath.display().to_string())].spacing(4.0),
row![label("Dimensions:"), value(dims_str)].spacing(4.0),
row![label("Format:"), value(self.format.clone())].spacing(4.0),
row![label("File size:"), value(size_str)].spacing(4.0),
];
let mut all_rows: Vec<Element<'_, PropertiesMessage>> = vec![
header.into(),
text("").height(Length::Fixed(8.0)).into(),
filename_text.into(),
text("").height(Length::Fixed(12.0)).into(),
];
// Info section
for r in info_rows {
all_rows.push(r.into());
}
// EXIF section (if available)
if let Some(exif) = &self.exif {
all_rows.push(text("").height(Length::Fixed(16.0)).into());
let exif_title = text("EXIF Metadata")
.color(theme::ACCENT)
.size(12);
all_rows.push(exif_title.into());
all_rows.push(text("").height(Length::Fixed(8.0)).into());
let exif_rows = build_exif_rows(exif, &label, &value);
for r in exif_rows {
all_rows.push(r.into());
}
}
let content = column(all_rows)
.padding(iced::Padding {
top: 20.0,
right: 20.0,
bottom: 20.0,
left: 20.0,
})
.spacing(4.0);
let scrollable_content = scrollable(content)
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new().width(4).scroller_width(4),
))
.width(Length::Fill)
.height(Length::Fill);
let card = container(scrollable_content)
.max_width(PANEL_WIDTH)
.max_height(500.0)
.style(|_| container::Style {
background: Some(theme::BG_CHROME.into()),
border: iced::Border {
color: theme::BG_CHROME_BORDER,
width: 1.0,
radius: 8.0.into(),
},
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.7),
offset: iced::Vector::new(0.0, 8.0),
blur_radius: 32.0,
},
..Default::default()
});
let centered = container(card)
.width(Length::Fill)
.height(Length::Fill)
.align_x(iced::Alignment::Center)
.align_y(iced::Alignment::Center)
.style(|_| container::Style {
background: Some(
iced::Color::from_rgba(0.0, 0.0, 0.0, 0.75).into(),
),
..Default::default()
});
Some(centered.into())
}
}
fn build_exif_rows(
exif: &ExifData,
label: &impl Fn(&'static str) -> iced::widget::Text<'static>,
value: &impl Fn(String) -> iced::widget::Text<'static>,
) -> Vec<iced::widget::Row<'static, PropertiesMessage>> {
let mut rows = Vec::new();
if let Some(make) = &exif.camera_make {
rows.push(row![label("Make:"), value(make.clone())].spacing(4.0));
}
if let Some(model) = &exif.camera_model {
rows.push(row![label("Model:"), value(model.clone())].spacing(4.0));
}
if let Some(lens) = &exif.lens {
rows.push(row![label("Lens:"), value(lens.clone())].spacing(4.0));
}
if let Some(iso) = exif.iso {
rows.push(row![label("ISO:"), value(format!("{}", iso))].spacing(4.0));
}
if let Some(aperture) = &exif.aperture {
rows.push(row![label("Aperture:"), value(aperture.clone())].spacing(4.0));
}
if let Some(shutter) = &exif.shutter_speed {
rows.push(row![label("Shutter:"), value(shutter.clone())].spacing(4.0));
}
if let Some(focal) = &exif.focal_length {
rows.push(row![label("Focal:"), value(focal.clone())].spacing(4.0));
}
if let Some(ts) = &exif.timestamp {
rows.push(row![label("Date:"), value(ts.clone())].spacing(4.0));
}
if let Some(lat) = &exif.gps_lat {
rows.push(row![label("GPS Lat:"), value(lat.clone())].spacing(4.0));
}
if let Some(lon) = &exif.gps_lon {
rows.push(row![label("GPS Lon:"), value(lon.clone())].spacing(4.0));
}
if let Some(orient) = exif.orientation {
rows.push(row![label("Orientation:"), value(format!("{}", orient))].spacing(4.0));
}
rows
}
fn format_file_size(bytes: u64) -> String {
if bytes >= 1_000_000_000 {
format!("{:.2} GB", bytes as f64 / 1_000_000_000.0)
} else if bytes >= 1_000_000 {
format!("{:.1} MB", bytes as f64 / 1_000_000.0)
} else if bytes >= 1_000 {
format!("{:.1} KB", bytes as f64 / 1_000.0)
} else {
format!("{} B", bytes)
}
}
/// Parse EXIF data from a file using kamadak-exif.
fn parse_exif(path: &std::path::Path) -> Option<ExifData> {
let file = std::fs::File::open(path).ok()?;
let mut bufreader = std::io::BufReader::new(&file);
let exif_reader = exif::Reader::new();
let exif = exif_reader.read_from_container(&mut bufreader).ok()?;
let mut data = ExifData::default();
for field in exif.fields() {
let tag = field.tag;
let value_str = field.display_value().with_unit(&exif).to_string();
match tag {
exif::Tag::Make => {
data.camera_make = Some(value_str.trim().to_string());
}
exif::Tag::Model => {
data.camera_model = Some(value_str.trim().to_string());
}
exif::Tag::LensModel => {
data.lens = Some(value_str.trim().to_string());
}
exif::Tag::PhotographicSensitivity => {
data.iso = value_str.trim().parse().ok();
}
exif::Tag::FNumber => {
data.aperture = Some(format!("f/{}", value_str.trim()));
}
exif::Tag::ExposureTime => {
data.shutter_speed = Some(value_str.trim().to_string());
}
exif::Tag::FocalLength => {
data.focal_length = Some(value_str.trim().to_string());
}
exif::Tag::DateTimeOriginal | exif::Tag::DateTime => {
data.timestamp = Some(value_str.trim().to_string());
}
exif::Tag::GPSLatitude => {
data.gps_lat = Some(value_str.trim().to_string());
}
exif::Tag::GPSLongitude => {
data.gps_lon = Some(value_str.trim().to_string());
}
exif::Tag::Orientation => {
data.orientation = value_str.trim().parse().ok();
}
_ => {}
}
}
if data.camera_make.is_none()
&& data.camera_model.is_none()
&& data.iso.is_none()
&& data.timestamp.is_none()
{
return None;
}
Some(data)
}

555
src/ui/export_dialog.rs Normal file
View File

@ -0,0 +1,555 @@
//! Video export dialog — exports a folder of images as a slideshow video
//! with a user-selected audio track.
//!
//! Uses ffmpeg (external dependency) to encode the video. Supports two
//! codecs:
//! - WebM with VP9 + Opus (broad compatibility, good quality/size ratio)
//! - WebM with AV1 + Opus (better compression, slower encoding)
//!
//! The export flow:
//! 1. User right-clicks → "Export folder as video…"
//! 2. Dialog opens showing image count + folder name
//! 3. User selects audio file (any format ffmpeg supports)
//! 4. User selects output path (.webm)
//! 5. User picks codec and duration per image
//! 6. User clicks Export → ffmpeg runs in a background thread
//! 7. Dialog shows "Exporting…" then "Export complete" or error
use std::path::{Path, PathBuf};
use iced::widget::{button, column, container, row, text, text_input};
use iced::{Element, Length};
use super::icons::Icon;
use super::theme;
const PANEL_WIDTH: f32 = 460.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportCodec {
VP9,
AV1,
}
impl ExportCodec {
pub fn ffmpeg_vcodec(self) -> &'static str {
match self {
ExportCodec::VP9 => "libvpx-vp9",
ExportCodec::AV1 => "libaom-av1",
}
}
pub fn label(self) -> &'static str {
match self {
ExportCodec::VP9 => "WebM (VP9)",
ExportCodec::AV1 => "WebM (AV1)",
}
}
}
#[derive(Debug, Clone)]
pub struct ExportDialog {
pub visible: bool,
pub folder_path: Option<PathBuf>,
pub image_count: usize,
pub audio_path: Option<PathBuf>,
pub output_path: Option<PathBuf>,
pub codec: ExportCodec,
pub duration_per_image: String,
pub exporting: bool,
pub result_message: Option<String>,
pub result_is_error: bool,
}
#[derive(Debug, Clone)]
pub enum ExportMessage {
SelectAudio,
AudioSelected(Option<PathBuf>),
SelectOutput,
OutputSelected(Option<PathBuf>),
CodecChanged(ExportCodec),
DurationChanged(String),
StartExport,
ExportCompleted(Result<(), String>),
Dismiss,
}
impl Default for ExportDialog {
fn default() -> Self {
Self {
visible: false,
folder_path: None,
image_count: 0,
audio_path: None,
output_path: None,
codec: ExportCodec::VP9,
duration_per_image: "3.0".to_string(),
exporting: false,
result_message: None,
result_is_error: false,
}
}
}
impl ExportDialog {
pub fn new() -> Self {
Self::default()
}
pub fn show(&mut self, folder: PathBuf, image_count: usize) {
self.folder_path = Some(folder);
self.image_count = image_count;
self.audio_path = None;
self.output_path = None;
self.codec = ExportCodec::VP9;
self.duration_per_image = "3.0".to_string();
self.exporting = false;
self.result_message = None;
self.result_is_error = false;
self.visible = true;
}
pub fn dismiss(&mut self) {
self.visible = false;
}
fn can_export(&self) -> bool {
!self.exporting
&& self.audio_path.is_some()
&& self.output_path.is_some()
&& self.image_count > 0
&& self.duration_per_image.parse::<f32>().map(|d| d > 0.0).unwrap_or(false)
}
pub fn view(&self) -> Option<Element<'_, ExportMessage>> {
if !self.visible {
return None;
}
let folder_name = self
.folder_path
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "(unknown)".to_string());
let title = row![
Icon::Film.widget(18.0, &theme::text_secondary_hex()),
text("Export Slideshow to Video")
.color(theme::TEXT_PRIMARY)
.size(16),
]
.align_y(iced::Alignment::Center)
.spacing(8.0);
let info = text(format!(
"{} images from {}",
self.image_count, folder_name
))
.color(theme::TEXT_SECONDARY)
.size(12);
// Audio file selector
let audio_label = text("Audio track:")
.color(theme::TEXT_SECONDARY)
.size(11);
let audio_btn_label = self
.audio_path
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Select audio file…".to_string());
let audio_btn = button(
row![
audio_label.color(theme::TEXT_PRIMARY).size(12),
text("").width(Length::Fill),
text(audio_btn_label)
.color(theme::TEXT_DIM)
.size(11),
]
.align_y(iced::Alignment::Center),
)
.on_press(ExportMessage::SelectAudio)
.width(Length::Fill)
.padding(iced::Padding {
top: 8.0,
right: 12.0,
bottom: 8.0,
left: 12.0,
})
.style(|_theme, status| {
let bg = match status {
button::Status::Hovered => Some(theme::BG_CHROME_HOVER),
_ => Some(theme::BG_IMAGE_AREA.into()),
};
button::Style {
background: bg.map(iced::Background::Color),
border: iced::Border {
color: theme::BG_CHROME_BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
}
});
// Output file selector
let output_btn_label = self
.output_path
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Select output file…".to_string());
let output_btn = button(
row![
text("Output file:").color(theme::TEXT_PRIMARY).size(12),
text("").width(Length::Fill),
text(output_btn_label)
.color(theme::TEXT_DIM)
.size(11),
]
.align_y(iced::Alignment::Center),
)
.on_press(ExportMessage::SelectOutput)
.width(Length::Fill)
.padding(iced::Padding {
top: 8.0,
right: 12.0,
bottom: 8.0,
left: 12.0,
})
.style(|_theme, status| {
let bg = match status {
button::Status::Hovered => Some(theme::BG_CHROME_HOVER),
_ => Some(theme::BG_IMAGE_AREA.into()),
};
button::Style {
background: bg.map(iced::Background::Color),
border: iced::Border {
color: theme::BG_CHROME_BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
}
});
// Codec selector
let codec_label = text("Codec:")
.color(theme::TEXT_SECONDARY)
.size(11);
let vp9_btn = button(text("VP9").color(theme::TEXT_PRIMARY).size(11))
.on_press(ExportMessage::CodecChanged(ExportCodec::VP9))
.padding(iced::Padding {
top: 5.0,
right: 14.0,
bottom: 5.0,
left: 14.0,
})
.style(move |_theme, _status| {
let active = self.codec == ExportCodec::VP9;
button::Style {
background: Some(
if active {
theme::ACCENT_DIM
} else {
theme::BG_IMAGE_AREA
}
.into(),
),
border: iced::Border {
color: if active {
theme::ACCENT
} else {
theme::BG_CHROME_BORDER
},
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
}
});
let av1_btn = button(text("AV1").color(theme::TEXT_PRIMARY).size(11))
.on_press(ExportMessage::CodecChanged(ExportCodec::AV1))
.padding(iced::Padding {
top: 5.0,
right: 14.0,
bottom: 5.0,
left: 14.0,
})
.style(move |_theme, _status| {
let active = self.codec == ExportCodec::AV1;
button::Style {
background: Some(
if active {
theme::ACCENT_DIM
} else {
theme::BG_IMAGE_AREA
}
.into(),
),
border: iced::Border {
color: if active {
theme::ACCENT
} else {
theme::BG_CHROME_BORDER
},
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
}
});
let codec_row = row![
codec_label,
text("").width(Length::Fixed(8.0)),
vp9_btn,
text("").width(Length::Fixed(4.0)),
av1_btn,
]
.align_y(iced::Alignment::Center);
// Duration input
let duration_row = row![
text("Seconds per image:")
.color(theme::TEXT_SECONDARY)
.size(11),
text("").width(Length::Fixed(8.0)),
text_input("3.0", &self.duration_per_image)
.on_input(ExportMessage::DurationChanged)
.width(Length::Fixed(80.0))
.size(11),
]
.align_y(iced::Alignment::Center);
// Result message (if any)
let result_el: Option<Element<'_, ExportMessage>> = self.result_message.as_ref().map(|msg| {
let color = if self.result_is_error {
theme::DANGER
} else {
theme::ACCENT
};
text(msg.clone()).color(color).size(12).into()
});
// Action buttons
let action_label = if self.exporting {
"Exporting…"
} else if self.result_message.is_some() {
"Close"
} else {
"Export"
};
let action_msg = if self.result_message.is_some() {
ExportMessage::Dismiss
} else {
ExportMessage::StartExport
};
let can_press = self.exporting || self.can_export() || self.result_message.is_some();
let export_btn = button(
text(action_label)
.color(theme::TEXT_PRIMARY)
.size(12),
)
.on_press_maybe(if can_press { Some(action_msg) } else { None })
.padding(iced::Padding {
top: 8.0,
right: 24.0,
bottom: 8.0,
left: 24.0,
})
.style(|_theme, status| {
let bg = match status {
button::Status::Hovered => Some(theme::ACCENT_DIM),
_ => Some(theme::ACCENT.into()),
};
button::Style {
background: bg.map(iced::Background::Color),
border: iced::Border {
radius: 4.0.into(),
..Default::default()
},
..Default::default()
}
});
let cancel_btn = button(
text("Cancel").color(theme::TEXT_PRIMARY).size(12),
)
.on_press(ExportMessage::Dismiss)
.padding(iced::Padding {
top: 8.0,
right: 24.0,
bottom: 8.0,
left: 24.0,
})
.style(|_theme, status| {
let bg = match status {
button::Status::Hovered => Some(theme::BG_CHROME_HOVER),
_ => Some(theme::BG_IMAGE_AREA.into()),
};
button::Style {
background: bg.map(iced::Background::Color),
border: iced::Border {
color: theme::BG_CHROME_BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
}
});
let action_row = row![
text("").width(Length::Fill),
cancel_btn,
text("").width(Length::Fixed(8.0)),
export_btn,
]
.align_y(iced::Alignment::Center);
// Assemble
let mut children: Vec<Element<'_, ExportMessage>> = vec![
title.into(),
text("").height(Length::Fixed(4.0)).into(),
info.into(),
text("").height(Length::Fixed(16.0)).into(),
audio_btn.into(),
text("").height(Length::Fixed(8.0)).into(),
output_btn.into(),
text("").height(Length::Fixed(12.0)).into(),
codec_row.into(),
text("").height(Length::Fixed(8.0)).into(),
duration_row.into(),
];
if let Some(r) = result_el {
children.push(text("").height(Length::Fixed(12.0)).into());
children.push(r);
}
children.push(text("").height(Length::Fixed(16.0)).into());
children.push(action_row.into());
let content = column(children)
.padding(iced::Padding {
top: 20.0,
right: 20.0,
bottom: 20.0,
left: 20.0,
})
.spacing(0.0);
let card = container(content)
.max_width(PANEL_WIDTH)
.style(|_| container::Style {
background: Some(theme::BG_CHROME.into()),
border: iced::Border {
color: theme::BG_CHROME_BORDER,
width: 1.0,
radius: 8.0.into(),
},
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.7),
offset: iced::Vector::new(0.0, 8.0),
blur_radius: 32.0,
},
..Default::default()
});
let centered = container(card)
.width(Length::Fill)
.height(Length::Fill)
.align_x(iced::Alignment::Center)
.align_y(iced::Alignment::Center)
.style(|_| container::Style {
background: Some(
iced::Color::from_rgba(0.0, 0.0, 0.0, 0.75).into(),
),
..Default::default()
});
Some(centered.into())
}
}
/// Run ffmpeg to create a slideshow video from a list of images with an
/// audio track. Called from a background thread via Task::perform.
///
/// Uses the concat demuxer: writes a temporary file list, then invokes:
/// ffmpeg -y -f concat -safe 0 -i filelist.txt -i audio.mp3 \
/// -c:v <vcodec> -crf 30 -b:v 0 -c:a libopus -shortest output.webm
pub fn run_ffmpeg_export(
images: &[PathBuf],
audio: &Path,
output: &Path,
codec: ExportCodec,
duration: f32,
) -> Result<(), String> {
if images.is_empty() {
return Err("No images to export".to_string());
}
// Build the concat file list.
let mut filelist = String::new();
for img in images {
let path_str = img.display().to_string();
// Escape single quotes in paths for the concat demuxer.
let escaped = path_str.replace('\'', r"'\''");
filelist.push_str(&format!("file '{}'\n", escaped));
filelist.push_str(&format!("duration {:.1}\n", duration));
}
// Repeat the last file without a duration (ffmpeg concat quirk:
// the last duration entry is ignored unless the file is repeated).
if let Some(last) = images.last() {
let path_str = last.display().to_string();
let escaped = path_str.replace('\'', r"'\''");
filelist.push_str(&format!("file '{}'\n", escaped));
}
let list_path = std::env::temp_dir().join("marten_export_list.txt");
std::fs::write(&list_path, &filelist)
.map_err(|e| format!("Could not write temp file list: {e}"))?;
let vcodec = codec.ffmpeg_vcodec();
let output = std::process::Command::new("ffmpeg")
.args(["-y", "-f", "concat", "-safe", "0", "-i"])
.arg(&list_path)
.args(["-i"])
.arg(audio)
.args([
"-c:v", vcodec,
"-crf", "30",
"-b:v", "0",
"-c:a", "libopus",
"-shortest",
])
.arg(output)
.output()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"ffmpeg not found. Install ffmpeg to use video export.".to_string()
} else {
format!("Failed to launch ffmpeg: {e}")
}
})?;
let _ = std::fs::remove_file(&list_path);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let trimmed = stderr.lines().last().unwrap_or("Unknown ffmpeg error");
return Err(trimmed.to_string());
}
Ok(())
}

79
src/ui/icons.rs Normal file
View File

@ -0,0 +1,79 @@
//! Lucide icon set (ISC license) embedded as SVG string constants.
//!
//! Each icon is the inner XML of a 24×24 Lucide SVG. The full SVG document
//! is generated at runtime by `icon()` with the correct stroke color for
//! the current theme.
//!
//! Source: https://lucide.dev (ISC License, compatible with MIT/Apache-2.0)
use iced::widget::svg;
use iced::Length;
/// Build an SVG handle from inner Lucide paths with a given stroke color.
pub fn handle(paths: &str, color: &str) -> svg::Handle {
let doc = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{paths}</svg>"#
);
svg::Handle::from_memory(doc.into_bytes())
}
/// Typed icon enum — one variant per action that has an icon.
/// Makes it impossible to use an undefined icon.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Icon {
ChevronLeft,
ChevronRight,
Fit, // maximize-2
ActualSize, // scan
ZoomIn,
ZoomOut,
RotateCw,
RotateCcw,
Fullscreen,
FolderOpen,
Trash,
Info,
Copy,
Wallpaper, // image
OpenWith, // external-link
Properties, // file-text
Error, // alert-circle
Close, // x
Shuffle, // shuffle
Film, // film (for video export)
}
impl Icon {
/// The inner Lucide SVG paths for this icon.
fn paths(self) -> &'static str {
match self {
Icon::ChevronLeft => r#"<path d="m15 18-6-6 6-6"/>"#,
Icon::ChevronRight => r#"<path d="m9 18 6-6-6-6"/>"#,
Icon::Fit => r#"<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>"#,
Icon::ActualSize => r#"<path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M7 12h10"/>"#,
Icon::ZoomIn => r#"<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><line x1="11" x2="11" y1="8" y2="14"/><line x1="8" x2="14" y1="11" y2="11"/>"#,
Icon::ZoomOut => r#"<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><line x1="8" x2="14" y1="11" y2="11"/>"#,
Icon::RotateCw => r#"<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>"#,
Icon::RotateCcw => r#"<path d="M3 12a9 9 0 1 0 9-9c-2.52 0-4.93 1-6.74 2.74L3 8"/><path d="M3 3v5h5"/>"#,
Icon::Fullscreen => r#"<path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><rect x="8" y="8" width="8" height="8" rx="1"/>"#,
Icon::FolderOpen => r#"<path d="m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.93a2 2 0 0 1 1.66.9l.82 1.2a2 2 0 0 0 1.66.9H18a2 2 0 0 1 2 2v2"/>"#,
Icon::Trash => r#"<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>"#,
Icon::Info => r#"<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>"#,
Icon::Copy => r#"<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>"#,
Icon::Wallpaper => r#"<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>"#,
Icon::OpenWith => r#"<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>"#,
Icon::Properties => r#"<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>"#,
Icon::Error => r#"<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>"#,
Icon::Close => r#"<path d="M18 6 6 18"/><path d="m6 6 12 12"/>"#,
Icon::Shuffle => r#"<path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l6.1-8.6c.7-1.1 2-1.7 3.3-1.7H22"/><path d="m18 2 4 4-4 4"/><path d="M2 6h1.9c1.5 0 2.9.9 3.6 2.2"/><path d="M22 18h-5.9c-1.3 0-2.6-.7-3.3-1.8l-.5-.8"/><path d="m18 14 4 4-4 4"/>"#,
Icon::Film => r#"<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 3v18"/><path d="M3 7.5h4"/><path d="M3 12h18"/><path d="M3 16.5h4"/><path d="M17 3v18"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/>"#,
}
}
/// Render this icon as an iced SVG widget at the given pixel size.
pub fn widget(self, size: f32, color: &str) -> svg::Svg<'static> {
svg::Svg::new(handle(self.paths(), color))
.width(Length::Fixed(size))
.height(Length::Fixed(size))
}
}

245
src/ui/image_view.rs Normal file
View File

@ -0,0 +1,245 @@
//! The central image display — fit-to-window, zoom, pan, and rotation.
//!
//! Rendering strategy:
//! - `FitToWindow`: image widget is `Length::Fill` + `ContentFit::Contain`.
//! iced handles all centering and scaling automatically. No manual math.
//! - `ActualSize` / `CustomZoom`: image is `Length::Fixed(w)` + `Length::Fixed(h)`,
//! wrapped in a `scrollable` so overflow is handled with scrollbars/drag.
//! Scroll wheel is consumed by the scrollable (pan) instead of navigating.
//!
//! Rotation is applied at decode time by pre-rotating the RGBA buffer. This
//! works around iced 0.13's lack of native image rotation.
use std::sync::Arc;
use iced::widget::{container, image, mouse_area, scrollable, text};
use iced::{Element, Length, Size};
use super::theme;
use crate::codec::{DecodedImage, rotate_rgba};
/// The zoom mode the image view is currently in.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ZoomMode {
FitToWindow,
ActualSize,
Custom(f32),
}
impl ZoomMode {
pub fn factor(&self, viewport: Size, image_size: Size) -> f32 {
match self {
ZoomMode::FitToWindow => {
if image_size.width <= 0.0 || image_size.height <= 0.0 {
return 1.0;
}
let sx = viewport.width / image_size.width;
let sy = viewport.height / image_size.height;
sx.min(sy).min(1.0)
}
ZoomMode::ActualSize => 1.0,
ZoomMode::Custom(f) => *f,
}
}
pub fn pct(&self) -> u32 {
match self {
ZoomMode::FitToWindow => 0,
ZoomMode::ActualSize => 100,
ZoomMode::Custom(f) => (f * 100.0).round() as u32,
}
}
pub fn is_fit(&self) -> bool {
matches!(self, ZoomMode::FitToWindow)
}
}
/// Messages emitted by the image view.
#[derive(Debug, Clone)]
pub enum ImageMessage {
/// User right-clicked (position comes from cursor subscription).
RightClicked,
}
/// State for the image view.
#[derive(Debug, Clone)]
pub struct ImageView {
/// The ORIGINAL decoded image (before rotation).
pub original: Option<Arc<DecodedImage>>,
/// The handle that iced renders (after rotation applied).
pub handle: Option<Arc<image::Handle>>,
/// Current zoom mode.
pub zoom: ZoomMode,
/// Rotation in degrees (0, 90, 180, 270).
pub rotation: i32,
}
impl Default for ImageView {
fn default() -> Self {
Self {
original: None,
handle: None,
zoom: ZoomMode::FitToWindow,
rotation: 0,
}
}
}
impl ImageView {
/// Set the current image. Resets zoom to the given default mode and pan.
/// Keeps rotation.
pub fn set_image(&mut self, img: Arc<DecodedImage>, default_zoom: ZoomMode) {
self.original = Some(img);
self.zoom = default_zoom;
self.rebuild_handle();
}
pub fn clear(&mut self) {
self.original = None;
self.handle = None;
self.zoom = ZoomMode::FitToWindow;
}
/// Rebuild the iced handle from the original image + current rotation.
fn rebuild_handle(&mut self) {
let Some(img) = &self.original else {
return;
};
let (pixels, w, h) =
rotate_rgba(&img.pixels, img.width, img.height, self.rotation);
self.handle = Some(Arc::new(image::Handle::from_rgba(w, h, pixels)));
}
pub fn zoom_in(&mut self) {
self.zoom = match self.zoom {
ZoomMode::FitToWindow => ZoomMode::Custom(1.1),
ZoomMode::ActualSize => ZoomMode::Custom(1.1),
ZoomMode::Custom(f) => ZoomMode::Custom((f * 1.1).min(16.0)),
};
}
pub fn zoom_out(&mut self) {
self.zoom = match self.zoom {
ZoomMode::FitToWindow => ZoomMode::Custom(0.9),
ZoomMode::ActualSize => ZoomMode::Custom(0.9),
ZoomMode::Custom(f) => ZoomMode::Custom((f * 0.9).max(0.05)),
};
}
pub fn fit_to_window(&mut self) {
self.zoom = ZoomMode::FitToWindow;
}
pub fn actual_size(&mut self) {
self.zoom = ZoomMode::ActualSize;
}
pub fn rotate_cw(&mut self) {
self.rotation = (self.rotation + 90) % 360;
self.rebuild_handle();
}
pub fn rotate_ccw(&mut self) {
self.rotation = (self.rotation + 270) % 360;
self.rebuild_handle();
}
/// The displayed image dimensions (after rotation).
pub fn displayed_dimensions(&self) -> Option<(u32, u32)> {
let img = self.original.as_ref()?;
if self.rotation % 180 == 0 {
Some((img.width, img.height))
} else {
Some((img.height, img.width))
}
}
/// Render the image view.
/// `viewport` is the available size for the image area (already minus chrome).
pub fn view(&self, _viewport: Size) -> Element<'_, ImageMessage> {
// Guard: no image loaded → render the empty-state placeholder.
let (handle, img) = match (&self.handle, &self.original) {
(Some(h), Some(i)) => (h, i),
_ => return self.view_empty(),
};
let (iw, ih) = if self.rotation % 180 == 0 {
(img.width as f32, img.height as f32)
} else {
(img.height as f32, img.width as f32)
};
let image_element: Element<'_, ImageMessage> = match self.zoom {
ZoomMode::FitToWindow => {
container(
image(handle.as_ref().clone())
.width(Length::Fill)
.height(Length::Fill)
.content_fit(iced::ContentFit::Contain),
)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
ZoomMode::ActualSize | ZoomMode::Custom(_) => {
let factor = self.zoom.factor(
Size::new(f32::MAX, f32::MAX),
Size::new(iw, ih),
);
let dw = iw * factor;
let dh = ih * factor;
// The image widget is the direct child of the scrollable
// (no intermediate Fill container, which collapses inside
// scrollable and hides the image). ContentFit::Contain scales
// the image to fill the Fixed box while preserving aspect
// ratio — since dw/dh matches iw/ih, the image fills exactly.
scrollable(
image(handle.as_ref().clone())
.width(Length::Fixed(dw))
.height(Length::Fixed(dh))
.content_fit(iced::ContentFit::Contain),
)
.direction(scrollable::Direction::Both {
horizontal: scrollable::Scrollbar::new().width(6).scroller_width(6),
vertical: scrollable::Scrollbar::new().width(6).scroller_width(6),
})
.width(Length::Fill)
.height(Length::Fill)
.into()
}
};
// Wrap in mouse_area to capture right-clicks for context menu.
let with_mouse = mouse_area(image_element)
.on_right_press(ImageMessage::RightClicked);
container(with_mouse)
.width(Length::Fill)
.height(Length::Fill)
.style(|_| container::Style {
background: Some(theme::BG_IMAGE_AREA.into()),
..Default::default()
})
.into()
}
/// Empty-state placeholder shown before any folder is opened.
fn view_empty(&self) -> Element<'_, ImageMessage> {
container(
text("No image — press O to open a folder")
.color(theme::TEXT_SECONDARY)
.size(16),
)
.width(Length::Fill)
.height(Length::Fill)
.align_x(iced::Alignment::Center)
.align_y(iced::Alignment::Center)
.style(|_| container::Style {
background: Some(theme::BG_IMAGE_AREA.into()),
..Default::default()
})
.into()
}
}

43
src/ui/mod.rs Normal file
View File

@ -0,0 +1,43 @@
//! UI layer — toolbar, image area, status bar, thumbnail bar, context menu.
pub mod about_dialog;
pub mod context_menu;
pub mod error_modal;
pub mod exif_panel;
pub mod export_dialog;
pub mod icons;
pub mod image_view;
pub mod sidebar;
pub mod status_bar;
pub mod thumbnail_bar;
pub mod toolbar;
/// The refined dark palette. See DECISION.md D004.
pub mod theme {
use iced::Color;
pub const BG_IMAGE_AREA: Color = Color::from_rgb(0.055, 0.055, 0.063); // #0e0e10
pub const BG_CHROME: Color = Color::from_rgb(0.086, 0.086, 0.102); // #16161a
pub const BG_CHROME_BORDER: Color = Color::from_rgb(0.149, 0.149, 0.173); // #26262c
pub const BG_CHROME_HOVER: Color = Color::from_rgb(0.118, 0.118, 0.137); // #1e1e23
pub const TEXT_PRIMARY: Color = Color::from_rgb(0.894, 0.894, 0.922); // #e4e4e7
pub const TEXT_SECONDARY: Color = Color::from_rgb(0.631, 0.631, 0.678); // #a1a1aa
pub const TEXT_DIM: Color = Color::from_rgb(0.502, 0.502, 0.549); // #80808c
pub const ACCENT: Color = Color::from_rgb(0.486, 0.227, 0.929); // #7c3aed
pub const ACCENT_DIM: Color = Color::from_rgb(0.353, 0.165, 0.675); // #5a2a9c
pub const DANGER: Color = Color::from_rgb(0.863, 0.149, 0.149); // #dc2626
pub const DANGER_DIM: Color = Color::from_rgb(0.624, 0.106, 0.106); // #9f1b1b
/// Convert a Color to a hex string for SVG stroke attributes.
pub fn to_hex(c: Color) -> String {
let r = (c.r * 255.0) as u8;
let g = (c.g * 255.0) as u8;
let b = (c.b * 255.0) as u8;
format!("#{:02x}{:02x}{:02x}", r, g, b)
}
pub fn text_primary_hex() -> String { to_hex(TEXT_PRIMARY) }
pub fn text_secondary_hex() -> String { to_hex(TEXT_SECONDARY) }
pub fn text_dim_hex() -> String { to_hex(TEXT_DIM) }
pub fn danger_hex() -> String { to_hex(DANGER) }
}

228
src/ui/sidebar.rs Normal file
View File

@ -0,0 +1,228 @@
//! Togglable folder tree sidebar — gPhoto-inspired.
//!
//! Slides in from the left when activated (default key: Tab). Shows a
//! vertical list of image-containing folders sibling to the current one.
//! Click a folder to switch the navigator to it.
//!
//! The sidebar is intentionally simple: it shows folders (not a full file
//! tree), and only folders that contain at least one supported image.
//! This keeps the UI focused on browsing photos, not on file management.
use std::path::{Path, PathBuf};
use iced::widget::{button, column, container, row, scrollable, text};
use iced::{Element, Length};
use super::theme;
use crate::codec::FormatRegistry;
const SIDEBAR_WIDTH: f32 = 240.0;
#[derive(Debug, Clone)]
pub enum SidebarMessage {
/// User clicked a folder entry.
FolderSelected(PathBuf),
/// User requested the sidebar toggle (Tab key or button).
Toggle,
}
#[derive(Debug, Clone, Default)]
pub struct Sidebar {
pub visible: bool,
/// The root directory whose children we're listing.
/// Typically the parent of the currently-open folder.
pub root: Option<PathBuf>,
/// List of (path, name, image_count) for each sibling folder.
pub entries: Vec<SidebarEntry>,
/// Currently-selected folder path.
pub current: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct SidebarEntry {
pub path: PathBuf,
pub name: String,
pub image_count: usize,
}
impl Sidebar {
pub fn new() -> Self {
Self::default()
}
pub fn toggle(&mut self) {
self.visible = !self.visible;
}
/// Rebuild the sidebar entries from the current folder's parent.
/// Called when a new folder is opened.
pub fn refresh(&mut self, current_folder: &Path, registry: &FormatRegistry) {
let parent = match current_folder.parent() {
Some(p) => p.to_path_buf(),
None => {
self.root = None;
self.entries.clear();
self.current = Some(current_folder.to_path_buf());
return;
}
};
self.root = Some(parent.clone());
self.current = Some(current_folder.to_path_buf());
self.entries = std::fs::read_dir(&parent)
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.path())
.filter(|p| p.is_dir())
.filter_map(|path| {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string();
// Skip hidden directories.
if name.starts_with('.') {
return None;
}
let count = count_images(&path, registry);
if count == 0 {
return None;
}
Some(SidebarEntry {
path,
name,
image_count: count,
})
})
.collect();
self.entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
pub fn view(&self) -> Element<'_, SidebarMessage> {
let title = text("Folders")
.color(theme::TEXT_SECONDARY)
.size(11);
let mut children: Vec<Element<'_, SidebarMessage>> = vec![title.into()];
if self.entries.is_empty() {
children.push(
text("No folders with images")
.color(theme::TEXT_DIM)
.size(11)
.into(),
);
} else {
for entry in &self.entries {
let is_current = self.current.as_deref() == Some(entry.path.as_path());
let name_text = text(entry.name.clone())
.color(if is_current {
theme::TEXT_PRIMARY
} else {
theme::TEXT_SECONDARY
})
.size(12);
let count_text = text(format!("{}", entry.image_count))
.color(theme::TEXT_DIM)
.size(10);
let entry_row = row![
name_text,
text("").width(Length::Fill),
count_text,
]
.align_y(iced::Alignment::Center)
.spacing(4.0);
let btn = button(entry_row)
.on_press(SidebarMessage::FolderSelected(entry.path.clone()))
.width(Length::Fill)
.padding(iced::Padding {
top: 5.0,
right: 10.0,
bottom: 5.0,
left: 10.0,
})
.style(move |_theme, status| {
let bg = match (status, is_current) {
(button::Status::Hovered, _) => Some(theme::BG_CHROME_HOVER),
(button::Status::Pressed, _) => Some(theme::ACCENT_DIM),
(_, true) => Some(theme::ACCENT_DIM),
_ => None,
};
button::Style {
background: bg.map(iced::Background::Color),
border: iced::Border {
radius: 3.0.into(),
..Default::default()
},
..Default::default()
}
});
children.push(btn.into());
}
}
let content = column(children)
.padding(iced::Padding {
top: 8.0,
right: 6.0,
bottom: 8.0,
left: 8.0,
})
.spacing(2.0);
let scrollable_content = scrollable(content)
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new().width(4).scroller_width(4),
))
.width(Length::Fill)
.height(Length::Fill);
container(scrollable_content)
.width(Length::Fixed(SIDEBAR_WIDTH))
.height(Length::Fill)
.style(|_| container::Style {
background: Some(theme::BG_CHROME.into()),
border: iced::Border {
color: theme::BG_CHROME_BORDER,
width: 0.0,
radius: 0.0.into(),
},
..Default::default()
})
.into()
}
pub fn width(&self) -> f32 {
if self.visible {
SIDEBAR_WIDTH
} else {
0.0
}
}
}
/// Count supported images in a directory (non-recursive).
fn count_images(dir: &Path, registry: &FormatRegistry) -> usize {
std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| !p
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.'))
.unwrap_or(true))
.filter(|p| registry.check_anti_list(p).is_none())
.filter(|p| registry.is_supported(p))
.count()
}

81
src/ui/status_bar.rs Normal file
View File

@ -0,0 +1,81 @@
//! Bottom status bar — filename, index/total, dimensions, zoom level.
use iced::widget::{container, row, text};
use iced::{Element, Length};
use super::theme;
/// State for the status bar.
#[derive(Debug, Clone, Default)]
pub struct StatusBar {
pub filename: String,
pub current_index: usize,
pub total: usize,
pub width: u32,
pub height: u32,
pub format: String,
pub zoom_pct: u32,
}
/// Messages the status bar can emit. Currently empty — the status bar is
/// display-only and emits no messages.
#[derive(Debug, Clone, Copy)]
pub enum StatusMessage {}
pub fn view(state: &StatusBar) -> Element<'static, StatusMessage> {
let position_text = if state.total == 0 {
"".to_string()
} else {
format!("{}/{}", state.current_index + 1, state.total)
};
let dims_text = if state.width == 0 {
String::new()
} else {
format!("{}×{}", state.width, state.height)
};
let zoom_text = if state.zoom_pct == 0 {
String::new()
} else {
format!("{}%", state.zoom_pct)
};
let fmt_text = state.format.clone();
let content = row![
text(state.filename.clone())
.color(theme::TEXT_SECONDARY)
.size(11),
text(" · ").color(theme::TEXT_SECONDARY).size(11),
text(position_text).color(theme::TEXT_PRIMARY).size(11),
text(" · ").color(theme::TEXT_SECONDARY).size(11),
text(dims_text).color(theme::TEXT_SECONDARY).size(11),
text(" · ").color(theme::TEXT_SECONDARY).size(11),
text(fmt_text).color(theme::TEXT_SECONDARY).size(11),
text(" · ").color(theme::TEXT_SECONDARY).size(11),
text(zoom_text).color(theme::TEXT_PRIMARY).size(11),
]
.padding(iced::Padding {
top: 0.0,
right: 10.0,
bottom: 0.0,
left: 10.0,
})
.align_y(iced::Alignment::Center);
container(content)
.width(Length::Fill)
.height(Length::Fixed(22.0))
.align_y(iced::Alignment::Center)
.style(|_theme| container::Style {
background: Some(theme::BG_CHROME.into()),
border: iced::Border {
color: theme::BG_CHROME_BORDER,
width: 0.0,
radius: 0.0.into(),
},
..Default::default()
})
.into()
}

200
src/ui/thumbnail_bar.rs Normal file
View File

@ -0,0 +1,200 @@
//! Bottom thumbnail strip — lazy-loaded, horizontal scrollable.
//!
//! Thumbnails are decoded in the background at 80px max dimension. The cache
//! is keyed by image index. On navigation, the app requests thumbnails for
//! the current ±10 window that aren't yet cached.
use std::collections::HashMap;
use std::path::PathBuf;
use iced::widget::{button, container, row, scrollable, text};
use iced::{Element, Length};
use super::theme;
pub const THUMB_SIZE: f32 = 72.0;
pub const THUMB_PADDING: f32 = 4.0;
pub const BAR_HEIGHT: f32 = THUMB_SIZE + 2.0 * THUMB_PADDING + 4.0; // 80
const THUMB_ENTRY_WIDTH: f32 = THUMB_SIZE + 2.0 + 2.0; // image + border + spacing
/// The scrollable Id for the thumbnail strip. Used by the app layer
/// to programmatically scroll the strip so the current image stays visible
/// (video-editor-timeline behavior).
pub fn thumb_scroll_id() -> scrollable::Id {
scrollable::Id::new("thumbnails")
}
/// State for the thumbnail bar.
#[derive(Debug, Clone, Default)]
pub struct ThumbnailBar {
/// Paths to all images in the folder.
pub paths: Vec<PathBuf>,
/// Current index (highlighted).
pub current: usize,
/// Cached thumbnail handles, keyed by index.
pub cache: HashMap<usize, iced::widget::image::Handle>,
}
#[derive(Debug, Clone)]
pub enum ThumbnailMessage {
Clicked(usize),
}
impl ThumbnailBar {
pub fn new() -> Self {
Self::default()
}
pub fn set_paths(&mut self, paths: Vec<PathBuf>, current: usize) {
self.paths = paths;
self.current = current;
self.cache.clear();
}
pub fn set_current(&mut self, current: usize) {
self.current = current;
}
pub fn insert_thumbnail(&mut self, index: usize, handle: iced::widget::image::Handle) {
self.cache.insert(index, handle);
}
/// Which thumbnail indices should be requested for decoding?
/// Returns the current ±10 window that aren't in the cache.
pub fn needed_thumbnails(&self, window: usize) -> Vec<usize> {
if self.paths.is_empty() {
return Vec::new();
}
let start = self.current.saturating_sub(window);
let end = (self.current + window + 1).min(self.paths.len());
(start..end)
.filter(|i| !self.cache.contains_key(i))
.collect()
}
/// Render the thumbnail bar.
pub fn view(&self) -> Element<'_, ThumbnailMessage> {
if self.paths.is_empty() {
return container(
text("No images loaded")
.color(theme::TEXT_DIM)
.size(11),
)
.width(Length::Fill)
.height(Length::Fixed(BAR_HEIGHT))
.align_x(iced::Alignment::Center)
.align_y(iced::Alignment::Center)
.style(|_| container::Style {
background: Some(theme::BG_CHROME.into()),
border: iced::Border {
color: theme::BG_CHROME_BORDER,
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
})
.into();
}
let thumbs: Vec<Element<'_, ThumbnailMessage>> = self
.paths
.iter()
.enumerate()
.map(|(i, path)| {
let is_current = i == self.current;
let inner: Element<'_, ThumbnailMessage> = if let Some(handle) = self.cache.get(&i) {
// Cached thumbnail — show the image.
iced::widget::image(handle.clone())
.width(Length::Fixed(THUMB_SIZE))
.height(Length::Fixed(THUMB_SIZE))
.content_fit(iced::ContentFit::Cover)
.into()
} else {
// Not yet decoded — show a placeholder with the file extension.
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("?")
.to_uppercase();
container(
text(ext).color(theme::TEXT_DIM).size(10),
)
.width(Length::Fixed(THUMB_SIZE))
.height(Length::Fixed(THUMB_SIZE))
.align_x(iced::Alignment::Center)
.align_y(iced::Alignment::Center)
.style(|_| container::Style {
background: Some(theme::BG_CHROME_BORDER.into()),
..Default::default()
})
.into()
};
// Wrap in a container that adds a border for the current thumb.
let bordered = container(inner)
.width(Length::Fixed(THUMB_SIZE + 2.0))
.height(Length::Fixed(THUMB_SIZE + 2.0))
.padding(1.0)
.style(move |_| container::Style {
border: iced::Border {
color: if is_current {
theme::ACCENT
} else {
iced::Color::TRANSPARENT
},
width: if is_current { 2.0 } else { 0.0 },
radius: 3.0.into(),
},
background: Some(theme::BG_IMAGE_AREA.into()),
..Default::default()
});
button(bordered)
.on_press(ThumbnailMessage::Clicked(i))
.padding(0.0)
.style(|_theme, _status| button::Style {
background: Some(iced::Color::TRANSPARENT.into()),
border: iced::Border {
radius: 3.0.into(),
..Default::default()
},
..Default::default()
})
.into()
})
.collect();
let strip = row(thumbs)
.padding(iced::Padding {
top: THUMB_PADDING,
right: THUMB_PADDING,
bottom: THUMB_PADDING,
left: THUMB_PADDING,
})
.spacing(2.0)
.align_y(iced::Alignment::Center);
let scrollable = scrollable(strip)
.id(thumb_scroll_id())
.direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::new().width(4).scroller_width(4),
))
.width(Length::Fill)
.height(Length::Fixed(BAR_HEIGHT));
container(scrollable)
.width(Length::Fill)
.height(Length::Fixed(BAR_HEIGHT))
.style(|_| container::Style {
background: Some(theme::BG_CHROME.into()),
border: iced::Border {
color: theme::BG_CHROME_BORDER,
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
})
.into()
}
}

167
src/ui/toolbar.rs Normal file
View File

@ -0,0 +1,167 @@
//! Bottom toolbar — gpicview-style, with SVG icons.
//!
//! In marten v0.2 the layout was flipped: thumbnail bar is now at the TOP
//! and the toolbar (this module) is at the BOTTOM. This gives the image
//! area more vertical room and matches the gpicview mental model where
//! the toolbar sits below the content.
use iced::widget::{button, container, row, text};
use iced::{Element, Length};
use super::icons::Icon;
use super::theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolbarMessage {
Prev,
Next,
FitToWindow,
ActualSize,
ZoomIn,
ZoomOut,
RotateCw,
RotateCcw,
ToggleFullscreen,
OpenFolder,
About,
Shuffle,
}
const ICON_SIZE: f32 = 16.0;
const BTN_HEIGHT: f32 = 28.0;
fn icon_btn(icon: Icon, msg: ToolbarMessage, disabled: bool) -> button::Button<'static, ToolbarMessage> {
let color = if disabled {
theme::text_dim_hex()
} else {
theme::text_primary_hex()
};
let content: Element<'static, ToolbarMessage> = icon
.widget(ICON_SIZE, &color)
.into();
button(content)
.on_press_maybe(if disabled { None } else { Some(msg) })
.height(Length::Fixed(BTN_HEIGHT))
.padding(iced::Padding {
top: 4.0,
right: 8.0,
bottom: 4.0,
left: 8.0,
})
.style(move |_theme, status| {
let bg = match status {
button::Status::Hovered => Some(theme::BG_CHROME_HOVER),
button::Status::Pressed => Some(theme::ACCENT_DIM),
_ => None,
};
button::Style {
background: bg.map(iced::Background::Color),
text_color: theme::TEXT_PRIMARY,
border: iced::Border {
radius: 4.0.into(),
..Default::default()
},
..Default::default()
}
})
}
fn text_btn(label: &'static str, msg: ToolbarMessage, disabled: bool) -> button::Button<'static, ToolbarMessage> {
let txt = text(label)
.color(if disabled {
theme::TEXT_DIM
} else {
theme::TEXT_PRIMARY
})
.size(12);
button(txt)
.on_press_maybe(if disabled { None } else { Some(msg) })
.height(Length::Fixed(BTN_HEIGHT))
.padding(iced::Padding {
top: 4.0,
right: 8.0,
bottom: 4.0,
left: 8.0,
})
.style(move |_theme, status| {
let bg = match status {
button::Status::Hovered => Some(theme::BG_CHROME_HOVER),
button::Status::Pressed => Some(theme::ACCENT_DIM),
_ => None,
};
button::Style {
background: bg.map(iced::Background::Color),
text_color: theme::TEXT_PRIMARY,
border: iced::Border {
radius: 4.0.into(),
..Default::default()
},
..Default::default()
}
})
}
pub fn view(disabled: bool) -> Element<'static, ToolbarMessage> {
let spacer = || text("").width(Length::Fixed(8.0));
let fill_spacer = || text("").width(Length::Fill);
// Left group: navigation + zoom
let left_group = row![
icon_btn(Icon::ChevronLeft, ToolbarMessage::Prev, disabled),
icon_btn(Icon::ChevronRight, ToolbarMessage::Next, disabled),
icon_btn(Icon::Shuffle, ToolbarMessage::Shuffle, disabled),
spacer(),
icon_btn(Icon::Fit, ToolbarMessage::FitToWindow, disabled),
icon_btn(Icon::ActualSize, ToolbarMessage::ActualSize, disabled),
icon_btn(Icon::ZoomIn, ToolbarMessage::ZoomIn, disabled),
icon_btn(Icon::ZoomOut, ToolbarMessage::ZoomOut, disabled),
spacer(),
icon_btn(Icon::RotateCw, ToolbarMessage::RotateCw, disabled),
icon_btn(Icon::RotateCcw, ToolbarMessage::RotateCcw, disabled),
spacer(),
icon_btn(Icon::Fullscreen, ToolbarMessage::ToggleFullscreen, disabled),
]
.spacing(2.0)
.align_y(iced::Alignment::Center);
// Right group: Open + About
let right_group = row![
text_btn("Open", ToolbarMessage::OpenFolder, false),
spacer(),
icon_btn(Icon::Info, ToolbarMessage::About, false),
]
.spacing(2.0)
.align_y(iced::Alignment::Center);
let content = row![
left_group,
fill_spacer(),
right_group,
]
.padding(iced::Padding {
top: 0.0,
right: 8.0,
bottom: 0.0,
left: 8.0,
})
.spacing(2.0)
.align_y(iced::Alignment::Center);
container(content)
.width(Length::Fill)
.height(Length::Fixed(34.0))
.align_y(iced::Alignment::Center)
.style(|_| container::Style {
background: Some(theme::BG_CHROME.into()),
border: iced::Border {
color: theme::BG_CHROME_BORDER,
width: 0.0,
radius: 0.0.into(),
},
..Default::default()
})
.into()
}