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.
+9997 −0
43 files changed
- A .gitignore +1 −0
- A CLAUDE.md +483 −0
- A Cargo.lock +4727 −0
- A Cargo.toml +53 −0
- A assets/fonts/Doto-Variable.ttf
- A assets/fonts/OFL.txt +95 −0
- A assets/fonts/SpaceGrotesk-Light.otf
- A assets/fonts/SpaceGrotesk-Medium.otf
- A assets/fonts/SpaceGrotesk-Regular.otf
- A assets/fonts/SpaceMono-Bold.ttf
- A assets/fonts/SpaceMono-Regular.ttf
- A contrib/.gitkeep +0 −0
- A src/action.rs +39 −0
- A src/anim.rs +155 −0
- A src/app.rs +568 −0
- A src/cache.rs +99 −0
- A src/cli.rs +88 −0
- A src/config/mod.rs +3 −0
- A src/config/theme.rs +115 −0
- A src/decode/color.rs +1 −0
- A src/decode/mod.rs +165 −0
- A src/exec.rs +1 −0
- A src/input/keymap.rs +1 −0
- A src/input/keys.rs +1 −0
- A src/input/mod.rs +173 −0
- A src/ipc.rs +1 −0
- A src/main.rs +88 −0
- A src/render/image.rs +383 −0
- A src/render/mod.rs +359 −0
- A src/render/prims.rs +154 −0
- A src/render/shaders/blit.wgsl +24 −0
- A src/render/shaders/image.wgsl +52 −0
- A src/render/shaders/prims.wgsl +60 −0
- A src/render/text.rs +285 −0
- A src/scan.rs +143 −0
- A src/state/grid.rs +1 −0
- A src/state/mod.rs +467 −0
- A src/state/queue.rs +299 −0
- A src/state/view.rs +435 −0
- A src/thumbs.rs +1 −0
- A src/ui/mod.rs +221 −0
- A src/worker.rs +256 −0
- A tests/fixtures/.gitkeep +0 −0
| @@ -0,0 +1 @@ | |||
| 1 | + | /target | |
| @@ -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. | |
[Diff too large] View file
| @@ -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 | |
[Binary file]
| @@ -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. | |
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[No content changes]
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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; | |
| @@ -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 | + | } | |
| @@ -0,0 +1 @@ | |||
| 1 | + | //! ICC/CICP to sRGB conversion, EXIF orientation and HDR tone-mapping. | |
| @@ -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 | + | } | |
| @@ -0,0 +1 @@ | |||
| 1 | + | //! User commands: placeholder expansion, spawning, confirmation and output handling. | |
| @@ -0,0 +1 @@ | |||
| 1 | + | //! Per-mode key trie: pending sequences, counts and which-key candidates. | |
| @@ -0,0 +1 @@ | |||
| 1 | + | //! Vim key-notation parser: `<C-x>`, `<S-Tab>`, `gg`, `<Space>w`, mouse buttons and wheel. | |
| @@ -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 | + | } | |
| @@ -0,0 +1 @@ | |||
| 1 | + | //! Unix-socket server speaking newline-delimited JSON, with event subscriptions. | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -0,0 +1 @@ | |||
| 1 | + | //! Grid layout, cursor, scrolling and the visual-mode range. | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -0,0 +1 @@ | |||
| 1 | + | //! freedesktop thumbnail cache: lookup, validation, generation and atomic writes. | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
[No content changes]