Krolyxon
Commit f2bd31a

Branches

Tags

Refs →

remagef2bd31a

Add viewer core: queue, GPU rendering, status bar

Scaffold the crate (M0) and implement M1: CLI and file scanning with
natural sort and background sniffing, a priority decode pool with epoch
cancellation, a decoded-image LRU with GPU preloading, animated
fit/zoom/pan/rotate/flip, crossfades, and a Nothing-styled status bar
rendered with wgpu and glyphon.
Author
krolyxon
Date
2026-11-04 20:01 +0530
Parents
None
Commit
f2bd31a3374312309846015f4ff3448e970b7987

+9997 −0

43 files changed

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

[Diff too large] View file

A Cargo.toml View
@@ -0,0 +1,53 @@
1+[package]
2+name = "remage"
3+version = "0.1.0"
4+edition = "2024"
5+description = "Keyboard-centric image viewer with a Nothing-inspired look"
6+rust-version = "1.96"
7+
8+[dependencies]
9+anyhow = "1.0.104"
10+bytemuck = { version = "1.25.2", features = ["derive"] }
11+clap = { version = "4.6.7", features = ["derive"] }
12+clap_complete = "4.6.11"
13+crossbeam-channel = "0.5.17"
14+dirs = "7.0.0"
15+env_logger = "0.11.11"
16+fast_image_resize = "6.1.0"
17+glyphon = "0.12.0"
18+image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "tiff", "bmp", "ico", "tga", "pnm", "qoi", "dds", "hdr", "exr"] }
19+jxl-oxide = { version = "0.12.6", optional = true }
20+kamadak-exif = "0.6.1"
21+libheif-rs = { version = "3.0.0", optional = true }
22+log = "0.4.35"
23+md-5 = "0.11.0"
24+moxcms = "0.9.1"
25+notify = "8.2.1"
26+nucleo-matcher = "0.3.1"
27+png = "0.18.1"
28+pollster = "1.0.1"
29+rawler = { version = "0.8.0", optional = true }
30+resvg = { version = "0.49.0", optional = true }
31+serde = { version = "1.0.229", features = ["derive"] }
32+serde_json = "1.0.154"
33+signal-hook = "0.4.6"
34+thiserror = "2.0.21"
35+toml = "1.2.0"
36+wgpu = "30"
37+winit = "0.30"
38+
39+[features]
40+default = ["jxl", "svg", "avif", "heif", "raw"]
41+jxl = ["dep:jxl-oxide"]
42+svg = ["dep:resvg"]
43+avif = ["image/avif-native"] # system dav1d
44+heif = ["dep:libheif-rs"] # system libheif
45+raw = ["dep:rawler"]
46+
47+[profile.release]
48+lto = "thin"
49+codegen-units = 1
50+
51+# Decoders are unusably slow unoptimised; keep our own crate fast to rebuild.
52+[profile.dev.package."*"]
53+opt-level = 3
A assets/fonts/Doto-Variable.ttf View

[Binary file]

A assets/fonts/OFL.txt View
@@ -0,0 +1,95 @@
1+Copyright 2024 The Doto Project Authors (https://github.com/oliverlalan/Doto)
2+Copyright 2016 The Space Mono Project Authors (https://github.com/googlefonts/spacemono)
3+Copyright 2016 Google Inc. & Colophon Foundry. Copyright 2017 Florian Karsten (https://github.com/floriankarsten/space-grotesk)
4+
5+This Font Software is licensed under the SIL Open Font License, Version 1.1.
6+This license is copied below, and is also available with a FAQ at:
7+https://openfontlicense.org
8+
9+
10+-----------------------------------------------------------
11+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
12+-----------------------------------------------------------
13+
14+PREAMBLE
15+The goals of the Open Font License (OFL) are to stimulate worldwide
16+development of collaborative font projects, to support the font creation
17+efforts of academic and linguistic communities, and to provide a free and
18+open framework in which fonts may be shared and improved in partnership
19+with others.
20+
21+The OFL allows the licensed fonts to be used, studied, modified and
22+redistributed freely as long as they are not sold by themselves. The
23+fonts, including any derivative works, can be bundled, embedded,
24+redistributed and/or sold with any software provided that any reserved
25+names are not used by derivative works. The fonts and derivatives,
26+however, cannot be released under any other type of license. The
27+requirement for fonts to remain under this license does not apply
28+to any document created using the fonts or their derivatives.
29+
30+DEFINITIONS
31+"Font Software" refers to the set of files released by the Copyright
32+Holder(s) under this license and clearly marked as such. This may
33+include source files, build scripts and documentation.
34+
35+"Reserved Font Name" refers to any names specified as such after the
36+copyright statement(s).
37+
38+"Original Version" refers to the collection of Font Software components as
39+distributed by the Copyright Holder(s).
40+
41+"Modified Version" refers to any derivative made by adding to, deleting,
42+or substituting -- in part or in whole -- any of the components of the
43+Original Version, by changing formats or by porting the Font Software to a
44+new environment.
45+
46+"Author" refers to any designer, engineer, programmer, technical
47+writer or other person who contributed to the Font Software.
48+
49+PERMISSION & CONDITIONS
50+Permission is hereby granted, free of charge, to any person obtaining
51+a copy of the Font Software, to use, study, copy, merge, embed, modify,
52+redistribute, and sell modified and unmodified copies of the Font
53+Software, subject to the following conditions:
54+
55+1) Neither the Font Software nor any of its individual components,
56+in Original or Modified Versions, may be sold by itself.
57+
58+2) Original or Modified Versions of the Font Software may be bundled,
59+redistributed and/or sold with any software, provided that each copy
60+contains the above copyright notice and this license. These can be
61+included either as stand-alone text files, human-readable headers or
62+in the appropriate machine-readable metadata fields within text or
63+binary files as long as those fields can be easily viewed by the user.
64+
65+3) No Modified Version of the Font Software may use the Reserved Font
66+Name(s) unless explicit written permission is granted by the corresponding
67+Copyright Holder. This restriction only applies to the primary font name as
68+presented to the users.
69+
70+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
71+Software shall not be used to promote, endorse or advertise any
72+Modified Version, except to acknowledge the contribution(s) of the
73+Copyright Holder(s) and the Author(s) or with their explicit written
74+permission.
75+
76+5) The Font Software, modified or unmodified, in part or in whole,
77+must be distributed entirely under this license, and must not be
78+distributed under any other license. The requirement for fonts to
79+remain under this license does not apply to any document created
80+using the Font Software.
81+
82+TERMINATION
83+This license becomes null and void if any of the above conditions are
84+not met.
85+
86+DISCLAIMER
87+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
88+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
89+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
90+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
91+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
92+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
93+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
94+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
95+OTHER DEALINGS IN THE FONT SOFTWARE.
A assets/fonts/SpaceGrotesk-Light.otf View

[Binary file]

A assets/fonts/SpaceGrotesk-Medium.otf View

[Binary file]

A assets/fonts/SpaceGrotesk-Regular.otf View

[Binary file]

A assets/fonts/SpaceMono-Bold.ttf View

[Binary file]

A assets/fonts/SpaceMono-Regular.ttf View

[Binary file]

A contrib/.gitkeep View

[No content changes]

