359 lines
16 KiB
Markdown
359 lines
16 KiB
Markdown
# 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 (D001–D016)
|
||
├── 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.
|