# 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.