A src/action.rs View
@@ -0,0 +1,39 @@
1+//! Every user-facing operation. Keys produce these today; the palette, IPC and command
2+//! output will share the same grammar (parser and `Display` arrive with the keymap in M2).
3+
4+use crate::state::view::{Axis, Dir, FitMode};
5+
6+#[derive(Clone, Copy, Debug, PartialEq)]
7+pub enum Zoom {
8+ In,
9+ Out,
10+ /// Absolute zoom, 100 = one image pixel per screen pixel.
11+ Percent(f32),
12+}
13+
14+#[derive(Clone, Copy, Debug, PartialEq)]
15+pub enum Action {
16+ Next(usize),
17+ Prev(usize),
18+ First,
19+ Last,
20+ /// 1-based, like the status bar.
21+ Goto(usize),
22+ Zoom(Zoom),
23+ Fit(FitMode),
24+ /// From a key this pans for as long as the key is held; otherwise one step.
25+ Pan(Dir),
26+ PanEdge(Dir),
27+ Center,
28+ /// Quarter turns, positive = clockwise.
29+ Rotate(i32),
30+ Flip(Axis),
31+ FilterToggle,
32+ AlphaBgCycle,
33+ Fullscreen,
34+ BarToggle,
35+ Reload,
36+ Remove,
37+ Cancel,
38+ Quit,
39+}
A src/anim.rs View
@@ -0,0 +1,155 @@
1+//! Retargetable ease-out tweens; callers redraw only while one is active.
2+
3+use std::time::{Duration, Instant};
4+
5+/// Values a [`Tween`] can interpolate.
6+pub trait Lerp: Copy {
7+ fn lerp(a: Self, b: Self, t: f32) -> Self;
8+}
9+
10+impl Lerp for f32 {
11+ fn lerp(a: Self, b: Self, t: f32) -> Self {
12+ a + (b - a) * t
13+ }
14+}
15+
16+impl Lerp for [f32; 2] {
17+ fn lerp(a: Self, b: Self, t: f32) -> Self {
18+ [f32::lerp(a[0], b[0], t), f32::lerp(a[1], b[1], t)]
19+ }
20+}
21+
22+/// The Nothing ease-out curve, `cubic-bezier(0.25, 0.1, 0.25, 1)`.
23+pub fn ease(t: f32) -> f32 {
24+ const X1: f32 = 0.25;
25+ const Y1: f32 = 0.1;
26+ const X2: f32 = 0.25;
27+ const Y2: f32 = 1.0;
28+ let t = t.clamp(0.0, 1.0);
29+ let bez = |a: f32, b: f32, s: f32| {
30+ let inv = 1.0 - s;
31+ 3.0 * inv * inv * s * a + 3.0 * inv * s * s * b + s * s * s
32+ };
33+ // Solve x(s) = t for s with Newton steps, falling back to bisection.
34+ let mut s = t;
35+ for _ in 0..8 {
36+ let x = bez(X1, X2, s) - t;
37+ if x.abs() < 1e-5 {
38+ return bez(Y1, Y2, s);
39+ }
40+ let inv = 1.0 - s;
41+ let dx = 3.0 * inv * inv * X1 + 6.0 * inv * s * (X2 - X1) + 3.0 * s * s * (1.0 - X2);
42+ if dx.abs() < 1e-6 {
43+ break;
44+ }
45+ s = (s - x / dx).clamp(0.0, 1.0);
46+ }
47+ let (mut lo, mut hi) = (0.0, 1.0);
48+ for _ in 0..24 {
49+ s = (lo + hi) * 0.5;
50+ if bez(X1, X2, s) < t {
51+ lo = s;
52+ } else {
53+ hi = s;
54+ }
55+ }
56+ bez(Y1, Y2, s)
57+}
58+
59+#[derive(Debug, Clone, Copy)]
60+pub struct Tween<T: Lerp> {
61+ from: T,
62+ to: T,
63+ start: Instant,
64+ dur: Duration,
65+}
66+
67+impl<T: Lerp> Tween<T> {
68+ pub fn new(value: T, now: Instant) -> Self {
69+ Self {
70+ from: value,
71+ to: value,
72+ start: now,
73+ dur: Duration::ZERO,
74+ }
75+ }
76+
77+ pub fn value(&self, now: Instant) -> T {
78+ if self.dur.is_zero() {
79+ return self.to;
80+ }
81+ let t = now.saturating_duration_since(self.start).as_secs_f32() / self.dur.as_secs_f32();
82+ if t >= 1.0 {
83+ self.to
84+ } else {
85+ T::lerp(self.from, self.to, ease(t))
86+ }
87+ }
88+
89+ pub fn is_active(&self, now: Instant) -> bool {
90+ !self.dur.is_zero() && now < self.start + self.dur
91+ }
92+
93+ /// Animate from the current value to `to`; a zero duration jumps.
94+ pub fn retarget(&mut self, to: T, now: Instant, dur: Duration) {
95+ self.from = self.value(now);
96+ self.to = to;
97+ self.start = now;
98+ self.dur = dur;
99+ }
100+
101+ /// Animate between explicit endpoints.
102+ pub fn animate(&mut self, from: T, to: T, now: Instant, dur: Duration) {
103+ self.from = from;
104+ self.to = to;
105+ self.start = now;
106+ self.dur = dur;
107+ }
108+
109+ pub fn set(&mut self, value: T) {
110+ self.from = value;
111+ self.to = value;
112+ self.dur = Duration::ZERO;
113+ }
114+}
115+
116+impl Tween<[f32; 2]> {
117+ /// Move both endpoints, keeping any animation in flight.
118+ pub fn shift(&mut self, d: [f32; 2]) {
119+ self.from = [self.from[0] + d[0], self.from[1] + d[1]];
120+ self.to = [self.to[0] + d[0], self.to[1] + d[1]];
121+ }
122+}
123+
124+#[cfg(test)]
125+mod tests {
126+ use super::*;
127+
128+ #[test]
129+ fn ease_endpoints_and_monotonic() {
130+ assert!(ease(0.0).abs() < 1e-4);
131+ assert!((ease(1.0) - 1.0).abs() < 1e-4);
132+ let mut last = 0.0;
133+ for i in 1..=100 {
134+ let v = ease(i as f32 / 100.0);
135+ assert!(v >= last - 1e-5);
136+ last = v;
137+ }
138+ // CSS `ease` is well past linear at the midpoint.
139+ assert!(ease(0.5) > 0.75);
140+ }
141+
142+ #[test]
143+ fn retarget_starts_from_current_value() {
144+ let t0 = Instant::now();
145+ let mut tw = Tween::new(0.0f32, t0);
146+ tw.retarget(10.0, t0, Duration::from_millis(100));
147+ let mid = t0 + Duration::from_millis(50);
148+ let v = tw.value(mid);
149+ assert!(v > 0.0 && v < 10.0);
150+ tw.retarget(0.0, mid, Duration::from_millis(100));
151+ assert!((tw.value(mid) - v).abs() < 1e-5);
152+ assert_eq!(tw.value(mid + Duration::from_millis(200)), 0.0);
153+ assert!(!tw.is_active(mid + Duration::from_millis(200)));
154+ }
155+}
A src/app.rs View
@@ -0,0 +1,568 @@
1+//! winit `ApplicationHandler`: routes events, owns the state and the renderer.
2+
3+use std::collections::{HashMap, HashSet, VecDeque};
4+use std::path::{Path, PathBuf};
5+use std::sync::atomic::{AtomicU32, Ordering};
6+use std::sync::{Arc, Mutex};
7+use std::time::{Duration, Instant};
8+
9+use winit::application::ApplicationHandler;
10+use winit::dpi::LogicalSize;
11+use winit::event::{ElementState, MouseButton, MouseScrollDelta, StartCause, WindowEvent};
12+use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy};
13+use winit::keyboard::{ModifiersState, PhysicalKey};
14+use winit::window::{Fullscreen, Window, WindowId};
15+
16+use crate::action::Action;
17+use crate::cache::ImageCache;
18+use crate::config::theme::Theme;
19+use crate::decode::{self, DecodedImage};
20+use crate::input::{self, Input};
21+use crate::render::{Renderer, text};
22+use crate::state::view::Dir;
23+use crate::state::{AppState, Effects, MsgKind};
24+use crate::ui;
25+use crate::worker::{JobKey, Pool, Priority};
26+
27+/// GPU upload budget per event-loop turn, so preloads never cause a hitch.
28+const UPLOAD_BUDGET: usize = 32 << 20;
29+/// Show the window once the first image is ready, or after this long regardless.
30+const SHOW_TIMEOUT: Duration = Duration::from_millis(300);
31+/// Wheel pixels per zoom step for touchpads.
32+const PIXELS_PER_STEP: f32 = 60.0;
33+
34+pub enum UserEvent {
35+ Decoded {
36+ path: PathBuf,
37+ result: Result<Arc<DecodedImage>, String>,
38+ },
39+ /// Unknown-extension files that turned out to be images.
40+ Found(Vec<PathBuf>),
41+ /// Embedded plus system fonts, loaded off-thread after the first frame.
42+ Fonts(glyphon::fontdb::Database),
43+}
44+
45+pub struct Options {
46+ pub fullscreen: bool,
47+ pub geometry: Option<(u32, u32)>,
48+ pub class: String,
49+ pub cache_mb: usize,
50+}
51+
52+struct Gfx {
53+ renderer: Renderer,
54+ // Dropped after the renderer, whose surface borrows the window.
55+ window: Arc<Window>,
56+}
57+
58+pub struct App {
59+ opts: Options,
60+ state: AppState,
61+ theme: Theme,
62+ input: Input,
63+ pool: Pool,
64+ proxy: EventLoopProxy<UserEvent>,
65+ cache: ImageCache,
66+ max_dim: Arc<AtomicU32>,
67+ /// Decoded images whose result event is still in the loop's queue.
68+ arriving: Arc<Mutex<HashSet<PathBuf>>>,
69+ gfx: Option<Gfx>,
70+ uploads: VecDeque<(PathBuf, Arc<DecodedImage>)>,
71+ modifiers: ModifiersState,
72+ cursor: [f32; 2],
73+ dragging: bool,
74+ held: HashMap<PhysicalKey, Dir>,
75+ started: Instant,
76+ shown_window: bool,
77+ pub error: Option<anyhow::Error>,
78+}
79+
80+impl App {
81+ pub fn new(opts: Options, state: AppState, proxy: EventLoopProxy<UserEvent>) -> Self {
82+ let cache = ImageCache::new(opts.cache_mb << 20);
83+ let mut app = Self {
84+ opts,
85+ state,
86+ theme: Theme::nothing_dark(),
87+ input: Input::default(),
88+ pool: Pool::new(),
89+ proxy,
90+ cache,
91+ // Until the GPU reports its limit; uploads re-check.
92+ max_dim: Arc::new(AtomicU32::new(16384)),
93+ arriving: Arc::default(),
94+ gfx: None,
95+ uploads: VecDeque::new(),
96+ modifiers: ModifiersState::empty(),
97+ cursor: [0.0, 0.0],
98+ dragging: false,
99+ held: HashMap::new(),
100+ started: Instant::now(),
101+ shown_window: false,
102+ error: None,
103+ };
104+ // Decode the first image while the window and GPU come up.
105+ app.sync(true);
106+ app
107+ }
108+
109+ fn request_redraw(&self) {
110+ if let Some(g) = &self.gfx {
111+ g.window.request_redraw();
112+ }
113+ }
114+
115+ fn decode(&self, path: &Path, prio: Priority) {
116+ if lock(&self.arriving).contains(path) {
117+ return;
118+ }
119+ let proxy = self.proxy.clone();
120+ let max_dim = self.max_dim.clone();
121+ let arriving = self.arriving.clone();
122+ let path = path.to_path_buf();
123+ self.pool
124+ .submit(JobKey::Decode(path.clone()), prio, move || {
125+ let t = Instant::now();
126+ let result = decode::decode(&path, max_dim.load(Ordering::Relaxed))
127+ .map(Arc::new)
128+ .map_err(|e| e.to_string());
129+ log::debug!("decoded {} in {:.1?}", path.display(), t.elapsed());
130+ lock(&arriving).insert(path.clone());
131+ let _ = proxy.send_event(UserEvent::Decoded { path, result });
132+ });
133+ }
134+
135+ /// The current file and its preload neighbours, current first.
136+ fn window_paths(&self) -> Vec<PathBuf> {
137+ let q = &self.state.queue;
138+ let Some(cur) = q.current() else {
139+ return Vec::new();
140+ };
141+ let mut out = vec![cur.to_path_buf()];
142+ for i in q.neighbours(self.state.settings.preload, self.state.settings.wrap) {
143+ if let Some(p) = q.get(i) {
144+ out.push(p.to_path_buf());
145+ }
146+ }
147+ out
148+ }
149+
150+ /// Make the GPU and decode queue match the current position.
151+ fn sync(&mut self, navigated: bool) {
152+ let now = Instant::now();
153+ if navigated {
154+ self.pool.bump_epoch();
155+ self.input.cancel();
156+ self.state.pending.clear();
157+ }
158+ let wanted = self.window_paths();
159+ self.update_title();
160+ let Some(cur) = wanted.first().cloned() else {
161+ self.state.clear_shown();
162+ self.uploads.clear();
163+ if let Some(g) = &mut self.gfx {
164+ g.renderer.retain(|_| false);
165+ }
166+ self.request_redraw();
167+ return;
168+ };
169+
170+ let keep = |p: &Path| {
171+ wanted.iter().any(|w| w == p)
172+ || self.state.shown.as_ref().is_some_and(|s| s.path == p)
173+ || self.state.fade.as_ref().is_some_and(|f| f.path == p)
174+ };
175+ if let Some(g) = &mut self.gfx {
176+ g.renderer.retain(keep);
177+ }
178+ self.uploads.retain(|(p, _)| wanted.contains(p));
179+
180+ let showing_cur = self.state.shown.as_ref().is_some_and(|s| s.path == cur);
181+ if !showing_cur {
182+ match self.cache.get(&cur) {
183+ Some(img) => self.show(&cur, img, now),
184+ None => {
185+ self.state.loading_since.get_or_insert(now);
186+ self.decode(&cur, Priority::Current);
187+ }
188+ }
189+ }
190+ for p in &wanted[1..] {
191+ match self.cache.get(p) {
192+ Some(img) => {
193+ let ready = self.gfx.as_ref().is_some_and(|g| g.renderer.is_ready(p));
194+ if !ready && !self.uploads.iter().any(|(u, _)| u == p) {
195+ self.uploads.push_back((p.clone(), img));
196+ }
197+ }
198+ None => self.decode(p, Priority::Preload),
199+ }
200+ }
201+ self.request_redraw();
202+ }
203+
204+ /// Upload `img` in full and put it on screen.
205+ fn show(&mut self, path: &Path, img: Arc<DecodedImage>, now: Instant) {
206+ let Some(g) = &mut self.gfx else {
207+ // No GPU yet: keep it cached; `resumed` syncs again.
208+ self.state.loading_since.get_or_insert(now);
209+ return;
210+ };
211+ let img = fit_gpu(img, g.renderer.max_texture_dim());
212+ g.renderer.upload(path, &img, usize::MAX);
213+ self.uploads.retain(|(p, _)| p != path);
214+ self.state.show(path, img.source_size, img.format, now);
215+ self.request_redraw();
216+ }
217+
218+ fn update_title(&self) {
219+ if let Some(g) = &self.gfx {
220+ let title = match self.state.queue.current() {
221+ Some(p) => format!("{} — remage", ui::file_name(p)),
222+ None => "remage".into(),
223+ };
224+ g.window.set_title(&title);
225+ }
226+ }
227+
228+ fn on_decoded(&mut self, path: PathBuf, result: Result<Arc<DecodedImage>, String>) {
229+ lock(&self.arriving).remove(&path);
230+ let now = Instant::now();
231+ let is_current = self.state.queue.current() == Some(path.as_path());
232+ match result {
233+ Ok(img) => {
234+ let wanted = self.window_paths();
235+ let keep: Vec<&Path> = wanted.iter().map(PathBuf::as_path).collect();
236+ self.cache.insert(path.clone(), img.clone(), &keep);
237+ if is_current {
238+ // Already on screen means this was a reload: replace the texture.
239+ let reload = self.state.shown.as_ref().is_some_and(|s| s.path == path);
240+ if reload && let Some(g) = &mut self.gfx {
241+ g.renderer.retain(|p| p != path);
242+ }
243+ self.show(&path, img, now);
244+ } else if wanted.contains(&path) && !self.uploads.iter().any(|(p, _)| *p == path) {
245+ self.uploads.push_back((path, img));
246+ }
247+ }
248+ Err(e) => {
249+ log::warn!("{}: {e}", path.display());
250+ self.state.queue.remove(&path);
251+ self.state.skipped += 1;
252+ if is_current {
253+ self.state.loading_since = None;
254+ let msg = format!("error: {}: {e}", ui::file_name(&path));
255+ self.state.notify(msg, MsgKind::Error, now);
256+ self.sync(true);
257+ }
258+ self.request_redraw();
259+ }
260+ }
261+ }
262+
263+ fn reload(&mut self) {
264+ if let Some(cur) = self.state.queue.current().map(Path::to_path_buf) {
265+ self.cache.remove(&cur);
266+ self.decode(&cur, Priority::Current);
267+ }
268+ }
269+
270+ fn apply(&mut self, action: Action, event_loop: &ActiveEventLoop) {
271+ let fx = self.state.apply(action, Instant::now());
272+ self.effects(fx, event_loop);
273+ }
274+
275+ fn effects(&mut self, fx: Effects, event_loop: &ActiveEventLoop) {
276+ if fx.quit {
277+ event_loop.exit();
278+ return;
279+ }
280+ if fx.fullscreen
281+ && let Some(g) = &self.gfx
282+ {
283+ let on = g.window.fullscreen().is_some();
284+ g.window
285+ .set_fullscreen((!on).then_some(Fullscreen::Borderless(None)));
286+ }
287+ if fx.reload {
288+ self.reload();
289+ }
290+ if fx.navigated {
291+ self.sync(true);
292+ }
293+ if fx.redraw || fx.layout {
294+ self.request_redraw();
295+ }
296+ }
297+
298+ fn on_key(&mut self, event: winit::event::KeyEvent, event_loop: &ActiveEventLoop) {
299+ let now = Instant::now();
300+ if event.state == ElementState::Released {
301+ if let Some(dir) = self.held.remove(&event.physical_key) {
302+ self.state.hold_pan(dir, false, now);
303+ self.request_redraw();
304+ }
305+ return;
306+ }
307+ let action = self.input.key(&event.logical_key, self.modifiers);
308+ let pending = self.input.pending();
309+ if pending != self.state.pending {
310+ self.state.pending = pending;
311+ self.request_redraw();
312+ }
313+ let Some(action) = action else { return };
314+ if event.repeat && !input::repeats(&action) {
315+ return;
316+ }
317+ if let Action::Pan(dir) = action {
318+ if self.state.shown.is_some() {
319+ self.held.insert(event.physical_key, dir);
320+ self.state.hold_pan(dir, true, now);
321+ self.request_redraw();
322+ }
323+ return;
324+ }
325+ self.apply(action, event_loop);
326+ }
327+
328+ fn redraw(&mut self) {
329+ let now = Instant::now();
330+ let ready = self.state.shown.is_some()
331+ || self.state.queue.is_empty()
332+ || now >= self.started + SHOW_TIMEOUT;
333+ if !self.shown_window && !ready {
334+ return; // about_to_wait wakes us at the timeout
335+ }
336+ self.state.advance(now);
337+ self.state.tick(now);
338+ let Some(g) = &mut self.gfx else { return };
339+ if !self.shown_window {
340+ g.window.set_visible(true);
341+ }
342+ let scene = ui::scene(&self.state, &self.theme, now, &mut |t| {
343+ g.renderer.measure(t)
344+ });
345+ let result = g.renderer.render(&scene);
346+ if let Some(path) = std::env::var_os("REMAGE_SNAPSHOT")
347+ && !self.state.is_animating(now)
348+ && self.state.loading_since.is_none()
349+ {
350+ match g.renderer.snapshot(&scene, Path::new(&path)) {
351+ Ok(()) => log::info!("snapshot written to {}", Path::new(&path).display()),
352+ Err(e) => log::error!("snapshot: {e:#}"),
353+ }
354+ }
355+ drop(scene);
356+ if let Err(e) = result {
357+ log::error!("render: {e:#}");
358+ self.state
359+ .notify(format!("error: {e}"), MsgKind::Error, now);
360+ }
361+ if !self.shown_window {
362+ self.shown_window = true;
363+ log::debug!("first frame after {:.1?}", self.started.elapsed());
364+ let proxy = self.proxy.clone();
365+ std::thread::spawn(move || {
366+ let _ = proxy.send_event(UserEvent::Fonts(text::all_fonts()));
367+ });
368+ }
369+ if self.state.is_animating(Instant::now()) {
370+ g.window.request_redraw();
371+ }
372+ }
373+
374+ /// Upload preloaded images a slice at a time. Returns whether work remains.
375+ fn pump_uploads(&mut self) -> bool {
376+ let Some(g) = &mut self.gfx else { return false };
377+ if let Some((path, img)) = self.uploads.front() {
378+ let img = fit_gpu(img.clone(), g.renderer.max_texture_dim());
379+ if g.renderer.upload(path, &img, UPLOAD_BUDGET) {
380+ self.uploads.pop_front();
381+ }
382+ }
383+ !self.uploads.is_empty()
384+ }
385+}
386+
387+fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
388+ m.lock().unwrap_or_else(|e| e.into_inner())
389+}
390+
391+/// Shrink an image that exceeds the GPU's texture limit (decoded before the limit was known).
392+fn fit_gpu(img: Arc<DecodedImage>, max_dim: u32) -> Arc<DecodedImage> {
393+ if img.size[0] <= max_dim && img.size[1] <= max_dim {
394+ return img;
395+ }
396+ let owned = Arc::try_unwrap(img).unwrap_or_else(|a| DecodedImage {
397+ pixels: a.pixels.clone(),
398+ ..*a
399+ });
400+ match decode::downscale(owned, max_dim) {
401+ Ok(small) => Arc::new(small),
402+ Err(e) => {
403+ log::error!("downscale: {e:#}");
404+ Arc::new(DecodedImage {
405+ size: [1, 1],
406+ pixels: vec![0; 4],
407+ format: "?",
408+ source_size: [1, 1],
409+ })
410+ }
411+ }
412+}
413+
414+impl ApplicationHandler<UserEvent> for App {
415+ fn resumed(&mut self, event_loop: &ActiveEventLoop) {
416+ if self.gfx.is_some() {
417+ return;
418+ }
419+ let (w, h) = self.opts.geometry.unwrap_or((1280, 800));
420+ let mut attrs = Window::default_attributes()
421+ .with_title("remage")
422+ .with_inner_size(LogicalSize::new(w, h))
423+ .with_decorations(false)
424+ .with_visible(false);
425+ if self.opts.fullscreen {
426+ attrs = attrs.with_fullscreen(Some(Fullscreen::Borderless(None)));
427+ }
428+ let class = self.opts.class.clone();
429+ attrs = winit::platform::wayland::WindowAttributesExtWayland::with_name(
430+ attrs,
431+ class.clone(),
432+ class.clone(),
433+ );
434+ attrs =
435+ winit::platform::x11::WindowAttributesExtX11::with_name(attrs, class.clone(), class);
436+ let window = match event_loop.create_window(attrs) {
437+ Ok(w) => Arc::new(w),
438+ Err(e) => {
439+ self.error = Some(anyhow::anyhow!("create window: {e}"));
440+ event_loop.exit();
441+ return;
442+ }
443+ };
444+ let renderer = match Renderer::new(window.clone(), event_loop.owned_display_handle()) {
445+ Ok(r) => r,
446+ Err(e) => {
447+ self.error = Some(e.context("initialise GPU"));
448+ event_loop.exit();
449+ return;
450+ }
451+ };
452+ log::debug!("GPU ready after {:.1?}", self.started.elapsed());
453+ self.max_dim
454+ .store(renderer.max_texture_dim(), Ordering::Relaxed);
455+ let size = window.inner_size();
456+ self.state.set_window(
457+ size.width as f32,
458+ size.height as f32,
459+ window.scale_factor() as f32,
460+ );
461+ self.gfx = Some(Gfx { renderer, window });
462+ self.sync(false);
463+ }
464+
465+ fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: UserEvent) {
466+ match event {
467+ UserEvent::Decoded { path, result } => self.on_decoded(path, result),
468+ UserEvent::Found(paths) => {
469+ log::debug!("sniffing found {} more images", paths.len());
470+ let was_empty = self.state.queue.is_empty();
471+ self.state.queue.insert_sorted(paths);
472+ self.sync(was_empty);
473+ }
474+ UserEvent::Fonts(db) => {
475+ if let Some(g) = &mut self.gfx {
476+ g.renderer.set_fonts(db);
477+ g.window.request_redraw();
478+ }
479+ }
480+ }
481+ }
482+
483+ fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
484+ let now = Instant::now();
485+ match event {
486+ WindowEvent::CloseRequested => event_loop.exit(),
487+ WindowEvent::Resized(size) => {
488+ let Some(g) = &mut self.gfx else { return };
489+ g.renderer.resize(size.width, size.height);
490+ let sf = g.window.scale_factor() as f32;
491+ self.state
492+ .set_window(size.width as f32, size.height as f32, sf);
493+ self.request_redraw();
494+ }
495+ WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
496+ let [w, h] = self.state.window();
497+ self.state.set_window(w, h, scale_factor as f32);
498+ self.request_redraw();
499+ }
500+ WindowEvent::RedrawRequested => self.redraw(),
501+ WindowEvent::ModifiersChanged(m) => self.modifiers = m.state(),
502+ WindowEvent::Focused(false) => {
503+ self.held.clear();
504+ self.state.stop_all_pans();
505+ self.dragging = false;
506+ }
507+ WindowEvent::KeyboardInput {
508+ event,
509+ is_synthetic: false,
510+ ..
511+ } => {
512+ self.on_key(event, event_loop);
513+ }
514+ WindowEvent::CursorMoved { position, .. } => {
515+ let p = [position.x as f32, position.y as f32];
516+ if self.dragging && self.state.shown.is_some() {
517+ self.state
518+ .view
519+ .pan_pixels([p[0] - self.cursor[0], p[1] - self.cursor[1]]);
520+ self.request_redraw();
521+ }
522+ self.cursor = p;
523+ }
524+ WindowEvent::MouseInput {
525+ state,
526+ button: MouseButton::Left,
527+ ..
528+ } => {
529+ self.dragging = state == ElementState::Pressed;
530+ }
531+ WindowEvent::MouseWheel { delta, .. } => {
532+ let steps = match delta {
533+ MouseScrollDelta::LineDelta(_, y) => y,
534+ MouseScrollDelta::PixelDelta(p) => p.y as f32 / PIXELS_PER_STEP,
535+ };
536+ if steps != 0.0 {
537+ self.state.zoom_at(steps, self.cursor, now);
538+ self.request_redraw();
539+ }
540+ }
541+ _ => {}
542+ }
543+ }
544+
545+ fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) {
546+ if matches!(cause, StartCause::ResumeTimeReached { .. }) {
547+ self.request_redraw();
548+ }
549+ }
550+
551+ fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
552+ let now = Instant::now();
553+ let busy = self.pump_uploads();
554+ let (redraw, mut next) = self.state.tick(now);
555+ if redraw {
556+ self.request_redraw();
557+ }
558+ if !self.shown_window {
559+ let at = self.started + SHOW_TIMEOUT;
560+ next = Some(next.map_or(at, |n| n.min(at)));
561+ }
562+ event_loop.set_control_flow(match (busy, next) {
563+ (true, _) => ControlFlow::Poll,
564+ (false, Some(t)) => ControlFlow::WaitUntil(t),
565+ (false, None) => ControlFlow::Wait,
566+ });
567+ }
568+}
A src/cache.rs View
@@ -0,0 +1,99 @@
1+//! Decoded-image LRU with a byte budget.
2+
3+use std::collections::HashMap;
4+use std::path::{Path, PathBuf};
5+use std::sync::Arc;
6+
7+use crate::decode::DecodedImage;
8+
9+struct Slot {
10+ img: Arc<DecodedImage>,
11+ used: u64,
12+}
13+
14+pub struct ImageCache {
15+ map: HashMap<PathBuf, Slot>,
16+ budget: usize,
17+ bytes: usize,
18+ clock: u64,
19+}
20+
21+impl ImageCache {
22+ pub fn new(budget_bytes: usize) -> Self {
23+ Self {
24+ map: HashMap::new(),
25+ budget: budget_bytes,
26+ bytes: 0,
27+ clock: 0,
28+ }
29+ }
30+
31+ pub fn get(&mut self, path: &Path) -> Option<Arc<DecodedImage>> {
32+ self.clock += 1;
33+ let slot = self.map.get_mut(path)?;
34+ slot.used = self.clock;
35+ Some(slot.img.clone())
36+ }
37+
38+ #[cfg(test)]
39+ pub fn contains(&self, path: &Path) -> bool {
40+ self.map.contains_key(path)
41+ }
42+
43+ /// Insert, then evict least-recently-used entries over budget, sparing `keep`.
44+ pub fn insert(&mut self, path: PathBuf, img: Arc<DecodedImage>, keep: &[&Path]) {
45+ self.clock += 1;
46+ self.bytes += img.bytes();
47+ if let Some(old) = self.map.insert(
48+ path,
49+ Slot {
50+ img,
51+ used: self.clock,
52+ },
53+ ) {
54+ self.bytes -= old.img.bytes();
55+ }
56+ while self.bytes > self.budget {
57+ let victim = self
58+ .map
59+ .iter()
60+ .filter(|(p, _)| !keep.contains(&p.as_path()))
61+ .min_by_key(|(_, s)| s.used)
62+ .map(|(p, _)| p.clone());
63+ let Some(victim) = victim else { break };
64+ self.remove(&victim);
65+ }
66+ }
67+
68+ pub fn remove(&mut self, path: &Path) {
69+ if let Some(old) = self.map.remove(path) {
70+ self.bytes -= old.img.bytes();
71+ }
72+ }
73+}
74+
75+#[cfg(test)]
76+mod tests {
77+ use super::*;
78+
79+ fn img(bytes: usize) -> Arc<DecodedImage> {
80+ Arc::new(DecodedImage {
81+ size: [1, 1],
82+ pixels: vec![0; bytes],
83+ format: "PNG",
84+ source_size: [1, 1],
85+ })
86+ }
87+
88+ #[test]
89+ fn evicts_least_recent_but_spares_kept() {
90+ let mut c = ImageCache::new(250);
91+ c.insert("a".into(), img(100), &[]);
92+ c.insert("b".into(), img(100), &[]);
93+ c.get(Path::new("a"));
94+ c.insert("c".into(), img(100), &[]);
95+ assert!(c.contains(Path::new("a")) && !c.contains(Path::new("b")));
96+ c.insert("d".into(), img(100), &[Path::new("a")]);
97+ assert!(c.contains(Path::new("a")) && !c.contains(Path::new("c")));
98+ }
99+}
A src/cli.rs View
@@ -0,0 +1,88 @@
1+//! Command-line interface.
2+
3+use std::path::PathBuf;
4+
5+use clap::Parser;
6+
7+use crate::state::queue::SortKey;
8+use crate::state::view::FitMode;
9+
10+#[derive(Parser, Debug)]
11+#[command(name = "remage", version, about = "Keyboard-centric image viewer")]
12+pub struct Cli {
13+ /// Files or directories to open [default: .]
14+ pub paths: Vec<PathBuf>,
15+
16+ /// Descend into directories
17+ #[arg(short, long)]
18+ pub recursive: bool,
19+
20+ /// Start at the Nth image (1-based)
21+ #[arg(short = 'n', long, value_name = "N")]
22+ pub start: Option<usize>,
23+
24+ /// Start in fullscreen
25+ #[arg(short, long)]
26+ pub fullscreen: bool,
27+
28+ /// Hide the status bar
29+ #[arg(short = 'b', long)]
30+ pub no_bar: bool,
31+
32+ /// How images fit the window
33+ #[arg(short = 's', long, value_enum, value_name = "MODE")]
34+ pub fit: Option<FitMode>,
35+
36+ /// Fixed zoom level in percent (overrides --fit)
37+ #[arg(short = 'z', long, value_name = "PCT")]
38+ pub zoom: Option<f32>,
39+
40+ /// Initial window size in logical pixels
41+ #[arg(short = 'g', long, value_name = "WxH", value_parser = parse_geometry)]
42+ pub geometry: Option<(u32, u32)>,
43+
44+ /// Wayland app_id / X11 class
45+ #[arg(long, value_name = "NAME", default_value = "remage")]
46+ pub class: String,
47+
48+ /// Sort order for directory contents
49+ #[arg(long, value_enum, value_name = "KEY", default_value = "natural")]
50+ pub sort: SortKey,
51+
52+ /// Reverse the sort order
53+ #[arg(long)]
54+ pub reverse: bool,
55+
56+ /// Open only the named file, not the rest of its directory
57+ #[arg(long)]
58+ pub no_siblings: bool,
59+}
60+
61+fn parse_geometry(s: &str) -> Result<(u32, u32), String> {
62+ let (w, h) = s
63+ .split_once(['x', 'X'])
64+ .ok_or("expected WxH, e.g. 1280x800")?;
65+ let parse = |v: &str| v.trim().parse::<u32>().ok().filter(|n| *n > 0);
66+ match (parse(w), parse(h)) {
67+ (Some(w), Some(h)) => Ok((w, h)),
68+ _ => Err("expected positive WxH, e.g. 1280x800".into()),
69+ }
70+}
71+
72+#[cfg(test)]
73+mod tests {
74+ use super::*;
75+ use clap::CommandFactory;
76+
77+ #[test]
78+ fn cli_is_consistent() {
79+ Cli::command().debug_assert();
80+ }
81+
82+ #[test]
83+ fn geometry() {
84+ assert_eq!(parse_geometry("800x600"), Ok((800, 600)));
85+ assert!(parse_geometry("800").is_err());
86+ assert!(parse_geometry("0x600").is_err());
87+ }
88+}
A src/config/mod.rs View
@@ -0,0 +1,3 @@
1+//! Configuration. Until the TOML schema lands in M2, defaults come from code and the CLI.
2+
3+pub mod theme;
A src/config/theme.rs View
@@ -0,0 +1,115 @@
1+//! Colour tokens, presets and palette derivation.
2+
3+/// An sRGB colour with straight alpha.
4+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5+pub struct Rgba(pub [u8; 4]);
6+
7+impl Rgba {
8+ pub const fn rgb(hex: u32) -> Self {
9+ Self([(hex >> 16) as u8, (hex >> 8) as u8, hex as u8, 255])
10+ }
11+
12+ /// Mix `self` (share `t`) over `other` in sRGB space.
13+ pub fn mix_over(self, other: Rgba, t: f32) -> Rgba {
14+ let mut out = [0u8; 4];
15+ for (i, o) in out.iter_mut().enumerate() {
16+ let v = other.0[i] as f32 + (self.0[i] as f32 - other.0[i] as f32) * t;
17+ *o = v.round().clamp(0.0, 255.0) as u8;
18+ }
19+ Rgba(out)
20+ }
21+
22+ /// Linear-light RGBA for the GPU.
23+ pub fn linear(self) -> [f32; 4] {
24+ let f = |c: u8| {
25+ let c = c as f32 / 255.0;
26+ if c <= 0.04045 {
27+ c / 12.92
28+ } else {
29+ ((c + 0.055) / 1.055).powf(2.4)
30+ }
31+ };
32+ [
33+ f(self.0[0]),
34+ f(self.0[1]),
35+ f(self.0[2]),
36+ self.0[3] as f32 / 255.0,
37+ ]
38+ }
39+}
40+
41+#[derive(Clone, Debug, PartialEq)]
42+pub struct Theme {
43+ pub background: Rgba,
44+ pub foreground: Rgba,
45+ pub surface: Rgba,
46+ pub surface_raised: Rgba,
47+ pub border: Rgba,
48+ pub border_visible: Rgba,
49+ pub text_disabled: Rgba,
50+ pub text_secondary: Rgba,
51+ pub text_primary: Rgba,
52+ pub text_display: Rgba,
53+ pub accent: Rgba,
54+ pub success: Rgba,
55+ pub warning: Rgba,
56+ pub image_background: Rgba,
57+}
58+
59+impl Theme {
60+ /// Derive every token from background, foreground and accent. The fg shares reproduce the
61+ /// Nothing dark palette exactly from black/white.
62+ pub fn derive(background: Rgba, foreground: Rgba, accent: Rgba) -> Self {
63+ let mix = |t: f32| foreground.mix_over(background, t);
64+ Self {
65+ background,
66+ foreground,
67+ surface: mix(0.067),
68+ surface_raised: mix(0.10),
69+ border: mix(0.133),
70+ border_visible: mix(0.20),
71+ text_disabled: mix(0.40),
72+ text_secondary: mix(0.60),
73+ text_primary: mix(0.91),
74+ text_display: foreground,
75+ accent,
76+ success: Rgba::rgb(0x4A9E5C),
77+ warning: Rgba::rgb(0xD4A843),
78+ image_background: background,
79+ }
80+ }
81+
82+ pub fn nothing_dark() -> Self {
83+ Self::derive(
84+ Rgba::rgb(0x000000),
85+ Rgba::rgb(0xFFFFFF),
86+ Rgba::rgb(0xD71921),
87+ )
88+ }
89+}
90+
91+#[cfg(test)]
92+mod tests {
93+ use super::*;
94+
95+ #[test]
96+ fn derivation_reproduces_nothing_dark() {
97+ let t = Theme::nothing_dark();
98+ assert_eq!(t.surface, Rgba::rgb(0x111111));
99+ assert_eq!(t.surface_raised, Rgba::rgb(0x1A1A1A));
100+ assert_eq!(t.border, Rgba::rgb(0x222222));
101+ assert_eq!(t.border_visible, Rgba::rgb(0x333333));
102+ assert_eq!(t.text_disabled, Rgba::rgb(0x666666));
103+ assert_eq!(t.text_secondary, Rgba::rgb(0x999999));
104+ assert_eq!(t.text_primary, Rgba::rgb(0xE8E8E8));
105+ assert_eq!(t.text_display, Rgba::rgb(0xFFFFFF));
106+ }
107+
108+ #[test]
109+ fn linear_conversion() {
110+ assert_eq!(Rgba::rgb(0x000000).linear(), [0.0, 0.0, 0.0, 1.0]);
111+ assert_eq!(Rgba::rgb(0xFFFFFF).linear(), [1.0, 1.0, 1.0, 1.0]);
112+ let mid = Rgba::rgb(0x808080).linear()[0];
113+ assert!((mid - 0.2158).abs() < 1e-3);
114+ }
115+}
A src/decode/color.rs View
@@ -0,0 +1 @@
1+//! ICC/CICP to sRGB conversion, EXIF orientation and HDR tone-mapping.
A src/decode/mod.rs View
@@ -0,0 +1,165 @@
1+//! Format sniffing and dispatch to the decoder backends.
2+
3+pub mod color;
4+
5+use std::fs::File;
6+use std::io::Read;
7+use std::path::Path;
8+use std::sync::LazyLock;
9+
10+use anyhow::{Context, anyhow};
11+use fast_image_resize as fir;
12+use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, metadata::Orientation};
13+
14+/// Refuse single images that would need more than this to decode.
15+const MAX_ALLOC: u64 = 4 << 30;
16+
17+/// Straight-alpha RGBA8 in sRGB, oriented upright, ready to upload.
18+pub struct DecodedImage {
19+ pub size: [u32; 2],
20+ pub pixels: Vec<u8>,
21+ pub format: &'static str,
22+ /// Size before any downscale to fit the GPU's texture limit.
23+ pub source_size: [u32; 2],
24+}
25+
26+impl DecodedImage {
27+ pub fn bytes(&self) -> usize {
28+ self.pixels.len()
29+ }
30+}
31+
32+impl std::fmt::Debug for DecodedImage {
33+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34+ f.debug_struct("DecodedImage")
35+ .field("size", &self.size)
36+ .field("format", &self.format)
37+ .finish_non_exhaustive()
38+ }
39+}
40+
41+static EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
42+ ImageFormat::all()
43+ .filter(ImageFormat::reading_enabled)
44+ .flat_map(|f| f.extensions_str().iter().copied())
45+ .chain(["apng", "jfif"])
46+ .collect()
47+});
48+
49+/// Whether the extension alone says we can decode this file.
50+pub fn is_known_extension(path: &Path) -> bool {
51+ path.extension()
52+ .and_then(|e| e.to_str())
53+ .is_some_and(|e| EXTENSIONS.iter().any(|k| k.eq_ignore_ascii_case(e)))
54+}
55+
56+/// Check magic bytes for files whose extension is unknown.
57+pub fn sniff(path: &Path) -> bool {
58+ let mut buf = [0u8; 64];
59+ let Ok(mut f) = File::open(path) else {
60+ return false;
61+ };
62+ let Ok(n) = f.read(&mut buf) else {
63+ return false;
64+ };
65+ image::guess_format(&buf[..n]).is_ok_and(|f| f.reading_enabled())
66+}
67+
68+fn label(format: ImageFormat) -> &'static str {
69+ match format {
70+ ImageFormat::Png => "PNG",
71+ ImageFormat::Jpeg => "JPEG",
72+ ImageFormat::Gif => "GIF",
73+ ImageFormat::WebP => "WEBP",
74+ ImageFormat::Pnm => "PNM",
75+ ImageFormat::Tiff => "TIFF",
76+ ImageFormat::Tga => "TGA",
77+ ImageFormat::Dds => "DDS",
78+ ImageFormat::Bmp => "BMP",
79+ ImageFormat::Ico => "ICO",
80+ ImageFormat::Hdr => "HDR",
81+ ImageFormat::OpenExr => "EXR",
82+ ImageFormat::Farbfeld => "FARBFELD",
83+ ImageFormat::Avif => "AVIF",
84+ ImageFormat::Qoi => "QOI",
85+ _ => "IMAGE",
86+ }
87+}
88+
89+/// Decode `path`, downscaling if either side exceeds `max_dim` (the GPU texture limit).
90+pub fn decode(path: &Path, max_dim: u32) -> anyhow::Result<DecodedImage> {
91+ // `open` guesses from the extension; magic bytes take precedence when recognised.
92+ let mut reader = ImageReader::open(path)?.with_guessed_format()?;
93+ let mut limits = image::Limits::default();
94+ limits.max_alloc = Some(MAX_ALLOC);
95+ reader.limits(limits);
96+ let format = reader.format().ok_or_else(|| anyhow!("unknown format"))?;
97+ let mut decoder = reader.into_decoder()?;
98+ let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
99+ let mut img = DynamicImage::from_decoder(decoder)?;
100+ img.apply_orientation(orientation);
101+ let rgba = img.into_rgba8();
102+ let source_size = [rgba.width(), rgba.height()];
103+ let mut out = DecodedImage {
104+ size: source_size,
105+ pixels: rgba.into_raw(),
106+ format: label(format),
107+ source_size,
108+ };
109+ if source_size[0] > max_dim || source_size[1] > max_dim {
110+ out = downscale(out, max_dim)?;
111+ }
112+ Ok(out)
113+}
114+
115+/// Shrink so neither side exceeds `max_dim`. A stopgap until M5 tiles huge images.
116+pub fn downscale(img: DecodedImage, max_dim: u32) -> anyhow::Result<DecodedImage> {
117+ let [w, h] = img.size;
118+ let k = max_dim as f64 / w.max(h) as f64;
119+ let (nw, nh) = (
120+ ((w as f64 * k) as u32).max(1),
121+ ((h as f64 * k) as u32).max(1),
122+ );
123+ let src = fir::images::Image::from_vec_u8(w, h, img.pixels, fir::PixelType::U8x4)
124+ .context("resize source")?;
125+ let mut dst = fir::images::Image::new(nw, nh, fir::PixelType::U8x4);
126+ fir::Resizer::new()
127+ .resize(&src, &mut dst, None)
128+ .context("resize")?;
129+ log::debug!("downscaled {w}×{h} to {nw}×{nh} for the GPU");
130+ Ok(DecodedImage {
131+ size: [nw, nh],
132+ pixels: dst.into_vec(),
133+ ..img
134+ })
135+}
136+
137+#[cfg(test)]
138+mod tests {
139+ use super::*;
140+
141+ #[test]
142+ fn known_extensions() {
143+ assert!(is_known_extension(Path::new("a/b.JPG")));
144+ assert!(is_known_extension(Path::new("b.png")));
145+ assert!(!is_known_extension(Path::new("b.txt")));
146+ assert!(!is_known_extension(Path::new("png")));
147+ }
148+
149+ #[test]
150+ fn decodes_and_downscales_png() {
151+ let dir = std::env::temp_dir().join(format!("remage-test-{}", std::process::id()));
152+ std::fs::create_dir_all(&dir).unwrap();
153+ let path = dir.join("noext");
154+ image::RgbaImage::from_pixel(64, 32, image::Rgba([255, 0, 0, 128]))
155+ .save_with_format(&path, ImageFormat::Png)
156+ .unwrap();
157+ assert!(sniff(&path));
158+ let img = decode(&path, 16).unwrap();
159+ assert_eq!(img.format, "PNG");
160+ assert_eq!(img.source_size, [64, 32]);
161+ assert_eq!(img.size, [16, 8]);
162+ assert_eq!(img.pixels.len(), 16 * 8 * 4);
163+ std::fs::remove_dir_all(&dir).unwrap();
164+ }
165+}
A src/exec.rs View
@@ -0,0 +1 @@
1+//! User commands: placeholder expansion, spawning, confirmation and output handling.
A src/input/keymap.rs View
@@ -0,0 +1 @@
1+//! Per-mode key trie: pending sequences, counts and which-key candidates.
A src/input/keys.rs View
@@ -0,0 +1 @@
1+//! Vim key-notation parser: `<C-x>`, `<S-Tab>`, `gg`, `<Space>w`, mouse buttons and wheel.
A src/input/mod.rs View
@@ -0,0 +1,173 @@
1+//! Keyboard and mouse input: vim key notation and per-mode keymaps.
2+//!
3+//! Until the configurable keymap lands (M2), [`Input`] holds the default bindings in code.
4+
5+pub mod keymap;
6+pub mod keys;
7+
8+use winit::keyboard::{Key, ModifiersState, NamedKey};
9+
10+use crate::action::{Action, Zoom};
11+use crate::state::view::{Axis, Dir, FitMode};
12+
13+#[derive(Debug, Default)]
14+pub struct Input {
15+ /// A `g` is waiting for its second key.
16+ pending_g: bool,
17+ /// A count prefix (`5n`, `12G`).
18+ count: Option<usize>,
19+}
20+
21+impl Input {
22+ /// The incomplete sequence typed so far, for the status bar.
23+ pub fn pending(&self) -> String {
24+ let mut s = self.count.map(|c| c.to_string()).unwrap_or_default();
25+ if self.pending_g {
26+ s.push('g');
27+ }
28+ s
29+ }
30+
31+ pub fn cancel(&mut self) {
32+ self.pending_g = false;
33+ self.count = None;
34+ }
35+
36+ /// Map a key press to an action using the default bindings.
37+ pub fn key(&mut self, key: &Key, mods: ModifiersState) -> Option<Action> {
38+ if mods.control_key() || mods.alt_key() || mods.super_key() {
39+ return None;
40+ }
41+ if let Key::Character(s) = key {
42+ // `0` is a motion unless a count is already pending.
43+ if let Some(d) = s.chars().next().and_then(|c| c.to_digit(10))
44+ && s.len() == 1
45+ && !self.pending_g
46+ && (d != 0 || self.count.is_some())
47+ {
48+ self.count = Some(
49+ self.count
50+ .unwrap_or(0)
51+ .saturating_mul(10)
52+ .saturating_add(d as usize)
53+ .min(9_999_999),
54+ );
55+ return None;
56+ }
57+ }
58+ let was_g = std::mem::take(&mut self.pending_g);
59+ if !was_g && matches!(key, Key::Character(s) if s == "g") {
60+ self.pending_g = true;
61+ return None;
62+ }
63+ let count = self.count.take();
64+ let n = count.unwrap_or(1);
65+ let action = match key {
66+ Key::Character(s) => match s.as_str() {
67+ "g" => count.map_or(Action::First, Action::Goto),
68+ "q" => Action::Quit,
69+ "n" => Action::Next(n),
70+ "p" => Action::Prev(n),
71+ "]" => Action::Next(10 * n),
72+ "[" => Action::Prev(10 * n),
73+ "G" => count.map_or(Action::Last, Action::Goto),
74+ "h" => Action::Pan(Dir::Left),
75+ "j" => Action::Pan(Dir::Down),
76+ "k" => Action::Pan(Dir::Up),
77+ "l" => Action::Pan(Dir::Right),
78+ "H" => Action::PanEdge(Dir::Left),
79+ "J" => Action::PanEdge(Dir::Down),
80+ "K" => Action::PanEdge(Dir::Up),
81+ "L" => Action::PanEdge(Dir::Right),
82+ "+" => Action::Zoom(Zoom::In),
83+ "-" => Action::Zoom(Zoom::Out),
84+ "=" => Action::Zoom(Zoom::Percent(100.0)),
85+ "w" => Action::Fit(FitMode::Fit),
86+ "W" => Action::Fit(FitMode::Fill),
87+ "e" => Action::Fit(FitMode::Width),
88+ "E" => Action::Fit(FitMode::Height),
89+ "z" => Action::Center,
90+ "<" => Action::Rotate(-1),
91+ ">" => Action::Rotate(1),
92+ "|" => Action::Flip(Axis::Horizontal),
93+ "_" => Action::Flip(Axis::Vertical),
94+ "a" => Action::FilterToggle,
95+ "A" => Action::AlphaBgCycle,
96+ "f" => Action::Fullscreen,
97+ "b" => Action::BarToggle,
98+ "r" => Action::Reload,
99+ "D" => Action::Remove,
100+ _ => return None,
101+ },
102+ Key::Named(named) => match named {
103+ NamedKey::Backspace => Action::Prev(n),
104+ NamedKey::Escape => Action::Cancel,
105+ NamedKey::ArrowLeft => Action::Pan(Dir::Left),
106+ NamedKey::ArrowDown => Action::Pan(Dir::Down),
107+ NamedKey::ArrowUp => Action::Pan(Dir::Up),
108+ NamedKey::ArrowRight => Action::Pan(Dir::Right),
109+ NamedKey::Home => Action::First,
110+ NamedKey::End => Action::Last,
111+ _ => return None,
112+ },
113+ _ => return None,
114+ };
115+ Some(action)
116+ }
117+}
118+
119+/// Whether holding the key should repeat the action.
120+pub fn repeats(action: &Action) -> bool {
121+ matches!(
122+ action,
123+ Action::Next(_) | Action::Prev(_) | Action::Zoom(Zoom::In | Zoom::Out)
124+ )
125+}
126+
127+#[cfg(test)]
128+mod tests {
129+ use super::*;
130+
131+ fn ch(s: &str) -> Key {
132+ Key::Character(s.into())
133+ }
134+
135+ #[test]
136+ fn gg_is_a_sequence() {
137+ let mut input = Input::default();
138+ let none = ModifiersState::empty();
139+ assert_eq!(input.key(&ch("g"), none), None);
140+ assert_eq!(input.pending(), "g");
141+ assert_eq!(input.key(&ch("g"), none), Some(Action::First));
142+ assert_eq!(input.key(&ch("g"), none), None);
143+ assert_eq!(input.key(&ch("n"), none), Some(Action::Next(1)));
144+ assert_eq!(input.pending(), "");
145+ }
146+
147+ #[test]
148+ fn counts() {
149+ let mut input = Input::default();
150+ let none = ModifiersState::empty();
151+ for k in ["1", "2"] {
152+ assert_eq!(input.key(&ch(k), none), None);
153+ }
154+ assert_eq!(input.pending(), "12");
155+ assert_eq!(input.key(&ch("G"), none), Some(Action::Goto(12)));
156+ assert_eq!(input.key(&ch("3"), none), None);
157+ assert_eq!(input.key(&ch("n"), none), Some(Action::Next(3)));
158+ assert_eq!(input.key(&ch("G"), none), Some(Action::Last));
159+ // `0` alone is not a count.
160+ assert_eq!(input.key(&ch("0"), none), None);
161+ assert_eq!(input.pending(), "");
162+ input.key(&ch("5"), none);
163+ input.key(&ch("g"), none);
164+ assert_eq!(input.pending(), "5g");
165+ assert_eq!(input.key(&ch("g"), none), Some(Action::Goto(5)));
166+ }
167+
168+ #[test]
169+ fn modifiers_block_plain_bindings() {
170+ let mut input = Input::default();
171+ assert_eq!(input.key(&ch("n"), ModifiersState::CONTROL), None);
172+ }
173+}
A src/ipc.rs View
@@ -0,0 +1 @@
1+//! Unix-socket server speaking newline-delimited JSON, with event subscriptions.
A src/main.rs View
@@ -0,0 +1,88 @@
1+//! remage: a keyboard-centric image viewer.
2+
3+mod action;
4+mod anim;
5+mod app;
6+mod cache;
7+mod cli;
8+mod config;
9+mod decode;
10+mod exec;
11+mod input;
12+mod ipc;
13+mod render;
14+mod scan;
15+mod state;
16+mod thumbs;
17+mod ui;
18+mod worker;
19+
20+use std::time::Instant;
21+
22+use clap::Parser;
23+use winit::event_loop::EventLoop;
24+
25+use app::{App, Options, UserEvent};
26+use state::queue::Queue;
27+use state::{AppState, MsgKind, Settings};
28+
29+fn main() -> anyhow::Result<()> {
30+ let started = Instant::now();
31+ env_logger::init();
32+ let cli = cli::Cli::parse();
33+ log::info!("remage {}", env!("CARGO_PKG_VERSION"));
34+
35+ let scan = scan::scan(
36+ &cli.paths,
37+ &scan::ScanOptions {
38+ recursive: cli.recursive,
39+ siblings: !cli.no_siblings,
40+ sort: cli.sort,
41+ reverse: cli.reverse,
42+ },
43+ );
44+ log::debug!(
45+ "scanned {} files ({} to sniff) in {:.1?}",
46+ scan.files.len(),
47+ scan.unknown.len(),
48+ started.elapsed()
49+ );
50+
51+ let settings = Settings {
52+ fit: cli.fit.unwrap_or(Settings::default().fit),
53+ zoom: cli.zoom.map(|z| z / 100.0),
54+ ..Settings::default()
55+ };
56+ let start_index = cli.start.unwrap_or(1).saturating_sub(1);
57+ let queue = Queue::new(scan.files, scan.start.as_deref(), start_index);
58+ let mut state = AppState::new(queue, settings, !cli.no_bar, Instant::now());
59+ if let Some(err) = scan.errors.first() {
60+ state.notify(format!("error: {err}"), MsgKind::Error, Instant::now());
61+ }
62+
63+ let event_loop = EventLoop::<UserEvent>::with_user_event().build()?;
64+ let proxy = event_loop.create_proxy();
65+ if !scan.unknown.is_empty() {
66+ let proxy = proxy.clone();
67+ let unknown = scan.unknown;
68+ std::thread::spawn(move || {
69+ let found: Vec<_> = unknown.into_iter().filter(|p| decode::sniff(p)).collect();
70+ if !found.is_empty() {
71+ let _ = proxy.send_event(UserEvent::Found(found));
72+ }
73+ });
74+ }
75+
76+ let opts = Options {
77+ fullscreen: cli.fullscreen,
78+ geometry: cli.geometry,
79+ class: cli.class,
80+ cache_mb: 512,
81+ };
82+ let mut app = App::new(opts, state, proxy);
83+ event_loop.run_app(&mut app)?;
84+ match app.error.take() {
85+ Some(e) => Err(e),
86+ None => Ok(()),
87+ }
88+}
A src/render/image.rs View
@@ -0,0 +1,383 @@
1+//! Image textures: incremental row uploads, GPU mipmaps, linear/nearest sampling.
2+
3+use bytemuck::{Pod, Zeroable};
4+use wgpu::util::DeviceExt;
5+
6+use crate::decode::DecodedImage;
7+use crate::state::view::Affine;
8+
9+const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
10+
11+#[repr(C)]
12+#[derive(Clone, Copy, Pod, Zeroable)]
13+struct Uniforms {
14+ m: [f32; 4],
15+ t: [f32; 2],
16+ viewport: [f32; 2],
17+ bg: [f32; 4],
18+ dot: [f32; 4],
19+ params: [f32; 4],
20+}
21+
22+/// How the area behind transparent pixels is painted.
23+#[derive(Clone, Copy, Debug)]
24+pub struct AlphaStyle {
25+ /// 0 solid, 1 dots, 2 checker.
26+ pub mode: u32,
27+ pub bg: [f32; 4],
28+ pub dot: [f32; 4],
29+ pub cell: f32,
30+ pub radius: f32,
31+}
32+
33+pub struct GpuImage {
34+ texture: wgpu::Texture,
35+ bind_group: wgpu::BindGroup,
36+ uniforms: wgpu::Buffer,
37+ size: [u32; 2],
38+ rows_done: u32,
39+ ready: bool,
40+}
41+
42+impl GpuImage {
43+ pub fn is_ready(&self) -> bool {
44+ self.ready
45+ }
46+}
47+
48+pub struct ImagePipeline {
49+ pipeline: wgpu::RenderPipeline,
50+ layout: wgpu::BindGroupLayout,
51+ linear: wgpu::BindGroup,
52+ nearest: wgpu::BindGroup,
53+ mip_pipeline: wgpu::RenderPipeline,
54+ mip_layout: wgpu::BindGroupLayout,
55+ mip_sampler: wgpu::Sampler,
56+}
57+
58+fn sampler_group(
59+ device: &wgpu::Device,
60+ layout: &wgpu::BindGroupLayout,
61+ filter: wgpu::FilterMode,
62+) -> wgpu::BindGroup {
63+ let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
64+ label: Some("image sampler"),
65+ mag_filter: filter,
66+ min_filter: filter,
67+ mipmap_filter: wgpu::MipmapFilterMode::Linear,
68+ ..Default::default()
69+ });
70+ device.create_bind_group(&wgpu::BindGroupDescriptor {
71+ label: Some("image sampler"),
72+ layout,
73+ entries: &[wgpu::BindGroupEntry {
74+ binding: 0,
75+ resource: wgpu::BindingResource::Sampler(&sampler),
76+ }],
77+ })
78+}
79+
80+fn texture_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
81+ wgpu::BindGroupLayoutEntry {
82+ binding,
83+ visibility: wgpu::ShaderStages::FRAGMENT,
84+ ty: wgpu::BindingType::Texture {
85+ sample_type: wgpu::TextureSampleType::Float { filterable: true },
86+ view_dimension: wgpu::TextureViewDimension::D2,
87+ multisampled: false,
88+ },
89+ count: None,
90+ }
91+}
92+
93+fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
94+ wgpu::BindGroupLayoutEntry {
95+ binding,
96+ visibility: wgpu::ShaderStages::FRAGMENT,
97+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
98+ count: None,
99+ }
100+}
101+
102+impl ImagePipeline {
103+ pub fn new(device: &wgpu::Device, target: wgpu::TextureFormat) -> Self {
104+ let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
105+ label: Some("image"),
106+ entries: &[
107+ wgpu::BindGroupLayoutEntry {
108+ binding: 0,
109+ visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
110+ ty: wgpu::BindingType::Buffer {
111+ ty: wgpu::BufferBindingType::Uniform,
112+ has_dynamic_offset: false,
113+ min_binding_size: None,
114+ },
115+ count: None,
116+ },
117+ texture_entry(1),
118+ ],
119+ });
120+ let sampler_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
121+ label: Some("image sampler"),
122+ entries: &[sampler_entry(0)],
123+ });
124+ let shader = device.create_shader_module(wgpu::include_wgsl!("shaders/image.wgsl"));
125+ let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
126+ label: Some("image"),
127+ bind_group_layouts: &[Some(&layout), Some(&sampler_layout)],
128+ immediate_size: 0,
129+ });
130+ let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
131+ label: Some("image"),
132+ layout: Some(&pipeline_layout),
133+ vertex: wgpu::VertexState {
134+ module: &shader,
135+ entry_point: Some("vs"),
136+ compilation_options: Default::default(),
137+ buffers: &[],
138+ },
139+ primitive: wgpu::PrimitiveState::default(),
140+ depth_stencil: None,
141+ multisample: wgpu::MultisampleState::default(),
142+ fragment: Some(wgpu::FragmentState {
143+ module: &shader,
144+ entry_point: Some("fs"),
145+ compilation_options: Default::default(),
146+ targets: &[Some(wgpu::ColorTargetState {
147+ format: target,
148+ blend: Some(wgpu::BlendState::ALPHA_BLENDING),
149+ write_mask: wgpu::ColorWrites::ALL,
150+ })],
151+ }),
152+ multiview_mask: None,
153+ cache: None,
154+ });
155+
156+ let mip_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
157+ label: Some("mip"),
158+ entries: &[texture_entry(0), sampler_entry(1)],
159+ });
160+ let blit = device.create_shader_module(wgpu::include_wgsl!("shaders/blit.wgsl"));
161+ let mip_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
162+ label: Some("mip"),
163+ bind_group_layouts: &[Some(&mip_layout)],
164+ immediate_size: 0,
165+ });
166+ let mip_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
167+ label: Some("mip"),
168+ layout: Some(&mip_pipeline_layout),
169+ vertex: wgpu::VertexState {
170+ module: &blit,
171+ entry_point: Some("vs"),
172+ compilation_options: Default::default(),
173+ buffers: &[],
174+ },
175+ primitive: wgpu::PrimitiveState::default(),
176+ depth_stencil: None,
177+ multisample: wgpu::MultisampleState::default(),
178+ fragment: Some(wgpu::FragmentState {
179+ module: &blit,
180+ entry_point: Some("fs"),
181+ compilation_options: Default::default(),
182+ targets: &[Some(FORMAT.into())],
183+ }),
184+ multiview_mask: None,
185+ cache: None,
186+ });
187+ let mip_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
188+ label: Some("mip"),
189+ mag_filter: wgpu::FilterMode::Linear,
190+ min_filter: wgpu::FilterMode::Linear,
191+ ..Default::default()
192+ });
193+
194+ Self {
195+ pipeline,
196+ linear: sampler_group(device, &sampler_layout, wgpu::FilterMode::Linear),
197+ nearest: sampler_group(device, &sampler_layout, wgpu::FilterMode::Nearest),
198+ layout,
199+ mip_pipeline,
200+ mip_layout,
201+ mip_sampler,
202+ }
203+ }
204+
205+ /// Allocate a texture for `img`; fill it with [`ImagePipeline::upload`].
206+ pub fn create(&self, device: &wgpu::Device, img: &DecodedImage) -> GpuImage {
207+ let [w, h] = img.size;
208+ let mips = 32 - w.max(h).max(1).leading_zeros();
209+ let texture = device.create_texture(&wgpu::TextureDescriptor {
210+ label: Some("image"),
211+ size: wgpu::Extent3d {
212+ width: w,
213+ height: h,
214+ depth_or_array_layers: 1,
215+ },
216+ mip_level_count: mips,
217+ sample_count: 1,
218+ dimension: wgpu::TextureDimension::D2,
219+ format: FORMAT,
220+ usage: wgpu::TextureUsages::TEXTURE_BINDING
221+ | wgpu::TextureUsages::COPY_DST
222+ | wgpu::TextureUsages::RENDER_ATTACHMENT,
223+ view_formats: &[],
224+ });
225+ let uniforms = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
226+ label: Some("image uniforms"),
227+ contents: bytemuck::bytes_of(&Uniforms::zeroed()),
228+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
229+ });
230+ let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
231+ let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
232+ label: Some("image"),
233+ layout: &self.layout,
234+ entries: &[
235+ wgpu::BindGroupEntry {
236+ binding: 0,
237+ resource: uniforms.as_entire_binding(),
238+ },
239+ wgpu::BindGroupEntry {
240+ binding: 1,
241+ resource: wgpu::BindingResource::TextureView(&view),
242+ },
243+ ],
244+ });
245+ GpuImage {
246+ texture,
247+ bind_group,
248+ uniforms,
249+ size: [w, h],
250+ rows_done: 0,
251+ ready: false,
252+ }
253+ }
254+
255+ /// Upload up to `budget` bytes of rows. Returns true once the image is complete.
256+ pub fn upload(
257+ &self,
258+ device: &wgpu::Device,
259+ queue: &wgpu::Queue,
260+ gpu: &mut GpuImage,
261+ img: &DecodedImage,
262+ budget: usize,
263+ ) -> bool {
264+ if gpu.ready {
265+ return true;
266+ }
267+ let [w, h] = gpu.size;
268+ let row_bytes = w as usize * 4;
269+ let rows = ((budget / row_bytes.max(1)) as u32).clamp(1, h - gpu.rows_done);
270+ let start = gpu.rows_done as usize * row_bytes;
271+ let end = start + rows as usize * row_bytes;
272+ queue.write_texture(
273+ wgpu::TexelCopyTextureInfo {
274+ texture: &gpu.texture,
275+ mip_level: 0,
276+ origin: wgpu::Origin3d {
277+ x: 0,
278+ y: gpu.rows_done,
279+ z: 0,
280+ },
281+ aspect: wgpu::TextureAspect::All,
282+ },
283+ &img.pixels[start..end],
284+ wgpu::TexelCopyBufferLayout {
285+ offset: 0,
286+ bytes_per_row: Some(row_bytes as u32),
287+ rows_per_image: Some(rows),
288+ },
289+ wgpu::Extent3d {
290+ width: w,
291+ height: rows,
292+ depth_or_array_layers: 1,
293+ },
294+ );
295+ gpu.rows_done += rows;
296+ if gpu.rows_done == h {
297+ self.generate_mips(device, queue, &gpu.texture);
298+ gpu.ready = true;
299+ } else {
300+ queue.submit([]);
301+ }
302+ gpu.ready
303+ }
304+
305+ fn generate_mips(&self, device: &wgpu::Device, queue: &wgpu::Queue, texture: &wgpu::Texture) {
306+ let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
307+ label: Some("mips"),
308+ });
309+ let level_view = |level| {
310+ texture.create_view(&wgpu::TextureViewDescriptor {
311+ base_mip_level: level,
312+ mip_level_count: Some(1),
313+ ..Default::default()
314+ })
315+ };
316+ for level in 1..texture.mip_level_count() {
317+ let src = level_view(level - 1);
318+ let dst = level_view(level);
319+ let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
320+ label: Some("mip"),
321+ layout: &self.mip_layout,
322+ entries: &[
323+ wgpu::BindGroupEntry {
324+ binding: 0,
325+ resource: wgpu::BindingResource::TextureView(&src),
326+ },
327+ wgpu::BindGroupEntry {
328+ binding: 1,
329+ resource: wgpu::BindingResource::Sampler(&self.mip_sampler),
330+ },
331+ ],
332+ });
333+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
334+ label: Some("mip"),
335+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
336+ view: &dst,
337+ depth_slice: None,
338+ resolve_target: None,
339+ ops: wgpu::Operations {
340+ load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
341+ store: wgpu::StoreOp::Store,
342+ },
343+ })],
344+ depth_stencil_attachment: None,
345+ timestamp_writes: None,
346+ occlusion_query_set: None,
347+ multiview_mask: None,
348+ });
349+ pass.set_pipeline(&self.mip_pipeline);
350+ pass.set_bind_group(0, &bind_group, &[]);
351+ pass.draw(0..3, 0..1);
352+ }
353+ queue.submit([encoder.finish()]);
354+ }
355+
356+ /// Write this frame's placement; call before the render pass begins.
357+ pub fn prepare(
358+ &self,
359+ queue: &wgpu::Queue,
360+ gpu: &GpuImage,
361+ affine: Affine,
362+ viewport: [f32; 2],
363+ alpha: f32,
364+ style: &AlphaStyle,
365+ ) {
366+ let u = Uniforms {
367+ m: affine.m,
368+ t: affine.t,
369+ viewport,
370+ bg: style.bg,
371+ dot: style.dot,
372+ params: [alpha, style.mode as f32, style.cell, style.radius],
373+ };
374+ queue.write_buffer(&gpu.uniforms, 0, bytemuck::bytes_of(&u));
375+ }
376+
377+ pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, gpu: &GpuImage, nearest: bool) {
378+ pass.set_pipeline(&self.pipeline);
379+ pass.set_bind_group(0, &gpu.bind_group, &[]);
380+ pass.set_bind_group(1, if nearest { &self.nearest } else { &self.linear }, &[]);
381+ pass.draw(0..6, 0..1);
382+ }
383+}
A src/render/mod.rs View
@@ -0,0 +1,359 @@
1+//! wgpu device and surface setup, and per-frame orchestration.
2+
3+pub mod image;
4+pub mod prims;
5+pub mod text;
6+
7+use std::collections::HashMap;
8+use std::path::{Path, PathBuf};
9+use std::sync::Arc;
10+
11+use anyhow::Context;
12+use winit::window::Window;
13+
14+use crate::config::theme::Rgba;
15+use crate::decode::DecodedImage;
16+use crate::state::view::Affine;
17+use image::{AlphaStyle, GpuImage, ImagePipeline};
18+use prims::{Prim, PrimPipeline};
19+use text::{TextItem, TextSystem};
20+
21+pub struct ImageDraw<'a> {
22+ pub path: &'a Path,
23+ pub affine: Affine,
24+ pub alpha: f32,
25+ pub nearest: bool,
26+}
27+
28+/// Everything one frame shows, in physical pixels. Built by `ui`, drawn here.
29+pub struct Scene<'a> {
30+ pub clear: Rgba,
31+ /// Images are clipped to this rect (x, y, w, h): the area above the status bar.
32+ pub image_clip: [f32; 4],
33+ pub alpha: AlphaStyle,
34+ pub images: Vec<ImageDraw<'a>>,
35+ pub prims: Vec<Prim>,
36+ pub texts: Vec<TextItem>,
37+}
38+
39+pub struct Renderer {
40+ window: Arc<Window>,
41+ instance: wgpu::Instance,
42+ surface: wgpu::Surface<'static>,
43+ device: wgpu::Device,
44+ queue: wgpu::Queue,
45+ config: wgpu::SurfaceConfiguration,
46+ view_format: wgpu::TextureFormat,
47+ images: ImagePipeline,
48+ prims: PrimPipeline,
49+ text: TextSystem,
50+ textures: HashMap<PathBuf, GpuImage>,
51+ max_dim: u32,
52+}
53+
54+impl Renderer {
55+ pub fn new(
56+ window: Arc<Window>,
57+ display: winit::event_loop::OwnedDisplayHandle,
58+ ) -> anyhow::Result<Self> {
59+ let mut desc =
60+ wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(display));
61+ if wgpu::Backends::from_env().is_none() {
62+ desc.backends = wgpu::Backends::VULKAN | wgpu::Backends::GL;
63+ }
64+ let instance = wgpu::Instance::new(desc);
65+ let surface = instance
66+ .create_surface(window.clone())
67+ .context("create surface")?;
68+ let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
69+ // The integrated GPU is plenty for images and doesn't wake a sleeping dGPU.
70+ power_preference:
71+ wgpu::PowerPreference::from_env().unwrap_or(wgpu::PowerPreference::LowPower),
72+ compatible_surface: Some(&surface),
73+ ..Default::default()
74+ }))
75+ .context("no suitable GPU adapter")?;
76+ let info = adapter.get_info();
77+ log::debug!("adapter: {} ({:?})", info.name, info.backend);
78+ let limits = wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits());
79+ let max_dim = limits.max_texture_dimension_2d;
80+ let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
81+ label: Some("remage"),
82+ required_limits: limits,
83+ ..Default::default()
84+ }))
85+ .context("request GPU device")?;
86+
87+ let caps = surface.get_capabilities(&adapter);
88+ let format = caps
89+ .formats
90+ .iter()
91+ .copied()
92+ .find(|f| f.is_srgb() || f.add_srgb_suffix() != *f)
93+ .or_else(|| caps.formats.first().copied())
94+ .context("surface supports no formats")?;
95+ // Render through an sRGB view so blending happens in linear light.
96+ let view_format = format.add_srgb_suffix();
97+ let alpha_mode = if caps.alpha_modes.contains(&wgpu::CompositeAlphaMode::Opaque) {
98+ wgpu::CompositeAlphaMode::Opaque
99+ } else {
100+ caps.alpha_modes
101+ .first()
102+ .copied()
103+ .unwrap_or(wgpu::CompositeAlphaMode::Auto)
104+ };
105+ let size = window.inner_size();
106+ let config = wgpu::SurfaceConfiguration {
107+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
108+ format,
109+ width: size.width.max(1),
110+ height: size.height.max(1),
111+ present_mode: wgpu::PresentMode::Fifo,
112+ alpha_mode,
113+ view_formats: if view_format == format {
114+ vec![]
115+ } else {
116+ vec![view_format]
117+ },
118+ desired_maximum_frame_latency: 1,
119+ color_space: wgpu::SurfaceColorSpace::Auto,
120+ };
121+ surface.configure(&device, &config);
122+
123+ Ok(Self {
124+ images: ImagePipeline::new(&device, view_format),
125+ prims: PrimPipeline::new(&device, view_format),
126+ text: TextSystem::new(&device, &queue, view_format),
127+ window,
128+ instance,
129+ surface,
130+ device,
131+ queue,
132+ config,
133+ view_format,
134+ textures: HashMap::new(),
135+ max_dim,
136+ })
137+ }
138+
139+ pub fn max_texture_dim(&self) -> u32 {
140+ self.max_dim
141+ }
142+
143+ pub fn resize(&mut self, width: u32, height: u32) {
144+ if width == 0 || height == 0 {
145+ return;
146+ }
147+ self.config.width = width;
148+ self.config.height = height;
149+ self.surface.configure(&self.device, &self.config);
150+ }
151+
152+ pub fn measure(&mut self, item: &TextItem) -> f32 {
153+ self.text.measure(item)
154+ }
155+
156+ pub fn set_fonts(&mut self, db: glyphon::fontdb::Database) {
157+ self.text.set_fonts(&self.device, &self.queue, db);
158+ }
159+
160+ pub fn is_ready(&self, path: &Path) -> bool {
161+ self.textures.get(path).is_some_and(GpuImage::is_ready)
162+ }
163+
164+ /// Upload up to `budget` bytes of `img`. Returns true once it is ready to draw.
165+ pub fn upload(&mut self, path: &Path, img: &DecodedImage, budget: usize) -> bool {
166+ let gpu = match self.textures.get_mut(path) {
167+ Some(g) => g,
168+ None => {
169+ let g = self.images.create(&self.device, img);
170+ self.textures.entry(path.to_path_buf()).or_insert(g)
171+ }
172+ };
173+ self.images
174+ .upload(&self.device, &self.queue, gpu, img, budget)
175+ }
176+
177+ /// Drop textures that are no longer wanted.
178+ pub fn retain(&mut self, keep: impl Fn(&Path) -> bool) {
179+ self.textures.retain(|p, _| keep(p));
180+ }
181+
182+ pub fn render(&mut self, scene: &Scene) -> anyhow::Result<()> {
183+ let frame = match self.surface.get_current_texture() {
184+ wgpu::CurrentSurfaceTexture::Success(f) => f,
185+ wgpu::CurrentSurfaceTexture::Suboptimal(f) => {
186+ self.surface.configure(&self.device, &self.config);
187+ f
188+ }
189+ wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => {
190+ return Ok(());
191+ }
192+ wgpu::CurrentSurfaceTexture::Outdated => {
193+ self.surface.configure(&self.device, &self.config);
194+ self.window.request_redraw();
195+ return Ok(());
196+ }
197+ wgpu::CurrentSurfaceTexture::Lost => {
198+ self.surface = self.instance.create_surface(self.window.clone())?;
199+ self.surface.configure(&self.device, &self.config);
200+ self.window.request_redraw();
201+ return Ok(());
202+ }
203+ wgpu::CurrentSurfaceTexture::Validation => anyhow::bail!("surface validation error"),
204+ };
205+ let size = [self.config.width, self.config.height];
206+ let view = frame.texture.create_view(&wgpu::TextureViewDescriptor {
207+ format: Some(self.view_format),
208+ ..Default::default()
209+ });
210+ let commands = self.encode(scene, &view, size)?;
211+ self.queue.submit([commands]);
212+ self.window.pre_present_notify();
213+ self.queue.present(frame);
214+ self.text.trim();
215+ Ok(())
216+ }
217+
218+ /// Record the frame into `view`. Uniform and text uploads happen here too.
219+ fn encode(
220+ &mut self,
221+ scene: &Scene,
222+ view: &wgpu::TextureView,
223+ size: [u32; 2],
224+ ) -> anyhow::Result<wgpu::CommandBuffer> {
225+ let viewport = [size[0] as f32, size[1] as f32];
226+
227+ let draws: Vec<_> = scene
228+ .images
229+ .iter()
230+ .filter_map(|d| {
231+ self.textures
232+ .get(d.path)
233+ .filter(|g| g.is_ready())
234+ .map(|g| (d, g))
235+ })
236+ .collect();
237+ for (d, gpu) in &draws {
238+ self.images
239+ .prepare(&self.queue, gpu, d.affine, viewport, d.alpha, &scene.alpha);
240+ }
241+ self.prims
242+ .prepare(&self.device, &self.queue, &scene.prims, viewport);
243+ self.text
244+ .prepare(&self.device, &self.queue, &scene.texts, size)?;
245+
246+ let mut encoder = self
247+ .device
248+ .create_command_encoder(&wgpu::CommandEncoderDescriptor {
249+ label: Some("frame"),
250+ });
251+ {
252+ let [r, g, b, a] = scene.clear.linear();
253+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
254+ label: Some("frame"),
255+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
256+ view,
257+ depth_slice: None,
258+ resolve_target: None,
259+ ops: wgpu::Operations {
260+ load: wgpu::LoadOp::Clear(wgpu::Color {
261+ r: r as f64,
262+ g: g as f64,
263+ b: b as f64,
264+ a: a as f64,
265+ }),
266+ store: wgpu::StoreOp::Store,
267+ },
268+ })],
269+ depth_stencil_attachment: None,
270+ timestamp_writes: None,
271+ occlusion_query_set: None,
272+ multiview_mask: None,
273+ });
274+ if let Some((x, y, w, h)) = clip(scene.image_clip, size) {
275+ pass.set_scissor_rect(x, y, w, h);
276+ for (d, gpu) in &draws {
277+ self.images.draw(&mut pass, gpu, d.nearest);
278+ }
279+ pass.set_scissor_rect(0, 0, size[0], size[1]);
280+ }
281+ self.prims.draw(&mut pass);
282+ self.text.render(&mut pass)?;
283+ }
284+ Ok(encoder.finish())
285+ }
286+
287+ /// Debug aid (`REMAGE_SNAPSHOT=out.png`): render `scene` offscreen and save it.
288+ pub fn snapshot(&mut self, scene: &Scene, path: &Path) -> anyhow::Result<()> {
289+ let size = [self.config.width, self.config.height];
290+ let texture = self.device.create_texture(&wgpu::TextureDescriptor {
291+ label: Some("snapshot"),
292+ size: wgpu::Extent3d {
293+ width: size[0],
294+ height: size[1],
295+ depth_or_array_layers: 1,
296+ },
297+ mip_level_count: 1,
298+ sample_count: 1,
299+ dimension: wgpu::TextureDimension::D2,
300+ format: self.view_format,
301+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
302+ view_formats: &[],
303+ });
304+ let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
305+ let commands = self.encode(scene, &view, size)?;
306+ let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
307+ let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
308+ label: Some("snapshot"),
309+ size: row as u64 * size[1] as u64,
310+ usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
311+ mapped_at_creation: false,
312+ });
313+ let mut encoder = self
314+ .device
315+ .create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
316+ encoder.copy_texture_to_buffer(
317+ texture.as_image_copy(),
318+ wgpu::TexelCopyBufferInfo {
319+ buffer: &buffer,
320+ layout: wgpu::TexelCopyBufferLayout {
321+ offset: 0,
322+ bytes_per_row: Some(row),
323+ rows_per_image: Some(size[1]),
324+ },
325+ },
326+ texture.size(),
327+ );
328+ self.queue.submit([commands, encoder.finish()]);
329+ buffer.map_async(wgpu::MapMode::Read, .., |_| {});
330+ self.device.poll(wgpu::PollType::wait_indefinitely())?;
331+ let data = buffer.get_mapped_range(..)?;
332+ let bgra = matches!(self.view_format, wgpu::TextureFormat::Bgra8UnormSrgb);
333+ let mut pixels = Vec::with_capacity((size[0] * size[1] * 4) as usize);
334+ for y in 0..size[1] as usize {
335+ let line = &data[y * row as usize..][..size[0] as usize * 4];
336+ for px in line.chunks_exact(4) {
337+ let rgb = if bgra {
338+ [px[2], px[1], px[0]]
339+ } else {
340+ [px[0], px[1], px[2]]
341+ };
342+ pixels.extend_from_slice(&[rgb[0], rgb[1], rgb[2], 255]);
343+ }
344+ }
345+ ::image::RgbaImage::from_raw(size[0], size[1], pixels)
346+ .context("snapshot buffer size")?
347+ .save(path)?;
348+ Ok(())
349+ }
350+}
351+
352+/// Integer scissor rect inside the surface, or `None` if empty.
353+fn clip(r: [f32; 4], size: [u32; 2]) -> Option<(u32, u32, u32, u32)> {
354+ let x0 = r[0].max(0.0).round() as u32;
355+ let y0 = r[1].max(0.0).round() as u32;
356+ let x1 = ((r[0] + r[2]).round() as u32).min(size[0]);
357+ let y1 = ((r[1] + r[3]).round() as u32).min(size[1]);
358+ (x1 > x0 && y1 > y0).then(|| (x0, y0, x1 - x0, y1 - y0))
359+}
A src/render/prims.rs View
@@ -0,0 +1,154 @@
1+//! One instanced SDF shader for rects, rounded rects, borders and dots.
2+
3+use bytemuck::{Pod, Zeroable};
4+use wgpu::util::DeviceExt;
5+
6+/// A primitive in physical pixels; colours are linear with straight alpha.
7+#[repr(C)]
8+#[derive(Clone, Copy, Debug, Pod, Zeroable)]
9+pub struct Prim {
10+ pub rect: [f32; 4],
11+ pub color: [f32; 4],
12+ pub border_color: [f32; 4],
13+ /// x: corner radius, y: border width.
14+ pub params: [f32; 4],
15+}
16+
17+impl Prim {
18+ pub fn rect(rect: [f32; 4], color: [f32; 4]) -> Self {
19+ Self {
20+ rect,
21+ color,
22+ border_color: [0.0; 4],
23+ params: [0.0; 4],
24+ }
25+ }
26+}
27+
28+pub struct PrimPipeline {
29+ pipeline: wgpu::RenderPipeline,
30+ globals: wgpu::Buffer,
31+ bind_group: wgpu::BindGroup,
32+ instances: wgpu::Buffer,
33+ capacity: usize,
34+ count: u32,
35+}
36+
37+const ATTRS: [wgpu::VertexAttribute; 4] =
38+ wgpu::vertex_attr_array![0 => Float32x4, 1 => Float32x4, 2 => Float32x4, 3 => Float32x4];
39+
40+fn instance_buffer(device: &wgpu::Device, capacity: usize) -> wgpu::Buffer {
41+ device.create_buffer(&wgpu::BufferDescriptor {
42+ label: Some("prims"),
43+ size: (capacity * size_of::<Prim>()) as u64,
44+ usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
45+ mapped_at_creation: false,
46+ })
47+}
48+
49+impl PrimPipeline {
50+ pub fn new(device: &wgpu::Device, target: wgpu::TextureFormat) -> Self {
51+ let globals = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
52+ label: Some("prim globals"),
53+ contents: bytemuck::bytes_of(&[0.0f32; 4]),
54+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
55+ });
56+ let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
57+ label: Some("prims"),
58+ entries: &[wgpu::BindGroupLayoutEntry {
59+ binding: 0,
60+ visibility: wgpu::ShaderStages::VERTEX,
61+ ty: wgpu::BindingType::Buffer {
62+ ty: wgpu::BufferBindingType::Uniform,
63+ has_dynamic_offset: false,
64+ min_binding_size: None,
65+ },
66+ count: None,
67+ }],
68+ });
69+ let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
70+ label: Some("prims"),
71+ layout: &layout,
72+ entries: &[wgpu::BindGroupEntry {
73+ binding: 0,
74+ resource: globals.as_entire_binding(),
75+ }],
76+ });
77+ let shader = device.create_shader_module(wgpu::include_wgsl!("shaders/prims.wgsl"));
78+ let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
79+ label: Some("prims"),
80+ bind_group_layouts: &[Some(&layout)],
81+ immediate_size: 0,
82+ });
83+ let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
84+ label: Some("prims"),
85+ layout: Some(&pipeline_layout),
86+ vertex: wgpu::VertexState {
87+ module: &shader,
88+ entry_point: Some("vs"),
89+ compilation_options: Default::default(),
90+ buffers: &[Some(wgpu::VertexBufferLayout {
91+ array_stride: size_of::<Prim>() as u64,
92+ step_mode: wgpu::VertexStepMode::Instance,
93+ attributes: &ATTRS,
94+ })],
95+ },
96+ primitive: wgpu::PrimitiveState::default(),
97+ depth_stencil: None,
98+ multisample: wgpu::MultisampleState::default(),
99+ fragment: Some(wgpu::FragmentState {
100+ module: &shader,
101+ entry_point: Some("fs"),
102+ compilation_options: Default::default(),
103+ targets: &[Some(wgpu::ColorTargetState {
104+ format: target,
105+ blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
106+ write_mask: wgpu::ColorWrites::ALL,
107+ })],
108+ }),
109+ multiview_mask: None,
110+ cache: None,
111+ });
112+ let capacity = 64;
113+ Self {
114+ pipeline,
115+ globals,
116+ bind_group,
117+ instances: instance_buffer(device, capacity),
118+ capacity,
119+ count: 0,
120+ }
121+ }
122+
123+ pub fn prepare(
124+ &mut self,
125+ device: &wgpu::Device,
126+ queue: &wgpu::Queue,
127+ prims: &[Prim],
128+ viewport: [f32; 2],
129+ ) {
130+ queue.write_buffer(
131+ &self.globals,
132+ 0,
133+ bytemuck::bytes_of(&[viewport[0], viewport[1], 0.0, 0.0]),
134+ );
135+ if prims.len() > self.capacity {
136+ self.capacity = prims.len().next_power_of_two();
137+ self.instances = instance_buffer(device, self.capacity);
138+ }
139+ if !prims.is_empty() {
140+ queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(prims));
141+ }
142+ self.count = prims.len() as u32;
143+ }
144+
145+ pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
146+ if self.count == 0 {
147+ return;
148+ }
149+ pass.set_pipeline(&self.pipeline);
150+ pass.set_bind_group(0, &self.bind_group, &[]);
151+ pass.set_vertex_buffer(0, self.instances.slice(..));
152+ pass.draw(0..6, 0..self.count);
153+ }
154+}
A src/render/shaders/blit.wgsl View
@@ -0,0 +1,24 @@
1+// Mipmap generation: each level is a bilinear 2×2 average of the one above.
2+
3+@group(0) @binding(0) var src: texture_2d<f32>;
4+@group(0) @binding(1) var samp: sampler;
5+
6+struct VOut {
7+ @builtin(position) pos: vec4<f32>,
8+ @location(0) uv: vec2<f32>,
9+};
10+
11+@vertex
12+fn vs(@builtin(vertex_index) i: u32) -> VOut {
13+ // One oversized triangle covering the target.
14+ let uv = vec2(f32((i << 1u) & 2u), f32(i & 2u));
15+ var out: VOut;
16+ out.pos = vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0);
17+ out.uv = uv;
18+ return out;
19+}
20+
21+@fragment
22+fn fs(in: VOut) -> @location(0) vec4<f32> {
23+ return textureSample(src, samp, in.uv);
24+}
A src/render/shaders/image.wgsl View
@@ -0,0 +1,52 @@
1+// One image quad: affine placement, alpha background (solid / dots / checker), crossfade alpha.
2+
3+struct Uniforms {
4+ // screen = mat2(m.xy, m.zw) * uv + t, in physical pixels
5+ m: vec4<f32>,
6+ t: vec2<f32>,
7+ viewport: vec2<f32>,
8+ bg: vec4<f32>,
9+ dot_color: vec4<f32>,
10+ // x: opacity, y: alpha-bg mode (0 solid, 1 dots, 2 checker), z: cell px, w: dot radius px
11+ params: vec4<f32>,
12+};
13+
14+@group(0) @binding(0) var<uniform> u: Uniforms;
15+@group(0) @binding(1) var tex: texture_2d<f32>;
16+@group(1) @binding(0) var samp: sampler;
17+
18+struct VOut {
19+ @builtin(position) pos: vec4<f32>,
20+ @location(0) uv: vec2<f32>,
21+};
22+
23+@vertex
24+fn vs(@builtin(vertex_index) i: u32) -> VOut {
25+ var corners = array<vec2<f32>, 6>(
26+ vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0),
27+ vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0),
28+ );
29+ let uv = corners[i];
30+ let p = vec2(u.m.x * uv.x + u.m.y * uv.y + u.t.x, u.m.z * uv.x + u.m.w * uv.y + u.t.y);
31+ var out: VOut;
32+ out.pos = vec4(p.x / u.viewport.x * 2.0 - 1.0, 1.0 - p.y / u.viewport.y * 2.0, 0.0, 1.0);
33+ out.uv = uv;
34+ return out;
35+}
36+
37+@fragment
38+fn fs(in: VOut) -> @location(0) vec4<f32> {
39+ let c = textureSample(tex, samp, in.uv);
40+ var bg = u.bg.rgb;
41+ let mode = u32(u.params.y);
42+ let cell = u.params.z;
43+ if mode == 1u {
44+ let d = length(in.pos.xy - (floor(in.pos.xy / cell) + 0.5) * cell);
45+ let k = 1.0 - smoothstep(u.params.w - 0.5, u.params.w + 0.5, d);
46+ bg = mix(bg, u.dot_color.rgb, k);
47+ } else if mode == 2u {
48+ let q = vec2<u32>(floor(in.pos.xy / cell));
49+ bg = mix(bg, u.dot_color.rgb, f32((q.x + q.y) & 1u));
50+ }
51+ return vec4(mix(bg, c.rgb, c.a), u.params.x);
52+}
A src/render/shaders/prims.wgsl View
@@ -0,0 +1,60 @@
1+// Instanced SDF primitives: rects, rounded rects, borders and dots. Output is premultiplied.
2+
3+struct Globals {
4+ viewport: vec2<f32>,
5+ _pad: vec2<f32>,
6+};
7+
8+@group(0) @binding(0) var<uniform> g: Globals;
9+
10+struct Instance {
11+ @location(0) rect: vec4<f32>, // x, y, w, h in physical px
12+ @location(1) color: vec4<f32>, // linear, straight alpha
13+ @location(2) border_color: vec4<f32>,
14+ @location(3) params: vec4<f32>, // x: corner radius, y: border width
15+};
16+
17+struct VOut {
18+ @builtin(position) pos: vec4<f32>,
19+ @location(0) local: vec2<f32>,
20+ @location(1) half: vec2<f32>,
21+ @location(2) color: vec4<f32>,
22+ @location(3) border_color: vec4<f32>,
23+ @location(4) params: vec4<f32>,
24+};
25+
26+@vertex
27+fn vs(@builtin(vertex_index) i: u32, inst: Instance) -> VOut {
28+ var corners = array<vec2<f32>, 6>(
29+ vec2(-1.0, -1.0), vec2(1.0, -1.0), vec2(-1.0, 1.0),
30+ vec2(-1.0, 1.0), vec2(1.0, -1.0), vec2(1.0, 1.0),
31+ );
32+ let half = inst.rect.zw * 0.5;
33+ // Pad by a pixel so the anti-aliased edge isn't clipped.
34+ let local = corners[i] * (half + 1.0);
35+ let p = inst.rect.xy + half + local;
36+ var out: VOut;
37+ out.pos = vec4(p.x / g.viewport.x * 2.0 - 1.0, 1.0 - p.y / g.viewport.y * 2.0, 0.0, 1.0);
38+ out.local = local;
39+ out.half = half;
40+ out.color = inst.color;
41+ out.border_color = inst.border_color;
42+ out.params = inst.params;
43+ return out;
44+}
45+
46+@fragment
47+fn fs(in: VOut) -> @location(0) vec4<f32> {
48+ let r = min(in.params.x, min(in.half.x, in.half.y));
49+ let q = abs(in.local) - in.half + r;
50+ let d = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r;
51+ let coverage = clamp(0.5 - d, 0.0, 1.0);
52+ let fill = vec4(in.color.rgb * in.color.a, in.color.a);
53+ var c = fill;
54+ let bw = in.params.y;
55+ if bw > 0.0 {
56+ let border = vec4(in.border_color.rgb * in.border_color.a, in.border_color.a);
57+ c = mix(border, fill, clamp(0.5 - (d + bw), 0.0, 1.0));
58+ }
59+ return c * coverage;
60+}
A src/render/text.rs View
@@ -0,0 +1,285 @@
1+//! Text through glyphon, with the embedded Nothing fonts.
2+
3+use std::collections::HashMap;
4+
5+use glyphon::{
6+ Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache,
7+ TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, Weight, Wrap,
8+ cosmic_text::{Ellipsize, EllipsizeHeightLimit},
9+ fontdb,
10+};
11+
12+use crate::config::theme::Rgba;
13+
14+const FONTS: [&[u8]; 6] = [
15+ include_bytes!("../../assets/fonts/SpaceGrotesk-Light.otf"),
16+ include_bytes!("../../assets/fonts/SpaceGrotesk-Regular.otf"),
17+ include_bytes!("../../assets/fonts/SpaceGrotesk-Medium.otf"),
18+ include_bytes!("../../assets/fonts/SpaceMono-Regular.ttf"),
19+ include_bytes!("../../assets/fonts/SpaceMono-Bold.ttf"),
20+ include_bytes!("../../assets/fonts/Doto-Variable.ttf"),
21+];
22+
23+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
24+pub enum FontRole {
25+ /// Space Grotesk.
26+ Ui,
27+ /// Space Mono, for labels and data.
28+ Mono,
29+ /// Doto, dot-matrix, 36 px and up.
30+ Display,
31+}
32+
33+impl FontRole {
34+ fn family(self) -> &'static str {
35+ match self {
36+ FontRole::Ui => "Space Grotesk",
37+ FontRole::Mono => "Space Mono",
38+ FontRole::Display => "Doto",
39+ }
40+ }
41+}
42+
43+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44+pub enum Align {
45+ Left,
46+ Center,
47+ Right,
48+}
49+
50+/// A single line of text. `x` is the left edge, centre or right edge depending on `align`;
51+/// `y` is the vertical centre of the line. All units are physical pixels.
52+#[derive(Clone, Debug)]
53+pub struct TextItem {
54+ pub spans: Vec<(String, Rgba)>,
55+ pub role: FontRole,
56+ pub size: f32,
57+ pub weight: u16,
58+ /// Tracking in em.
59+ pub tracking: f32,
60+ pub x: f32,
61+ pub y: f32,
62+ pub align: Align,
63+ pub max_width: Option<f32>,
64+}
65+
66+#[derive(Clone, PartialEq, Eq, Hash)]
67+struct Key {
68+ spans: Vec<(String, [u8; 4])>,
69+ role: FontRole,
70+ size: u32,
71+ weight: u16,
72+ tracking: u32,
73+ max_width: Option<u32>,
74+}
75+
76+impl Key {
77+ fn of(t: &TextItem) -> Self {
78+ Self {
79+ spans: t.spans.iter().map(|(s, c)| (s.clone(), c.0)).collect(),
80+ role: t.role,
81+ size: t.size.to_bits(),
82+ weight: t.weight,
83+ tracking: t.tracking.to_bits(),
84+ max_width: t.max_width.map(|m| m.max(0.0).round() as u32),
85+ }
86+ }
87+}
88+
89+struct Shaped {
90+ buffer: Buffer,
91+ width: f32,
92+ line_height: f32,
93+ used: bool,
94+}
95+
96+pub struct TextSystem {
97+ fonts: FontSystem,
98+ swash: SwashCache,
99+ cache: Cache,
100+ atlas: TextAtlas,
101+ renderer: TextRenderer,
102+ viewport: Viewport,
103+ format: wgpu::TextureFormat,
104+ shaped: HashMap<Key, Shaped>,
105+}
106+
107+/// Locale for shaping, from the usual environment variables.
108+fn locale() -> String {
109+ ["LC_ALL", "LC_MESSAGES", "LANG"]
110+ .iter()
111+ .filter_map(|v| std::env::var(v).ok())
112+ .find(|v| !v.is_empty() && v != "C" && v != "POSIX")
113+ .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
114+ .unwrap_or_else(|| "en-US".into())
115+}
116+
117+/// The embedded fonts only: fast to build, enough for the UI itself.
118+pub fn embedded_fonts() -> fontdb::Database {
119+ let mut db = fontdb::Database::new();
120+ for data in FONTS {
121+ db.load_font_data(data.to_vec());
122+ }
123+ db.set_sans_serif_family("Space Grotesk");
124+ db.set_monospace_family("Space Mono");
125+ db
126+}
127+
128+/// Embedded plus system fonts, for filenames in any script. Slow; build it off-thread.
129+pub fn all_fonts() -> fontdb::Database {
130+ let mut db = embedded_fonts();
131+ db.load_system_fonts();
132+ db
133+}
134+
135+impl TextSystem {
136+ pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
137+ let fonts = FontSystem::new_with_locale_and_db(locale(), embedded_fonts());
138+ let cache = Cache::new(device);
139+ let mut atlas = TextAtlas::new(device, queue, &cache, format);
140+ let renderer =
141+ TextRenderer::new(&mut atlas, device, wgpu::MultisampleState::default(), None);
142+ let viewport = Viewport::new(device, &cache);
143+ Self {
144+ fonts,
145+ swash: SwashCache::new(),
146+ cache,
147+ atlas,
148+ renderer,
149+ viewport,
150+ format,
151+ shaped: HashMap::new(),
152+ }
153+ }
154+
155+ /// Swap in a larger font database (glyph caches are keyed by font id, so start fresh).
156+ pub fn set_fonts(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, db: fontdb::Database) {
157+ self.fonts = FontSystem::new_with_locale_and_db(locale(), db);
158+ self.swash = SwashCache::new();
159+ self.atlas = TextAtlas::new(device, queue, &self.cache, self.format);
160+ self.renderer = TextRenderer::new(
161+ &mut self.atlas,
162+ device,
163+ wgpu::MultisampleState::default(),
164+ None,
165+ );
166+ self.shaped.clear();
167+ }
168+
169+ fn shape(&mut self, item: &TextItem) -> Key {
170+ let key = Key::of(item);
171+ if let Some(s) = self.shaped.get_mut(&key) {
172+ s.used = true;
173+ return key;
174+ }
175+ let line_height = (item.size * 1.25).ceil();
176+ let mut buffer = Buffer::new(&mut self.fonts, Metrics::new(item.size, line_height));
177+ buffer.set_size(None, None);
178+ let base = Attrs::new()
179+ .family(Family::Name(item.role.family()))
180+ .weight(Weight(item.weight))
181+ .letter_spacing(item.tracking);
182+ let spans = item.spans.iter().map(|(s, c)| {
183+ let [r, g, b, a] = c.0;
184+ (s.as_str(), base.clone().color(Color::rgba(r, g, b, a)))
185+ });
186+ buffer.set_rich_text(spans, &base, Shaping::Advanced, None);
187+ buffer.shape_until_scroll(&mut self.fonts, false);
188+ let mut width = buffer.layout_runs().map(|r| r.line_w).fold(0.0, f32::max);
189+ if let Some(max) = key.max_width.map(|m| m as f32)
190+ && width > max
191+ {
192+ // Middle ellipsis keeps both the start and the extension of a filename.
193+ buffer.set_size(Some(max), None);
194+ buffer.set_wrap(Wrap::None);
195+ buffer.set_ellipsize(Ellipsize::Middle(EllipsizeHeightLimit::Lines(1)));
196+ buffer.shape_until_scroll(&mut self.fonts, false);
197+ width = buffer
198+ .layout_runs()
199+ .map(|r| r.line_w)
200+ .fold(0.0, f32::max)
201+ .min(max);
202+ }
203+ self.shaped.insert(
204+ key.clone(),
205+ Shaped {
206+ buffer,
207+ width,
208+ line_height,
209+ used: true,
210+ },
211+ );
212+ key
213+ }
214+
215+ /// Width of `item` once laid out, in physical pixels.
216+ pub fn measure(&mut self, item: &TextItem) -> f32 {
217+ let key = self.shape(item);
218+ self.shaped.get(&key).map_or(0.0, |s| s.width)
219+ }
220+
221+ /// Shape and upload this frame's text.
222+ pub fn prepare(
223+ &mut self,
224+ device: &wgpu::Device,
225+ queue: &wgpu::Queue,
226+ items: &[TextItem],
227+ size: [u32; 2],
228+ ) -> anyhow::Result<()> {
229+ let keys: Vec<Key> = items.iter().map(|i| self.shape(i)).collect();
230+ // Keep what this frame (including any `measure` calls) used, and reset for the next.
231+ self.shaped.retain(|_, s| std::mem::take(&mut s.used));
232+ self.viewport.update(
233+ queue,
234+ Resolution {
235+ width: size[0],
236+ height: size[1],
237+ },
238+ );
239+
240+ let areas = items.iter().zip(&keys).filter_map(|(item, key)| {
241+ let s = self.shaped.get(key)?;
242+ let width = s.width;
243+ let left = match item.align {
244+ Align::Left => item.x,
245+ Align::Center => item.x - width * 0.5,
246+ Align::Right => item.x - width,
247+ };
248+ let top = item.y - s.line_height * 0.5;
249+ let color = item.spans.first().map_or(Rgba([255; 4]), |(_, c)| *c).0;
250+ Some(TextArea {
251+ buffer: &s.buffer,
252+ left,
253+ top,
254+ scale: 1.0,
255+ bounds: TextBounds {
256+ left: left.floor() as i32,
257+ top: top.floor() as i32,
258+ right: (left + width).ceil() as i32,
259+ bottom: (top + s.line_height).ceil() as i32,
260+ },
261+ default_color: Color::rgba(color[0], color[1], color[2], color[3]),
262+ custom_glyphs: &[],
263+ })
264+ });
265+ self.renderer.prepare(
266+ device,
267+ queue,
268+ &mut self.fonts,
269+ &mut self.atlas,
270+ &self.viewport,
271+ areas,
272+ &mut self.swash,
273+ )?;
274+ Ok(())
275+ }
276+
277+ pub fn render(&self, pass: &mut wgpu::RenderPass<'_>) -> anyhow::Result<()> {
278+ self.renderer.render(&self.atlas, &self.viewport, pass)?;
279+ Ok(())
280+ }
281+
282+ pub fn trim(&mut self) {
283+ self.atlas.trim();
284+ }
285+}
A src/scan.rs View
@@ -0,0 +1,143 @@
1+//! Turn command-line paths into the initial file list.
2+
3+use std::path::{Path, PathBuf};
4+use std::time::{SystemTime, UNIX_EPOCH};
5+
6+use crate::decode;
7+use crate::state::queue::{SortKey, sort_files};
8+
9+#[derive(Debug, Default)]
10+pub struct Scan {
11+ pub files: Vec<PathBuf>,
12+ /// The file to open first (a single file argument opened with its siblings).
13+ pub start: Option<PathBuf>,
14+ /// Files with unrecognised extensions, to sniff in the background.
15+ pub unknown: Vec<PathBuf>,
16+ pub errors: Vec<String>,
17+}
18+
19+pub struct ScanOptions {
20+ pub recursive: bool,
21+ pub siblings: bool,
22+ pub sort: SortKey,
23+ pub reverse: bool,
24+}
25+
26+pub fn scan(args: &[PathBuf], opts: &ScanOptions) -> Scan {
27+ let dot = [PathBuf::from(".")];
28+ let args = if args.is_empty() { &dot[..] } else { args };
29+ let mut out = Scan::default();
30+ for arg in args {
31+ let path = std::path::absolute(arg).unwrap_or_else(|_| arg.clone());
32+ let Ok(meta) = std::fs::metadata(&path) else {
33+ out.errors.push(format!("{}: not found", arg.display()));
34+ continue;
35+ };
36+ if meta.is_dir() {
37+ let mut files = Vec::new();
38+ list_dir(&path, opts.recursive, &mut files, &mut out.unknown);
39+ sort(&mut files, opts);
40+ out.files.extend(files);
41+ } else if opts.siblings && args.len() == 1 {
42+ let parent = path.parent().unwrap_or(Path::new("/"));
43+ let mut files = Vec::new();
44+ list_dir(parent, false, &mut files, &mut out.unknown);
45+ out.unknown.retain(|u| *u != path);
46+ if !files.contains(&path) {
47+ files.push(path.clone());
48+ }
49+ sort(&mut files, opts);
50+ out.files.extend(files);
51+ out.start = Some(path);
52+ } else {
53+ // Explicitly named files are always attempted, whatever their extension.
54+ out.files.push(path);
55+ }
56+ }
57+ out
58+}
59+
60+fn sort(files: &mut [PathBuf], opts: &ScanOptions) {
61+ let meta = |p: &Path| {
62+ std::fs::metadata(p).map_or((0, 0), |m| {
63+ let mtime = m
64+ .modified()
65+ .ok()
66+ .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
67+ .map_or(0, |d| d.as_secs() as i64);
68+ (mtime, m.len())
69+ })
70+ };
71+ let seed = SystemTime::now()
72+ .duration_since(UNIX_EPOCH)
73+ .map_or(1, |d| d.as_nanos() as u64);
74+ sort_files(files, opts.sort, opts.reverse, meta, seed);
75+}
76+
77+/// Collect visible files under `dir`. Symlinked directories are not followed.
78+fn list_dir(dir: &Path, recursive: bool, known: &mut Vec<PathBuf>, unknown: &mut Vec<PathBuf>) {
79+ let Ok(entries) = std::fs::read_dir(dir) else {
80+ log::warn!("cannot read {}", dir.display());
81+ return;
82+ };
83+ for entry in entries.flatten() {
84+ if entry.file_name().as_encoded_bytes().starts_with(b".") {
85+ continue;
86+ }
87+ let path = entry.path();
88+ let Ok(ft) = entry.file_type() else { continue };
89+ if ft.is_dir() {
90+ if recursive {
91+ list_dir(&path, true, known, unknown);
92+ }
93+ continue;
94+ }
95+ if ft.is_symlink() && std::fs::metadata(&path).is_ok_and(|m| m.is_dir()) {
96+ continue;
97+ }
98+ if decode::is_known_extension(&path) {
99+ known.push(path);
100+ } else {
101+ unknown.push(path);
102+ }
103+ }
104+}
105+
106+#[cfg(test)]
107+mod tests {
108+ use super::*;
109+
110+ fn opts() -> ScanOptions {
111+ ScanOptions {
112+ recursive: false,
113+ siblings: true,
114+ sort: SortKey::Natural,
115+ reverse: false,
116+ }
117+ }
118+
119+ #[test]
120+ fn single_file_opens_with_siblings() {
121+ let dir = std::env::temp_dir().join(format!("remage-scan-{}", std::process::id()));
122+ std::fs::create_dir_all(dir.join("sub")).unwrap();
123+ for f in ["b10.png", "b2.jpg", ".hidden.png", "notes.txt", "sub/c.png"] {
124+ std::fs::write(dir.join(f), b"").unwrap();
125+ }
126+ let s = scan(&[dir.join("b10.png")], &opts());
127+ let names: Vec<_> = s.files.iter().map(|p| p.file_name().unwrap()).collect();
128+ assert_eq!(names, ["b2.jpg", "b10.png"]);
129+ assert_eq!(s.start.as_deref(), Some(dir.join("b10.png").as_path()));
130+ assert_eq!(s.unknown, [dir.join("notes.txt")]);
131+
132+ let s = scan(
133+ std::slice::from_ref(&dir),
134+ &ScanOptions {
135+ recursive: true,
136+ ..opts()
137+ },
138+ );
139+ assert_eq!(s.files.len(), 3);
140+ assert!(s.start.is_none());
141+ std::fs::remove_dir_all(&dir).unwrap();
142+ }
143+}
A src/state/grid.rs View
@@ -0,0 +1 @@
1+//! Grid layout, cursor, scrolling and the visual-mode range.
A src/state/mod.rs View
@@ -0,0 +1,467 @@
1+//! Pure application state: no winit or wgpu types, so everything here is unit-testable.
2+
3+pub mod grid;
4+pub mod queue;
5+pub mod view;
6+
7+use std::path::{Path, PathBuf};
8+use std::time::{Duration, Instant};
9+
10+use crate::action::{Action, Zoom};
11+use crate::anim::Tween;
12+use queue::Queue;
13+use view::{Affine, Dir, FitMode, View};
14+
15+/// `[LOADING]` appears only once a load has taken this long, so it never flickers.
16+pub const LOADING_DELAY: Duration = Duration::from_millis(120);
17+/// A tap on a pan key moves for at least this long.
18+const MIN_PAN_HOLD: Duration = Duration::from_millis(120);
19+/// Held-key pan speed, in viewports per second.
20+const PAN_SPEED: f32 = 1.25;
21+/// Logical height of the status bar.
22+pub const BAR_HEIGHT: f32 = 32.0;
23+
24+#[derive(Clone, Debug)]
25+pub struct Settings {
26+ pub fit: FitMode,
27+ pub zoom: Option<f32>,
28+ pub zoom_step: f32,
29+ pub wrap: bool,
30+ pub preload: usize,
31+ pub anim: Duration,
32+ pub crossfade: Duration,
33+}
34+
35+impl Default for Settings {
36+ fn default() -> Self {
37+ Self {
38+ fit: FitMode::Down,
39+ zoom: None,
40+ zoom_step: 1.25,
41+ wrap: true,
42+ preload: 2,
43+ anim: Duration::from_millis(180),
44+ crossfade: Duration::from_millis(100),
45+ }
46+ }
47+}
48+
49+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50+pub enum AlphaBg {
51+ Dots,
52+ Checker,
53+ Solid,
54+}
55+
56+impl AlphaBg {
57+ fn next(self) -> Self {
58+ match self {
59+ AlphaBg::Dots => AlphaBg::Checker,
60+ AlphaBg::Checker => AlphaBg::Solid,
61+ AlphaBg::Solid => AlphaBg::Dots,
62+ }
63+ }
64+}
65+
66+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67+pub enum MsgKind {
68+ Info,
69+ Error,
70+}
71+
72+#[derive(Clone, Debug)]
73+pub struct Message {
74+ pub text: String,
75+ pub kind: MsgKind,
76+ pub until: Instant,
77+}
78+
79+/// The image currently on screen (which may lag the queue's current file while it loads).
80+#[derive(Clone, Debug)]
81+pub struct Shown {
82+ pub path: PathBuf,
83+ /// Pixel size before any downscale for the GPU.
84+ pub size: [u32; 2],
85+ pub format: &'static str,
86+}
87+
88+/// The previous image fading out under the new one.
89+#[derive(Clone, Debug)]
90+pub struct Fade {
91+ pub path: PathBuf,
92+ pub affine: Affine,
93+ pub alpha: Tween<f32>,
94+}
95+
96+#[derive(Clone, Copy, Debug)]
97+struct Hold {
98+ dir: Dir,
99+ pressed: Instant,
100+ released: Option<Instant>,
101+}
102+
103+/// What the event loop must do after an action.
104+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
105+pub struct Effects {
106+ pub redraw: bool,
107+ /// The queue's current file changed: load it and refresh the preload window.
108+ pub navigated: bool,
109+ /// The status bar appeared or vanished, so the image area changed.
110+ pub layout: bool,
111+ pub fullscreen: bool,
112+ pub reload: bool,
113+ pub quit: bool,
114+}
115+
116+#[derive(Debug)]
117+pub struct AppState {
118+ pub queue: Queue,
119+ pub view: View,
120+ pub settings: Settings,
121+ pub bar: bool,
122+ /// `None` = automatic (nearest above 300%), otherwise forced nearest/linear.
123+ pub nearest: Option<bool>,
124+ pub alpha_bg: AlphaBg,
125+ pub shown: Option<Shown>,
126+ pub fade: Option<Fade>,
127+ pub loading_since: Option<Instant>,
128+ pub skipped: usize,
129+ pub message: Option<Message>,
130+ /// Keys typed so far of an incomplete sequence.
131+ pub pending: String,
132+ window: [f32; 2],
133+ scale_factor: f32,
134+ holds: Vec<Hold>,
135+ last_tick: Option<Instant>,
136+}
137+
138+impl AppState {
139+ pub fn new(queue: Queue, settings: Settings, bar: bool, now: Instant) -> Self {
140+ Self {
141+ queue,
142+ view: View::new(now),
143+ settings,
144+ bar,
145+ nearest: None,
146+ alpha_bg: AlphaBg::Dots,
147+ shown: None,
148+ fade: None,
149+ loading_since: None,
150+ skipped: 0,
151+ message: None,
152+ pending: String::new(),
153+ window: [1.0, 1.0],
154+ scale_factor: 1.0,
155+ holds: Vec::new(),
156+ last_tick: None,
157+ }
158+ }
159+
160+ /// Window size in physical pixels.
161+ pub fn set_window(&mut self, w: f32, h: f32, scale_factor: f32) {
162+ self.window = [w, h];
163+ self.scale_factor = scale_factor;
164+ self.update_viewport();
165+ }
166+
167+ pub fn window(&self) -> [f32; 2] {
168+ self.window
169+ }
170+
171+ pub fn scale_factor(&self) -> f32 {
172+ self.scale_factor
173+ }
174+
175+ /// Physical height of the status bar, 0 when hidden.
176+ pub fn bar_height(&self) -> f32 {
177+ if self.bar {
178+ (BAR_HEIGHT * self.scale_factor).round()
179+ } else {
180+ 0.0
181+ }
182+ }
183+
184+ fn update_viewport(&mut self) {
185+ let h = (self.window[1] - self.bar_height()).max(1.0);
186+ self.view.set_viewport(self.window[0], h);
187+ }
188+
189+ fn dur(&self) -> Duration {
190+ self.settings.anim
191+ }
192+
193+ pub fn apply(&mut self, action: Action, now: Instant) -> Effects {
194+ let mut fx = Effects {
195+ redraw: true,
196+ ..Effects::default()
197+ };
198+ let dur = self.dur();
199+ let has_image = self.shown.is_some();
200+ match action {
201+ Action::Next(n) => fx.navigated = self.queue.step(n as isize, self.settings.wrap),
202+ Action::Prev(n) => fx.navigated = self.queue.step(-(n as isize), self.settings.wrap),
203+ Action::First => fx.navigated = self.queue.goto(0),
204+ Action::Last => fx.navigated = self.queue.goto(usize::MAX),
205+ Action::Goto(n) => fx.navigated = self.queue.goto(n.saturating_sub(1)),
206+ Action::Zoom(z) if has_image => {
207+ let step = self.settings.zoom_step;
208+ match z {
209+ Zoom::In => self.view.zoom_by(step, None, now, dur),
210+ Zoom::Out => self.view.zoom_by(1.0 / step, None, now, dur),
211+ Zoom::Percent(p) => self.view.zoom_to(p / 100.0, None, now, dur),
212+ }
213+ }
214+ Action::Fit(fit) if has_image => self.view.set_fit(fit, now, dur),
215+ Action::Pan(dir) if has_image => self.view.pan_step(dir, 0.2, now, dur),
216+ Action::PanEdge(dir) if has_image => self.view.pan_edge(dir, now, dur),
217+ Action::Center if has_image => self.view.recenter(now, dur),
218+ Action::Rotate(q) if has_image => self.view.rotate(q, now, dur),
219+ Action::Flip(axis) if has_image => self.view.flip(axis, now, dur),
220+ Action::FilterToggle => {
221+ let nearest = !self.effective_nearest(now);
222+ self.nearest = Some(nearest);
223+ self.notify(
224+ if nearest { "NEAREST" } else { "LINEAR" },
225+ MsgKind::Info,
226+ now,
227+ );
228+ }
229+ Action::AlphaBgCycle => self.alpha_bg = self.alpha_bg.next(),
230+ Action::Fullscreen => fx.fullscreen = true,
231+ Action::BarToggle => {
232+ self.bar = !self.bar;
233+ self.update_viewport();
234+ fx.layout = true;
235+ }
236+ Action::Reload => fx.reload = true,
237+ Action::Remove => {
238+ if let Some(cur) = self.queue.current().map(Path::to_path_buf) {
239+ self.queue.remove(&cur);
240+ fx.navigated = true;
241+ }
242+ }
243+ Action::Cancel => self.message = None,
244+ Action::Quit => fx.quit = true,
245+ Action::Zoom(_)
246+ | Action::Fit(_)
247+ | Action::Pan(_)
248+ | Action::PanEdge(_)
249+ | Action::Center
250+ | Action::Rotate(_)
251+ | Action::Flip(_) => fx.redraw = false,
252+ }
253+ fx
254+ }
255+
256+ /// Start or stop a held-key pan.
257+ pub fn hold_pan(&mut self, dir: Dir, held: bool, now: Instant) {
258+ if held {
259+ if !self
260+ .holds
261+ .iter()
262+ .any(|h| h.dir == dir && h.released.is_none())
263+ {
264+ self.holds.retain(|h| h.dir != dir);
265+ self.holds.push(Hold {
266+ dir,
267+ pressed: now,
268+ released: None,
269+ });
270+ }
271+ } else {
272+ for h in self.holds.iter_mut().filter(|h| h.dir == dir) {
273+ h.released.get_or_insert(now);
274+ }
275+ }
276+ }
277+
278+ pub fn stop_all_pans(&mut self) {
279+ self.holds.clear();
280+ }
281+
282+ /// Advance time-driven state (held pans). Call once per frame before drawing.
283+ pub fn advance(&mut self, now: Instant) {
284+ let last = self.last_tick.replace(now).unwrap_or(now);
285+ if self.holds.is_empty() || self.shown.is_none() {
286+ self.holds.retain(|h| h.released.is_none());
287+ return;
288+ }
289+ let vp = self.view.viewport();
290+ let mut delta = [0.0f32; 2];
291+ for h in &self.holds {
292+ let end = h.released.map(|r| r.max(h.pressed + MIN_PAN_HOLD));
293+ let from = last.max(h.pressed);
294+ let to = end.map_or(now, |e| e.min(now));
295+ let dt = to.saturating_duration_since(from).as_secs_f32();
296+ let v = h.dir.vector();
297+ delta[0] -= v[0] * vp[0] * PAN_SPEED * dt;
298+ delta[1] -= v[1] * vp[1] * PAN_SPEED * dt;
299+ }
300+ self.holds.retain(|h| {
301+ h.released
302+ .is_none_or(|r| now < r.max(h.pressed + MIN_PAN_HOLD))
303+ });
304+ if delta != [0.0, 0.0] {
305+ self.view.pan_pixels(delta);
306+ }
307+ }
308+
309+ /// The image for the current file is ready to show.
310+ /// `size` is the original pixel size; the texture may be smaller (huge images), but the
311+ /// view works in original pixels so zoom percentages stay true.
312+ pub fn show(&mut self, path: &Path, size: [u32; 2], format: &'static str, now: Instant) {
313+ if let Some(prev) = self.shown.take()
314+ && prev.path != path
315+ && !self.settings.crossfade.is_zero()
316+ {
317+ let mut alpha = Tween::new(0.0, now);
318+ alpha.retarget(1.0, now, self.settings.crossfade);
319+ let affine = self.view.transform(now);
320+ self.fade = Some(Fade {
321+ path: prev.path,
322+ affine,
323+ alpha,
324+ });
325+ }
326+ self.shown = Some(Shown {
327+ path: path.to_path_buf(),
328+ size,
329+ format,
330+ });
331+ self.view
332+ .reset(size, self.settings.fit, self.settings.zoom, now);
333+ self.loading_since = None;
334+ self.holds.clear();
335+ }
336+
337+ /// The current file can't be shown (or the queue emptied).
338+ pub fn clear_shown(&mut self) {
339+ self.shown = None;
340+ self.fade = None;
341+ self.loading_since = None;
342+ }
343+
344+ /// Zoom by `steps` zoom steps around a viewport point (mouse wheel).
345+ pub fn zoom_at(&mut self, steps: f32, anchor: [f32; 2], now: Instant) {
346+ if self.shown.is_some() {
347+ let factor = self.settings.zoom_step.powf(steps);
348+ self.view
349+ .zoom_by(factor, Some(anchor), now, self.settings.anim);
350+ }
351+ }
352+
353+ pub fn notify(&mut self, text: impl Into<String>, kind: MsgKind, now: Instant) {
354+ let secs = if kind == MsgKind::Error { 5 } else { 2 };
355+ self.message = Some(Message {
356+ text: text.into(),
357+ kind,
358+ until: now + Duration::from_secs(secs),
359+ });
360+ }
361+
362+ pub fn effective_nearest(&self, now: Instant) -> bool {
363+ self.nearest
364+ .unwrap_or_else(|| self.view.display_scale(now) > view::NEAREST_ABOVE)
365+ }
366+
367+ pub fn loading_visible(&self, now: Instant) -> bool {
368+ self.loading_since.is_some_and(|t| now >= t + LOADING_DELAY)
369+ }
370+
371+ /// Whether frames must keep coming (animations, held keys).
372+ pub fn is_animating(&self, now: Instant) -> bool {
373+ self.view.is_animating(now)
374+ || self.fade.as_ref().is_some_and(|f| f.alpha.is_active(now))
375+ || !self.holds.is_empty()
376+ }
377+
378+ /// Expire timed state. Returns whether a redraw is due and the next wake-up time.
379+ pub fn tick(&mut self, now: Instant) -> (bool, Option<Instant>) {
380+ let mut redraw = false;
381+ let mut next: Option<Instant> = None;
382+ let mut wake = |t: Instant| next = Some(next.map_or(t, |n| n.min(t)));
383+ if let Some(m) = &self.message {
384+ if now >= m.until {
385+ self.message = None;
386+ redraw = true;
387+ } else {
388+ wake(m.until);
389+ }
390+ }
391+ if let Some(t) = self.loading_since {
392+ let at = t + LOADING_DELAY;
393+ if now < at {
394+ wake(at);
395+ }
396+ }
397+ if self.fade.as_ref().is_some_and(|f| !f.alpha.is_active(now)) {
398+ self.fade = None;
399+ redraw = true;
400+ }
401+ (redraw, next)
402+ }
403+}
404+
405+#[cfg(test)]
406+mod tests {
407+ use super::*;
408+
409+ fn state(n: usize) -> (AppState, Instant) {
410+ let now = Instant::now();
411+ let files = (0..n).map(|i| PathBuf::from(format!("{i}.png"))).collect();
412+ let mut s = AppState::new(Queue::new(files, None, 0), Settings::default(), true, now);
413+ s.set_window(800.0, 632.0, 1.0);
414+ (s, now)
415+ }
416+
417+ #[test]
418+ fn navigation_reports_changes() {
419+ let (mut s, now) = state(3);
420+ assert!(s.apply(Action::Next(1), now).navigated);
421+ assert!(s.apply(Action::Last, now).navigated);
422+ assert!(!s.apply(Action::Last, now).navigated);
423+ assert!(s.apply(Action::Goto(1), now).navigated);
424+ assert_eq!(s.queue.index(), 0);
425+ }
426+
427+ #[test]
428+ fn bar_shrinks_the_image_area() {
429+ let (mut s, now) = state(1);
430+ assert_eq!(s.view.viewport(), [800.0, 600.0]);
431+ assert!(s.apply(Action::BarToggle, now).layout);
432+ assert_eq!(s.view.viewport(), [800.0, 632.0]);
433+ }
434+
435+ #[test]
436+ fn showing_a_second_image_crossfades() {
437+ let (mut s, now) = state(2);
438+ s.show(Path::new("0.png"), [10, 10], "PNG", now);
439+ assert!(s.fade.is_none());
440+ s.show(Path::new("1.png"), [10, 10], "PNG", now);
441+ assert!(s.fade.is_some());
442+ assert!(s.is_animating(now));
443+ let later = now + Duration::from_secs(1);
444+ assert!(s.tick(later).0);
445+ assert!(s.fade.is_none());
446+ }
447+
448+ #[test]
449+ fn tapped_pan_moves_a_minimum_distance() {
450+ let (mut s, now) = state(1);
451+ s.show(Path::new("0.png"), [4000, 4000], "PNG", now);
452+ s.apply(Action::Fit(FitMode::Original), now);
453+ s.advance(now);
454+ let before = s.view.transform(now + Duration::from_secs(1)).t;
455+ s.hold_pan(Dir::Right, true, now);
456+ s.hold_pan(Dir::Right, false, now + Duration::from_millis(10));
457+ s.advance(now + Duration::from_millis(500));
458+ assert!(!s.is_animating(now + Duration::from_secs(1)));
459+ let after = s.view.transform(now + Duration::from_secs(1)).t;
460+ let moved = before[0] - after[0];
461+ let expected = 800.0 * PAN_SPEED * MIN_PAN_HOLD.as_secs_f32();
462+ assert!(
463+ (moved - expected).abs() < 1.0,
464+ "moved {moved}, expected {expected}"
465+ );
466+ }
467+}
A src/state/queue.rs View
@@ -0,0 +1,299 @@
1+//! The file list: current index, ordering and navigation.
2+
3+use std::cmp::Ordering;
4+use std::path::{Path, PathBuf};
5+
6+#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
7+pub enum SortKey {
8+ Natural,
9+ Name,
10+ Mtime,
11+ Size,
12+ Random,
13+}
14+
15+#[derive(Debug, Default)]
16+pub struct Queue {
17+ files: Vec<PathBuf>,
18+ index: usize,
19+}
20+
21+impl Queue {
22+ /// Build a queue positioned on `start` if present, else on `start_index` (clamped).
23+ pub fn new(files: Vec<PathBuf>, start: Option<&Path>, start_index: usize) -> Self {
24+ let index = start
25+ .and_then(|s| files.iter().position(|f| f == s))
26+ .unwrap_or(start_index);
27+ let mut q = Self { files, index: 0 };
28+ q.index = q.clamp(index);
29+ q
30+ }
31+
32+ pub fn len(&self) -> usize {
33+ self.files.len()
34+ }
35+
36+ pub fn is_empty(&self) -> bool {
37+ self.files.is_empty()
38+ }
39+
40+ pub fn index(&self) -> usize {
41+ self.index
42+ }
43+
44+ pub fn current(&self) -> Option<&Path> {
45+ self.files.get(self.index).map(PathBuf::as_path)
46+ }
47+
48+ pub fn get(&self, i: usize) -> Option<&Path> {
49+ self.files.get(i).map(PathBuf::as_path)
50+ }
51+
52+ /// Jump to `i` (clamped). Returns whether the current file changed.
53+ pub fn goto(&mut self, i: usize) -> bool {
54+ let i = self.clamp(i);
55+ let changed = i != self.index;
56+ self.index = i;
57+ changed
58+ }
59+
60+ /// Move by `delta`, wrapping around the ends when `wrap` is set.
61+ pub fn step(&mut self, delta: isize, wrap: bool) -> bool {
62+ if self.files.is_empty() {
63+ return false;
64+ }
65+ let len = self.files.len() as isize;
66+ let target = self.index as isize + delta;
67+ let i = if wrap {
68+ target.rem_euclid(len)
69+ } else {
70+ target.clamp(0, len - 1)
71+ };
72+ self.goto(i as usize)
73+ }
74+
75+ /// Remove `path`; the current position moves to the following file.
76+ pub fn remove(&mut self, path: &Path) -> bool {
77+ let Some(i) = self.files.iter().position(|f| f == path) else {
78+ return false;
79+ };
80+ self.files.remove(i);
81+ if i < self.index {
82+ self.index -= 1;
83+ }
84+ self.index = self.clamp(self.index);
85+ true
86+ }
87+
88+ /// Insert paths in natural order, keeping the current file selected.
89+ pub fn insert_sorted(&mut self, paths: impl IntoIterator<Item = PathBuf>) {
90+ let current = self.current().map(Path::to_path_buf);
91+ for p in paths {
92+ if self.files.contains(&p) {
93+ continue;
94+ }
95+ let at = self
96+ .files
97+ .partition_point(|f| natural_cmp_path(f, &p) != Ordering::Greater);
98+ self.files.insert(at, p);
99+ }
100+ if let Some(cur) = current
101+ && let Some(i) = self.files.iter().position(|f| *f == cur)
102+ {
103+ self.index = i;
104+ }
105+ }
106+
107+ /// Up to `n` neighbours on each side of the current file, nearest first.
108+ pub fn neighbours(&self, n: usize, wrap: bool) -> Vec<usize> {
109+ let len = self.files.len();
110+ let mut out = Vec::with_capacity(n * 2);
111+ for k in 1..=n {
112+ for delta in [k as isize, -(k as isize)] {
113+ let raw = self.index as isize + delta;
114+ let i = if wrap {
115+ raw.rem_euclid(len as isize) as usize
116+ } else if (0..len as isize).contains(&raw) {
117+ raw as usize
118+ } else {
119+ continue;
120+ };
121+ if i != self.index && !out.contains(&i) {
122+ out.push(i);
123+ }
124+ }
125+ }
126+ out
127+ }
128+
129+ fn clamp(&self, i: usize) -> usize {
130+ i.min(self.files.len().saturating_sub(1))
131+ }
132+}
133+
134+/// Sort `files` in place. `meta` supplies (mtime seconds, size) for the stat-based keys.
135+pub fn sort_files(
136+ files: &mut [PathBuf],
137+ key: SortKey,
138+ reverse: bool,
139+ meta: impl Fn(&Path) -> (i64, u64),
140+ seed: u64,
141+) {
142+ match key {
143+ SortKey::Natural => files.sort_by(|a, b| natural_cmp_path(a, b)),
144+ SortKey::Name => files.sort(),
145+ SortKey::Mtime => {
146+ files.sort_by_cached_key(|p| meta(p).0);
147+ }
148+ SortKey::Size => {
149+ files.sort_by_cached_key(|p| meta(p).1);
150+ }
151+ SortKey::Random => shuffle(files, seed),
152+ }
153+ if reverse {
154+ files.reverse();
155+ }
156+}
157+
158+fn shuffle<T>(items: &mut [T], seed: u64) {
159+ // xorshift64*: plenty for shuffling a file list.
160+ let mut s = seed | 1;
161+ for i in (1..items.len()).rev() {
162+ s ^= s >> 12;
163+ s ^= s << 25;
164+ s ^= s >> 27;
165+ let r = s.wrapping_mul(0x2545_F491_4F6C_DD1D);
166+ items.swap(i, (r % (i as u64 + 1)) as usize);
167+ }
168+}
169+
170+pub fn natural_cmp_path(a: &Path, b: &Path) -> Ordering {
171+ natural_cmp(&a.to_string_lossy(), &b.to_string_lossy())
172+}
173+
174+/// Compare strings with digit runs ordered numerically and letters case-insensitively,
175+/// so `img2` sorts before `img10` and `B` sits next to `b`.
176+pub fn natural_cmp(a: &str, b: &str) -> Ordering {
177+ let (mut x, mut y) = (a.chars().peekable(), b.chars().peekable());
178+ loop {
179+ match (x.peek().copied(), y.peek().copied()) {
180+ (None, None) => return a.cmp(b),
181+ (None, Some(_)) => return Ordering::Less,
182+ (Some(_), None) => return Ordering::Greater,
183+ (Some(c), Some(d)) if c.is_ascii_digit() && d.is_ascii_digit() => {
184+ let take = |it: &mut std::iter::Peekable<std::str::Chars>| {
185+ let mut s = String::new();
186+ while let Some(&c) = it.peek().filter(|c| c.is_ascii_digit()) {
187+ s.push(c);
188+ it.next();
189+ }
190+ s
191+ };
192+ let (m, n) = (take(&mut x), take(&mut y));
193+ let (mt, nt) = (m.trim_start_matches('0'), n.trim_start_matches('0'));
194+ let ord = mt.len().cmp(&nt.len()).then_with(|| mt.cmp(nt));
195+ if ord != Ordering::Equal {
196+ return ord;
197+ }
198+ }
199+ (Some(c), Some(d)) => {
200+ let ord = c.to_lowercase().cmp(d.to_lowercase());
201+ if ord != Ordering::Equal {
202+ return ord;
203+ }
204+ x.next();
205+ y.next();
206+ }
207+ }
208+ }
209+}
210+
211+#[cfg(test)]
212+mod tests {
213+ use super::*;
214+
215+ fn paths(names: &[&str]) -> Vec<PathBuf> {
216+ names.iter().map(PathBuf::from).collect()
217+ }
218+
219+ #[test]
220+ fn natural_order() {
221+ let mut v = vec![
222+ "img10.png",
223+ "img2.png",
224+ "Img1.png",
225+ "img02.png",
226+ "a.png",
227+ "img1b.png",
228+ ];
229+ v.sort_by(|a, b| natural_cmp(a, b));
230+ assert_eq!(
231+ v,
232+ [
233+ "a.png",
234+ "Img1.png",
235+ "img1b.png",
236+ "img02.png",
237+ "img2.png",
238+ "img10.png"
239+ ]
240+ );
241+ }
242+
243+ #[test]
244+ fn step_wraps_or_clamps() {
245+ let mut q = Queue::new(paths(&["a", "b", "c"]), None, 0);
246+ assert!(q.step(-1, true));
247+ assert_eq!(q.index(), 2);
248+ assert!(!q.step(1, false));
249+ assert!(q.step(-5, false));
250+ assert_eq!(q.index(), 0);
251+ }
252+
253+ #[test]
254+ fn starts_on_named_file() {
255+ let q = Queue::new(paths(&["a", "b", "c"]), Some(Path::new("b")), 0);
256+ assert_eq!(q.current(), Some(Path::new("b")));
257+ let q = Queue::new(paths(&["a", "b"]), None, 9);
258+ assert_eq!(q.index(), 1);
259+ }
260+
261+ #[test]
262+ fn remove_keeps_position() {
263+ let mut q = Queue::new(paths(&["a", "b", "c"]), Some(Path::new("b")), 0);
264+ q.remove(Path::new("a"));
265+ assert_eq!(q.current(), Some(Path::new("b")));
266+ q.remove(Path::new("b"));
267+ assert_eq!(q.current(), Some(Path::new("c")));
268+ q.remove(Path::new("c"));
269+ assert!(q.current().is_none());
270+ }
271+
272+ #[test]
273+ fn insert_sorted_keeps_current() {
274+ let mut q = Queue::new(paths(&["img1", "img3"]), Some(Path::new("img3")), 0);
275+ q.insert_sorted(paths(&["img2", "img10", "img1"]));
276+ assert_eq!(q.len(), 4);
277+ assert_eq!(q.get(1), Some(Path::new("img2")));
278+ assert_eq!(q.get(3), Some(Path::new("img10")));
279+ assert_eq!(q.current(), Some(Path::new("img3")));
280+ }
281+
282+ #[test]
283+ fn neighbours_nearest_first() {
284+ let q = Queue::new(paths(&["a", "b", "c", "d", "e"]), None, 0);
285+ assert_eq!(q.neighbours(2, false), vec![1, 2]);
286+ assert_eq!(q.neighbours(2, true), vec![1, 4, 2, 3]);
287+ let q = Queue::new(paths(&["a", "b"]), None, 0);
288+ assert_eq!(q.neighbours(2, true), vec![1]);
289+ }
290+
291+ #[test]
292+ fn random_sort_is_a_permutation() {
293+ let mut v = paths(&["a", "b", "c", "d", "e"]);
294+ sort_files(&mut v, SortKey::Random, false, |_| (0, 0), 42);
295+ let mut s = v.clone();
296+ s.sort();
297+ assert_eq!(s, paths(&["a", "b", "c", "d", "e"]));
298+ }
299+}
A src/state/view.rs View
@@ -0,0 +1,435 @@
1+//! Zoom/pan/fit/rotate/flip maths and their animation targets.
2+//!
3+//! Coordinates: the image is centred on the origin of *display space* (image pixels after
4+//! rotation and flip). `center` is the display-space point shown at the middle of the viewport.
5+//! A screen point is `viewport_centre + scale * (q - center)` for display-space point `q`.
6+
7+use std::f32::consts::FRAC_PI_2;
8+use std::time::{Duration, Instant};
9+
10+use crate::anim::Tween;
11+
12+pub const MIN_SCALE: f32 = 0.01;
13+pub const MAX_SCALE: f32 = 64.0;
14+/// Magnification above which `filter = auto` switches to nearest.
15+pub const NEAREST_ABOVE: f32 = 3.0;
16+
17+#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
18+pub enum FitMode {
19+ /// Fit, but never enlarge.
20+ Down,
21+ Fit,
22+ Fill,
23+ Width,
24+ Height,
25+ Original,
26+}
27+
28+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29+pub enum Dir {
30+ Left,
31+ Right,
32+ Up,
33+ Down,
34+}
35+
36+impl Dir {
37+ pub fn vector(self) -> [f32; 2] {
38+ match self {
39+ Dir::Left => [-1.0, 0.0],
40+ Dir::Right => [1.0, 0.0],
41+ Dir::Up => [0.0, -1.0],
42+ Dir::Down => [0.0, 1.0],
43+ }
44+ }
45+}
46+
47+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48+pub enum Axis {
49+ Horizontal,
50+ Vertical,
51+}
52+
53+/// Maps the unit quad onto the screen: `screen = m * uv + t`, in physical pixels.
54+#[derive(Clone, Copy, Debug, PartialEq)]
55+pub struct Affine {
56+ pub m: [f32; 4],
57+ pub t: [f32; 2],
58+}
59+
60+#[derive(Debug, Clone)]
61+pub struct View {
62+ img: [f32; 2],
63+ viewport: [f32; 2],
64+ fit: Option<FitMode>,
65+ // Targets.
66+ scale: f32,
67+ center: [f32; 2],
68+ quarter: i32,
69+ flip: [f32; 2],
70+ // Displayed values: scale tweens in log space around a fixed screen anchor `d`
71+ // (offset from the viewport centre), so `center = anchor - d / scale`.
72+ log_s: Tween<f32>,
73+ anchor: Tween<[f32; 2]>,
74+ d: [f32; 2],
75+ rot: Tween<f32>,
76+ flip_t: Tween<[f32; 2]>,
77+}
78+
79+fn add(a: [f32; 2], b: [f32; 2]) -> [f32; 2] {
80+ [a[0] + b[0], a[1] + b[1]]
81+}
82+
83+fn sub(a: [f32; 2], b: [f32; 2]) -> [f32; 2] {
84+ [a[0] - b[0], a[1] - b[1]]
85+}
86+
87+fn mul(a: [f32; 2], s: f32) -> [f32; 2] {
88+ [a[0] * s, a[1] * s]
89+}
90+
91+impl View {
92+ pub fn new(now: Instant) -> Self {
93+ Self {
94+ img: [1.0, 1.0],
95+ viewport: [1.0, 1.0],
96+ fit: Some(FitMode::Down),
97+ scale: 1.0,
98+ center: [0.0, 0.0],
99+ quarter: 0,
100+ flip: [1.0, 1.0],
101+ log_s: Tween::new(0.0, now),
102+ anchor: Tween::new([0.0, 0.0], now),
103+ d: [0.0, 0.0],
104+ rot: Tween::new(0.0, now),
105+ flip_t: Tween::new([1.0, 1.0], now),
106+ }
107+ }
108+
109+ /// Show a new image: rotation and flips reset, then `fit` or a fixed `zoom` applies.
110+ pub fn reset(&mut self, img: [u32; 2], fit: FitMode, zoom: Option<f32>, now: Instant) {
111+ self.img = [img[0].max(1) as f32, img[1].max(1) as f32];
112+ self.quarter = 0;
113+ self.flip = [1.0, 1.0];
114+ self.rot.set(0.0);
115+ self.flip_t.set([1.0, 1.0]);
116+ match zoom {
117+ Some(z) => {
118+ self.fit = None;
119+ self.scale = z.clamp(MIN_SCALE, MAX_SCALE);
120+ }
121+ None => {
122+ self.fit = Some(fit);
123+ self.scale = self.fit_scale(fit);
124+ }
125+ }
126+ self.center = self.clamp_center([0.0, 0.0], self.scale);
127+ self.jump();
128+ let _ = now;
129+ }
130+
131+ /// The image area changed; refit if a fit mode is active, without animating.
132+ pub fn set_viewport(&mut self, w: f32, h: f32) {
133+ self.viewport = [w.max(1.0), h.max(1.0)];
134+ if let Some(fit) = self.fit {
135+ self.scale = self.fit_scale(fit);
136+ }
137+ self.center = self.clamp_center(self.center, self.scale);
138+ self.jump();
139+ }
140+
141+ pub fn viewport(&self) -> [f32; 2] {
142+ self.viewport
143+ }
144+
145+ #[cfg(test)]
146+ pub fn fit_mode(&self) -> Option<FitMode> {
147+ self.fit
148+ }
149+
150+ /// Target scale (1.0 = one image pixel per physical pixel).
151+ pub fn scale(&self) -> f32 {
152+ self.scale
153+ }
154+
155+ pub fn display_scale(&self, now: Instant) -> f32 {
156+ self.log_s.value(now).exp()
157+ }
158+
159+ pub fn is_animating(&self, now: Instant) -> bool {
160+ self.log_s.is_active(now)
161+ || self.anchor.is_active(now)
162+ || self.rot.is_active(now)
163+ || self.flip_t.is_active(now)
164+ }
165+
166+ pub fn set_fit(&mut self, fit: FitMode, now: Instant, dur: Duration) {
167+ self.fit = Some(fit);
168+ let s = self.fit_scale(fit);
169+ let c = self.clamp_center(self.center, s);
170+ self.animate_to(s, c, [0.0, 0.0], now, dur);
171+ }
172+
173+ /// Zoom by `factor` around `anchor` (viewport pixels), or the centre when `None`.
174+ pub fn zoom_by(&mut self, factor: f32, anchor: Option<[f32; 2]>, now: Instant, dur: Duration) {
175+ self.zoom_to(self.scale * factor, anchor, now, dur);
176+ }
177+
178+ pub fn zoom_to(&mut self, scale: f32, anchor: Option<[f32; 2]>, now: Instant, dur: Duration) {
179+ let d = match anchor {
180+ Some(a) => sub(a, mul(self.viewport, 0.5)),
181+ None => [0.0, 0.0],
182+ };
183+ let s1 = scale.clamp(MIN_SCALE, MAX_SCALE);
184+ let p = add(self.center, mul(d, 1.0 / self.scale));
185+ let c1 = self.clamp_center(sub(p, mul(d, 1.0 / s1)), s1);
186+ self.fit = None;
187+ self.animate_to(s1, c1, d, now, dur);
188+ }
189+
190+ /// Move the image by a screen-space delta immediately (drag, held keys).
191+ pub fn pan_pixels(&mut self, delta: [f32; 2]) {
192+ let c1 = self.clamp_center(sub(self.center, mul(delta, 1.0 / self.scale)), self.scale);
193+ let moved = sub(c1, self.center);
194+ self.center = c1;
195+ self.anchor.shift(moved);
196+ }
197+
198+ /// Pan by a fraction of the viewport, animated.
199+ pub fn pan_step(&mut self, dir: Dir, frac: f32, now: Instant, dur: Duration) {
200+ let v = dir.vector();
201+ let step = [
202+ v[0] * self.viewport[0] * frac,
203+ v[1] * self.viewport[1] * frac,
204+ ];
205+ let c1 = self.clamp_center(add(self.center, mul(step, 1.0 / self.scale)), self.scale);
206+ self.animate_to(self.scale, c1, [0.0, 0.0], now, dur);
207+ }
208+
209+ pub fn pan_edge(&mut self, dir: Dir, now: Instant, dur: Duration) {
210+ let far = mul(dir.vector(), 1e9);
211+ let c1 = self.clamp_center(far, self.scale);
212+ let c1 = match dir {
213+ Dir::Left | Dir::Right => [c1[0], self.center[1]],
214+ Dir::Up | Dir::Down => [self.center[0], c1[1]],
215+ };
216+ self.animate_to(self.scale, c1, [0.0, 0.0], now, dur);
217+ }
218+
219+ pub fn recenter(&mut self, now: Instant, dur: Duration) {
220+ let c1 = self.clamp_center([0.0, 0.0], self.scale);
221+ self.animate_to(self.scale, c1, [0.0, 0.0], now, dur);
222+ }
223+
224+ /// Rotate clockwise on screen by `quarters` quarter turns (negative = counter-clockwise).
225+ pub fn rotate(&mut self, quarters: i32, now: Instant, dur: Duration) {
226+ // A single mirror reverses the visual sense of the rotation angle.
227+ let sign = if self.flip[0] * self.flip[1] < 0.0 {
228+ -1
229+ } else {
230+ 1
231+ };
232+ self.quarter += sign * quarters;
233+ let mut c = self.center;
234+ for _ in 0..quarters.rem_euclid(4) {
235+ c = [-c[1], c[0]];
236+ }
237+ let s = match self.fit {
238+ Some(fit) => self.fit_scale(fit),
239+ None => self.scale,
240+ };
241+ let c = self.clamp_center(c, s);
242+ self.rot.retarget(self.quarter as f32 * FRAC_PI_2, now, dur);
243+ self.animate_to(s, c, [0.0, 0.0], now, dur);
244+ }
245+
246+ pub fn flip(&mut self, axis: Axis, now: Instant, dur: Duration) {
247+ let i = match axis {
248+ Axis::Horizontal => 0,
249+ Axis::Vertical => 1,
250+ };
251+ self.flip[i] = -self.flip[i];
252+ let mut c = self.center;
253+ c[i] = -c[i];
254+ self.flip_t.retarget(self.flip, now, dur);
255+ self.animate_to(self.scale, c, [0.0, 0.0], now, dur);
256+ }
257+
258+ /// The transform for the frame at `now`.
259+ pub fn transform(&self, now: Instant) -> Affine {
260+ let s = self.display_scale(now);
261+ let c = sub(self.anchor.value(now), mul(self.d, 1.0 / s));
262+ let (sin, cos) = self.rot.value(now).sin_cos();
263+ let f = self.flip_t.value(now);
264+ // M = F · R(θ) · diag(img) · s
265+ let m = [
266+ f[0] * cos * self.img[0] * s,
267+ -f[0] * sin * self.img[1] * s,
268+ f[1] * sin * self.img[0] * s,
269+ f[1] * cos * self.img[1] * s,
270+ ];
271+ let vc = mul(self.viewport, 0.5);
272+ let t = [
273+ vc[0] - s * c[0] - 0.5 * (m[0] + m[1]),
274+ vc[1] - s * c[1] - 0.5 * (m[2] + m[3]),
275+ ];
276+ Affine { m, t }
277+ }
278+
279+ fn display_dims(&self) -> [f32; 2] {
280+ if self.quarter.rem_euclid(2) == 1 {
281+ [self.img[1], self.img[0]]
282+ } else {
283+ self.img
284+ }
285+ }
286+
287+ fn fit_scale(&self, fit: FitMode) -> f32 {
288+ let [dw, dh] = self.display_dims();
289+ let [vw, vh] = self.viewport;
290+ let fit_s = (vw / dw).min(vh / dh);
291+ let s = match fit {
292+ FitMode::Down => fit_s.min(1.0),
293+ FitMode::Fit => fit_s,
294+ FitMode::Fill => (vw / dw).max(vh / dh),
295+ FitMode::Width => vw / dw,
296+ FitMode::Height => vh / dh,
297+ FitMode::Original => 1.0,
298+ };
299+ s.clamp(MIN_SCALE, MAX_SCALE)
300+ }
301+
302+ /// Keep the image covering the viewport where it is larger, and centred where smaller.
303+ fn clamp_center(&self, c: [f32; 2], s: f32) -> [f32; 2] {
304+ let dims = self.display_dims();
305+ let mut out = c;
306+ for i in 0..2 {
307+ let extent = (dims[i] - self.viewport[i] / s) * 0.5;
308+ out[i] = if extent <= 0.0 {
309+ 0.0
310+ } else {
311+ c[i].clamp(-extent, extent)
312+ };
313+ }
314+ out
315+ }
316+
317+ fn animate_to(&mut self, s1: f32, c1: [f32; 2], d: [f32; 2], now: Instant, dur: Duration) {
318+ let s0 = self.display_scale(now);
319+ let c0 = sub(self.anchor.value(now), mul(self.d, 1.0 / s0));
320+ let p0 = add(c0, mul(d, 1.0 / s0));
321+ let p1 = add(c1, mul(d, 1.0 / s1));
322+ self.log_s.animate(s0.ln(), s1.ln(), now, dur);
323+ self.anchor.animate(p0, p1, now, dur);
324+ self.d = d;
325+ self.scale = s1;
326+ self.center = c1;
327+ }
328+
329+ fn jump(&mut self) {
330+ self.log_s.set(self.scale.ln());
331+ self.anchor.set(self.center);
332+ self.d = [0.0, 0.0];
333+ self.rot.set(self.quarter as f32 * FRAC_PI_2);
334+ self.flip_t.set(self.flip);
335+ }
336+}
337+
338+#[cfg(test)]
339+mod tests {
340+ use super::*;
341+
342+ const D: Duration = Duration::from_millis(100);
343+
344+ fn view(img: [u32; 2], vp: [f32; 2], fit: FitMode) -> (View, Instant) {
345+ let now = Instant::now();
346+ let mut v = View::new(now);
347+ v.set_viewport(vp[0], vp[1]);
348+ v.reset(img, fit, None, now);
349+ (v, now)
350+ }
351+
352+ fn apply(a: Affine, u: [f32; 2]) -> [f32; 2] {
353+ [
354+ a.m[0] * u[0] + a.m[1] * u[1] + a.t[0],
355+ a.m[2] * u[0] + a.m[3] * u[1] + a.t[1],
356+ ]
357+ }
358+
359+ fn close(a: [f32; 2], b: [f32; 2]) -> bool {
360+ (a[0] - b[0]).abs() < 0.01 && (a[1] - b[1]).abs() < 0.01
361+ }
362+
363+ #[test]
364+ fn fit_modes() {
365+ let vp = [1000.0, 500.0];
366+ assert_eq!(view([2000, 2000], vp, FitMode::Fit).0.scale(), 0.25);
367+ assert_eq!(view([2000, 2000], vp, FitMode::Fill).0.scale(), 0.5);
368+ assert_eq!(view([2000, 2000], vp, FitMode::Width).0.scale(), 0.5);
369+ assert_eq!(view([2000, 2000], vp, FitMode::Height).0.scale(), 0.25);
370+ assert_eq!(view([100, 100], vp, FitMode::Down).0.scale(), 1.0);
371+ assert_eq!(view([100, 100], vp, FitMode::Fit).0.scale(), 5.0);
372+ assert_eq!(view([100, 100], vp, FitMode::Original).0.scale(), 1.0);
373+ }
374+
375+ #[test]
376+ fn fitted_image_is_centred() {
377+ let (v, now) = view([200, 100], [400.0, 400.0], FitMode::Fit);
378+ let a = v.transform(now);
379+ assert!(close(apply(a, [0.0, 0.0]), [0.0, 100.0]));
380+ assert!(close(apply(a, [1.0, 1.0]), [400.0, 300.0]));
381+ }
382+
383+ #[test]
384+ fn zoom_keeps_anchor_fixed_throughout() {
385+ let (mut v, now) = view([4000, 4000], [1000.0, 1000.0], FitMode::Fit);
386+ let cursor = [700.0, 300.0];
387+ let a0 = v.transform(now);
388+ // Image uv under the cursor before zooming.
389+ let uv = [
390+ (cursor[0] - a0.t[0]) / a0.m[0],
391+ (cursor[1] - a0.t[1]) / a0.m[3],
392+ ];
393+ v.zoom_by(2.0, Some(cursor), now, D);
394+ for ms in [0, 20, 50, 80, 100, 150] {
395+ let a = v.transform(now + Duration::from_millis(ms));
396+ assert!(close(apply(a, uv), cursor), "drifted at {ms} ms");
397+ }
398+ assert!(v.fit_mode().is_none());
399+ }
400+
401+ #[test]
402+ fn rotation_swaps_fit_dimensions() {
403+ let (mut v, now) = view([200, 100], [400.0, 400.0], FitMode::Fit);
404+ assert_eq!(v.scale(), 2.0);
405+ v.rotate(1, now, Duration::ZERO);
406+ assert_eq!(v.scale(), 2.0);
407+ let (mut v, now) = view([400, 100], [400.0, 200.0], FitMode::Fit);
408+ assert_eq!(v.scale(), 1.0);
409+ v.rotate(1, now, Duration::ZERO);
410+ assert_eq!(v.scale(), 0.5);
411+ // A clockwise turn puts the image's top-left corner at the top-right.
412+ let a = v.transform(now);
413+ assert!(close(apply(a, [0.0, 0.0]), [225.0, 0.0]));
414+ }
415+
416+ #[test]
417+ fn pan_is_clamped_to_edges() {
418+ let (mut v, now) = view([1000, 1000], [500.0, 500.0], FitMode::Original);
419+ v.pan_pixels([10_000.0, 0.0]);
420+ let a = v.transform(now);
421+ // Dragging right as far as possible shows the left edge.
422+ assert!(close(apply(a, [0.0, 0.0]), [0.0, -250.0]));
423+ v.pan_edge(Dir::Right, now, Duration::ZERO);
424+ let a = v.transform(now);
425+ assert!(close(apply(a, [1.0, 1.0]), [500.0, 750.0]));
426+ }
427+
428+ #[test]
429+ fn flip_mirrors_on_screen() {
430+ let (mut v, now) = view([200, 100], [200.0, 100.0], FitMode::Fit);
431+ v.flip(Axis::Horizontal, now, Duration::ZERO);
432+ let a = v.transform(now);
433+ assert!(close(apply(a, [0.0, 0.0]), [200.0, 0.0]));
434+ }
435+}
A src/thumbs.rs View
@@ -0,0 +1 @@
1+//! freedesktop thumbnail cache: lookup, validation, generation and atomic writes.
A src/ui/mod.rs View
@@ -0,0 +1,221 @@
1+//! UI surfaces: status bar, grid view, palette, which-key, help and info panel.
2+//!
3+//! Each frame the UI turns state into a [`Scene`]; the renderer only draws it.
4+
5+use std::path::Path;
6+use std::time::Instant;
7+
8+use crate::config::theme::{Rgba, Theme};
9+use crate::render::image::AlphaStyle;
10+use crate::render::prims::Prim;
11+use crate::render::text::{Align, FontRole, TextItem};
12+use crate::render::{ImageDraw, Scene};
13+use crate::state::{AlphaBg, AppState, MsgKind};
14+
15+/// Space Mono label tracking.
16+const LABEL_TRACKING: f32 = 0.08;
17+
18+pub fn file_name(path: &Path) -> String {
19+ path.file_name().map_or_else(
20+ || path.display().to_string(),
21+ |n| n.to_string_lossy().into(),
22+ )
23+}
24+
25+fn label(text: &str, color: Rgba, sf: f32, x: f32, y: f32, align: Align) -> TextItem {
26+ TextItem {
27+ spans: vec![(text.to_uppercase(), color)],
28+ role: FontRole::Mono,
29+ size: (11.0 * sf).round(),
30+ weight: 400,
31+ tracking: LABEL_TRACKING,
32+ x,
33+ y,
34+ align,
35+ max_width: None,
36+ }
37+}
38+
39+/// Longest message shown before truncation, in characters.
40+const MAX_MESSAGE: usize = 72;
41+
42+/// `measure` returns the laid-out width of a text item, for layouts that depend on it.
43+pub fn scene<'a>(
44+ state: &'a AppState,
45+ theme: &Theme,
46+ now: Instant,
47+ measure: &mut dyn FnMut(&TextItem) -> f32,
48+) -> Scene<'a> {
49+ let sf = state.scale_factor();
50+ let [w, h] = state.window();
51+ let bar_h = state.bar_height();
52+ let mut scene = Scene {
53+ clear: theme.background,
54+ image_clip: [0.0, 0.0, w, h - bar_h],
55+ alpha: alpha_style(state.alpha_bg, theme, sf),
56+ images: Vec::new(),
57+ prims: Vec::new(),
58+ texts: Vec::new(),
59+ };
60+
61+ let nearest = state.effective_nearest(now);
62+ if let Some(fade) = &state.fade {
63+ scene.images.push(ImageDraw {
64+ path: &fade.path,
65+ affine: fade.affine,
66+ alpha: 1.0,
67+ nearest,
68+ });
69+ }
70+ if let Some(shown) = &state.shown {
71+ let alpha = state.fade.as_ref().map_or(1.0, |f| f.alpha.value(now));
72+ let affine = state.view.transform(now);
73+ scene.images.push(ImageDraw {
74+ path: &shown.path,
75+ affine,
76+ alpha,
77+ nearest,
78+ });
79+ }
80+
81+ if state.queue.is_empty() {
82+ empty_state(&mut scene, theme, sf, [w, h - bar_h]);
83+ }
84+ if bar_h > 0.0 {
85+ status_bar(&mut scene, state, theme, now, [w, h], bar_h, measure);
86+ }
87+ scene
88+}
89+
90+fn alpha_style(bg: AlphaBg, theme: &Theme, sf: f32) -> AlphaStyle {
91+ let (mode, dot) = match bg {
92+ AlphaBg::Solid => (0, theme.image_background),
93+ AlphaBg::Dots => (1, theme.border_visible),
94+ AlphaBg::Checker => (2, theme.surface_raised),
95+ };
96+ let cell = if mode == 2 { 8.0 } else { 16.0 };
97+ AlphaStyle {
98+ mode,
99+ bg: theme.image_background.linear(),
100+ dot: dot.linear(),
101+ cell: (cell * sf).round(),
102+ radius: sf.max(1.0),
103+ }
104+}
105+
106+/// Doto `NO IMAGES` and one hint line.
107+fn empty_state(scene: &mut Scene, theme: &Theme, sf: f32, area: [f32; 2]) {
108+ let cx = area[0] * 0.5;
109+ let cy = area[1] * 0.5;
110+ scene.texts.push(TextItem {
111+ spans: vec![("NO IMAGES".into(), theme.text_secondary)],
112+ role: FontRole::Display,
113+ size: (48.0 * sf).round(),
114+ weight: 700,
115+ tracking: -0.02,
116+ x: cx,
117+ y: cy - 12.0 * sf,
118+ align: Align::Center,
119+ max_width: None,
120+ });
121+ let hint = "remage <file|dir>…";
122+ scene.texts.push(label(
123+ hint,
124+ theme.text_disabled,
125+ sf,
126+ cx,
127+ cy + 32.0 * sf,
128+ Align::Center,
129+ ));
130+}
131+
132+fn status_bar(
133+ scene: &mut Scene,
134+ state: &AppState,
135+ theme: &Theme,
136+ now: Instant,
137+ [w, h]: [f32; 2],
138+ bar_h: f32,
139+ measure: &mut dyn FnMut(&TextItem) -> f32,
140+) {
141+ let sf = state.scale_factor();
142+ let pad = 16.0 * sf;
143+ let y = h - bar_h * 0.5;
144+ scene.prims.push(Prim::rect(
145+ [0.0, h - bar_h, w, bar_h],
146+ theme.background.linear(),
147+ ));
148+
149+ let mut spans: Vec<(String, Rgba)> = Vec::new();
150+ if !state.pending.is_empty() {
151+ spans.push((state.pending.clone(), theme.text_disabled));
152+ }
153+ if let Some(m) = &state.message {
154+ let color = if m.kind == MsgKind::Error {
155+ theme.accent
156+ } else {
157+ theme.text_primary
158+ };
159+ let mut text = m.text.to_uppercase();
160+ if let Some((cut, _)) = text.char_indices().nth(MAX_MESSAGE) {
161+ text.truncate(cut);
162+ text.push('…');
163+ }
164+ spans.push((format!("[{text}]"), color));
165+ }
166+ if state.loading_visible(now) {
167+ spans.push(("[LOADING]".into(), theme.text_secondary));
168+ }
169+ if state.skipped > 0 {
170+ spans.push((format!("[SKIPPED {}]", state.skipped), theme.text_secondary));
171+ }
172+ let mut info = Vec::new();
173+ if let Some(s) = state
174+ .shown
175+ .as_ref()
176+ .filter(|s| Some(s.path.as_path()) == state.queue.current())
177+ {
178+ info.push(s.format.to_string());
179+ info.push(format!("{}×{}", s.size[0], s.size[1]));
180+ info.push(format!("{}%", (state.view.scale() * 100.0).round()));
181+ }
182+ if !state.queue.is_empty() {
183+ let count = state.queue.len();
184+ let digits = count.to_string().len().max(3);
185+ info.push(format!("{:0digits$}/{count}", state.queue.index() + 1));
186+ }
187+ if !info.is_empty() {
188+ spans.push((info.join(" "), theme.text_secondary));
189+ }
190+ // Two spaces between segments, attached to the preceding span so colours stay put.
191+ let n = spans.len();
192+ for (s, _) in spans.iter_mut().take(n.saturating_sub(1)) {
193+ s.push_str(" ");
194+ }
195+ let mut right_w = 0.0;
196+ if !spans.is_empty() {
197+ let mut item = label("", theme.text_secondary, sf, w - pad, y, Align::Right);
198+ item.spans = spans
199+ .into_iter()
200+ .map(|(s, c)| (s.to_uppercase(), c))
201+ .collect();
202+ right_w = measure(&item);
203+ scene.texts.push(item);
204+ }
205+
206+ // The filename takes whatever the right side leaves, ellipsised in the middle.
207+ if let Some(cur) = state.queue.current() {
208+ let room = (w - 2.0 * pad - right_w - 32.0 * sf).max(0.0);
209+ scene.texts.push(TextItem {
210+ spans: vec![(file_name(cur), theme.text_primary)],
211+ role: FontRole::Ui,
212+ size: (14.0 * sf).round(),
213+ weight: 400,
214+ tracking: 0.0,
215+ x: pad,
216+ y,
217+ align: Align::Left,
218+ max_width: Some(room),
219+ });
220+ }
221+}
A src/worker.rs View
@@ -0,0 +1,256 @@
1+//! Priority job pool with epoch cancellation.
2+//!
3+//! Jobs are keyed: submitting a key that is already queued replaces the queued job (new
4+//! priority, new epoch), and submitting one that is running is a no-op. Bumping the epoch makes
5+//! every queued job of an epoch-sensitive priority stale, so mashing `n` never decodes the
6+//! images that were skipped over.
7+
8+use std::collections::{BinaryHeap, HashMap, HashSet};
9+use std::path::PathBuf;
10+use std::sync::{Arc, Condvar, Mutex, MutexGuard};
11+use std::thread;
12+
13+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
14+pub enum Priority {
15+ /// The image on screen.
16+ Current = 0,
17+ /// Neighbours of the current image.
18+ Preload = 1,
19+ /// Thumbnails in view.
20+ #[allow(dead_code)] // thumbnails arrive in M3
21+ ThumbVisible = 2,
22+ /// Other thumbnails.
23+ #[allow(dead_code)]
24+ Thumb = 3,
25+}
26+
27+impl Priority {
28+ fn epoch_sensitive(self) -> bool {
29+ matches!(self, Priority::Current | Priority::Preload)
30+ }
31+}
32+
33+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
34+pub enum JobKey {
35+ Decode(PathBuf),
36+}
37+
38+type Work = Box<dyn FnOnce() + Send>;
39+
40+struct Entry {
41+ prio: Priority,
42+ seq: u64,
43+ epoch: u64,
44+ key: JobKey,
45+ work: Work,
46+}
47+
48+impl PartialEq for Entry {
49+ fn eq(&self, other: &Self) -> bool {
50+ self.prio == other.prio && self.seq == other.seq
51+ }
52+}
53+
54+impl Eq for Entry {}
55+
56+impl PartialOrd for Entry {
57+ fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
58+ Some(self.cmp(other))
59+ }
60+}
61+
62+impl Ord for Entry {
63+ /// Max-heap order: lowest priority value first, then oldest submission.
64+ fn cmp(&self, other: &Self) -> std::cmp::Ordering {
65+ other
66+ .prio
67+ .cmp(&self.prio)
68+ .then_with(|| other.seq.cmp(&self.seq))
69+ }
70+}
71+
72+#[derive(Default)]
73+struct Queue {
74+ heap: BinaryHeap<Entry>,
75+ /// Key → sequence number of its live (non-superseded) entry.
76+ live: HashMap<JobKey, u64>,
77+ running: HashSet<JobKey>,
78+ seq: u64,
79+ epoch: u64,
80+ shutdown: bool,
81+}
82+
83+impl Queue {
84+ fn pop(&mut self) -> Option<Entry> {
85+ while let Some(e) = self.heap.pop() {
86+ if self.live.get(&e.key) != Some(&e.seq) {
87+ continue; // superseded by a later submit
88+ }
89+ self.live.remove(&e.key);
90+ if e.prio.epoch_sensitive() && e.epoch < self.epoch {
91+ continue; // stale
92+ }
93+ self.running.insert(e.key.clone());
94+ return Some(e);
95+ }
96+ None
97+ }
98+}
99+
100+struct Shared {
101+ queue: Mutex<Queue>,
102+ cv: Condvar,
103+}
104+
105+impl Shared {
106+ fn lock(&self) -> MutexGuard<'_, Queue> {
107+ // A panicking job never holds the lock, so poisoning can't leave it inconsistent.
108+ self.queue.lock().unwrap_or_else(|e| e.into_inner())
109+ }
110+}
111+
112+pub struct Pool {
113+ shared: Arc<Shared>,
114+ threads: Vec<thread::JoinHandle<()>>,
115+}
116+
117+impl Pool {
118+ /// Start `nproc - 1` workers (at least one).
119+ pub fn new() -> Self {
120+ let n = thread::available_parallelism()
121+ .map_or(2, |n| n.get())
122+ .saturating_sub(1)
123+ .max(1);
124+ Self::with_threads(n)
125+ }
126+
127+ pub fn with_threads(n: usize) -> Self {
128+ let shared = Arc::new(Shared {
129+ queue: Mutex::new(Queue::default()),
130+ cv: Condvar::new(),
131+ });
132+ let threads = (0..n)
133+ .map(|i| {
134+ let shared = shared.clone();
135+ thread::Builder::new()
136+ .name(format!("remage-worker-{i}"))
137+ .spawn(move || worker(&shared))
138+ .expect("spawn worker thread")
139+ })
140+ .collect();
141+ Self { shared, threads }
142+ }
143+
144+ /// Queue `work` under `key` unless that key is already running.
145+ pub fn submit(&self, key: JobKey, prio: Priority, work: impl FnOnce() + Send + 'static) {
146+ let mut q = self.shared.lock();
147+ if q.running.contains(&key) {
148+ return;
149+ }
150+ q.seq += 1;
151+ let (seq, epoch) = (q.seq, q.epoch);
152+ q.live.insert(key.clone(), seq);
153+ q.heap.push(Entry {
154+ prio,
155+ seq,
156+ epoch,
157+ key,
158+ work: Box::new(work),
159+ });
160+ drop(q);
161+ self.shared.cv.notify_one();
162+ }
163+
164+ /// Invalidate every queued current/preload job.
165+ pub fn bump_epoch(&self) {
166+ self.shared.lock().epoch += 1;
167+ }
168+}
169+
170+impl Drop for Pool {
171+ fn drop(&mut self) {
172+ self.shared.lock().shutdown = true;
173+ self.shared.cv.notify_all();
174+ for t in self.threads.drain(..) {
175+ let _ = t.join();
176+ }
177+ }
178+}
179+
180+fn worker(shared: &Shared) {
181+ loop {
182+ let entry = {
183+ let mut q = shared.lock();
184+ loop {
185+ if q.shutdown {
186+ return;
187+ }
188+ if let Some(e) = q.pop() {
189+ break e;
190+ }
191+ q = shared.cv.wait(q).unwrap_or_else(|e| e.into_inner());
192+ }
193+ };
194+ let key = entry.key.clone();
195+ if std::panic::catch_unwind(std::panic::AssertUnwindSafe(entry.work)).is_err() {
196+ log::error!("worker job {key:?} panicked");
197+ }
198+ shared.lock().running.remove(&key);
199+ }
200+}
201+
202+#[cfg(test)]
203+mod tests {
204+ use super::*;
205+ use crossbeam_channel::unbounded;
206+ use std::time::Duration;
207+
208+ fn key(s: &str) -> JobKey {
209+ JobKey::Decode(PathBuf::from(s))
210+ }
211+
212+ #[test]
213+ fn runs_by_priority_and_drops_stale_jobs() {
214+ let pool = Pool::with_threads(1);
215+ let (gate_tx, gate_rx) = unbounded::<()>();
216+ let (tx, rx) = unbounded();
217+ // Block the single worker so the rest queue up.
218+ pool.submit(key("gate"), Priority::Current, move || {
219+ let _ = gate_rx.recv();
220+ });
221+ std::thread::sleep(Duration::from_millis(50));
222+ for (k, p) in [("thumb", Priority::Thumb), ("stale", Priority::Preload)] {
223+ let tx = tx.clone();
224+ pool.submit(key(k), p, move || tx.send(k).unwrap());
225+ }
226+ pool.bump_epoch();
227+ for (k, p) in [("pre", Priority::Preload), ("cur", Priority::Current)] {
228+ let tx = tx.clone();
229+ pool.submit(key(k), p, move || tx.send(k).unwrap());
230+ }
231+ gate_tx.send(()).unwrap();
232+ let got: Vec<_> = (0..3)
233+ .map(|_| rx.recv_timeout(Duration::from_secs(2)).unwrap())
234+ .collect();
235+ assert_eq!(got, ["cur", "pre", "thumb"]);
236+ assert!(rx.recv_timeout(Duration::from_millis(100)).is_err());
237+ }
238+
239+ #[test]
240+ fn resubmitting_replaces_the_queued_job() {
241+ let pool = Pool::with_threads(1);
242+ let (gate_tx, gate_rx) = unbounded::<()>();
243+ let (tx, rx) = unbounded();
244+ pool.submit(key("gate"), Priority::Current, move || {
245+ let _ = gate_rx.recv();
246+ });
247+ std::thread::sleep(Duration::from_millis(50));
248+ for tag in ["old", "new"] {
249+ let tx = tx.clone();
250+ pool.submit(key("a"), Priority::Preload, move || tx.send(tag).unwrap());
251+ }
252+ gate_tx.send(()).unwrap();
253+ assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), "new");
254+ assert!(rx.recv_timeout(Duration::from_millis(100)).is_err());
255+ }
256+}
A tests/fixtures/.gitkeep View

[No content changes]