marten/DECISION.md

395 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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