Krolyxon
Commit dbc22bf

Branches

Tags

Refs →

remageCLAUDE.md

484 lines · 27.0 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- **Fast paths:** JPEG uses a DCT-scaled decode (`jpeg-decoder` `Decoder::scale`). RAW uses the
182 embedded preview. Then `fast_image_resize` finishes the job.
183- **Generation and storage:** visible thumbnails are generated first. On the GPU they live in atlas
184 pages with LRU slot eviction.
185- **Navigation:** `hjkl`, `gg`/`G`/`[count]G`, `<C-d>`/`<C-u>`, `<C-f>`/`<C-b>` and `0`/`$`.
186 Scrolling animates smoothly, and `+`/`-` steps through `grid.sizes`.
187
188### Marks
189- `m` toggles a mark, `M` inverts all marks and `u` unmarks everything.
190- `N`/`P` jump to the next/previous marked image.
191- Marks are the default target of user commands, and `-o` prints them on exit.
192
193### Input
194- **Key notation** follows vim: `n`, `G`, `<C-x>`, `<S-Tab>`, `<Space>`, `<BS>`, `<Esc>`, `<CR>`,
195 `<Left>`, `<F5>`. Sequences work too, such as `gg` and `<Space>w`.
196- **Mouse notation:** `<ScrollUp>`, `<C-ScrollUp>`, `<LeftMouse>`, `<RightMouse>`, `<MiddleMouse>`.
197 Drag-to-pan is built in.
198- **Tables:** `[keys.global]`, `[keys.image]`, `[keys.grid]` and `[keys.visual]`. A mode's table
199 overrides `global`.
200- **Binding values:** an action string, an array of actions (run in order, with `run` awaited before
201 the next one), or `"none"` to remove a default binding.
202- **Counts:** a digit prefix becomes the count (`5n`, `12G`, `3+`). `0` acts as a motion when no
203 count is pending. A pending prefix or count shows in the status bar.
204
205### Actions
206
207Actions share one grammar, `name [args]`, used by keys, the palette, IPC and command stdout. Each
208action carries a one-line description, which feeds the palette, which-key and help.
209
210| Group | Actions |
211|---|---|
212| Navigation | `next [n]`, `prev [n]`, `first`, `last`, `goto N`, `random`, `next-mark`, `prev-mark`, `next-frame`, `prev-frame` |
213| Marks | `mark [toggle\|on\|off]`, `mark-all`, `unmark-all`, `invert-marks` |
214| Grid | `move <dir> [n]`, `page down\|up\|half-down\|half-up`, `row-start`, `row-end`, `scroll <dir> [n]` (pans a step in image mode) |
215| 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` |
216| Playback | `play toggle`, `slideshow toggle`, `slideshow-delay ±N` |
217| UI | `mode image\|grid\|toggle`, `visual`, `fullscreen`, `bar toggle`, `info toggle`, `help`, `palette`, `search`, `grid-size +\|-` |
218| Queue | `sort <key>`, `reverse`, `filter <pattern>`, `filter-clear`, `remove`, `reload`, `refresh`, `open <path>` |
219| App | `run <command>`, `reload-config`, `echo <msg>`, `quit`, `quit-no-output` |
220
221### Commands and scripting (replaces nsxiv's key-handler)
222
223```toml
224[commands.wallpaper]
225desc = "Set as wallpaper"
226run = ["setwall", "-p", "{file}"]
227
228[commands.copy]
229desc = "Copy image"
230run = "wl-copy --type image/png < {file}" # string = sh -c, placeholders shell-quoted
231
232[commands.delete]
233desc = "Delete files"
234run = ["rm", "--", "{files}"] # array = exec directly, {files} → N args
235confirm = "Delete {count} file(s)?"
236
237[commands.legacy] # an old nsxiv key-handler works unchanged
238run = ["~/.config/nsxiv/exec/key-handler", "w"]
239stdin = "files"
240
241[keys.global]
242"<Space>w" = "run wallpaper"
243"<Space>c" = "run copy"
244"<Space>d" = "run delete"
245```
246
247**Command fields:**
248- **Placeholders:** `{file} {files} {dir} {name} {stem} {ext} {index} {count} {width} {height}`.
249- **`targets`:** `auto` (marked files if any, otherwise the current one; the default), `current`,
250 `marked` or `all`.
251- **`stdin`:** `none`, `files` (newline-separated) or `files0` (NUL-separated).
252- **Environment:** `REMAGE_FILE`, `REMAGE_INDEX`, `REMAGE_COUNT`, `REMAGE_MODE`, `REMAGE_SOCKET`,
253 `REMAGE_PID`.
254- **`confirm`:** turns the command into an inline y/n prompt. remage has no modal dialogs.
255- **`output`:**
256 - `status` (default): the last stdout line goes to the status line.
257 - `actions`: every stdout line is run as an action.
258 - `ignore`.
259- **Running:** commands always run async. The status bar shows `[RUNNING WALLPAPER]`, then `[DONE]`
260 or `[ERROR: <code> <first stderr line>]`.
261- **`after`:** actions to run on success. The default is `refresh`, which re-stats the targets,
262 drops missing files and re-decodes changed ones. That way delete and rotate scripts work with no
263 extra setup.
264- **Unbound commands** still appear in the `:` palette.
265
266**IPC socket.** It lives at `$XDG_RUNTIME_DIR/remage/<pid>.sock` and speaks newline-delimited JSON.
267- **Requests:**
268 - `{"action":"goto 5"}`
269 - `{"query":"current"|"marked"|"queue"|"state"}`
270 - `{"subscribe":["image","marks","mode"]}`, which turns the connection into an event stream.
271- **Replies:** `{"ok":true,"data":…}` or `{"ok":false,"error":"…"}`.
272- **Client:** `remage msg <action>`, `remage msg --query marked` and `remage msg --subscribe image`.
273 It targets `$REMAGE_SOCKET`, otherwise the newest instance; `--pid` picks one explicitly.
274- **Uses:** Hyprland binds, long-running watchers and other tools driving the viewer.
275
276### Config
277- **Location:** `$XDG_CONFIG_HOME/remage/config.toml`, falling back to `~/.config/remage/`. `-c`
278 overrides it.
279- **Defaults:** every option has a compiled-in default, and the user file deep-merges over them, so a
280 partial file is fine. `remage --dump-config` prints the full commented default.
281 `remage --check-config` validates the file and exits non-zero with `file:line:col` errors.
282 Unknown keys are errors and come with a "did you mean" suggestion.
283- **`include`:**
284 - Format: `include = ["~/.cache/matugen/remage.toml"]`. Paths expand `~` and `$VAR`, and relative
285 paths resolve against the config dir.
286 - Semantics follow alacritty's `import`. Includes load in order, and a missing include is a
287 warning, not an error. The including file loads last and wins.
288- **Live reload:** a notify watch on the config and every include, debounced to 100 ms. SIGUSR1 and
289 the `reload-config` action also reload. An invalid config keeps the last good one and shows
290 `[CONFIG ERROR: …]`. Theme changes crossfade over about 250 ms, so a wallpaper change recolours
291 smoothly.
292- **Sections:** `general`, `window`, `image`, `grid`, `ui`, `animation`, `theme`, `keys.<mode>`,
293 `commands.<name>` and `[[decoders]]`. Representative keys:
294
295```toml
296[general] open_siblings = true; recursive = false; sort = "natural"; start_mode = "image"; wrap = true; watch_dir = true
297[window] decorations = false; title = "{name} — remage"; class = "remage"; fullscreen = false
298[image] fit = "down"; zoom_step = 1.25; filter = "auto"; alpha_background = "dots"; auto_orient = true
299 color_management = true; preload = 2; cache_mb = 512; slideshow_delay = 5.0
300[grid] sizes = [96, 128, 160, 224, 320]; size = 160; gap = 16; thumbnail_cache = true; names = false
301[ui] statusbar = true; which_key_delay_ms = 300; scale = 1.0
302 statusbar_left = "{name}"; statusbar_right = "{format} {width}×{height} {zoom}% {index}/{count}"
303 info_command = "" # optional script whose output fills a status segment (nsxiv image-info)
304 font_ui = "Space Grotesk"; font_mono = "Space Mono"; font_display = "Doto"
305[animation] enabled = true; duration_ms = 180; transition = "fade"
306[theme] preset = "nothing-dark"
307```
308
309### Theming
310- **Tokens:** `background`, `foreground`, `surface`, `surface_raised`, `border`, `border_visible`,
311 `text_disabled`, `text_secondary`, `text_primary`, `text_display`, `accent`, `success`,
312 `warning` and `image_background` (defaults to `background`).
313- **Presets:** `nothing-dark` is the default (`#000000` / `#FFFFFF` / accent `#D71921`).
314 `nothing-light` is `#F5F5F5` / `#000000` / `#D71921`.
315- **Derivation:** only `background`, `foreground` and `accent` are needed. remage derives any token
316 that is missing by mixing fg over bg in sRGB:
317
318 | Token | fg share |
319 |---|---|
320 | `text_display` | 1.0 |
321 | `text_primary` | 0.91 |
322 | `text_secondary` | 0.60 |
323 | `text_disabled` | 0.40 |
324 | `border_visible` | 0.20 |
325 | `border` | 0.133 |
326 | `surface_raised` | 0.10 |
327 | `surface` | 0.067 |
328
329 These ratios reproduce the Nothing hex tokens exactly, and they give any wallpaper palette a
330 coherent four-level hierarchy.
331- **matugen:** ship `contrib/matugen/remage.toml`:
332 ```toml
333 [theme]
334 background = "{{colors.surface_container_lowest.default.hex}}"
335 foreground = "{{colors.on_surface.default.hex}}"
336 accent = "{{colors.primary.default.hex}}"
337 ```
338 Register it in `~/.config/matugen/config.toml` with
339 `[templates.remage] input_path = '~/.config/matugen/templates/remage.toml'` and
340 `output_path = '~/.cache/matugen/remage.toml'`. Then set
341 `include = ["~/.cache/matugen/remage.toml"]` in the remage config. The live reload picks up every
342 wallpaper change, so no `post_hook` is needed.
343- **pywal:** read it natively with `[theme] pywal = "~/.cache/wal/colors.json"`. That maps
344 `special.background` / `special.foreground` / `colors.color1` to background / foreground / accent.
345 A template is also provided in `contrib/pywal/`.
346
347### UI design language (Nothing)
348
349**Load the `nothing-design` skill before any UI work.** These are the remage-specific rules:
350- **Fonts:** embedded, all under the OFL; copy them from `~/.local/share/fonts/glyph/` along with
351 the licence.
352 - Space Grotesk for UI text.
353 - Space Mono for labels and data, in ALL CAPS with 0.08em tracking.
354 - Doto (dot-matrix) only at 36 px and above, for hero moments.
355
356 A screen uses at most 2 families, 3 sizes and 2 weights.
357- **The image is the hero.** There is no chrome except the optional status bar (`b`), which sits at
358 the bottom on a solid background with no border.
359 - **Left side:** the filename in Space Grotesk 14, `text_primary`.
360 - **Right side:** Space Mono caps 11, `text_secondary`, for example
361 `JPEG 4032×3024 72% 012/240`.
362 - **Marks:** an accent dot plus the count, shown only when the count is > 0.
363 - **Pending keys:** shown in `text_disabled`.
364 - **Messages:** inline (`[COPIED]`, `[ERROR: …]`). Never toasts.
365- **Accent red is a signal only:** marks, errors and the palette's selection bar. Nothing else on
366 screen is coloured.
367- **One surprise per screen.** A count prefix or `goto` shows as large Doto numerals in a corner.
368 The empty state is Doto `NO IMAGES` plus a single hint line.
369- **Overlays** (palette, which-key, info, help) use a `surface` background, a 1 px `border_visible`
370 border and a radius of 8–16, with no shadow or blur. The selected row is `surface_raised` with a
371 2 px accent bar on the left. Overlays anchor to an edge (bottom or right), never a centred modal.
372- **Grid:**
373 - Gaps are 16 px.
374 - The cursor is a 1 px `text_display` outline with a 4 px radius.
375 - A marked thumbnail gets an accent dot.
376 - A pending thumbnail is a single dim dot. No skeletons.
377- **Motion:** ease-out `cubic-bezier(0.25, 0.1, 0.25, 1)` over 150–250 ms. Fade rather than slide,
378 with no spring or bounce. Every animation can be retargeted from its current value, and
379 `animation.enabled = false` turns them all off.
380
381### Formats
382
383| Formats | Backend |
384|---|---|
385| JPEG, PNG/APNG, GIF, WebP (animated), BMP, ICO, TIFF (multi-page), TGA, PNM, QOI, DDS, HDR, OpenEXR | `image` (pure Rust) |
386| JPEG XL (incl. animation) | `jxl-oxide` (pure Rust) |
387| AVIF | `image` `avif-native` (system dav1d) |
388| HEIC / HEIF | `libheif-rs` (system libheif) |
389| SVG / SVGZ | `resvg`, re-rasterised on zoom |
390| Camera RAW (CR2/CR3/NEF/ARW/DNG/RAF/ORF/RW2…) | `rawler`: the embedded preview first (fast), with a full develop on demand |
391| Anything else (PSD, XCF, JP2, …) | `[[decoders]]` entries `{ match = ["*.psd"], run = ["magick", "{file}[0]", "png:-"] }`, which emit PNG/PPM/TIFF on stdout |
392
393Pipeline rules:
394- Detect the format by magic bytes first and the extension second.
395- Convert ICC and CICP colour to sRGB.
396- Tone-map 16-bit and float images (EXR/HDR) to 8-bit sRGB for display.
397- Apply EXIF orientation.
398
399### CLI
400
401```
402remage [OPTIONS] [PATHS]...
403 -r --recursive -i --stdin -0 --null -o --print-marked -t --grid
404 -n --start N -f --fullscreen -b --no-bar -S --slideshow [SECS]
405 -s --fit MODE -z --zoom PCT -g --geometry WxH -c --config PATH
406 -p --private --class NAME --sort KEY --reverse --no-siblings
407 --dump-config --check-config
408remage msg [--pid PID] [--query Q | --subscribe EV] [ACTION...]
409remage completions <SHELL>
410```
411
412### Default keys
413
414| Mode | Keys |
415|---|---|
416| 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 |
417| 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 |
418| 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 |
419
420## Milestones
421
422Each milestone should end with a usable binary.
423
424- **M0 Scaffold**
425 - `git init` and `cargo init`.
426 - `Cargo.toml` with the features and profiles above.
427 - Fonts + OFL in `assets/fonts`, and the module skeleton.
428 - fmt, clippy and test all clean.
429- **M1 Viewer core** ✓ (2026-11-04). First frame in ~90 ms on the iGPU, 0% CPU when idle.
430 - CLI, and a queue with siblings, natural sort and sniffing.
431 - winit + wgpu window, decoding through the `image` crate.
432 - Fit/zoom/pan/rotate/flip with animation, and `n`/`p`.
433 - Preload + cache, a status bar with text, and the `nothing-dark` theme with hard-coded keys.
434- **M2 Input + config** ✓ (2026-11-04). Unbuilt actions parse and bind, then say "not available yet".
435 - Action grammar, and a keymap trie with sequences, counts and mouse bindings.
436 - TOML defaults, include and live reload, plus `--dump-config` and `--check-config`.
437 - Theme tokens, presets and derivation; matugen + pywal support.
438- **M3 Grid + marks** ✓ (2026-11-04). Cached grid opens in ~85 ms, 0% CPU when idle.
439 - Freedesktop thumbnails, worker priorities and the GPU atlas.
440 - Grid navigation, visual mode, marks and smooth scrolling.
441- **M4 Commands + IPC**
442 - `[commands]` with async exec, confirm, output modes and `after`/`refresh`.
443 - The socket, `remage msg` and subscriptions; `-i`/`-o`/`-0`.
444- **M5 Formats + fidelity**
445 - JXL, AVIF, HEIF, SVG, RAW and external decoders.
446 - ICC colour, animation, multi-page, tiling + mipmaps, and HDR tone-mapping.
447- **M6 Polish**
448 - Palette, search, which-key, help and the info panel.
449 - Slideshow, gamma/brightness/contrast and the `nothing-light` preset.
450 - `watch_dir`, title and status-bar templates.
451 - A performance pass against the targets.
452- **M7 Packaging**
453 - Man page, zsh completion and `remage.desktop` with the MimeType list.
454 - README, `contrib/config.example.toml` (with the old key-handler's delete/wallpaper/copy
455 migrated) and a PKGBUILD.
456
457## Testing and verification
458- **Unit tests** cover the pure modules: key-notation parsing, the keymap trie and counts, Action
459 parse ↔ Display round-trips, queue operations (natural sort, marks, filter, refresh), view maths
460 (fit modes, zoom anchor, rotation), config merge, include precedence and theme derivation,
461 placeholder expansion and shell quoting, and thumbnail paths (URI → MD5) per the spec.
462- **Decoder tests** run against `tests/fixtures/`: dimensions and pixels for each format, EXIF
463 orientations 1–8, ICC-tagged images and animated frame counts.
464- **Visual checks without a display:** `REMAGE_SNAPSHOT=/tmp/x.png remage …` renders each settled
465 frame offscreen to a PNG as well. Use it to check the UI when the screen is locked or remote.
466- **Before every commit:** `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` and
467 `cargo test`.
468- **Manual smoke test:** run `cargo run --release -- /mnt/media/pix` and check that:
469 - `n`/`p` are instant.
470 - `<CR>` opens the grid.
471 - `m` marks and `<Space>w` runs a command.
472 - `remage msg next` works from another terminal.
473 - Changing the wallpaper recolours the window live.
474 - `RUST_LOG=remage=debug` logs startup and first-frame timings.
475 - CPU is idle while the view is static.
476
477## Conventions
478- Never put Claude session links or `Claude-Session:` trailers in commits or PRs. Commit only when
479 the user asks, with imperative, concise messages.
480- No `unwrap()`/`expect()` on paths that touch user input, files or the GPU surface. Errors go to
481 the status line.
482- `state/` must stay free of winit and wgpu types.
483- Adding an action takes all of these: an enum variant, a parser case, a description, an optional
484 default binding, an entry in `--dump-config` and a test.