32 KiB
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:
- 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.
- iced's
Theme::Darkis closer to the ristretto+ target aesthetic without manual color overrides. - iced's declarative
Messageenum +Task::performasync 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 intostd::thread+JoinHandlepolling 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'swindowsubsystem rather than egui'sViewportBuilder. Alternatives considered: egui (archived atprototypes/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
librawbindings. - 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
F11orF) collapses all chrome → image fills window, controls float in on hover. - A left-side folder panel slides in on demand (default
Tabor\). 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):
#dc2626Reasoning: 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-oxide0.12. Theimagecrate has no JXL decoder;jxl-oxideis the maintained pure-Rust implementation. - TIFF via the
tiff0.11 crate. Theimagecrate's TIFF support lags on unusual sample formats; a directtiffdependency gives us Gray/GrayA/RGB/RGBA/CMYK for both U8 and U16. - SVG via
resvg0.47 (withusvgandtiny-skia). SVG is a vector format — it needs a full render pipeline, not a pixel decoder.resvgis the only maintained pure-Rust SVG renderer. - OpenEXR via the
exr1.74 crate. EXR is HDR float data; we apply a Reinhard tone-map to bring it into the 8-bit display range.
Reasoning:
- The
imagecrate 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
Codectrait, 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.
Tabis the binding because it is conventional (terminal multiplexers, editors, file managers all use it for panel switching), it is unmodified (noCtrl+Tabambiguity), 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::everyis the idiomatic iced timer. It produces aMessageon each tick, which flows through the normalupdate()path. No background thread, no manual scheduling, notokio::intervalplumbing.- Gating the subscription on a
boolfield 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). Escapealso stops the slideshow becauseEscapeis 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 8–12 fields; a modal is the right surface.
kamadak-exifwas already inCargo.tomlfor 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.
iwas 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 toz, also surfaced as a toolbar shuffle button) — picks a random index from the current folder's image list.RandomFolderTree(bound toShift+Z) — callswalk_folder_tree()insrc/nav/folder.rsto 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+Zpress and the result is consumed immediately. zwas unused.Shift+Zis 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:
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_blockingis 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::Fillcollapse insidescrollableis 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::Containon aLength::Fixedbox 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 insrc/ui/image_view.rsrecords 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+Falso 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 |