| 1 | # remage |
| 2 | |
| 3 | A keyboard-centric image viewer for Linux, written in Rust. It aims for nsxiv feature parity with |
| 4 | vim-style control and the Nothing OS design language. It is GPU-rendered so it feels smooth, and it |
| 5 | can be themed from the wallpaper (matugen or pywal). Scripting works through declarative commands |
| 6 | plus an IPC socket. The binary and crate are both `remage`, and the Wayland app_id / X11 class is |
| 7 | `remage`. |
| 8 | |
| 9 | ## Principles (in priority order) |
| 10 | |
| 11 | 1. **Fast.** Cold start to the first painted image takes < 200 ms. `n`/`p` to a preloaded image |
| 12 | takes one frame. CPU use is 0% when idle: render on demand only, with no continuous loop. |
| 13 | 2. **Smooth but light.** Draw with wgpu and use short ease-out animations. No GUI toolkit and no |
| 14 | embedded interpreter. Animation runs only while something moves. |
| 15 | 3. **Keyboard first.** Every feature is an *action*. Every action can be bound to a key and is |
| 16 | reachable from the `:` palette and over IPC. The mouse works but is secondary. |
| 17 | 4. **Everything configurable** in TOML, with live reload. |
| 18 | 5. **Never block the UI thread** on I/O, decoding or scripts. A broken image or config never panics. |
| 19 | The error shows in the status bar instead. |
| 20 | |
| 21 | ## Tech stack |
| 22 | |
| 23 | Use the latest stable crate versions at implementation time (`cargo add`) and never guess version |
| 24 | numbers. Check APIs on docs.rs, especially winit, wgpu and glyphon, which break between releases |
| 25 | and must stay mutually compatible. |
| 26 | |
| 27 | | Concern | Choice | |
| 28 | |---|---| |
| 29 | | Window / input | `winit` (ApplicationHandler API), Wayland + X11, no decorations by default | |
| 30 | | GPU | `wgpu`: Vulkan + GL backends, `PowerPreference::LowPower` (an iGPU is plenty, and waking a sleeping laptop dGPU costs ~2 s; `WGPU_POWER_PREF`/`WGPU_BACKEND` override). `PresentMode::Fifo`, `desired_maximum_frame_latency = 1`, call `pre_present_notify()` before present | |
| 31 | | Text | `glyphon` (cosmic-text) with embedded fonts | |
| 32 | | Decoding | `image` (png/apng, jpeg, gif, webp, tiff, bmp, ico, tga, pnm, qoi, dds, hdr, exr), `jxl-oxide`, `image/avif-native` (dav1d), `libheif-rs`, `resvg`, `rawler` | |
| 33 | | Colour | `moxcms` (ICC → sRGB, CICP); EXIF orientation from `image` / `kamadak-exif` | |
| 34 | | Thumbnails | `fast_image_resize` (SIMD), `png` + `md-5` for the freedesktop cache | |
| 35 | | Config | `serde` + `toml` (spans give `file:line:col` errors) | |
| 36 | | Fuzzy matching | `nucleo-matcher` | |
| 37 | | Watching / signals | `notify` (config, includes, directories), `signal-hook` (SIGUSR1 = reload) | |
| 38 | | CLI | `clap` derive + `clap_complete` + `clap_mangen` | |
| 39 | | Concurrency | std threads + `crossbeam-channel`; workers wake the loop via `EventLoopProxy` | |
| 40 | | IPC | std `UnixListener` + `serde_json` | |
| 41 | | Errors / logs | `anyhow`, `thiserror`, `log` + `env_logger` (`RUST_LOG=remage=debug`) | |
| 42 | |
| 43 | **Locked pairings (M0, 2026-11):** glyphon 0.12 ↔ wgpu 30 (cosmic-text 0.19). winit stays on |
| 44 | 0.30 until 0.31 leaves beta. Bump glyphon and wgpu together, never one alone. Crate still to add |
| 45 | when its milestone needs it: `clap_mangen` (M7). |
| 46 | |
| 47 | Cargo features are `default = ["jxl", "svg", "avif", "heif", "raw"]`. The only C dependencies |
| 48 | (dav1d, libheif) sit behind `avif` and `heif`. |
| 49 | |
| 50 | Build profiles: |
| 51 | - Release: `lto = "thin"`, `codegen-units = 1`. |
| 52 | - Dev: `[profile.dev.package."*"] opt-level = 3`. Unoptimised decoders are unusably slow. |
| 53 | |
| 54 | Rust 2024 edition, stable toolchain. |
| 55 | |
| 56 | ## Architecture |
| 57 | |
| 58 | ``` |
| 59 | src/ |
| 60 | main.rs CLI → App::run; `remage msg …` client path |
| 61 | cli.rs clap definitions |
| 62 | app.rs winit ApplicationHandler: routes events, owns State + Renderer |
| 63 | action.rs Action enum + parser/Display (one grammar for keys, palette, IPC, scripts) |
| 64 | scan.rs CLI paths → file list (siblings, recursion, hidden files, unknown extensions to sniff) |
| 65 | state/ PURE logic, no winit/wgpu types → unit-testable |
| 66 | mod.rs AppState: mode, pending input, status line |
| 67 | queue.rs file list, index, marks, sort, filter, refresh |
| 68 | view.rs zoom/pan/fit/rotate/flip maths, animation targets |
| 69 | grid.rs grid layout, cursor, scroll, visual range |
| 70 | input/ |
| 71 | keys.rs vim key-notation parser (<C-x>, <S-Tab>, gg, <Space>w, mouse) |
| 72 | keymap.rs per-mode trie, pending sequences, counts, which-key candidates |
| 73 | config/ |
| 74 | mod.rs schema, compiled-in defaults, include merge, validation, live reload |
| 75 | theme.rs tokens, presets, palette derivation, pywal import |
| 76 | decode/ |
| 77 | mod.rs sniff (magic bytes, then extension) → backend → DecodedImage{frames, icc, orientation, meta} |
| 78 | *.rs image_rs, jxl, svg, heif, raw, external |
| 79 | color.rs ICC/CICP → sRGB, orientation, HDR tone-map |
| 80 | worker.rs priority job pool with epoch cancellation |
| 81 | cache.rs decoded-image LRU (byte budget) + preload policy |
| 82 | thumbs.rs freedesktop thumbnail cache read/write + generation |
| 83 | render/ |
| 84 | mod.rs device/surface setup, frame orchestration |
| 85 | image.rs textures, tiling above max_texture_dimension_2d, mipmaps, filters |
| 86 | prims.rs one instanced SDF shader: rects, rounded rects, borders, dots |
| 87 | text.rs glyphon + embedded fonts |
| 88 | shaders/*.wgsl image (gamma/brightness/contrast, alpha dots), prims |
| 89 | ui/ statusbar, grid view, palette, which-key, help, info panel |
| 90 | anim.rs retargetable ease-out tweens; requests redraws only while active |
| 91 | exec.rs user commands: template expansion, spawn, confirm, output handling |
| 92 | ipc.rs socket server, JSON protocol, event subscriptions |
| 93 | assets/fonts/ SpaceGrotesk, SpaceMono, Doto + OFL licences |
| 94 | contrib/ config.example.toml, matugen/, pywal/, scripts/, remage.desktop, PKGBUILD |
| 95 | tests/fixtures/ tiny images per format, EXIF orientations 1–8, ICC-tagged, animated |
| 96 | ``` |
| 97 | |
| 98 | **Data flow.** Input goes through the keymap, which produces an `Action`. `State::apply` turns the |
| 99 | action into effects: jobs, spawns, redraw. Workers send `UserEvent`s back through the proxy, and |
| 100 | those update the state and renderer. The renderer reads state and never owns logic. |
| 101 | |
| 102 | **Threads.** |
| 103 | - **Main thread:** event loop, state, rendering and GPU uploads. Uploads are budgeted per frame to |
| 104 | about 32 MB so large textures never cause a hitch. |
| 105 | - **Decode pool:** `nproc − 1` workers, ordered by priority: |
| 106 | - P0: current image |
| 107 | - P1: neighbours (preload) |
| 108 | - P2: visible thumbnails |
| 109 | - P3: other thumbnails |
| 110 | |
| 111 | Every job carries an epoch, so mashing `n` never decodes images that were skipped. |
| 112 | - **Filesystem thread:** directory listing, sniffing and the notify watcher. |
| 113 | - **IPC thread:** accepts socket connections and forwards actions through the proxy. |
| 114 | - **Child processes:** run async. Their exit status and stdout come back through the proxy. |
| 115 | |
| 116 | **Startup.** |
| 117 | 1. Parse the CLI and config. |
| 118 | 2. Start decoding the first image right away. |
| 119 | 3. In parallel, create the window hidden and initialise wgpu. |
| 120 | 4. Show the window on the first finished frame, so it never flashes empty. |
| 121 | |
| 122 | The directory listing continues in the background; only the first image is awaited. |
| 123 | |
| 124 | ## Feature spec |
| 125 | |
| 126 | ### Queue |
| 127 | - `remage` with no arguments is the same as `remage .`. A directory argument loads its images, |
| 128 | non-recursively unless `-r` is given. A single file argument loads its siblings and starts on that |
| 129 | file (`general.open_siblings`, or `--no-siblings` to turn it off). |
| 130 | - Known extensions are accepted instantly. Unknown ones are sniffed in the background. Undecodable |
| 131 | files drop out of the queue, and the status line reports `[SKIPPED n]`. |
| 132 | - Sorting is `natural` (the default), `name`, `mtime`, `size` or `random`, plus `reverse`. With |
| 133 | `general.watch_dir`, files that appear or disappear on disk are added or removed live. |
| 134 | - `-i` reads paths from stdin (`-0` for NUL-separated). `-o` prints marked files on exit. |
| 135 | |
| 136 | ### Modes |
| 137 | - **Image:** shows a single image. |
| 138 | - **Grid:** nsxiv's thumbnail mode, the "menu mode" from doc.md. `<CR>` toggles between image and |
| 139 | grid. **Visual** is a sub-mode of grid: press `v`, move to extend a range, then `m` marks it. |
| 140 | - **Prompts:** |
| 141 | - `:` opens the command palette, which fuzzy-matches all actions and user commands and shows their |
| 142 | bound keys. |
| 143 | - `/` opens fuzzy filename search. It filters the grid live, and `<CR>` jumps to the selected file. |
| 144 | - The confirm prompt asks inline (`DELETE 3 FILES? Y / N`). |
| 145 | - **Overlays:** |
| 146 | - Which-key appears after a pending prefix has waited `ui.which_key_delay_ms` (300 ms). |
| 147 | - `?` shows help, generated from the live keymap. |
| 148 | - `i` shows the info panel with EXIF and metadata. |
| 149 | |
| 150 | ### Image view |
| 151 | - **Fit modes:** `down` (the default, as in nsxiv), `fit`, `fill`, `width`, `height` and |
| 152 | `original`. |
| 153 | - **Zoom:** happens around the cursor for the mouse and around the centre for keys. Zoom |
| 154 | interpolates in log space with the anchor held fixed. |
| 155 | - **Panning:** `hjkl` pans and `HJKL` jumps to an edge. A held key pans at constant velocity from |
| 156 | key-down to key-up, never in key-repeat steps. |
| 157 | - **Rotate and flip:** 90° rotation and h/v flips change only the view, never the file. EXIF |
| 158 | orientation is applied automatically. |
| 159 | - **Filtering:** linear with mipmaps when shrinking. Above 300% it switches to nearest automatically |
| 160 | for pixel art, and `a` toggles it by hand. |
| 161 | - **Adjustments:** gamma, brightness and contrast are shader uniforms. |
| 162 | - **Transparency background:** `dots` (the Nothing dot-matrix grid, default), `checker` or `solid`. |
| 163 | - **Animation and pages:** GIF, APNG, WebP and JXL animations play with their own timing. |
| 164 | `<C-Space>` pauses and `<C-n>`/`<C-p>` step frames. Multi-page TIFFs step the same way. |
| 165 | - **Slideshow:** `s` toggles it, with a configurable delay. |
| 166 | - **Huge images and SVG:** images larger than `max_texture_dimension_2d` are tiled. SVGs are |
| 167 | re-rasterised at the current zoom (debounced) so they stay sharp. |
| 168 | - **Preload and cache:** the `image.preload` images on each side are decoded *and* uploaded to the |
| 169 | GPU. Decoded images live in an LRU capped by `image.cache_mb`. |
| 170 | - **Image switching:** a 100 ms crossfade, or none. `[LOADING]` appears only if loading takes more |
| 171 | than 120 ms, so it never flickers. |
| 172 | |
| 173 | ### Grid |
| 174 | - **Thumbnail cache:** uses the freedesktop thumbnail cache in `$XDG_CACHE_HOME/thumbnails/`, with |
| 175 | `normal` 128, `large` 256, `x-large` 512 and `xx-large` 1024. |
| 176 | - Each entry is named by the MD5 of the file URI and validated by `Thumb::URI` + `Thumb::MTime`. |
| 177 | - remage picks the size bucket ≥ thumb size × scale factor. |
| 178 | - Writes are atomic (tmp + rename) with mode 0600, and failures go in `fail/remage/`. |
| 179 | - `-p` disables writing. Because the cache is shared with file managers, thumbnails are often |
| 180 | already there. |
| 181 | - URIs are escaped exactly as GLib's `g_filename_to_uri` escapes them (`!$&'()*+,;=:@` stay |
| 182 | literal, hex is uppercase), or the names won't match other tools' entries. A bare path in |
| 183 | `Thumb::URI` (ffmpegthumbnailer writes those) also counts as valid. |
| 184 | - A `fail/remage/` entry is trusted only if its `Software` is this remage version, so formats a |
| 185 | newer version can decode aren't hidden forever. |
| 186 | - Images no bigger than the bucket aren't written: re-decoding them costs about as much. |
| 187 | - **Fast paths:** JPEG uses a DCT-scaled decode (`jpeg-decoder` `Decoder::scale`). RAW uses the |
| 188 | embedded preview. Then `fast_image_resize` finishes the job (Lanczos, premultiplied alpha). |
| 189 | - **Generation and storage:** |
| 190 | - Visible thumbnails are P2. A page above and two pages below are P3. Requests that scroll out of |
| 191 | that range are cancelled (`Pool::cancel`). |
| 192 | - On the GPU, thumbnails live in 2048² atlas pages at 1:1 pixels, each with a 1 px clear border. |
| 193 | The least recently drawn thumbnail outside the prefetch range is evicted first; if every one is |
| 194 | in range, the atlas grows a page. A new grid size or scale factor rebuilds the atlas. |
| 195 | - Thumbnails fade in over 150 ms. A thumbnail that fails drops the file from the queue (counted in |
| 196 | `[SKIPPED n]`), as in image mode. |
| 197 | - **Cursor:** the grid cursor *is* the queue's current image, so `n`/`p`/`gg`/`G` move it too. The |
| 198 | full image decodes only once the cursor has rested 250 ms (`GRID_REST`). That way `<CR>` opens it |
| 199 | at once without decoding everything the cursor passes over. |
| 200 | - **Navigation:** `hjkl`, `gg`/`G`/`[count]G`, `<C-d>`/`<C-u>`, `<C-f>`/`<C-b>` and `0`/`$`. |
| 201 | Scrolling animates smoothly, and `+`/`-` steps through `grid.sizes`. The wheel scrolls by rows, |
| 202 | pixel-exact on touchpads. |
| 203 | - **Visual mode:** `v` anchors a range and movement extends it. `m` (`mark on`) marks it and returns |
| 204 | to the grid; `mark` toggles, unmarking a range that is all marked. `v` or `<Esc>` leaves. The range |
| 205 | sits on `surface_raised`, and the bar shows `[VISUAL n]`. |
| 206 | |
| 207 | ### Marks |
| 208 | - `m` toggles a mark, `M` inverts all marks and `u` unmarks everything. |
| 209 | - `N`/`P` jump to the next/previous marked image. |
| 210 | - Marks are the default target of user commands, and `-o` prints them on exit. |
| 211 | - Marks are kept by path, so sorting and reversing keep them. |
| 212 | - In the status bar, an accent dot plus the count shows the marks. The dot is filled when the |
| 213 | current image is marked and a ring when it isn't. |
| 214 | |
| 215 | ### Input |
| 216 | - **Key notation** follows vim: `n`, `G`, `<C-x>`, `<S-Tab>`, `<Space>`, `<BS>`, `<Esc>`, `<CR>`, |
| 217 | `<Left>`, `<F5>`. Sequences work too, such as `gg` and `<Space>w`. |
| 218 | - **Mouse notation:** `<ScrollUp>`, `<C-ScrollUp>`, `<LeftMouse>`, `<RightMouse>`, `<MiddleMouse>`. |
| 219 | Drag-to-pan is built in. |
| 220 | - **Tables:** `[keys.global]`, `[keys.image]`, `[keys.grid]` and `[keys.visual]`. A mode's table |
| 221 | overrides `global`. |
| 222 | - **Binding values:** an action string, an array of actions (run in order, with `run` awaited before |
| 223 | the next one), or `"none"` to remove a default binding. |
| 224 | - **Counts:** a digit prefix becomes the count (`5n`, `12G`, `3+`). `0` acts as a motion when no |
| 225 | count is pending. A pending prefix or count shows in the status bar. |
| 226 | |
| 227 | ### Actions |
| 228 | |
| 229 | Actions share one grammar, `name [args]`, used by keys, the palette, IPC and command stdout. Each |
| 230 | action carries a one-line description, which feeds the palette, which-key and help. |
| 231 | |
| 232 | | Group | Actions | |
| 233 | |---|---| |
| 234 | | Navigation | `next [n]`, `prev [n]`, `first`, `last`, `goto N`, `random`, `next-mark`, `prev-mark`, `next-frame`, `prev-frame` | |
| 235 | | Marks | `mark [toggle\|on\|off]`, `mark-all`, `unmark-all`, `invert-marks` | |
| 236 | | Grid | `move <dir> [n]`, `page down\|up\|half-down\|half-up`, `row-start`, `row-end`, `scroll <dir> [n]` (pans a step in image mode) | |
| 237 | | View | `zoom in\|out\|N%`, `fit <mode>`, `pan <dir>`, `pan-edge <dir>`, `center`, `rotate cw\|ccw\|180`, `flip h\|v`, `filter toggle`, `alpha-bg cycle`, `gamma\|brightness\|contrast +\|-\|reset` | |
| 238 | | Playback | `play toggle`, `slideshow toggle`, `slideshow-delay ±N` | |
| 239 | | UI | `mode image\|grid\|toggle`, `visual`, `fullscreen`, `bar toggle`, `info toggle`, `help`, `palette`, `search`, `grid-size +\|-` | |
| 240 | | Queue | `sort <key>`, `reverse`, `filter <pattern>`, `filter-clear`, `remove`, `reload`, `refresh`, `open <path>` | |
| 241 | | App | `run <command>`, `reload-config`, `echo <msg>`, `quit`, `quit-no-output` | |
| 242 | |
| 243 | ### Commands and scripting (replaces nsxiv's key-handler) |
| 244 | |
| 245 | ```toml |
| 246 | [commands.wallpaper] |
| 247 | desc = "Set as wallpaper" |
| 248 | run = ["setwall", "-p", "{file}"] |
| 249 | |
| 250 | [commands.copy] |
| 251 | desc = "Copy image" |
| 252 | run = "wl-copy --type image/png < {file}" # string = sh -c, placeholders shell-quoted |
| 253 | |
| 254 | [commands.delete] |
| 255 | desc = "Delete files" |
| 256 | run = ["rm", "--", "{files}"] # array = exec directly, {files} → N args |
| 257 | confirm = "Delete {count} file(s)?" |
| 258 | |
| 259 | [commands.legacy] # an old nsxiv key-handler works unchanged |
| 260 | run = ["~/.config/nsxiv/exec/key-handler", "w"] |
| 261 | stdin = "files" |
| 262 | |
| 263 | [keys.global] |
| 264 | "<Space>w" = "run wallpaper" |
| 265 | "<Space>c" = "run copy" |
| 266 | "<Space>d" = "run delete" |
| 267 | ``` |
| 268 | |
| 269 | **Command fields:** |
| 270 | - **Placeholders:** `{file} {files} {dir} {name} {stem} {ext} {index} {count} {width} {height}`. |
| 271 | - **`targets`:** `auto` (marked files if any, otherwise the current one; the default), `current`, |
| 272 | `marked` or `all`. |
| 273 | - **`stdin`:** `none`, `files` (newline-separated) or `files0` (NUL-separated). |
| 274 | - **Environment:** `REMAGE_FILE`, `REMAGE_INDEX`, `REMAGE_COUNT`, `REMAGE_MODE`, `REMAGE_SOCKET`, |
| 275 | `REMAGE_PID`. |
| 276 | - **`confirm`:** turns the command into an inline y/n prompt. remage has no modal dialogs. |
| 277 | - **`output`:** |
| 278 | - `status` (default): the last stdout line goes to the status line. |
| 279 | - `actions`: every stdout line is run as an action. |
| 280 | - `ignore`. |
| 281 | - **Running:** commands always run async. The status bar shows `[RUNNING WALLPAPER]`, then `[DONE]` |
| 282 | or `[ERROR: <code> <first stderr line>]`. |
| 283 | - **`after`:** actions to run on success. The default is `refresh`, which re-stats the targets, |
| 284 | drops missing files and re-decodes changed ones. That way delete and rotate scripts work with no |
| 285 | extra setup. |
| 286 | - **Unbound commands** still appear in the `:` palette. |
| 287 | |
| 288 | **IPC socket.** It lives at `$XDG_RUNTIME_DIR/remage/<pid>.sock` and speaks newline-delimited JSON. |
| 289 | - **Requests:** |
| 290 | - `{"action":"goto 5"}` |
| 291 | - `{"query":"current"|"marked"|"queue"|"state"}` |
| 292 | - `{"subscribe":["image","marks","mode"]}`, which turns the connection into an event stream. |
| 293 | - **Replies:** `{"ok":true,"data":…}` or `{"ok":false,"error":"…"}`. |
| 294 | - **Client:** `remage msg <action>`, `remage msg --query marked` and `remage msg --subscribe image`. |
| 295 | It targets `$REMAGE_SOCKET`, otherwise the newest instance; `--pid` picks one explicitly. |
| 296 | - **Uses:** Hyprland binds, long-running watchers and other tools driving the viewer. |
| 297 | |
| 298 | ### Config |
| 299 | - **Location:** `$XDG_CONFIG_HOME/remage/config.toml`, falling back to `~/.config/remage/`. `-c` |
| 300 | overrides it. |
| 301 | - **Defaults:** every option has a compiled-in default, and the user file deep-merges over them, so a |
| 302 | partial file is fine. `remage --dump-config` prints the full commented default. |
| 303 | `remage --check-config` validates the file and exits non-zero with `file:line:col` errors. |
| 304 | Unknown keys are errors and come with a "did you mean" suggestion. |
| 305 | - **`include`:** |
| 306 | - Format: `include = ["~/.cache/matugen/remage.toml"]`. Paths expand `~` and `$VAR`, and relative |
| 307 | paths resolve against the config dir. |
| 308 | - Semantics follow alacritty's `import`. Includes load in order, and a missing include is a |
| 309 | warning, not an error. The including file loads last and wins. |
| 310 | - **Live reload:** a notify watch on the config and every include, debounced to 100 ms. SIGUSR1 and |
| 311 | the `reload-config` action also reload. An invalid config keeps the last good one and shows |
| 312 | `[CONFIG ERROR: …]`. Theme changes crossfade over about 250 ms, so a wallpaper change recolours |
| 313 | smoothly. |
| 314 | - **Sections:** `general`, `window`, `image`, `grid`, `ui`, `animation`, `theme`, `keys.<mode>`, |
| 315 | `commands.<name>` and `[[decoders]]`. Representative keys: |
| 316 | |
| 317 | ```toml |
| 318 | [general] open_siblings = true; recursive = false; sort = "natural"; start_mode = "image"; wrap = true; watch_dir = true |
| 319 | [window] decorations = false; title = "{name} — remage"; class = "remage"; fullscreen = false |
| 320 | [image] fit = "down"; zoom_step = 1.25; filter = "auto"; alpha_background = "dots"; auto_orient = true |
| 321 | color_management = true; preload = 2; cache_mb = 512; slideshow_delay = 5.0 |
| 322 | [grid] sizes = [96, 128, 160, 224, 320]; size = 160; gap = 16; thumbnail_cache = true; names = false |
| 323 | [ui] statusbar = true; which_key_delay_ms = 300; scale = 1.0 |
| 324 | statusbar_left = "{name}"; statusbar_right = "{format} {width}×{height} {zoom}% {index}/{count}" |
| 325 | info_command = "" # optional script whose output fills a status segment (nsxiv image-info) |
| 326 | font_ui = "Space Grotesk"; font_mono = "Space Mono"; font_display = "Doto" |
| 327 | [animation] enabled = true; duration_ms = 180; transition = "fade" |
| 328 | [theme] preset = "nothing-dark" |
| 329 | ``` |
| 330 | |
| 331 | ### Theming |
| 332 | - **Tokens:** `background`, `foreground`, `surface`, `surface_raised`, `border`, `border_visible`, |
| 333 | `text_disabled`, `text_secondary`, `text_primary`, `text_display`, `accent`, `success`, |
| 334 | `warning` and `image_background` (defaults to `background`). |
| 335 | - **Presets:** `nothing-dark` is the default (`#000000` / `#FFFFFF` / accent `#D71921`). |
| 336 | `nothing-light` is `#F5F5F5` / `#000000` / `#D71921`. |
| 337 | - **Derivation:** only `background`, `foreground` and `accent` are needed. remage derives any token |
| 338 | that is missing by mixing fg over bg in sRGB: |
| 339 | |
| 340 | | Token | fg share | |
| 341 | |---|---| |
| 342 | | `text_display` | 1.0 | |
| 343 | | `text_primary` | 0.91 | |
| 344 | | `text_secondary` | 0.60 | |
| 345 | | `text_disabled` | 0.40 | |
| 346 | | `border_visible` | 0.20 | |
| 347 | | `border` | 0.133 | |
| 348 | | `surface_raised` | 0.10 | |
| 349 | | `surface` | 0.067 | |
| 350 | |
| 351 | These ratios reproduce the Nothing hex tokens exactly, and they give any wallpaper palette a |
| 352 | coherent four-level hierarchy. |
| 353 | - **matugen:** ship `contrib/matugen/remage.toml`: |
| 354 | ```toml |
| 355 | [theme] |
| 356 | background = "{{colors.surface_container_lowest.default.hex}}" |
| 357 | foreground = "{{colors.on_surface.default.hex}}" |
| 358 | accent = "{{colors.primary.default.hex}}" |
| 359 | ``` |
| 360 | Register it in `~/.config/matugen/config.toml` with |
| 361 | `[templates.remage] input_path = '~/.config/matugen/templates/remage.toml'` and |
| 362 | `output_path = '~/.cache/matugen/remage.toml'`. Then set |
| 363 | `include = ["~/.cache/matugen/remage.toml"]` in the remage config. The live reload picks up every |
| 364 | wallpaper change, so no `post_hook` is needed. |
| 365 | - **pywal:** read it natively with `[theme] pywal = "~/.cache/wal/colors.json"`. That maps |
| 366 | `special.background` / `special.foreground` / `colors.color1` to background / foreground / accent. |
| 367 | A template is also provided in `contrib/pywal/`. |
| 368 | |
| 369 | ### UI design language (Nothing) |
| 370 | |
| 371 | **Load the `nothing-design` skill before any UI work.** These are the remage-specific rules: |
| 372 | - **Fonts:** embedded, all under the OFL; copy them from `~/.local/share/fonts/glyph/` along with |
| 373 | the licence. |
| 374 | - Space Grotesk for UI text. |
| 375 | - Space Mono for labels and data, in ALL CAPS with 0.08em tracking. |
| 376 | - Doto (dot-matrix) only at 36 px and above, for hero moments. |
| 377 | |
| 378 | A screen uses at most 2 families, 3 sizes and 2 weights. |
| 379 | - **The image is the hero.** There is no chrome except the optional status bar (`b`), which sits at |
| 380 | the bottom on a solid background with no border. |
| 381 | - **Left side:** the filename in Space Grotesk 14, `text_primary`. |
| 382 | - **Right side:** Space Mono caps 11, `text_secondary`, for example |
| 383 | `JPEG 4032×3024 72% 012/240`. |
| 384 | - **Marks:** an accent dot plus the count, shown only when the count is > 0. |
| 385 | - **Pending keys:** shown in `text_disabled`. |
| 386 | - **Messages:** inline (`[COPIED]`, `[ERROR: …]`). Never toasts. |
| 387 | - **Accent red is a signal only:** marks, errors and the palette's selection bar. Nothing else on |
| 388 | screen is coloured. |
| 389 | - **One surprise per screen.** A count prefix or `goto` shows as large Doto numerals in a corner. |
| 390 | The empty state is Doto `NO IMAGES` plus a single hint line. |
| 391 | - **Overlays** (palette, which-key, info, help) use a `surface` background, a 1 px `border_visible` |
| 392 | border and a radius of 8–16, with no shadow or blur. The selected row is `surface_raised` with a |
| 393 | 2 px accent bar on the left. Overlays anchor to an edge (bottom or right), never a centred modal. |
| 394 | - **Grid:** |
| 395 | - Gaps are 16 px. |
| 396 | - The cursor is a 1 px `text_display` outline with a 4 px radius. |
| 397 | - A marked thumbnail gets an accent dot. |
| 398 | - A pending thumbnail is a single dim dot. No skeletons. |
| 399 | - **Motion:** ease-out `cubic-bezier(0.25, 0.1, 0.25, 1)` over 150–250 ms. Fade rather than slide, |
| 400 | with no spring or bounce. Every animation can be retargeted from its current value, and |
| 401 | `animation.enabled = false` turns them all off. |
| 402 | |
| 403 | ### Formats |
| 404 | |
| 405 | | Formats | Backend | |
| 406 | |---|---| |
| 407 | | JPEG, PNG/APNG, GIF, WebP (animated), BMP, ICO, TIFF (multi-page), TGA, PNM, QOI, DDS, HDR, OpenEXR | `image` (pure Rust) | |
| 408 | | JPEG XL (incl. animation) | `jxl-oxide` (pure Rust) | |
| 409 | | AVIF | `image` `avif-native` (system dav1d) | |
| 410 | | HEIC / HEIF | `libheif-rs` (system libheif) | |
| 411 | | SVG / SVGZ | `resvg`, re-rasterised on zoom | |
| 412 | | Camera RAW (CR2/CR3/NEF/ARW/DNG/RAF/ORF/RW2…) | `rawler`: the embedded preview first (fast), with a full develop on demand | |
| 413 | | Anything else (PSD, XCF, JP2, …) | `[[decoders]]` entries `{ match = ["*.psd"], run = ["magick", "{file}[0]", "png:-"] }`, which emit PNG/PPM/TIFF on stdout | |
| 414 | |
| 415 | Pipeline rules: |
| 416 | - Detect the format by magic bytes first and the extension second. |
| 417 | - Convert ICC and CICP colour to sRGB. |
| 418 | - Tone-map 16-bit and float images (EXR/HDR) to 8-bit sRGB for display. |
| 419 | - Apply EXIF orientation. |
| 420 | |
| 421 | ### CLI |
| 422 | |
| 423 | ``` |
| 424 | remage [OPTIONS] [PATHS]... |
| 425 | -r --recursive -i --stdin -0 --null -o --print-marked -t --grid |
| 426 | -n --start N -f --fullscreen -b --no-bar -S --slideshow [SECS] |
| 427 | -s --fit MODE -z --zoom PCT -g --geometry WxH -c --config PATH |
| 428 | -p --private --class NAME --sort KEY --reverse --no-siblings |
| 429 | --dump-config --check-config |
| 430 | remage msg [--pid PID] [--query Q | --subscribe EV] [ACTION...] |
| 431 | remage completions <SHELL> |
| 432 | ``` |
| 433 | |
| 434 | ### Default keys |
| 435 | |
| 436 | | Mode | Keys | |
| 437 | |---|---| |
| 438 | | global | `q` quit · `<CR>` image⇄grid · `n`/`p`/`<BS>` next/prev · `[`/`]` ±10 · `gg`/`G`/`[N]G` · `m`/`M`/`u` mark/invert/unmark-all · `N`/`P` next/prev mark · `f` fullscreen · `b` bar · `i` info · `r` reload · `D` remove from queue · `s` slideshow · `:` palette · `/` search · `?` help · `<Space>` leader (user commands, which-key) · `<Esc>` cancel | |
| 439 | | image | `hjkl`/arrows pan · `HJKL` edges · `z` centre · `+`/`-`/`=` zoom in/out/100% · `w`/`W`/`e`/`E` fit/fill/width/height · `<`/`>` rotate · `\|`/`_` flip · `a` filter · `A` alpha bg · `{`/`}` gamma · `<C-Space>` play/pause · `<C-n>`/`<C-p>` frame · wheel = zoom at cursor, drag = pan | |
| 440 | | grid | `hjkl` move · `<C-d>`/`<C-u>` half page · `<C-f>`/`<C-b>` page · `0`/`$` row ends · `+`/`-` thumb size · `v` visual · click = select, double-click = open | |
| 441 | |
| 442 | ## Milestones |
| 443 | |
| 444 | Each milestone should end with a usable binary. |
| 445 | |
| 446 | - **M0 Scaffold** |
| 447 | - `git init` and `cargo init`. |
| 448 | - `Cargo.toml` with the features and profiles above. |
| 449 | - Fonts + OFL in `assets/fonts`, and the module skeleton. |
| 450 | - fmt, clippy and test all clean. |
| 451 | - **M1 Viewer core** ✓ (2026-11-04). First frame in ~90 ms on the iGPU, 0% CPU when idle. |
| 452 | - CLI, and a queue with siblings, natural sort and sniffing. |
| 453 | - winit + wgpu window, decoding through the `image` crate. |
| 454 | - Fit/zoom/pan/rotate/flip with animation, and `n`/`p`. |
| 455 | - Preload + cache, a status bar with text, and the `nothing-dark` theme with hard-coded keys. |
| 456 | - **M2 Input + config** ✓ (2026-11-04). Unbuilt actions parse and bind, then say "not available yet". |
| 457 | - Action grammar, and a keymap trie with sequences, counts and mouse bindings. |
| 458 | - TOML defaults, include and live reload, plus `--dump-config` and `--check-config`. |
| 459 | - Theme tokens, presets and derivation; matugen + pywal support. |
| 460 | - **M3 Grid + marks** ✓ (2026-11-04). Cached grid opens in ~85 ms, 0% CPU when idle. |
| 461 | - Freedesktop thumbnails, worker priorities and the GPU atlas. |
| 462 | - Grid navigation, visual mode, marks and smooth scrolling. |
| 463 | - **M4 Commands + IPC** |
| 464 | - `[commands]` with async exec, confirm, output modes and `after`/`refresh`. |
| 465 | - The socket, `remage msg` and subscriptions; `-i`/`-o`/`-0`. |
| 466 | - **M5 Formats + fidelity** |
| 467 | - JXL, AVIF, HEIF, SVG, RAW and external decoders. |
| 468 | - ICC colour, animation, multi-page, tiling + mipmaps, and HDR tone-mapping. |
| 469 | - **M6 Polish** |
| 470 | - Palette, search, which-key, help and the info panel. |
| 471 | - Slideshow, gamma/brightness/contrast and the `nothing-light` preset. |
| 472 | - `watch_dir`, title and status-bar templates. |
| 473 | - A performance pass against the targets. |
| 474 | - **M7 Packaging** |
| 475 | - Man page, zsh completion and `remage.desktop` with the MimeType list. |
| 476 | - README, `contrib/config.example.toml` (with the old key-handler's delete/wallpaper/copy |
| 477 | migrated) and a PKGBUILD. |
| 478 | |
| 479 | ## Testing and verification |
| 480 | - **Unit tests** cover the pure modules: key-notation parsing, the keymap trie and counts, Action |
| 481 | parse ↔ Display round-trips, queue operations (natural sort, marks, filter, refresh), view maths |
| 482 | (fit modes, zoom anchor, rotation), config merge, include precedence and theme derivation, |
| 483 | placeholder expansion and shell quoting, and thumbnail paths (URI → MD5) per the spec. |
| 484 | - **Decoder tests** run against `tests/fixtures/`: dimensions and pixels for each format, EXIF |
| 485 | orientations 1–8, ICC-tagged images and animated frame counts. |
| 486 | - **Visual checks without a display:** `REMAGE_SNAPSHOT=/tmp/x.png remage …` renders each settled |
| 487 | frame offscreen to a PNG as well. Use it to check the UI when the screen is locked or remote. In |
| 488 | the grid, a frame counts as settled once every visible thumbnail is in the atlas. Point |
| 489 | `XDG_CACHE_HOME` at a scratch directory to test cold thumbnail generation without touching the |
| 490 | real cache. |
| 491 | - **Before every commit:** `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` and |
| 492 | `cargo test`. |
| 493 | - **Manual smoke test:** run `cargo run --release -- /mnt/media/pix` and check that: |
| 494 | - `n`/`p` are instant. |
| 495 | - `<CR>` opens the grid. |
| 496 | - `m` marks and `<Space>w` runs a command. |
| 497 | - `remage msg next` works from another terminal. |
| 498 | - Changing the wallpaper recolours the window live. |
| 499 | - `RUST_LOG=remage=debug` logs startup and first-frame timings. |
| 500 | - CPU is idle while the view is static. |
| 501 | |
| 502 | ## Conventions |
| 503 | - Never put Claude session links or `Claude-Session:` trailers in commits or PRs. Commit only when |
| 504 | the user asks, with imperative, concise messages. |
| 505 | - No `unwrap()`/`expect()` on paths that touch user input, files or the GPU surface. Errors go to |
| 506 | the status line. |
| 507 | - `state/` must stay free of winit and wgpu types. |
| 508 | - Adding an action takes all of these: an enum variant, a parser case, a description, an optional |
| 509 | default binding, an entry in `--dump-config` and a test. |