Krolyxon
Commit ab1dc3b

Branches

Tags

Refs →

remageCLAUDE.md

525 lines · 30.5 KB · Markdown

1# remage
2
3A keyboard-centric image viewer for Linux, written in Rust. It aims for nsxiv feature parity with
4vim-style control and the Nothing OS design language. It is GPU-rendered so it feels smooth, and it
5can be themed from the wallpaper (matugen or pywal). Scripting works through declarative commands
6plus 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
111. **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.
132. **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.
153. **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.
174. **Everything configurable** in TOML, with live reload.
185. **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
23Use the latest stable crate versions at implementation time (`cargo add`) and never guess version
24numbers. Check APIs on docs.rs, especially winit, wgpu and glyphon, which break between releases
25and 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
440.30 until 0.31 leaves beta. Bump glyphon and wgpu together, never one alone. Crate still to add
45when its milestone needs it: `clap_mangen` (M7).
46
47Cargo features are `default = ["jxl", "svg", "avif", "heif", "raw"]`. The only C dependencies
48(dav1d, libheif) sit behind `avif` and `heif`.
49
50Build profiles:
51- Release: `lto = "thin"`, `codegen-units = 1`.
52- Dev: `[profile.dev.package."*"] opt-level = 3`. Unoptimised decoders are unusably slow.
53
54Rust 2024 edition, stable toolchain.
55
56## Architecture
57
58```
59src/
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
93assets/fonts/ SpaceGrotesk, SpaceMono, Doto + OFL licences
94contrib/ config.example.toml, matugen/, pywal/, scripts/, remage.desktop, PKGBUILD
95tests/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
99action into effects: jobs, spawns, redraw. Workers send `UserEvent`s back through the proxy, and
100those 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.**
1171. Parse the CLI and config.
1182. Start decoding the first image right away.
1193. In parallel, create the window hidden and initialise wgpu.
1204. Show the window on the first finished frame, so it never flashes empty.
121
122The 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
229Actions share one grammar, `name [args]`, used by keys, the palette, IPC and command stdout. Each
230action 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]
247desc = "Set as wallpaper"
248run = ["setwall", "-p", "{file}"]
249
250[commands.copy]
251desc = "Copy image"
252run = "wl-copy --type image/png < {file}" # string = sh -c, placeholders shell-quoted
253
254[commands.delete]
255desc = "Delete files"
256run = ["rm", "--", "{files}"] # array = exec directly, {files} → N args
257confirm = "Delete {count} file(s)?"
258
259[commands.legacy] # an old nsxiv key-handler works unchanged
260run = ["~/.config/nsxiv/exec/key-handler", "w"]
261stdin = "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 `{file}`, `{dir}`, `{name}`, `{stem}`, `{ext}`, `{index}`, `{width}` and `{height}` describe the
272 current image; `{files}` and `{count}` (and `REMAGE_COUNT`) describe the targets. Only these
273 names are substituted, so `awk '{print $1}'` survives. In an exec array, an element containing
274 `{files}` repeats once per target (`--in={files}` → `--in=a --in=b`), and a leading `~/` in the
275 template expands. In a shell string every value is shell-quoted, so don't quote placeholders.
276 A command that mentions file placeholders (or reads stdin) refuses to run with no targets.
277- **`targets`:** `auto` (marked files if any, otherwise the current one; the default), `current`,
278 `marked` or `all`.
279- **`stdin`:** `none`, `files` (newline-separated) or `files0` (NUL-separated).
280- **Environment:** `REMAGE_FILE`, `REMAGE_INDEX`, `REMAGE_COUNT`, `REMAGE_MODE`, `REMAGE_SOCKET`,
281 `REMAGE_PID`.
282- **`confirm`:** turns the command into an inline y/n prompt. remage has no modal dialogs. `y`
283 runs it and any other key (or `cancel`) drops it. The prompt shows even with the bar hidden.
284- **`output`:**
285 - `status` (default): the last stdout line goes to the status line.
286 - `actions`: every stdout line is run as an action.
287 - `ignore`.
288- **Running:** commands always run async. The status bar shows `[RUNNING WALLPAPER]`, then `[DONE]`
289 or `[ERROR: <code> <first stderr line>]`.
290- **`after`:** actions to run on success. The default is `refresh`, which re-stats the targets,
291 drops missing files and re-decodes changed ones. That way delete and rotate scripts work with no
292 extra setup. "Changed" means the mtime differs from the one recorded when the file was decoded
293 or thumbnailed. A bare `refresh` (key, IPC) re-checks the whole queue.
294- **Chains:** in a binding array, the actions after a `run` (and the command's `after`) wait for
295 it to exit successfully; a failure or a declined prompt drops the rest.
296- **Unbound commands** still appear in the `:` palette.
297
298**IPC socket.** It lives at `$XDG_RUNTIME_DIR/remage/<pid>.sock` and speaks newline-delimited JSON.
299- **Requests:**
300 - `{"action":"goto 5"}`
301 - `{"query":"current"|"marked"|"queue"|"state"}`
302 - `{"subscribe":["image","marks","mode"]}`, which turns the connection into an event stream.
303- **Replies:** `{"ok":true,"data":…}` or `{"ok":false,"error":"…"}`.
304- **Client:** `remage msg <action>`, `remage msg --query marked` and `remage msg --subscribe image`.
305 It targets `$REMAGE_SOCKET`, otherwise the newest instance; `--pid` picks one explicitly.
306 Strings and string lists print one per line, anything else as JSON; errors go to stderr with
307 exit 1. Subscriptions print one JSON event per line.
308- **Details:** `"action"` also takes an array. Events are `image` (`path`, 1-based `index`,
309 `count`), `marks` (`count`, `files`) and `mode`. The socket is 0600 in a 0700 directory,
310 sockets of dead pids are cleaned up at startup, and the socket is removed on exit once any
311 in-flight reply (to a `quit`) has gone out.
312- **Uses:** Hyprland binds, long-running watchers and other tools driving the viewer.
313
314### Config
315- **Location:** `$XDG_CONFIG_HOME/remage/config.toml`, falling back to `~/.config/remage/`. `-c`
316 overrides it.
317- **Defaults:** every option has a compiled-in default, and the user file deep-merges over them, so a
318 partial file is fine. `remage --dump-config` prints the full commented default.
319 `remage --check-config` validates the file and exits non-zero with `file:line:col` errors.
320 Unknown keys are errors and come with a "did you mean" suggestion.
321- **`include`:**
322 - Format: `include = ["~/.cache/matugen/remage.toml"]`. Paths expand `~` and `$VAR`, and relative
323 paths resolve against the config dir.
324 - Semantics follow alacritty's `import`. Includes load in order, and a missing include is a
325 warning, not an error. The including file loads last and wins.
326- **Live reload:** a notify watch on the config and every include, debounced to 100 ms. SIGUSR1 and
327 the `reload-config` action also reload. An invalid config keeps the last good one and shows
328 `[CONFIG ERROR: …]`. Theme changes crossfade over about 250 ms, so a wallpaper change recolours
329 smoothly.
330- **Sections:** `general`, `window`, `image`, `grid`, `ui`, `animation`, `theme`, `keys.<mode>`,
331 `commands.<name>` and `[[decoders]]`. Representative keys:
332
333```toml
334[general] open_siblings = true; recursive = false; sort = "natural"; start_mode = "image"; wrap = true; watch_dir = true
335[window] decorations = false; title = "{name} — remage"; class = "remage"; fullscreen = false
336[image] fit = "down"; zoom_step = 1.25; filter = "auto"; alpha_background = "dots"; auto_orient = true
337 color_management = true; preload = 2; cache_mb = 512; slideshow_delay = 5.0
338[grid] sizes = [96, 128, 160, 224, 320]; size = 160; gap = 16; thumbnail_cache = true; names = false
339[ui] statusbar = true; which_key_delay_ms = 300; scale = 1.0
340 statusbar_left = "{name}"; statusbar_right = "{format} {width}×{height} {zoom}% {index}/{count}"
341 info_command = "" # optional script whose output fills a status segment (nsxiv image-info)
342 font_ui = "Space Grotesk"; font_mono = "Space Mono"; font_display = "Doto"
343[animation] enabled = true; duration_ms = 180; transition = "fade"
344[theme] preset = "nothing-dark"
345```
346
347### Theming
348- **Tokens:** `background`, `foreground`, `surface`, `surface_raised`, `border`, `border_visible`,
349 `text_disabled`, `text_secondary`, `text_primary`, `text_display`, `accent`, `success`,
350 `warning` and `image_background` (defaults to `background`).
351- **Presets:** `nothing-dark` is the default (`#000000` / `#FFFFFF` / accent `#D71921`).
352 `nothing-light` is `#F5F5F5` / `#000000` / `#D71921`.
353- **Derivation:** only `background`, `foreground` and `accent` are needed. remage derives any token
354 that is missing by mixing fg over bg in sRGB:
355
356 | Token | fg share |
357 |---|---|
358 | `text_display` | 1.0 |
359 | `text_primary` | 0.91 |
360 | `text_secondary` | 0.60 |
361 | `text_disabled` | 0.40 |
362 | `border_visible` | 0.20 |
363 | `border` | 0.133 |
364 | `surface_raised` | 0.10 |
365 | `surface` | 0.067 |
366
367 These ratios reproduce the Nothing hex tokens exactly, and they give any wallpaper palette a
368 coherent four-level hierarchy.
369- **matugen:** ship `contrib/matugen/remage.toml`:
370 ```toml
371 [theme]
372 background = "{{colors.surface_container_lowest.default.hex}}"
373 foreground = "{{colors.on_surface.default.hex}}"
374 accent = "{{colors.primary.default.hex}}"
375 ```
376 Register it in `~/.config/matugen/config.toml` with
377 `[templates.remage] input_path = '~/.config/matugen/templates/remage.toml'` and
378 `output_path = '~/.cache/matugen/remage.toml'`. Then set
379 `include = ["~/.cache/matugen/remage.toml"]` in the remage config. The live reload picks up every
380 wallpaper change, so no `post_hook` is needed.
381- **pywal:** read it natively with `[theme] pywal = "~/.cache/wal/colors.json"`. That maps
382 `special.background` / `special.foreground` / `colors.color1` to background / foreground / accent.
383 A template is also provided in `contrib/pywal/`.
384
385### UI design language (Nothing)
386
387**Load the `nothing-design` skill before any UI work.** These are the remage-specific rules:
388- **Fonts:** embedded, all under the OFL; copy them from `~/.local/share/fonts/glyph/` along with
389 the licence.
390 - Space Grotesk for UI text.
391 - Space Mono for labels and data, in ALL CAPS with 0.08em tracking.
392 - Doto (dot-matrix) only at 36 px and above, for hero moments.
393
394 A screen uses at most 2 families, 3 sizes and 2 weights.
395- **The image is the hero.** There is no chrome except the optional status bar (`b`), which sits at
396 the bottom on a solid background with no border.
397 - **Left side:** the filename in Space Grotesk 14, `text_primary`.
398 - **Right side:** Space Mono caps 11, `text_secondary`, for example
399 `JPEG 4032×3024 72% 012/240`.
400 - **Marks:** an accent dot plus the count, shown only when the count is > 0.
401 - **Pending keys:** shown in `text_disabled`.
402 - **Messages:** inline (`[COPIED]`, `[ERROR: …]`). Never toasts.
403- **Accent red is a signal only:** marks, errors and the palette's selection bar. Nothing else on
404 screen is coloured.
405- **One surprise per screen.** A count prefix or `goto` shows as large Doto numerals in a corner.
406 The empty state is Doto `NO IMAGES` plus a single hint line.
407- **Overlays** (palette, which-key, info, help) use a `surface` background, a 1 px `border_visible`
408 border and a radius of 8–16, with no shadow or blur. The selected row is `surface_raised` with a
409 2 px accent bar on the left. Overlays anchor to an edge (bottom or right), never a centred modal.
410- **Grid:**
411 - Gaps are 16 px.
412 - The cursor is a 1 px `text_display` outline with a 4 px radius.
413 - A marked thumbnail gets an accent dot.
414 - A pending thumbnail is a single dim dot. No skeletons.
415- **Motion:** ease-out `cubic-bezier(0.25, 0.1, 0.25, 1)` over 150–250 ms. Fade rather than slide,
416 with no spring or bounce. Every animation can be retargeted from its current value, and
417 `animation.enabled = false` turns them all off.
418
419### Formats
420
421| Formats | Backend |
422|---|---|
423| JPEG, PNG/APNG, GIF, WebP (animated), BMP, ICO, TIFF (multi-page), TGA, PNM, QOI, DDS, HDR, OpenEXR | `image` (pure Rust) |
424| JPEG XL (incl. animation) | `jxl-oxide` (pure Rust) |
425| AVIF | `image` `avif-native` (system dav1d) |
426| HEIC / HEIF | `libheif-rs` (system libheif) |
427| SVG / SVGZ | `resvg`, re-rasterised on zoom |
428| Camera RAW (CR2/CR3/NEF/ARW/DNG/RAF/ORF/RW2…) | `rawler`: the embedded preview first (fast), with a full develop on demand |
429| Anything else (PSD, XCF, JP2, …) | `[[decoders]]` entries `{ match = ["*.psd"], run = ["magick", "{file}[0]", "png:-"] }`, which emit PNG/PPM/TIFF on stdout |
430
431Pipeline rules:
432- Detect the format by magic bytes first and the extension second.
433- Convert ICC and CICP colour to sRGB.
434- Tone-map 16-bit and float images (EXR/HDR) to 8-bit sRGB for display.
435- Apply EXIF orientation.
436
437### CLI
438
439```
440remage [OPTIONS] [PATHS]...
441 -r --recursive -i --stdin -0 --null -o --print-marked -t --grid
442 -n --start N -f --fullscreen -b --no-bar -S --slideshow [SECS]
443 -s --fit MODE -z --zoom PCT -g --geometry WxH -c --config PATH
444 -p --private --class NAME --sort KEY --reverse --no-siblings
445 --dump-config --check-config
446remage msg [--pid PID] [--query Q | --subscribe EV] [ACTION...]
447remage completions <SHELL>
448```
449
450### Default keys
451
452| Mode | Keys |
453|---|---|
454| 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 |
455| 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 |
456| 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 |
457
458## Milestones
459
460Each milestone should end with a usable binary.
461
462- **M0 Scaffold**
463 - `git init` and `cargo init`.
464 - `Cargo.toml` with the features and profiles above.
465 - Fonts + OFL in `assets/fonts`, and the module skeleton.
466 - fmt, clippy and test all clean.
467- **M1 Viewer core** ✓ (2026-11-04). First frame in ~90 ms on the iGPU, 0% CPU when idle.
468 - CLI, and a queue with siblings, natural sort and sniffing.
469 - winit + wgpu window, decoding through the `image` crate.
470 - Fit/zoom/pan/rotate/flip with animation, and `n`/`p`.
471 - Preload + cache, a status bar with text, and the `nothing-dark` theme with hard-coded keys.
472- **M2 Input + config** ✓ (2026-11-04). Unbuilt actions parse and bind, then say "not available yet".
473 - Action grammar, and a keymap trie with sequences, counts and mouse bindings.
474 - TOML defaults, include and live reload, plus `--dump-config` and `--check-config`.
475 - Theme tokens, presets and derivation; matugen + pywal support.
476- **M3 Grid + marks** ✓ (2026-11-04). Cached grid opens in ~85 ms, 0% CPU when idle.
477 - Freedesktop thumbnails, worker priorities and the GPU atlas.
478 - Grid navigation, visual mode, marks and smooth scrolling.
479- **M4 Commands + IPC** ✓ (2026-11-04). Tested end to end over the socket and with `wtype`.
480 - `[commands]` with async exec, confirm, output modes and `after`/`refresh`.
481 - The socket, `remage msg` and subscriptions; `-i`/`-o`/`-0`.
482- **M5 Formats + fidelity**
483 - JXL, AVIF, HEIF, SVG, RAW and external decoders.
484 - ICC colour, animation, multi-page, tiling + mipmaps, and HDR tone-mapping.
485- **M6 Polish**
486 - Palette, search, which-key, help and the info panel.
487 - Slideshow, gamma/brightness/contrast and the `nothing-light` preset.
488 - `watch_dir`, title and status-bar templates.
489 - A performance pass against the targets.
490- **M7 Packaging**
491 - Man page, zsh completion and `remage.desktop` with the MimeType list.
492 - README, `contrib/config.example.toml` (with the old key-handler's delete/wallpaper/copy
493 migrated) and a PKGBUILD.
494
495## Testing and verification
496- **Unit tests** cover the pure modules: key-notation parsing, the keymap trie and counts, Action
497 parse ↔ Display round-trips, queue operations (natural sort, marks, filter, refresh), view maths
498 (fit modes, zoom anchor, rotation), config merge, include precedence and theme derivation,
499 placeholder expansion and shell quoting, and thumbnail paths (URI → MD5) per the spec.
500- **Decoder tests** run against `tests/fixtures/`: dimensions and pixels for each format, EXIF
501 orientations 1–8, ICC-tagged images and animated frame counts.
502- **Visual checks without a display:** `REMAGE_SNAPSHOT=/tmp/x.png remage …` renders each settled
503 frame offscreen to a PNG as well. Use it to check the UI when the screen is locked or remote. In
504 the grid, a frame counts as settled once every visible thumbnail is in the atlas. Point
505 `XDG_CACHE_HOME` at a scratch directory to test cold thumbnail generation without touching the
506 real cache.
507- **Before every commit:** `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` and
508 `cargo test`.
509- **Manual smoke test:** run `cargo run --release -- /mnt/media/pix` and check that:
510 - `n`/`p` are instant.
511 - `<CR>` opens the grid.
512 - `m` marks and `<Space>w` runs a command.
513 - `remage msg next` works from another terminal.
514 - Changing the wallpaper recolours the window live.
515 - `RUST_LOG=remage=debug` logs startup and first-frame timings.
516 - CPU is idle while the view is static.
517
518## Conventions
519- Never put Claude session links or `Claude-Session:` trailers in commits or PRs. Commit only when
520 the user asks, with imperative, concise messages.
521- No `unwrap()`/`expect()` on paths that touch user input, files or the GPU surface. Errors go to
522 the status line.
523- `state/` must stay free of winit and wgpu types.
524- Adding an action takes all of these: an enum variant, a parser case, a description, an optional
525 default binding, an entry in `--dump-config` and a test.