Krolyxon
Commit dbc22bf

Branches

Tags

Refs →

remagedbc22bf

Add grid, thumbnails and marks

Freedesktop thumbnail cache (GLib-compatible URIs, atomic 0600 writes,
DCT-scaled JPEG), prioritised thumbnail jobs, a GPU atlas, grid and visual
modes with smooth scrolling, marks, and the move/page/row/scroll actions.
Author
krolyxon
Date
2026-11-04 21:07 +0530
Parent
92928dd
Commit
dbc22bf9601b0262a2a5114ed826f2c0e25208f0

+2581 −64

20 files changed

  1. M CLAUDE.md +4 −3
  2. M Cargo.lock +10 −0
  3. M Cargo.toml +1 −0
  4. M src/action.rs +88 −0
  5. M src/anim.rs +5 −0
  6. M src/app.rs +282 −20
  7. M src/cli.rs +8 −0
  8. M src/config/default.toml +21 −0
  9. M src/main.rs +4 −0
  10. A src/render/atlas.rs +404 −0
  11. M src/render/mod.rs +41 −3
  12. M src/render/prims.rs +49 −5
  13. A src/render/shaders/thumbs.wgsl +43 −0
  14. M src/render/text.rs +18 −4
  15. M src/state/grid.rs +346 −0
  16. M src/state/mod.rs +342 −17
  17. M src/state/queue.rs +127 −2
  18. M src/thumbs.rs +610 −0
  19. M src/ui/mod.rs +152 −8
  20. M src/worker.rs +26 −2
M CLAUDE.md View
@@ -41,8 +41,8 @@ and must stay mutually compatible.
4141 | Errors / logs | `anyhow`, `thiserror`, `log` + `env_logger` (`RUST_LOG=remage=debug`) |
4242
4343 **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).
44+0.30 until 0.31 leaves beta. Bump glyphon and wgpu together, never one alone. Crate still to add
45+when its milestone needs it: `clap_mangen` (M7).
4646
4747 Cargo features are `default = ["jxl", "svg", "avif", "heif", "raw"]`. The only C dependencies
4848 (dav1d, libheif) sit behind `avif` and `heif`.
@@ -211,6 +211,7 @@ action carries a one-line description, which feeds the palette, which-key and he
211211 |---|---|
212212 | Navigation | `next [n]`, `prev [n]`, `first`, `last`, `goto N`, `random`, `next-mark`, `prev-mark`, `next-frame`, `prev-frame` |
213213 | Marks | `mark [toggle\|on\|off]`, `mark-all`, `unmark-all`, `invert-marks` |
214+| Grid | `move <dir> [n]`, `page down\|up\|half-down\|half-up`, `row-start`, `row-end`, `scroll <dir> [n]` (pans a step in image mode) |
214215 | 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` |
215216 | Playback | `play toggle`, `slideshow toggle`, `slideshow-delay ±N` |
216217 | UI | `mode image\|grid\|toggle`, `visual`, `fullscreen`, `bar toggle`, `info toggle`, `help`, `palette`, `search`, `grid-size +\|-` |
@@ -434,7 +435,7 @@ Each milestone should end with a usable binary.
434435 - Action grammar, and a keymap trie with sequences, counts and mouse bindings.
435436 - TOML defaults, include and live reload, plus `--dump-config` and `--check-config`.
436437 - Theme tokens, presets and derivation; matugen + pywal support.
437−- **M3 Grid + marks**
438+- **M3 Grid + marks** ✓ (2026-11-04). Cached grid opens in ~85 ms, 0% CPU when idle.
438439 - Freedesktop thumbnails, worker priorities and the GPU atlas.
439440 - Grid navigation, visual mode, marks and smooth scrolling.
440441 - **M4 Commands + IPC**
M Cargo.lock View
@@ -1525,6 +1525,15 @@ dependencies = [
15251525 "libc",
15261526 ]
15271527
1528+[[package]]
1529+name = "jpeg-decoder"
1530+version = "0.3.2"
1531+source = "registry+https://github.com/rust-lang/crates.io-index"
1532+checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
1533+dependencies = [
1534+ "rayon",
1535+]
1536+
15281537 [[package]]
15291538 name = "js-sys"
15301539 version = "0.3.107"
@@ -3049,6 +3058,7 @@ dependencies = [
30493058 "fast_image_resize",
30503059 "glyphon",
30513060 "image",
3061+ "jpeg-decoder",
30523062 "jxl-oxide",
30533063 "kamadak-exif",
30543064 "libheif-rs",
M Cargo.toml View
@@ -16,6 +16,7 @@ env_logger = "0.11.11"
1616 fast_image_resize = "6.1.0"
1717 glyphon = "0.12.0"
1818 image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "tiff", "bmp", "ico", "tga", "pnm", "qoi", "dds", "hdr", "exr"] }
19+jpeg-decoder = "0.3.2"
1920 jxl-oxide = { version = "0.12.6", optional = true }
2021 kamadak-exif = "0.6.1"
2122 libheif-rs = { version = "3.0.0", optional = true }
M src/action.rs View
@@ -40,6 +40,15 @@ pub enum Adjust {
4040 Contrast,
4141 }
4242
43+/// Grid paging, like vim's `<C-f>`/`<C-b>` and `<C-d>`/`<C-u>`.
44+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45+pub enum Page {
46+ Down,
47+ Up,
48+ HalfDown,
49+ HalfUp,
50+}
51+
4352 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
4453 pub enum Step {
4554 Up,
@@ -70,6 +79,13 @@ pub enum Action {
7079 Pan(Dir),
7180 PanEdge(Dir),
7281 Center,
82+ /// Grid cursor: cells left/right, rows up/down.
83+ Move(Dir, usize),
84+ Page(Page),
85+ RowStart,
86+ RowEnd,
87+ /// Scroll the grid by rows (the image view pans a step).
88+ Scroll(Dir, usize),
7389 /// Quarter turns, positive = clockwise.
7490 Rotate(i32),
7591 Flip(Axis),
@@ -139,6 +155,15 @@ pub const CATALOG: &[Entry] = &[
139155 e("pan", "left|right|up|down", "Pan (held keys pan smoothly)"),
140156 e("pan-edge", "left|right|up|down", "Jump to an edge"),
141157 e("center", "", "Centre the image"),
158+ e("move", "left|right|up|down [n]", "Move the grid cursor"),
159+ e("page", "down|up|half-down|half-up", "Page the grid"),
160+ e("row-start", "", "First thumbnail in the row"),
161+ e("row-end", "", "Last thumbnail in the row"),
162+ e(
163+ "scroll",
164+ "left|right|up|down [n]",
165+ "Scroll the grid (pan an image)",
166+ ),
142167 e("rotate", "cw|ccw|180", "Rotate the view"),
143168 e("flip", "h|v", "Flip the view"),
144169 e("filter", "toggle", "Toggle nearest/linear filtering"),
@@ -339,6 +364,25 @@ impl FromStr for Action {
339364 "fit" => Action::Fit(value_enum(name, rest)?),
340365 "pan" => Action::Pan(dir(name, rest)?),
341366 "pan-edge" => Action::PanEdge(dir(name, rest)?),
367+ "move" | "scroll" => {
368+ let (d, n) = rest.split_once(' ').unwrap_or((rest, ""));
369+ let (d, n) = (dir(name, d)?, count(name, n.trim())?);
370+ if name == "move" {
371+ Action::Move(d, n)
372+ } else {
373+ Action::Scroll(d, n)
374+ }
375+ }
376+ "page" => Action::Page(one_of(
377+ name,
378+ rest,
379+ &[
380+ ("down", Page::Down),
381+ ("up", Page::Up),
382+ ("half-down", Page::HalfDown),
383+ ("half-up", Page::HalfUp),
384+ ],
385+ )?),
342386 "rotate" => match rest {
343387 "cw" => Action::Rotate(1),
344388 "ccw" => Action::Rotate(-1),
@@ -415,6 +459,8 @@ impl FromStr for Action {
415459 "unmark-all" => Action::UnmarkAll,
416460 "invert-marks" => Action::InvertMarks,
417461 "center" | "centre" => Action::Center,
462+ "row-start" => Action::RowStart,
463+ "row-end" => Action::RowEnd,
418464 "visual" => Action::Visual,
419465 "help" => Action::Help,
420466 "palette" => Action::Palette,
@@ -461,6 +507,15 @@ fn dir_name(d: Dir) -> &'static str {
461507 }
462508 }
463509
510+fn page_name(p: Page) -> &'static str {
511+ match p {
512+ Page::Down => "down",
513+ Page::Up => "up",
514+ Page::HalfDown => "half-down",
515+ Page::HalfUp => "half-up",
516+ }
517+}
518+
464519 fn step_name(s: Step) -> &'static str {
465520 match s {
466521 Step::Up => "+",
@@ -489,6 +544,9 @@ impl fmt::Display for Action {
489544 Action::Zoom(Zoom::Percent(p)) => write!(f, "zoom {p}%"),
490545 Action::Fit(m) => write!(f, "fit {}", value_name(m)),
491546 Action::Pan(d) | Action::PanEdge(d) => write!(f, "{n} {}", dir_name(*d)),
547+ Action::Move(d, 1) | Action::Scroll(d, 1) => write!(f, "{n} {}", dir_name(*d)),
548+ Action::Move(d, c) | Action::Scroll(d, c) => write!(f, "{n} {} {c}", dir_name(*d)),
549+ Action::Page(p) => write!(f, "page {}", page_name(*p)),
492550 Action::Rotate(1) => f.write_str("rotate cw"),
493551 Action::Rotate(-1) => f.write_str("rotate ccw"),
494552 Action::Rotate(q) => write!(f, "rotate {}", q * 90),
@@ -548,6 +606,11 @@ impl Action {
548606 Action::Pan(_) => "pan",
549607 Action::PanEdge(_) => "pan-edge",
550608 Action::Center => "center",
609+ Action::Move(..) => "move",
610+ Action::Page(_) => "page",
611+ Action::RowStart => "row-start",
612+ Action::RowEnd => "row-end",
613+ Action::Scroll(..) => "scroll",
551614 Action::Rotate(_) => "rotate",
552615 Action::Flip(_) => "flip",
553616 Action::FilterToggle => "filter",
@@ -614,6 +677,14 @@ impl Action {
614677 },
615678 Action::Pan(d) => format!("Pan {}", dir_name(*d)),
616679 Action::PanEdge(d) => format!("Jump to {} edge", dir_name(*d)),
680+ Action::Move(d, _) => format!("Move {}", dir_name(*d)),
681+ Action::Scroll(d, _) => format!("Scroll {}", dir_name(*d)),
682+ Action::Page(p) => match p {
683+ Page::Down => "Page down".into(),
684+ Page::Up => "Page up".into(),
685+ Page::HalfDown => "Half a page down".into(),
686+ Page::HalfUp => "Half a page up".into(),
687+ },
617688 Action::Rotate(1) => "Rotate clockwise".into(),
618689 Action::Rotate(-1) => "Rotate counter-clockwise".into(),
619690 Action::Rotate(q) => format!("Rotate {}°", q * 90),
@@ -678,6 +749,8 @@ impl Action {
678749 Action::Rotate(total.signum() * ((total.abs() - 1) % 4 + 1))
679750 }
680751 Action::GridSize(d) => Action::GridSize(d.saturating_mul(n32)),
752+ Action::Move(d, k) => Action::Move(d, k.saturating_mul(n)),
753+ Action::Scroll(d, k) => Action::Scroll(d, k.saturating_mul(n)),
681754 other => other,
682755 }
683756 }
@@ -694,6 +767,9 @@ impl Action {
694767 | Action::PrevFrame
695768 | Action::Zoom(Zoom::In(_) | Zoom::Out(_))
696769 | Action::GridSize(_)
770+ | Action::Move(..)
771+ | Action::Scroll(..)
772+ | Action::Page(_)
697773 | Action::Adjust(_, Step::Up | Step::Down)
698774 )
699775 }
@@ -786,6 +862,14 @@ mod tests {
786862 "pan left",
787863 "pan-edge down",
788864 "center",
865+ "move left",
866+ "move down 3",
867+ "page half-down",
868+ "page up",
869+ "row-start",
870+ "row-end",
871+ "scroll up",
872+ "scroll down 2",
789873 "rotate cw",
790874 "rotate ccw",
791875 "rotate 180",
@@ -853,6 +937,10 @@ mod tests {
853937 Action::Zoom(Zoom::In(3))
854938 );
855939 assert_eq!(Action::GridSize(-1).with_count(2), Action::GridSize(-2));
940+ assert_eq!(
941+ Action::Move(Dir::Down, 1).with_count(5),
942+ Action::Move(Dir::Down, 5)
943+ );
856944 assert_eq!(Action::Quit.with_count(4), Action::Quit);
857945 }
858946 }
M src/anim.rs View
@@ -86,6 +86,11 @@ impl<T: Lerp> Tween<T> {
8686 }
8787 }
8888
89+ /// Where the tween ends up.
90+ pub fn target(&self) -> T {
91+ self.to
92+ }
93+
8994 pub fn is_active(&self, now: Instant) -> bool {
9095 !self.dur.is_zero() && now < self.start + self.dur
9196 }
M src/app.rs View
@@ -13,7 +13,7 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy};
1313 use winit::keyboard::{ModifiersState, PhysicalKey};
1414 use winit::window::{Fullscreen, Window, WindowId};
1515
16−use crate::action::{Action, Switch, Zoom};
16+use crate::action::{Action, ModeArg, Switch, Zoom};
1717 use crate::cache::ImageCache;
1818 use crate::config::watch::ConfigWatch;
1919 use crate::config::{Config, LoadError, Loaded};
@@ -24,7 +24,8 @@ use crate::input::{self};
2424 use crate::render::{Renderer, text};
2525 use crate::scan;
2626 use crate::state::view::{Dir, FitMode};
27−use crate::state::{AppState, Effects, MsgKind, Settings};
27+use crate::state::{AppState, Effects, Mode, MsgKind, Settings};
28+use crate::thumbs::{self, CacheOptions, Thumb, ThumbError};
2829 use crate::ui;
2930 use crate::worker::{JobKey, Pool, Priority};
3031
@@ -41,6 +42,11 @@ const SEQUENCE_TIMEOUT: Duration = Duration::from_millis(1000);
4142 const CLICK_SLOP: f32 = 4.0;
4243 /// Config errors stay on screen until fixed (or dismissed with `<Esc>`).
4344 const CONFIG_ERROR_TIME: Duration = Duration::from_secs(3600);
45+/// In the grid, the selected image decodes once the cursor has rested this long, so `<CR>`
46+/// opens it at once without decoding everything the cursor passes over.
47+const GRID_REST: Duration = Duration::from_millis(250);
48+/// Two clicks on the same thumbnail this close together open it.
49+const DOUBLE_CLICK: Duration = Duration::from_millis(400);
4450
4551 pub enum UserEvent {
4652 Decoded {
@@ -55,6 +61,12 @@ pub enum UserEvent {
5561 Config(Box<Result<Loaded, LoadError>>),
5662 /// A re-sort finished: the queue in its new order.
5763 Sorted(Vec<PathBuf>),
64+ /// A thumbnail for cells of `px` pixels.
65+ Thumb {
66+ path: PathBuf,
67+ px: u32,
68+ result: Result<Thumb, ThumbError>,
69+ },
5870 }
5971
6072 /// Command-line settings that beat the config, across reloads too.
@@ -81,6 +93,20 @@ pub struct Options {
8193 pub geometry: Option<(u32, u32)>,
8294 pub class: String,
8395 pub overrides: Overrides,
96+ /// Never write the shared thumbnail cache (`-p`).
97+ pub private: bool,
98+}
99+
100+/// Thumbnail jobs for the current cell size.
101+#[derive(Default)]
102+struct ThumbJobs {
103+ /// Cell size the atlas and requests are for (0 = none yet).
104+ cell: u32,
105+ requested: HashMap<PathBuf, Priority>,
106+ /// Paths near the view; the atlas never evicts these.
107+ wanted: HashSet<PathBuf>,
108+ /// Finished thumbnails waiting for the atlas.
109+ arrivals: VecDeque<(PathBuf, Thumb)>,
84110 }
85111
86112 struct Gfx {
@@ -111,6 +137,12 @@ pub struct App {
111137 arriving: Arc<Mutex<HashSet<PathBuf>>>,
112138 gfx: Option<Gfx>,
113139 uploads: VecDeque<(PathBuf, Arc<DecodedImage>)>,
140+ thumbs: ThumbJobs,
141+ thumb_cache: CacheOptions,
142+ /// When the grid cursor last moved, until the selected image is requested.
143+ grid_rest: Option<Instant>,
144+ /// The last click in the grid, for double-clicks.
145+ last_click: Option<(Instant, usize)>,
114146 modifiers: ModifiersState,
115147 cursor: [f32; 2],
116148 dragging: bool,
@@ -133,6 +165,7 @@ impl App {
133165 watch: ConfigWatch,
134166 ) -> Self {
135167 let cache = ImageCache::new(loaded.config.image.cache_mb << 20);
168+ let thumb_cache = CacheOptions::new(loaded.config.grid.thumbnail_cache, opts.private);
136169 let mut app = Self {
137170 opts,
138171 state,
@@ -151,6 +184,10 @@ impl App {
151184 arriving: Arc::default(),
152185 gfx: None,
153186 uploads: VecDeque::new(),
187+ thumbs: ThumbJobs::default(),
188+ thumb_cache,
189+ grid_rest: None,
190+ last_click: None,
154191 modifiers: ModifiersState::empty(),
155192 cursor: [0.0, 0.0],
156193 dragging: false,
@@ -224,6 +261,14 @@ impl App {
224261 self.request_redraw();
225262 return;
226263 };
264+ if self.state.in_grid() {
265+ if navigated || !self.cache_has(&cur) {
266+ self.grid_rest = Some(now);
267+ }
268+ self.sync_thumbs();
269+ self.request_redraw();
270+ return;
271+ }
227272
228273 let keep = |p: &Path| {
229274 wanted.iter().any(|w| w == p)
@@ -259,6 +304,117 @@ impl App {
259304 self.request_redraw();
260305 }
261306
307+ fn cache_has(&mut self, path: &Path) -> bool {
308+ self.cache.get(path).is_some()
309+ }
310+
311+ /// Request thumbnails around the grid view: visible ones first, then a page above and
312+ /// two below. Requests that scrolled out of range are cancelled.
313+ fn sync_thumbs(&mut self) {
314+ if !self.state.in_grid() {
315+ return;
316+ }
317+ let Some(g) = &mut self.gfx else { return };
318+ let px = self.state.cell_px();
319+ if px != self.thumbs.cell {
320+ for p in self.thumbs.requested.drain().map(|(p, _)| p) {
321+ self.pool.cancel(&JobKey::Thumb(p));
322+ }
323+ self.thumbs.arrivals.clear();
324+ self.state.thumbs.clear();
325+ g.renderer.reset_thumbs(px);
326+ self.thumbs.cell = px;
327+ }
328+ let l = self.state.grid_layout();
329+ let visible = l.visible(self.state.grid.target(&l));
330+ let span = l.page_rows() * l.cols;
331+ let near = visible.start.saturating_sub(span)..(visible.end + 2 * span).min(l.count);
332+ let q = &self.state.queue;
333+ let mut wanted = HashSet::with_capacity(near.len());
334+ let mut visible_paths = HashSet::with_capacity(visible.len());
335+ let mut submit = Vec::new();
336+ for i in near {
337+ let Some(p) = q.get(i) else { continue };
338+ wanted.insert(p.to_path_buf());
339+ let prio = if visible.contains(&i) {
340+ visible_paths.insert(p.to_path_buf());
341+ Priority::ThumbVisible
342+ } else {
343+ Priority::Thumb
344+ };
345+ if !self.state.thumbs.contains_key(p) && self.thumbs.requested.get(p) != Some(&prio) {
346+ submit.push((p.to_path_buf(), prio));
347+ }
348+ }
349+ // Visible first, in order.
350+ submit.sort_by_key(|(_, prio)| *prio);
351+ for (p, prio) in submit {
352+ self.request_thumb(p.clone(), prio);
353+ self.thumbs.requested.insert(p, prio);
354+ }
355+ let mut demote = Vec::new();
356+ self.thumbs.requested.retain(|p, prio| {
357+ if !wanted.contains(p) {
358+ self.pool.cancel(&JobKey::Thumb(p.clone()));
359+ return false;
360+ }
361+ if *prio == Priority::ThumbVisible && !visible_paths.contains(p) {
362+ *prio = Priority::Thumb;
363+ demote.push(p.clone());
364+ }
365+ true
366+ });
367+ for p in demote {
368+ self.request_thumb(p, Priority::Thumb);
369+ }
370+ self.thumbs.wanted = wanted;
371+ }
372+
373+ fn request_thumb(&self, path: PathBuf, prio: Priority) {
374+ let proxy = self.proxy.clone();
375+ let cache = self.thumb_cache.clone();
376+ let px = self.thumbs.cell;
377+ self.pool
378+ .submit(JobKey::Thumb(path.clone()), prio, move || {
379+ let t = Instant::now();
380+ let result = thumbs::load(&path, px, &cache);
381+ log::trace!("thumbnail {} in {:.1?}", path.display(), t.elapsed());
382+ let _ = proxy.send_event(UserEvent::Thumb { path, px, result });
383+ });
384+ }
385+
386+ fn on_thumb(&mut self, path: PathBuf, px: u32, result: Result<Thumb, ThumbError>) {
387+ self.thumbs.requested.remove(&path);
388+ if px != self.thumbs.cell {
389+ return;
390+ }
391+ match result {
392+ Ok(t) => self.thumbs.arrivals.push_back((path, t)),
393+ Err(e) => {
394+ // Undecodable: it leaves the queue, as in image mode.
395+ log::warn!("{}: {e}", path.display());
396+ let was_current = self.state.queue.current() == Some(path.as_path());
397+ if self.state.queue.remove(&path) {
398+ self.state.skipped += 1;
399+ }
400+ self.sync(was_current);
401+ self.request_redraw();
402+ }
403+ }
404+ }
405+
406+ /// Every thumbnail on screen is in the atlas.
407+ fn grid_settled(&self) -> bool {
408+ let l = self.state.grid_layout();
409+ let scroll = self.state.grid.scroll(&l, Instant::now());
410+ l.visible(scroll).all(|i| {
411+ self.state
412+ .queue
413+ .get(i)
414+ .is_some_and(|p| self.state.thumbs.contains_key(p))
415+ })
416+ }
417+
262418 /// Upload `img` in full and put it on screen.
263419 fn show(&mut self, path: &Path, img: Arc<DecodedImage>, now: Instant) {
264420 let Some(g) = &mut self.gfx else {
@@ -314,6 +470,12 @@ impl App {
314470 let mut settings = Settings::from_config(new);
315471 self.opts.overrides.apply(&mut settings);
316472 let preload_changed = settings.preload != self.state.settings.preload;
473+ if old.grid.size != new.grid.size {
474+ self.state.grid.size = new.grid.size;
475+ }
476+ if old.grid.thumbnail_cache != new.grid.thumbnail_cache {
477+ self.thumb_cache = CacheOptions::new(new.grid.thumbnail_cache, self.opts.private);
478+ }
317479 self.state.set_settings(settings);
318480 self.state.set_theme(loaded.theme, now);
319481 self.keymap = keymap(&loaded.keys);
@@ -348,6 +510,7 @@ impl App {
348510 if preload_changed {
349511 self.sync(false);
350512 }
513+ self.sync_thumbs();
351514 self.request_redraw();
352515 }
353516
@@ -355,12 +518,15 @@ impl App {
355518 lock(&self.arriving).remove(&path);
356519 let now = Instant::now();
357520 let is_current = self.state.queue.current() == Some(path.as_path());
521+ let in_grid = self.state.in_grid();
358522 match result {
359523 Ok(img) => {
360524 let wanted = self.window_paths();
361525 let keep: Vec<&Path> = wanted.iter().map(PathBuf::as_path).collect();
362526 self.cache.insert(path.clone(), img.clone(), &keep);
363− if is_current {
527+ if in_grid {
528+ // Cached for when the grid opens it.
529+ } else if is_current {
364530 // Already on screen means this was a reload: replace the texture.
365531 let reload = self.state.shown.as_ref().is_some_and(|s| s.path == path);
366532 if reload && let Some(g) = &mut self.gfx {
@@ -443,11 +609,16 @@ impl App {
443609 let _ = proxy.send_event(UserEvent::Sorted(files));
444610 });
445611 }
446− if fx.navigated {
612+ if fx.mode {
613+ // Opening an image from the grid loads it like navigating to it.
614+ self.sync(self.state.mode == Mode::Image || fx.navigated);
615+ } else if fx.navigated {
447616 self.sync(true);
448617 } else if fx.reordered {
449618 self.sync(false);
450619 }
620+ // Scrolling, a new size or the bar can bring new thumbnails into view.
621+ self.sync_thumbs();
451622 if fx.redraw || fx.layout {
452623 self.request_redraw();
453624 }
@@ -500,7 +671,9 @@ impl App {
500671 self.repeat = None;
501672 for run in runs {
502673 let actions = run.counted();
503− if let [Action::Pan(dir)] = actions[..] {
674+ if let [Action::Pan(dir)] = actions[..]
675+ && self.state.mode == Mode::Image
676+ {
504677 if self.state.shown.is_some() {
505678 self.held.insert(event.physical_key, dir);
506679 self.state.hold_pan(dir, true, now);
@@ -525,14 +698,28 @@ impl App {
525698 }
526699 }
527700
528− fn on_wheel(&mut self, delta: [f32; 2], event_loop: &ActiveEventLoop) {
701+ /// `pixels` is set for touchpads, whose deltas are pixels divided by [`PIXELS_PER_STEP`].
702+ fn on_wheel(&mut self, delta: [f32; 2], pixels: bool, event_loop: &ActiveEventLoop) {
529703 let now = Instant::now();
530704 let [dx, dy] = delta;
531705 if dy != 0.0 {
532706 let dir = if dy > 0.0 { Scroll::Up } else { Scroll::Down };
533707 let key = input::scroll(dir, self.modifiers);
534− // Wheel zoom stays continuous for touchpads rather than stepping per notch.
708+ // Wheel zoom and grid scrolling stay continuous for touchpads rather than stepping
709+ // per notch.
710+ let in_grid = self.state.in_grid();
535711 match self.keymap.lookup(self.state.mode, &[key]) {
712+ Some([Action::Scroll(d @ (Dir::Up | Dir::Down), n)]) if in_grid => {
713+ let sign = if *d == Dir::Up { -1.0 } else { 1.0 };
714+ let amount = dy.abs() * *n as f32 * sign;
715+ let moved = if pixels {
716+ self.state.grid_drag(-amount * PIXELS_PER_STEP, now)
717+ } else {
718+ self.state.grid_scroll(amount, now)
719+ };
720+ self.after_grid_scroll(moved);
721+ return;
722+ }
536723 Some([Action::Zoom(Zoom::In(n))]) => {
537724 let steps = dy.abs() * *n as f32;
538725 self.state.zoom_at(steps, self.cursor, now);
@@ -569,16 +756,49 @@ impl App {
569756 }
570757 }
571758
759+ /// The grid scrolled; `moved` says whether it pulled the cursor along.
760+ fn after_grid_scroll(&mut self, moved: bool) {
761+ if moved {
762+ self.sync(true);
763+ } else {
764+ self.sync_thumbs();
765+ }
766+ self.request_redraw();
767+ }
768+
769+ /// A click (press and release in place) in the grid selects; a second one opens.
770+ fn grid_click(&mut self, event_loop: &ActiveEventLoop) {
771+ let now = Instant::now();
772+ let Some(i) = self.state.grid_hit(self.cursor, now) else {
773+ return;
774+ };
775+ let double = self
776+ .last_click
777+ .is_some_and(|(t, j)| j == i && now < t + DOUBLE_CLICK);
778+ self.last_click = (!double).then_some((now, i));
779+ let mut actions = vec![Action::Goto(i + 1)];
780+ if double {
781+ actions.push(Action::Mode(ModeArg::Image));
782+ }
783+ self.run(actions, None, event_loop);
784+ }
785+
572786 fn redraw(&mut self) {
573787 let now = Instant::now();
574788 let ready = self.state.shown.is_some()
575789 || self.state.queue.is_empty()
790+ || (self.state.in_grid() && self.grid_settled())
576791 || now >= self.started + SHOW_TIMEOUT;
577792 if !self.shown_window && !ready {
578793 return; // about_to_wait wakes us at the timeout
579794 }
580795 self.state.advance(now);
581796 self.state.tick(now);
797+ let settled = if self.state.in_grid() {
798+ self.thumbs.arrivals.is_empty() && self.grid_settled()
799+ } else {
800+ self.state.loading_since.is_none()
801+ };
582802 let Some(g) = &mut self.gfx else { return };
583803 if !self.shown_window {
584804 g.window.set_visible(true);
@@ -588,7 +808,7 @@ impl App {
588808 let result = g.renderer.render(&scene);
589809 if let Some(path) = std::env::var_os("REMAGE_SNAPSHOT")
590810 && !self.state.is_animating(now)
591− && self.state.loading_since.is_none()
811+ && settled
592812 {
593813 match g.renderer.snapshot(&scene, Path::new(&path)) {
594814 Ok(()) => log::info!("snapshot written to {}", Path::new(&path).display()),
@@ -614,9 +834,27 @@ impl App {
614834 }
615835 }
616836
617− /// Upload preloaded images a slice at a time. Returns whether work remains.
837+ /// Upload thumbnails, then preloaded images a slice at a time. Returns whether work remains.
618838 fn pump_uploads(&mut self) -> bool {
619839 let Some(g) = &mut self.gfx else { return false };
840+ let now = Instant::now();
841+ let mut bytes = 0;
842+ while bytes < UPLOAD_BUDGET
843+ && let Some((path, t)) = self.thumbs.arrivals.pop_front()
844+ {
845+ bytes += t.pixels.len();
846+ let wanted = &self.thumbs.wanted;
847+ for gone in g.renderer.insert_thumb(&path, &t, |p| wanted.contains(p)) {
848+ self.state.thumbs.remove(&gone);
849+ }
850+ if g.renderer.has_thumb(&path) {
851+ self.state.thumb_ready(path, t.size, now);
852+ }
853+ g.window.request_redraw();
854+ }
855+ if !self.thumbs.arrivals.is_empty() {
856+ return true;
857+ }
620858 if let Some((path, img)) = self.uploads.front() {
621859 let img = fit_gpu(img.clone(), g.renderer.max_texture_dim());
622860 if g.renderer.upload(path, &img, UPLOAD_BUDGET) {
@@ -768,6 +1006,7 @@ impl ApplicationHandler<UserEvent> for App {
7681006 self.state.queue.reorder(files);
7691007 self.sync(false);
7701008 }
1009+ UserEvent::Thumb { path, px, result } => self.on_thumb(path, px, result),
7711010 }
7721011 }
7731012
@@ -780,11 +1019,13 @@ impl ApplicationHandler<UserEvent> for App {
7801019 let sf = g.window.scale_factor() as f32;
7811020 self.state
7821021 .set_window(size.width as f32, size.height as f32, sf);
1022+ self.sync_thumbs();
7831023 self.request_redraw();
7841024 }
7851025 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
7861026 let [w, h] = self.state.window();
7871027 self.state.set_window(w, h, scale_factor as f32);
1028+ self.sync_thumbs();
7881029 self.request_redraw();
7891030 }
7901031 WindowEvent::RedrawRequested => self.redraw(),
@@ -803,13 +1044,15 @@ impl ApplicationHandler<UserEvent> for App {
8031044 }
8041045 WindowEvent::CursorMoved { position, .. } => {
8051046 let p = [position.x as f32, position.y as f32];
806− if self.dragging && self.state.shown.is_some() {
807− self.state
808− .view
809− .pan_pixels([p[0] - self.cursor[0], p[1] - self.cursor[1]]);
1047+ let delta = [p[0] - self.cursor[0], p[1] - self.cursor[1]];
1048+ self.cursor = p;
1049+ if self.dragging && self.state.in_grid() {
1050+ let moved = self.state.grid_drag(delta[1], Instant::now());
1051+ self.after_grid_scroll(moved);
1052+ } else if self.dragging && self.state.shown.is_some() {
1053+ self.state.view.pan_pixels(delta);
8101054 self.request_redraw();
8111055 }
812− self.cursor = p;
8131056 }
8141057 WindowEvent::MouseInput { state, button, .. } => {
8151058 let pressed = state == ElementState::Pressed;
@@ -825,6 +1068,9 @@ impl ApplicationHandler<UserEvent> for App {
8251068 if moved > CLICK_SLOP * self.state.scale_factor() {
8261069 return;
8271070 }
1071+ if self.state.in_grid() {
1072+ self.grid_click(event_loop);
1073+ }
8281074 } else if !pressed {
8291075 return;
8301076 }
@@ -833,13 +1079,14 @@ impl ApplicationHandler<UserEvent> for App {
8331079 }
8341080 }
8351081 WindowEvent::MouseWheel { delta, .. } => {
836− let d = match delta {
837− MouseScrollDelta::LineDelta(x, y) => [x, y],
838− MouseScrollDelta::PixelDelta(p) => {
839− [p.x as f32 / PIXELS_PER_STEP, p.y as f32 / PIXELS_PER_STEP]
840− }
1082+ let (d, pixels) = match delta {
1083+ MouseScrollDelta::LineDelta(x, y) => ([x, y], false),
1084+ MouseScrollDelta::PixelDelta(p) => (
1085+ [p.x as f32 / PIXELS_PER_STEP, p.y as f32 / PIXELS_PER_STEP],
1086+ true,
1087+ ),
8411088 };
842− self.on_wheel(d, event_loop);
1089+ self.on_wheel(d, pixels, event_loop);
8431090 }
8441091 _ => {}
8451092 }
@@ -863,7 +1110,22 @@ impl ApplicationHandler<UserEvent> for App {
8631110 self.run(run.counted(), None, event_loop);
8641111 }
8651112 }
1113+ if let Some(t) = self.grid_rest
1114+ && now >= t + GRID_REST
1115+ {
1116+ self.grid_rest = None;
1117+ if self.state.in_grid()
1118+ && let Some(cur) = self.state.queue.current().map(Path::to_path_buf)
1119+ && !self.cache_has(&cur)
1120+ {
1121+ self.decode(&cur, Priority::Preload);
1122+ }
1123+ }
8661124 let (redraw, mut next) = self.state.tick(now);
1125+ if let Some(t) = self.grid_rest {
1126+ let at = t + GRID_REST;
1127+ next = Some(next.map_or(at, |n| n.min(at)));
1128+ }
8671129 if let Some(t) = self.ambiguous_since {
8681130 let at = t + SEQUENCE_TIMEOUT;
8691131 next = Some(next.map_or(at, |n| n.min(at)));
M src/cli.rs View
@@ -17,6 +17,14 @@ pub struct Cli {
1717 #[arg(short, long)]
1818 pub recursive: bool,
1919
20+ /// Start in the thumbnail grid
21+ #[arg(short = 't', long)]
22+ pub grid: bool,
23+
24+ /// Read the shared thumbnail cache but never write to it
25+ #[arg(short = 'p', long)]
26+ pub private: bool,
27+
2028 /// Start at the Nth image (1-based)
2129 #[arg(short = 'n', long, value_name = "N")]
2230 pub start: Option<usize>,
M src/config/default.toml View
@@ -140,13 +140,34 @@ preset = "nothing-dark" # nothing-dark | nothing-light
140140 "<ScrollUp>" = "zoom in"
141141 "<ScrollDown>" = "zoom out"
142142
143+# In the grid a click selects a thumbnail and a double-click opens it; dragging scrolls.
143144 [keys.grid]
145+"h" = "move left"
146+"j" = "move down"
147+"k" = "move up"
148+"l" = "move right"
149+"<Left>" = "move left"
150+"<Down>" = "move down"
151+"<Up>" = "move up"
152+"<Right>" = "move right"
153+"<C-d>" = "page half-down"
154+"<C-u>" = "page half-up"
155+"<C-f>" = "page down"
156+"<C-b>" = "page up"
157+"<PageDown>" = "page down"
158+"<PageUp>" = "page up"
159+"0" = "row-start"
160+"^" = "row-start"
161+"$" = "row-end"
144162 "+" = "grid-size +"
145163 "-" = "grid-size -"
146164 "v" = "visual"
165+"<ScrollUp>" = "scroll up"
166+"<ScrollDown>" = "scroll down"
147167
148168 [keys.visual]
149169 "m" = "mark on"
170+"v" = "mode grid"
150171 "<Esc>" = "mode grid"
151172
152173 # User commands: bind them as "run <name>"; unbound ones still show in the : palette.
M src/main.rs View
@@ -82,6 +82,9 @@ fn main() -> anyhow::Result<()> {
8282 let queue = Queue::new(scan.files, scan.start.as_deref(), start_index);
8383 let bar = overrides.bar.unwrap_or(cfg.ui.statusbar);
8484 let mut state = AppState::new(queue, settings, bar, Instant::now());
85+ if cli.grid || cfg.general.start_mode == config::StartMode::Grid {
86+ state.set_mode(state::Mode::Grid, Instant::now());
87+ }
8588 state.theme = loaded.theme.clone();
8689 state.nearest = cfg.image.filter.nearest();
8790 state.alpha_bg = cfg.image.alpha_background;
@@ -113,6 +116,7 @@ fn main() -> anyhow::Result<()> {
113116 geometry: cli.geometry,
114117 class: cli.class.unwrap_or_else(|| cfg.window.class.clone()),
115118 overrides,
119+ private: cli.private,
116120 };
117121 let mut app = App::new(opts, state, proxy, loaded, watch);
118122 if let Some(e) = config_error {
A src/render/atlas.rs View
@@ -0,0 +1,404 @@
1+//! Thumbnail atlas: square slots in texture pages, evicted least-recently-drawn first.
2+
3+use std::collections::HashMap;
4+use std::path::{Path, PathBuf};
5+
6+use bytemuck::{Pod, Zeroable};
7+use wgpu::util::DeviceExt;
8+
9+use crate::thumbs::Thumb;
10+
11+const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
12+/// Preferred page side; smaller if the GPU can't do it, larger for huge cells.
13+const PAGE: u32 = 2048;
14+
15+/// A thumbnail to draw: `rect` in physical pixels.
16+pub struct ThumbDraw<'a> {
17+ pub path: &'a Path,
18+ pub rect: [f32; 4],
19+ pub alpha: f32,
20+}
21+
22+#[repr(C)]
23+#[derive(Clone, Copy, Pod, Zeroable)]
24+struct Instance {
25+ rect: [f32; 4],
26+ uv: [f32; 4],
27+ params: [f32; 4],
28+}
29+
30+const ATTRS: [wgpu::VertexAttribute; 3] =
31+ wgpu::vertex_attr_array![0 => Float32x4, 1 => Float32x4, 2 => Float32x4];
32+
33+struct Page {
34+ texture: wgpu::Texture,
35+ bind_group: wgpu::BindGroup,
36+}
37+
38+#[derive(Clone, Copy)]
39+struct Slot {
40+ page: usize,
41+ index: u32,
42+ size: [u32; 2],
43+ used: u64,
44+}
45+
46+pub struct Atlas {
47+ pipeline: wgpu::RenderPipeline,
48+ globals: wgpu::Buffer,
49+ globals_group: wgpu::BindGroup,
50+ page_layout: wgpu::BindGroupLayout,
51+ instances: wgpu::Buffer,
52+ capacity: usize,
53+ /// Per page: instance range drawn this frame.
54+ batches: Vec<(usize, std::ops::Range<u32>)>,
55+ max_dim: u32,
56+ page_side: u32,
57+ /// Slot side: the cell plus a 1 px transparent border all round.
58+ slot: u32,
59+ pages: Vec<Page>,
60+ slots: HashMap<PathBuf, Slot>,
61+ free: Vec<(usize, u32)>,
62+ frame: u64,
63+}
64+
65+fn instance_buffer(device: &wgpu::Device, capacity: usize) -> wgpu::Buffer {
66+ device.create_buffer(&wgpu::BufferDescriptor {
67+ label: Some("thumb instances"),
68+ size: (capacity * size_of::<Instance>()) as u64,
69+ usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
70+ mapped_at_creation: false,
71+ })
72+}
73+
74+impl Atlas {
75+ pub fn new(device: &wgpu::Device, target: wgpu::TextureFormat, max_dim: u32) -> Self {
76+ let globals = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
77+ label: Some("thumb globals"),
78+ contents: bytemuck::bytes_of(&[0.0f32; 4]),
79+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
80+ });
81+ let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
82+ label: Some("thumbs"),
83+ entries: &[
84+ wgpu::BindGroupLayoutEntry {
85+ binding: 0,
86+ visibility: wgpu::ShaderStages::VERTEX,
87+ ty: wgpu::BindingType::Buffer {
88+ ty: wgpu::BufferBindingType::Uniform,
89+ has_dynamic_offset: false,
90+ min_binding_size: None,
91+ },
92+ count: None,
93+ },
94+ wgpu::BindGroupLayoutEntry {
95+ binding: 1,
96+ visibility: wgpu::ShaderStages::FRAGMENT,
97+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
98+ count: None,
99+ },
100+ ],
101+ });
102+ let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
103+ label: Some("thumbs"),
104+ mag_filter: wgpu::FilterMode::Linear,
105+ min_filter: wgpu::FilterMode::Linear,
106+ ..Default::default()
107+ });
108+ let globals_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
109+ label: Some("thumbs"),
110+ layout: &globals_layout,
111+ entries: &[
112+ wgpu::BindGroupEntry {
113+ binding: 0,
114+ resource: globals.as_entire_binding(),
115+ },
116+ wgpu::BindGroupEntry {
117+ binding: 1,
118+ resource: wgpu::BindingResource::Sampler(&sampler),
119+ },
120+ ],
121+ });
122+ let page_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
123+ label: Some("thumb page"),
124+ entries: &[wgpu::BindGroupLayoutEntry {
125+ binding: 0,
126+ visibility: wgpu::ShaderStages::FRAGMENT,
127+ ty: wgpu::BindingType::Texture {
128+ sample_type: wgpu::TextureSampleType::Float { filterable: true },
129+ view_dimension: wgpu::TextureViewDimension::D2,
130+ multisampled: false,
131+ },
132+ count: None,
133+ }],
134+ });
135+ let shader = device.create_shader_module(wgpu::include_wgsl!("shaders/thumbs.wgsl"));
136+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
137+ label: Some("thumbs"),
138+ bind_group_layouts: &[Some(&globals_layout), Some(&page_layout)],
139+ immediate_size: 0,
140+ });
141+ let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
142+ label: Some("thumbs"),
143+ layout: Some(&layout),
144+ vertex: wgpu::VertexState {
145+ module: &shader,
146+ entry_point: Some("vs"),
147+ compilation_options: Default::default(),
148+ buffers: &[Some(wgpu::VertexBufferLayout {
149+ array_stride: size_of::<Instance>() as u64,
150+ step_mode: wgpu::VertexStepMode::Instance,
151+ attributes: &ATTRS,
152+ })],
153+ },
154+ primitive: wgpu::PrimitiveState::default(),
155+ depth_stencil: None,
156+ multisample: wgpu::MultisampleState::default(),
157+ fragment: Some(wgpu::FragmentState {
158+ module: &shader,
159+ entry_point: Some("fs"),
160+ compilation_options: Default::default(),
161+ targets: &[Some(wgpu::ColorTargetState {
162+ format: target,
163+ blend: Some(wgpu::BlendState::ALPHA_BLENDING),
164+ write_mask: wgpu::ColorWrites::ALL,
165+ })],
166+ }),
167+ multiview_mask: None,
168+ cache: None,
169+ });
170+ let capacity = 256;
171+ Self {
172+ pipeline,
173+ globals,
174+ globals_group,
175+ page_layout,
176+ instances: instance_buffer(device, capacity),
177+ capacity,
178+ batches: Vec::new(),
179+ max_dim,
180+ page_side: PAGE.min(max_dim),
181+ slot: 0,
182+ pages: Vec::new(),
183+ slots: HashMap::new(),
184+ free: Vec::new(),
185+ frame: 0,
186+ }
187+ }
188+
189+ /// Start over for thumbnails of `cell` pixels (a new grid size or display scale).
190+ pub fn reset(&mut self, cell: u32) {
191+ let slot = (cell + 2).min(self.max_dim);
192+ self.slot = slot;
193+ self.page_side = PAGE.max(slot.next_power_of_two()).min(self.max_dim);
194+ self.pages.clear();
195+ self.slots.clear();
196+ self.free.clear();
197+ }
198+
199+ pub fn contains(&self, path: &Path) -> bool {
200+ self.slots.contains_key(path)
201+ }
202+
203+ fn per_row(&self) -> u32 {
204+ (self.page_side / self.slot.max(1)).max(1)
205+ }
206+
207+ fn add_page(&mut self, device: &wgpu::Device) {
208+ let texture = device.create_texture(&wgpu::TextureDescriptor {
209+ label: Some("thumb page"),
210+ size: wgpu::Extent3d {
211+ width: self.page_side,
212+ height: self.page_side,
213+ depth_or_array_layers: 1,
214+ },
215+ mip_level_count: 1,
216+ sample_count: 1,
217+ dimension: wgpu::TextureDimension::D2,
218+ format: FORMAT,
219+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
220+ view_formats: &[],
221+ });
222+ let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
223+ let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
224+ label: Some("thumb page"),
225+ layout: &self.page_layout,
226+ entries: &[wgpu::BindGroupEntry {
227+ binding: 0,
228+ resource: wgpu::BindingResource::TextureView(&view),
229+ }],
230+ });
231+ let page = self.pages.len();
232+ self.pages.push(Page {
233+ texture,
234+ bind_group,
235+ });
236+ let n = self.per_row() * self.per_row();
237+ self.free.extend((0..n).rev().map(|i| (page, i)));
238+ log::debug!(
239+ "thumbnail atlas: page {} ({n} slots of {} px)",
240+ page + 1,
241+ self.slot
242+ );
243+ }
244+
245+ /// Store `thumb` for `path`. When full, the least recently drawn thumbnail that isn't
246+ /// `wanted` makes room; if every one is wanted, the atlas grows. Returns evicted paths.
247+ pub fn insert(
248+ &mut self,
249+ device: &wgpu::Device,
250+ queue: &wgpu::Queue,
251+ path: &Path,
252+ thumb: &Thumb,
253+ wanted: impl Fn(&Path) -> bool,
254+ ) -> Vec<PathBuf> {
255+ let mut evicted = Vec::new();
256+ let (page, index) = if let Some(s) = self.slots.remove(path) {
257+ (s.page, s.index)
258+ } else if let Some(free) = self.free.pop() {
259+ free
260+ } else {
261+ let victim = self
262+ .slots
263+ .iter()
264+ .filter(|(p, _)| !wanted(p))
265+ .min_by_key(|(_, s)| s.used)
266+ .map(|(p, _)| p.clone());
267+ match victim.and_then(|v| self.slots.remove(&v).map(|s| (v, s))) {
268+ Some((v, s)) => {
269+ evicted.push(v);
270+ (s.page, s.index)
271+ }
272+ None => {
273+ self.add_page(device);
274+ match self.free.pop() {
275+ Some(f) => f,
276+ None => return evicted,
277+ }
278+ }
279+ }
280+ };
281+ // Clamp to the slot, keeping a 1 px clear border so filtering never picks up a
282+ // neighbour or a previous occupant.
283+ let inner = self.slot.saturating_sub(2).max(1);
284+ let w = thumb.size[0].min(inner);
285+ let h = thumb.size[1].min(inner);
286+ let (pw, ph) = (w + 2, h + 2);
287+ let mut padded = vec![0u8; (pw * ph * 4) as usize];
288+ let src_stride = thumb.size[0] as usize * 4;
289+ for y in 0..h as usize {
290+ let src = &thumb.pixels[y * src_stride..][..w as usize * 4];
291+ let dst = ((y + 1) * pw as usize + 1) * 4;
292+ padded[dst..dst + src.len()].copy_from_slice(src);
293+ }
294+ let per_row = self.per_row();
295+ let origin = [(index % per_row) * self.slot, (index / per_row) * self.slot];
296+ queue.write_texture(
297+ wgpu::TexelCopyTextureInfo {
298+ texture: &self.pages[page].texture,
299+ mip_level: 0,
300+ origin: wgpu::Origin3d {
301+ x: origin[0],
302+ y: origin[1],
303+ z: 0,
304+ },
305+ aspect: wgpu::TextureAspect::All,
306+ },
307+ &padded,
308+ wgpu::TexelCopyBufferLayout {
309+ offset: 0,
310+ bytes_per_row: Some(pw * 4),
311+ rows_per_image: Some(ph),
312+ },
313+ wgpu::Extent3d {
314+ width: pw,
315+ height: ph,
316+ depth_or_array_layers: 1,
317+ },
318+ );
319+ self.slots.insert(
320+ path.to_path_buf(),
321+ Slot {
322+ page,
323+ index,
324+ size: [w, h],
325+ used: self.frame,
326+ },
327+ );
328+ evicted
329+ }
330+
331+ /// Build this frame's instances. Thumbnails not in the atlas are skipped.
332+ pub fn prepare(
333+ &mut self,
334+ device: &wgpu::Device,
335+ queue: &wgpu::Queue,
336+ draws: &[ThumbDraw],
337+ viewport: [f32; 2],
338+ ) {
339+ self.frame += 1;
340+ self.batches.clear();
341+ if draws.is_empty() {
342+ return;
343+ }
344+ queue.write_buffer(
345+ &self.globals,
346+ 0,
347+ bytemuck::bytes_of(&[viewport[0], viewport[1], 0.0, 0.0]),
348+ );
349+ let side = self.page_side as f32;
350+ let per_row = self.per_row();
351+ let mut list: Vec<(usize, Instance)> = Vec::with_capacity(draws.len());
352+ for d in draws {
353+ let Some(slot) = self.slots.get_mut(d.path) else {
354+ continue;
355+ };
356+ slot.used = self.frame;
357+ let x = (slot.index % per_row) * self.slot + 1;
358+ let y = (slot.index / per_row) * self.slot + 1;
359+ list.push((
360+ slot.page,
361+ Instance {
362+ rect: d.rect,
363+ uv: [
364+ x as f32 / side,
365+ y as f32 / side,
366+ slot.size[0] as f32 / side,
367+ slot.size[1] as f32 / side,
368+ ],
369+ params: [d.alpha, 0.0, 0.0, 0.0],
370+ },
371+ ));
372+ }
373+ list.sort_by_key(|(p, _)| *p);
374+ if list.len() > self.capacity {
375+ self.capacity = list.len().next_power_of_two();
376+ self.instances = instance_buffer(device, self.capacity);
377+ }
378+ let instances: Vec<Instance> = list.iter().map(|(_, i)| *i).collect();
379+ if !instances.is_empty() {
380+ queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(&instances));
381+ }
382+ let mut start = 0;
383+ for (i, (page, _)) in list.iter().enumerate() {
384+ let end = i + 1;
385+ if end == list.len() || list[end].0 != *page {
386+ self.batches.push((*page, start as u32..end as u32));
387+ start = end;
388+ }
389+ }
390+ }
391+
392+ pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
393+ if self.batches.is_empty() {
394+ return;
395+ }
396+ pass.set_pipeline(&self.pipeline);
397+ pass.set_bind_group(0, &self.globals_group, &[]);
398+ pass.set_vertex_buffer(0, self.instances.slice(..));
399+ for (page, range) in &self.batches {
400+ pass.set_bind_group(1, &self.pages[*page].bind_group, &[]);
401+ pass.draw(0..6, range.clone());
402+ }
403+ }
404+}
M src/render/mod.rs View
@@ -1,5 +1,6 @@
11 //! wgpu device and surface setup, and per-frame orchestration.
22
3+pub mod atlas;
34 pub mod image;
45 pub mod prims;
56 pub mod text;
@@ -14,6 +15,8 @@ use winit::window::Window;
1415 use crate::config::theme::Rgba;
1516 use crate::decode::DecodedImage;
1617 use crate::state::view::Affine;
18+use crate::thumbs::Thumb;
19+use atlas::{Atlas, ThumbDraw};
1720 use image::{AlphaStyle, GpuImage, ImagePipeline};
1821 use prims::{Prim, PrimPipeline};
1922 use text::{TextItem, TextSystem};
@@ -32,6 +35,10 @@ pub struct Scene<'a> {
3235 pub image_clip: [f32; 4],
3336 pub alpha: AlphaStyle,
3437 pub images: Vec<ImageDraw<'a>>,
38+ /// Grid thumbnails, clipped like images.
39+ pub thumbs: Vec<ThumbDraw<'a>>,
40+ /// Drawn before images and thumbnails (clipped like them), e.g. selection backgrounds.
41+ pub under: Vec<Prim>,
3542 pub prims: Vec<Prim>,
3643 pub texts: Vec<TextItem>,
3744 }
@@ -46,6 +53,7 @@ pub struct Renderer {
4653 view_format: wgpu::TextureFormat,
4754 images: ImagePipeline,
4855 prims: PrimPipeline,
56+ atlas: Atlas,
4957 text: TextSystem,
5058 textures: HashMap<PathBuf, GpuImage>,
5159 max_dim: u32,
@@ -123,6 +131,7 @@ impl Renderer {
123131 Ok(Self {
124132 images: ImagePipeline::new(&device, view_format),
125133 prims: PrimPipeline::new(&device, view_format),
134+ atlas: Atlas::new(&device, view_format, max_dim),
126135 text: TextSystem::new(&device, &queue, view_format),
127136 window,
128137 instance,
@@ -178,6 +187,26 @@ impl Renderer {
178187 .upload(&self.device, &self.queue, gpu, img, budget)
179188 }
180189
190+ /// Start a fresh thumbnail atlas for `cell`-pixel thumbnails.
191+ pub fn reset_thumbs(&mut self, cell: u32) {
192+ self.atlas.reset(cell);
193+ }
194+
195+ pub fn has_thumb(&self, path: &Path) -> bool {
196+ self.atlas.contains(path)
197+ }
198+
199+ /// Put a thumbnail in the atlas. Returns the paths evicted to make room.
200+ pub fn insert_thumb(
201+ &mut self,
202+ path: &Path,
203+ thumb: &Thumb,
204+ wanted: impl Fn(&Path) -> bool,
205+ ) -> Vec<PathBuf> {
206+ self.atlas
207+ .insert(&self.device, &self.queue, path, thumb, wanted)
208+ }
209+
181210 /// Drop textures that are no longer wanted.
182211 pub fn retain(&mut self, keep: impl Fn(&Path) -> bool) {
183212 self.textures.retain(|p, _| keep(p));
@@ -242,8 +271,15 @@ impl Renderer {
242271 self.images
243272 .prepare(&self.queue, gpu, d.affine, viewport, d.alpha, &scene.alpha);
244273 }
245− self.prims
246− .prepare(&self.device, &self.queue, &scene.prims, viewport);
274+ self.atlas
275+ .prepare(&self.device, &self.queue, &scene.thumbs, viewport);
276+ self.prims.prepare(
277+ &self.device,
278+ &self.queue,
279+ &scene.under,
280+ &scene.prims,
281+ viewport,
282+ );
247283 self.text
248284 .prepare(&self.device, &self.queue, &scene.texts, size)?;
249285
@@ -277,12 +313,14 @@ impl Renderer {
277313 });
278314 if let Some((x, y, w, h)) = clip(scene.image_clip, size) {
279315 pass.set_scissor_rect(x, y, w, h);
316+ self.prims.draw_under(&mut pass);
280317 for (d, gpu) in &draws {
281318 self.images.draw(&mut pass, gpu, d.nearest);
282319 }
320+ self.atlas.draw(&mut pass);
283321 pass.set_scissor_rect(0, 0, size[0], size[1]);
284322 }
285− self.prims.draw(&mut pass);
323+ self.prims.draw_over(&mut pass);
286324 self.text.render(&mut pass)?;
287325 }
288326 Ok(encoder.finish())
M src/render/prims.rs View
@@ -23,6 +23,35 @@ impl Prim {
2323 params: [0.0; 4],
2424 }
2525 }
26+
27+ pub fn rounded(rect: [f32; 4], color: [f32; 4], radius: f32) -> Self {
28+ Self {
29+ params: [radius, 0.0, 0.0, 0.0],
30+ ..Self::rect(rect, color)
31+ }
32+ }
33+
34+ /// An outline only.
35+ pub fn outline(rect: [f32; 4], color: [f32; 4], radius: f32, width: f32) -> Self {
36+ Self {
37+ rect,
38+ color: [0.0; 4],
39+ border_color: color,
40+ params: [radius, width, 0.0, 0.0],
41+ }
42+ }
43+
44+ /// A dot centred on `c`, optionally ringed.
45+ pub fn dot(c: [f32; 2], r: f32, color: [f32; 4], ring: Option<([f32; 4], f32)>) -> Self {
46+ let (border_color, w) = ring.unwrap_or(([0.0; 4], 0.0));
47+ let r = r + w;
48+ Self {
49+ rect: [c[0] - r, c[1] - r, 2.0 * r, 2.0 * r],
50+ color,
51+ border_color,
52+ params: [r, w, 0.0, 0.0],
53+ }
54+ }
2655 }
2756
2857 pub struct PrimPipeline {
@@ -31,6 +60,8 @@ pub struct PrimPipeline {
3160 bind_group: wgpu::BindGroup,
3261 instances: wgpu::Buffer,
3362 capacity: usize,
63+ /// Instances of the under layer, then the over layer.
64+ split: u32,
3465 count: u32,
3566 }
3667
@@ -116,17 +147,21 @@ impl PrimPipeline {
116147 bind_group,
117148 instances: instance_buffer(device, capacity),
118149 capacity,
150+ split: 0,
119151 count: 0,
120152 }
121153 }
122154
155+ /// Upload both layers: `under` draws before images and thumbnails, `over` after.
123156 pub fn prepare(
124157 &mut self,
125158 device: &wgpu::Device,
126159 queue: &wgpu::Queue,
127− prims: &[Prim],
160+ under: &[Prim],
161+ over: &[Prim],
128162 viewport: [f32; 2],
129163 ) {
164+ let prims: Vec<Prim> = under.iter().chain(over).copied().collect();
130165 queue.write_buffer(
131166 &self.globals,
132167 0,
@@ -137,18 +172,27 @@ impl PrimPipeline {
137172 self.instances = instance_buffer(device, self.capacity);
138173 }
139174 if !prims.is_empty() {
140− queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(prims));
175+ queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(&prims));
141176 }
177+ self.split = under.len() as u32;
142178 self.count = prims.len() as u32;
143179 }
144180
145− pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
146− if self.count == 0 {
181+ fn draw_range(&self, pass: &mut wgpu::RenderPass<'_>, range: std::ops::Range<u32>) {
182+ if range.is_empty() {
147183 return;
148184 }
149185 pass.set_pipeline(&self.pipeline);
150186 pass.set_bind_group(0, &self.bind_group, &[]);
151187 pass.set_vertex_buffer(0, self.instances.slice(..));
152− pass.draw(0..6, 0..self.count);
188+ pass.draw(0..6, range);
189+ }
190+
191+ pub fn draw_under(&self, pass: &mut wgpu::RenderPass<'_>) {
192+ self.draw_range(pass, 0..self.split);
193+ }
194+
195+ pub fn draw_over(&self, pass: &mut wgpu::RenderPass<'_>) {
196+ self.draw_range(pass, self.split..self.count);
153197 }
154198 }
A src/render/shaders/thumbs.wgsl View
@@ -0,0 +1,43 @@
1+// Instanced thumbnail quads sampled from atlas pages. Output is straight alpha.
2+
3+struct Globals {
4+ viewport: vec2<f32>,
5+ _pad: vec2<f32>,
6+};
7+
8+@group(0) @binding(0) var<uniform> g: Globals;
9+@group(0) @binding(1) var samp: sampler;
10+@group(1) @binding(0) var page: texture_2d<f32>;
11+
12+struct Instance {
13+ @location(0) rect: vec4<f32>, // x, y, w, h in physical px
14+ @location(1) uv: vec4<f32>, // u, v, du, dv in the page
15+ @location(2) params: vec4<f32>, // x: opacity
16+};
17+
18+struct VOut {
19+ @builtin(position) pos: vec4<f32>,
20+ @location(0) uv: vec2<f32>,
21+ @location(1) alpha: f32,
22+};
23+
24+@vertex
25+fn vs(@builtin(vertex_index) i: u32, inst: Instance) -> VOut {
26+ var corners = array<vec2<f32>, 6>(
27+ vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0),
28+ vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0),
29+ );
30+ let c = corners[i];
31+ let p = inst.rect.xy + c * inst.rect.zw;
32+ var out: VOut;
33+ 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);
34+ out.uv = inst.uv.xy + c * inst.uv.zw;
35+ out.alpha = inst.params.x;
36+ return out;
37+}
38+
39+@fragment
40+fn fs(in: VOut) -> @location(0) vec4<f32> {
41+ let c = textureSample(page, samp, in.uv);
42+ return vec4(c.rgb, c.a * in.alpha);
43+}
M src/render/text.rs View
@@ -64,6 +64,8 @@ pub struct TextItem {
6464 pub y: f32,
6565 pub align: Align,
6666 pub max_width: Option<f32>,
67+ /// Only the part inside this rect (x, y, w, h) shows.
68+ pub clip: Option<[f32; 4]>,
6769 }
6870
6971 #[derive(Clone, PartialEq, Eq, Hash)]
@@ -261,16 +263,28 @@ impl TextSystem {
261263 };
262264 let top = item.y - s.line_height * 0.5;
263265 let color = item.spans.first().map_or(Rgba([255; 4]), |(_, c)| *c).0;
266+ let mut bounds = [left, top, left + width, top + s.line_height];
267+ if let Some([x, y, w, h]) = item.clip {
268+ bounds = [
269+ bounds[0].max(x),
270+ bounds[1].max(y),
271+ bounds[2].min(x + w),
272+ bounds[3].min(y + h),
273+ ];
274+ if bounds[2] <= bounds[0] || bounds[3] <= bounds[1] {
275+ return None;
276+ }
277+ }
264278 Some(TextArea {
265279 buffer: &s.buffer,
266280 left,
267281 top,
268282 scale: 1.0,
269283 bounds: TextBounds {
270− left: left.floor() as i32,
271− top: top.floor() as i32,
272− right: (left + width).ceil() as i32,
273− bottom: (top + s.line_height).ceil() as i32,
284+ left: bounds[0].floor() as i32,
285+ top: bounds[1].floor() as i32,
286+ right: bounds[2].ceil() as i32,
287+ bottom: bounds[3].ceil() as i32,
274288 },
275289 default_color: Color::rgba(color[0], color[1], color[2], color[3]),
276290 custom_glyphs: &[],
M src/state/grid.rs View
@@ -1 +1,347 @@
11 //! Grid layout, cursor, scrolling and the visual-mode range.
2+//!
3+//! All sizes are physical pixels. Content coordinates start at the top of the first row's
4+//! gap; the scroll offset is how far the content has moved up.
5+
6+use std::ops::Range;
7+use std::time::{Duration, Instant};
8+
9+use super::view::Dir;
10+use crate::anim::Tween;
11+
12+/// One frame's grid geometry.
13+#[derive(Clone, Copy, Debug, PartialEq)]
14+pub struct Layout {
15+ pub count: usize,
16+ pub cols: usize,
17+ /// Side of the square each thumbnail fits in.
18+ pub cell: f32,
19+ pub gap: f32,
20+ /// Room under each cell for its name (0 without names).
21+ pub label: f32,
22+ /// x of the first column; the grid is centred.
23+ pub left: f32,
24+ pub viewport: [f32; 2],
25+}
26+
27+impl Layout {
28+ pub fn new(count: usize, viewport: [f32; 2], cell: f32, gap: f32, label: f32) -> Self {
29+ let cell = cell.max(1.0);
30+ let cols = ((viewport[0] - gap) / (cell + gap)).floor().max(1.0) as usize;
31+ let used = cols as f32 * cell + (cols - 1) as f32 * gap;
32+ Self {
33+ count,
34+ cols,
35+ cell,
36+ gap,
37+ label,
38+ left: ((viewport[0] - used) * 0.5).max(0.0).round(),
39+ viewport,
40+ }
41+ }
42+
43+ pub fn row_height(&self) -> f32 {
44+ self.cell + self.label + self.gap
45+ }
46+
47+ pub fn rows(&self) -> usize {
48+ self.count.div_ceil(self.cols)
49+ }
50+
51+ pub fn row_of(&self, i: usize) -> usize {
52+ i / self.cols
53+ }
54+
55+ pub fn content_height(&self) -> f32 {
56+ self.gap + self.rows() as f32 * self.row_height()
57+ }
58+
59+ pub fn max_scroll(&self) -> f32 {
60+ (self.content_height() - self.viewport[1]).max(0.0)
61+ }
62+
63+ pub fn clamp_scroll(&self, s: f32) -> f32 {
64+ s.clamp(0.0, self.max_scroll())
65+ }
66+
67+ /// Top-left of cell `i` in content coordinates.
68+ pub fn origin(&self, i: usize) -> [f32; 2] {
69+ let (row, col) = (i / self.cols, i % self.cols);
70+ [
71+ self.left + col as f32 * (self.cell + self.gap),
72+ self.gap + row as f32 * self.row_height(),
73+ ]
74+ }
75+
76+ /// Cells at least partly on screen at `scroll`.
77+ pub fn visible(&self, scroll: f32) -> Range<usize> {
78+ self.rows_between(scroll, scroll + self.viewport[1])
79+ }
80+
81+ /// Cells whose rows overlap content `top..bottom`.
82+ pub fn rows_between(&self, top: f32, bottom: f32) -> Range<usize> {
83+ let rh = self.row_height();
84+ let first = ((top - self.gap - self.cell - self.label) / rh)
85+ .ceil()
86+ .max(0.0) as usize;
87+ let last = ((bottom - self.gap) / rh).ceil().max(0.0) as usize;
88+ let start = (first * self.cols).min(self.count);
89+ let end = (last * self.cols).min(self.count);
90+ start..end.max(start)
91+ }
92+
93+ /// Whole rows that fit on screen (at least one).
94+ pub fn page_rows(&self) -> usize {
95+ ((self.viewport[1] - self.gap) / self.row_height())
96+ .floor()
97+ .max(1.0) as usize
98+ }
99+
100+ /// The cell under a viewport point.
101+ pub fn hit(&self, p: [f32; 2], scroll: f32) -> Option<usize> {
102+ let y = p[1] + scroll - self.gap;
103+ let x = p[0] - self.left;
104+ if x < 0.0 || y < 0.0 {
105+ return None;
106+ }
107+ let (col, row) = (
108+ (x / (self.cell + self.gap)) as usize,
109+ (y / self.row_height()) as usize,
110+ );
111+ let in_cell = x - col as f32 * (self.cell + self.gap) <= self.cell
112+ && y - row as f32 * self.row_height() <= self.cell + self.label;
113+ let i = row * self.cols + col;
114+ (col < self.cols && in_cell && i < self.count).then_some(i)
115+ }
116+
117+ /// The scroll offset nearest to `scroll` that shows cell `i`'s row whole, gaps included.
118+ pub fn follow(&self, i: usize, scroll: f32) -> f32 {
119+ let top = self.row_of(i) as f32 * self.row_height();
120+ let bottom = top + self.row_height() + self.gap;
121+ let s = if top < scroll {
122+ top
123+ } else if bottom > scroll + self.viewport[1] {
124+ bottom - self.viewport[1]
125+ } else {
126+ scroll
127+ };
128+ self.clamp_scroll(s)
129+ }
130+
131+ /// Move the cursor `n` cells (left/right) or rows (up/down). Down onto a short last row
132+ /// lands on the last cell.
133+ pub fn step(&self, i: usize, dir: Dir, n: usize) -> usize {
134+ if self.count == 0 {
135+ return 0;
136+ }
137+ let last = self.count - 1;
138+ match dir {
139+ Dir::Left => i.saturating_sub(n),
140+ Dir::Right => i.saturating_add(n).min(last),
141+ Dir::Up => i - n.min(self.row_of(i)) * self.cols,
142+ Dir::Down => {
143+ let rows = n.min(self.row_of(last) - self.row_of(i));
144+ (i + rows * self.cols).min(last)
145+ }
146+ }
147+ }
148+
149+ /// Move by whole rows, clamping into the first or last row instead of stopping.
150+ pub fn step_rows(&self, i: usize, rows: isize) -> usize {
151+ if self.count == 0 {
152+ return 0;
153+ }
154+ let target = i as isize + rows * self.cols as isize;
155+ target.clamp(0, self.count as isize - 1) as usize
156+ }
157+
158+ pub fn row_start(&self, i: usize) -> usize {
159+ i - i % self.cols
160+ }
161+
162+ pub fn row_end(&self, i: usize) -> usize {
163+ (self.row_start(i) + self.cols - 1).min(self.count.saturating_sub(1))
164+ }
165+
166+ /// Bring the cursor onto the screen at `scroll`, keeping its column.
167+ pub fn pull_into_view(&self, i: usize, scroll: f32) -> usize {
168+ if self.count == 0 {
169+ return 0;
170+ }
171+ let rh = self.row_height();
172+ let row = self.row_of(i) as f32;
173+ // First and last rows fully on screen.
174+ let first = (scroll / rh).ceil();
175+ let last = ((scroll + self.viewport[1] - self.gap) / rh).floor() - 1.0;
176+ let target = if row < first {
177+ first
178+ } else if row > last && last >= first {
179+ last
180+ } else {
181+ return i;
182+ };
183+ let j = target as usize * self.cols + i % self.cols;
184+ j.min(self.count - 1)
185+ }
186+}
187+
188+/// Grid view state that outlives a frame: size, scroll and the visual anchor.
189+#[derive(Clone, Debug)]
190+pub struct Grid {
191+ /// Logical thumbnail size, one of `grid.sizes`.
192+ pub size: u32,
193+ scroll: Tween<f32>,
194+ /// Visual mode's fixed end.
195+ pub anchor: Option<usize>,
196+}
197+
198+impl Grid {
199+ pub fn new(size: u32, now: Instant) -> Self {
200+ Self {
201+ size,
202+ scroll: Tween::new(0.0, now),
203+ anchor: None,
204+ }
205+ }
206+
207+ /// The scroll offset to draw at `now`.
208+ pub fn scroll(&self, layout: &Layout, now: Instant) -> f32 {
209+ layout.clamp_scroll(self.scroll.value(now))
210+ }
211+
212+ /// Where scrolling is heading.
213+ pub fn target(&self, layout: &Layout) -> f32 {
214+ layout.clamp_scroll(self.scroll.target())
215+ }
216+
217+ pub fn scroll_to(&mut self, to: f32, now: Instant, dur: Duration) {
218+ if (self.scroll.target() - to).abs() > 0.01 {
219+ self.scroll.retarget(to, now, dur);
220+ }
221+ }
222+
223+ pub fn jump_to(&mut self, to: f32) {
224+ self.scroll.set(to);
225+ }
226+
227+ pub fn is_animating(&self, now: Instant) -> bool {
228+ self.scroll.is_active(now)
229+ }
230+
231+ /// The visual range, inclusive, given the cursor.
232+ pub fn range(&self, cursor: usize) -> Option<Range<usize>> {
233+ self.anchor.map(|a| a.min(cursor)..a.max(cursor) + 1)
234+ }
235+}
236+
237+/// Step through `sizes` from the entry nearest `current`.
238+pub fn next_size(sizes: &[u32], current: u32, delta: i32) -> u32 {
239+ if sizes.is_empty() {
240+ return current;
241+ }
242+ let i = sizes
243+ .iter()
244+ .enumerate()
245+ .min_by_key(|(_, s)| s.abs_diff(current))
246+ .map_or(0, |(i, _)| i);
247+ let j = (i as i64 + delta as i64).clamp(0, sizes.len() as i64 - 1) as usize;
248+ sizes[j]
249+}
250+
251+#[cfg(test)]
252+mod tests {
253+ use super::*;
254+
255+ /// 10 cells of 100 with gap 10 in 450×300: 4 columns, 3 rows.
256+ fn layout(count: usize) -> Layout {
257+ Layout::new(count, [450.0, 300.0], 100.0, 10.0, 0.0)
258+ }
259+
260+ #[test]
261+ fn columns_are_centred() {
262+ let l = layout(10);
263+ assert_eq!(l.cols, 4);
264+ assert_eq!(l.left, 10.0);
265+ assert_eq!(l.rows(), 3);
266+ assert_eq!(l.origin(5), [120.0, 120.0]);
267+ assert_eq!(l.content_height(), 340.0);
268+ assert_eq!(l.max_scroll(), 40.0);
269+ // Never fewer than one column.
270+ assert_eq!(Layout::new(3, [50.0, 50.0], 100.0, 10.0, 0.0).cols, 1);
271+ }
272+
273+ #[test]
274+ fn cursor_moves_like_nsxiv() {
275+ let l = layout(10);
276+ assert_eq!(l.step(0, Dir::Left, 1), 0);
277+ assert_eq!(l.step(4, Dir::Left, 1), 3);
278+ assert_eq!(l.step(3, Dir::Right, 1), 4);
279+ assert_eq!(l.step(9, Dir::Right, 5), 9);
280+ assert_eq!(l.step(5, Dir::Up, 1), 1);
281+ assert_eq!(l.step(1, Dir::Up, 1), 1);
282+ assert_eq!(l.step(5, Dir::Up, 9), 1);
283+ assert_eq!(l.step(1, Dir::Down, 1), 5);
284+ // Onto the short last row: the last cell.
285+ assert_eq!(l.step(7, Dir::Down, 1), 9);
286+ assert_eq!(l.step(9, Dir::Down, 1), 9);
287+ assert_eq!(l.step(2, Dir::Down, 5), 9);
288+ assert_eq!(l.row_start(6), 4);
289+ assert_eq!(l.row_end(6), 7);
290+ assert_eq!(l.row_end(8), 9);
291+ assert_eq!(l.step_rows(2, 5), 9);
292+ assert_eq!(l.step_rows(6, -5), 0);
293+ assert_eq!(layout(0).step(0, Dir::Down, 1), 0);
294+ }
295+
296+ #[test]
297+ fn visibility_and_following() {
298+ let l = layout(40); // 10 rows, content 1110 high
299+ assert_eq!(l.visible(0.0), 0..12);
300+ assert_eq!(l.visible(110.0), 0..16);
301+ assert_eq!(l.visible(115.0), 4..16);
302+ // Row 5 spans 550..670 including the gaps; a 300 viewport must scroll to 370.
303+ assert_eq!(l.follow(20, 0.0), 370.0);
304+ assert_eq!(l.follow(20, 400.0), 400.0);
305+ assert_eq!(l.follow(0, 400.0), 0.0);
306+ assert_eq!(l.page_rows(), 2);
307+ }
308+
309+ #[test]
310+ fn hit_testing() {
311+ let l = layout(10);
312+ assert_eq!(l.hit([15.0, 15.0], 0.0), Some(0));
313+ assert_eq!(l.hit([125.0, 125.0], 0.0), Some(5));
314+ // In a gap.
315+ assert_eq!(l.hit([115.0, 15.0], 0.0), None);
316+ // Past the last cell.
317+ assert_eq!(l.hit([345.0, 235.0], 0.0), None);
318+ assert_eq!(l.hit([15.0, 15.0], 110.0), Some(4));
319+ }
320+
321+ #[test]
322+ fn pulling_the_cursor_into_view() {
323+ let l = layout(40);
324+ assert_eq!(l.pull_into_view(1, 0.0), 1);
325+ // Scrolled so rows 2 and 3 are whole: row 0 comes down to row 2.
326+ assert_eq!(l.pull_into_view(1, 220.0), 9);
327+ assert_eq!(l.pull_into_view(37, 220.0), 13);
328+ }
329+
330+ #[test]
331+ fn sizes_step_and_clamp() {
332+ let s = [96, 128, 160, 224, 320];
333+ assert_eq!(next_size(&s, 160, 1), 224);
334+ assert_eq!(next_size(&s, 160, -2), 96);
335+ assert_eq!(next_size(&s, 320, 1), 320);
336+ assert_eq!(next_size(&s, 150, 0), 160);
337+ }
338+
339+ #[test]
340+ fn visual_range_is_inclusive() {
341+ let mut g = Grid::new(160, Instant::now());
342+ assert_eq!(g.range(3), None);
343+ g.anchor = Some(5);
344+ assert_eq!(g.range(2), Some(2..6));
345+ assert_eq!(g.range(7), Some(5..8));
346+ }
347+}
M src/state/mod.rs View
@@ -4,13 +4,15 @@ pub mod grid;
44 pub mod queue;
55 pub mod view;
66
7+use std::collections::HashMap;
78 use std::path::{Path, PathBuf};
89 use std::time::{Duration, Instant};
910
10−use crate::action::{Action, ModeArg, Switch, Zoom};
11+use crate::action::{Action, ModeArg, Page, Switch, Zoom};
1112 use crate::anim::Tween;
1213 use crate::config::theme::Theme;
1314 use crate::config::{Config, Transition};
15+use grid::{Grid, Layout};
1416 use queue::{Queue, SortKey};
1517 use view::{Affine, Dir, FitMode, View};
1618
@@ -26,9 +28,12 @@ pub const BAR_HEIGHT: f32 = 32.0;
2628 const CROSSFADE: Duration = Duration::from_millis(100);
2729 /// Theme changes (a new wallpaper palette) crossfade this long.
2830 const THEME_FADE: Duration = Duration::from_millis(250);
31+/// Thumbnails fade in this long as they arrive.
32+const THUMB_FADE: Duration = Duration::from_millis(150);
33+/// Thumbnail names: Space Grotesk at this size.
34+pub const GRID_NAME_SIZE: f32 = 12.0;
2935
30−/// Which keymap applies, and (from M3) what is on screen.
31−#[allow(dead_code)] // grid and visual arrive in M3
36+/// Which keymap applies and what is on screen.
3237 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
3338 pub enum Mode {
3439 #[default]
@@ -50,6 +55,12 @@ pub struct Settings {
5055 /// UI size multiplier on top of the display scale.
5156 pub ui_scale: f32,
5257 pub which_key_delay: Duration,
58+ pub thumb_fade: Duration,
59+ /// Logical thumbnail sizes `grid-size` steps through, and the starting one.
60+ pub grid_sizes: Vec<u32>,
61+ pub grid_size: u32,
62+ pub grid_gap: u32,
63+ pub grid_names: bool,
5364 }
5465
5566 impl Default for Settings {
@@ -65,6 +76,11 @@ impl Default for Settings {
6576 theme_fade: THEME_FADE,
6677 ui_scale: 1.0,
6778 which_key_delay: Duration::from_millis(300),
79+ thumb_fade: THUMB_FADE,
80+ grid_sizes: vec![96, 128, 160, 224, 320],
81+ grid_size: 160,
82+ grid_gap: 16,
83+ grid_names: false,
6884 }
6985 }
7086 }
@@ -87,6 +103,11 @@ impl Settings {
87103 theme_fade: anim(THEME_FADE),
88104 ui_scale: c.ui.scale,
89105 which_key_delay: Duration::from_millis(c.ui.which_key_delay_ms),
106+ thumb_fade: anim(THUMB_FADE),
107+ grid_sizes: c.grid.sizes.clone(),
108+ grid_size: c.grid.size,
109+ grid_gap: c.grid.gap,
110+ grid_names: c.grid.names,
90111 }
91112 }
92113 }
@@ -140,6 +161,14 @@ pub struct Fade {
140161 pub alpha: Tween<f32>,
141162 }
142163
164+/// A thumbnail in the atlas, as far as drawing it is concerned.
165+#[derive(Clone, Copy, Debug, PartialEq)]
166+pub struct ThumbState {
167+ pub size: [u32; 2],
168+ /// When it arrived, for the fade-in.
169+ pub at: Instant,
170+}
171+
143172 #[derive(Clone, Copy, Debug)]
144173 struct Hold {
145174 dir: Dir,
@@ -157,6 +186,8 @@ pub struct Effects {
157186 pub reordered: bool,
158187 /// The status bar appeared or vanished, so the image area changed.
159188 pub layout: bool,
189+ /// Image ⇄ grid (or visual) switched.
190+ pub mode: bool,
160191 pub fullscreen: Option<Switch>,
161192 pub reload: bool,
162193 pub reload_config: bool,
@@ -171,6 +202,10 @@ pub struct AppState {
171202 pub view: View,
172203 pub settings: Settings,
173204 pub mode: Mode,
205+ pub grid: Grid,
206+ /// Thumbnails in the atlas (or known to fail), by path.
207+ pub thumbs: HashMap<PathBuf, ThumbState>,
208+ last_thumb: Option<Instant>,
174209 pub theme: Theme,
175210 /// The previous theme fading out after a reload.
176211 theme_fade: Option<(Theme, Tween<f32>)>,
@@ -196,6 +231,9 @@ impl AppState {
196231 Self {
197232 queue,
198233 view: View::new(now),
234+ grid: Grid::new(settings.grid_size, now),
235+ thumbs: HashMap::new(),
236+ last_thumb: None,
199237 settings,
200238 mode: Mode::Image,
201239 theme: Theme::nothing_dark(),
@@ -221,6 +259,9 @@ impl AppState {
221259 self.window = [w, h];
222260 self.scale_factor = scale_factor;
223261 self.update_viewport();
262+ if self.in_grid() {
263+ self.grid_follow(Instant::now(), false);
264+ }
224265 }
225266
226267 pub fn window(&self) -> [f32; 2] {
@@ -245,6 +286,121 @@ impl AppState {
245286 }
246287 }
247288
289+ pub fn in_grid(&self) -> bool {
290+ matches!(self.mode, Mode::Grid | Mode::Visual)
291+ }
292+
293+ /// Side of a thumbnail cell in physical pixels.
294+ pub fn cell_px(&self) -> u32 {
295+ (self.grid.size as f32 * self.ui_scale()).round().max(16.0) as u32
296+ }
297+
298+ /// This frame's grid geometry.
299+ pub fn grid_layout(&self) -> Layout {
300+ let sf = self.ui_scale();
301+ let label = if self.settings.grid_names {
302+ ((GRID_NAME_SIZE * 1.25).ceil() + 8.0) * sf
303+ } else {
304+ 0.0
305+ };
306+ Layout::new(
307+ self.queue.len(),
308+ [
309+ self.window[0],
310+ (self.window[1] - self.bar_height()).max(1.0),
311+ ],
312+ self.cell_px() as f32,
313+ (self.settings.grid_gap as f32 * sf).round(),
314+ label.round(),
315+ )
316+ }
317+
318+ /// Scroll so the cursor's row is on screen.
319+ fn grid_follow(&mut self, now: Instant, animate: bool) {
320+ let l = self.grid_layout();
321+ let to = l.follow(self.queue.index(), self.grid.target(&l));
322+ if animate {
323+ self.grid.scroll_to(to, now, self.settings.anim);
324+ } else {
325+ self.grid.jump_to(to);
326+ }
327+ }
328+
329+ /// Scroll the grid by `rows` (fractional for touchpads), pulling the cursor along so it
330+ /// stays on screen. Returns whether the cursor moved.
331+ pub fn grid_scroll(&mut self, rows: f32, now: Instant) -> bool {
332+ let l = self.grid_layout();
333+ let to = l.clamp_scroll(self.grid.target(&l) + rows * l.row_height());
334+ self.grid.scroll_to(to, now, self.settings.anim);
335+ self.queue.goto(l.pull_into_view(self.queue.index(), to))
336+ }
337+
338+ /// Drag the grid content by `dy` pixels. Returns whether the cursor moved.
339+ pub fn grid_drag(&mut self, dy: f32, now: Instant) -> bool {
340+ let l = self.grid_layout();
341+ let to = l.clamp_scroll(self.grid.scroll(&l, now) - dy);
342+ self.grid.jump_to(to);
343+ self.queue.goto(l.pull_into_view(self.queue.index(), to))
344+ }
345+
346+ /// The thumbnail under a window point.
347+ pub fn grid_hit(&self, p: [f32; 2], now: Instant) -> Option<usize> {
348+ let l = self.grid_layout();
349+ if p[1] >= l.viewport[1] {
350+ return None;
351+ }
352+ l.hit(p, self.grid.scroll(&l, now))
353+ }
354+
355+ /// The selected range in visual mode.
356+ pub fn visual_range(&self) -> Option<std::ops::Range<usize>> {
357+ if self.mode != Mode::Visual {
358+ return None;
359+ }
360+ let r = self.grid.range(self.queue.index())?;
361+ Some(r.start.min(self.queue.len())..r.end.min(self.queue.len()))
362+ }
363+
364+ /// Switch modes. Returns whether the mode changed.
365+ pub fn set_mode(&mut self, mode: Mode, now: Instant) -> bool {
366+ if mode == self.mode {
367+ return false;
368+ }
369+ let was_grid = self.in_grid();
370+ self.mode = mode;
371+ if mode != Mode::Visual {
372+ self.grid.anchor = None;
373+ }
374+ self.holds.clear();
375+ match mode {
376+ Mode::Image => {
377+ // Don't crossfade from whatever was open before the grid.
378+ if self.shown.as_ref().map(|s| s.path.as_path()) != self.queue.current() {
379+ self.clear_shown();
380+ }
381+ }
382+ Mode::Grid | Mode::Visual if !was_grid => self.grid_follow(now, false),
383+ _ => {}
384+ }
385+ true
386+ }
387+
388+ /// A thumbnail landed in the atlas.
389+ pub fn thumb_ready(&mut self, path: PathBuf, size: [u32; 2], now: Instant) {
390+ self.thumbs.insert(path, ThumbState { size, at: now });
391+ self.last_thumb = Some(now);
392+ }
393+
394+ /// Opacity of a thumbnail fading in.
395+ pub fn thumb_alpha(&self, at: Instant, now: Instant) -> f32 {
396+ let d = self.settings.thumb_fade;
397+ if d.is_zero() {
398+ return 1.0;
399+ }
400+ let t = now.saturating_duration_since(at).as_secs_f32() / d.as_secs_f32();
401+ crate::anim::ease(t.min(1.0))
402+ }
403+
248404 fn update_viewport(&mut self) {
249405 let h = (self.window[1] - self.bar_height()).max(1.0);
250406 self.view.set_viewport(self.window[0], h);
@@ -295,7 +451,9 @@ impl AppState {
295451 ..Effects::default()
296452 };
297453 let dur = self.dur();
298− let has_image = self.shown.is_some();
454+ let in_grid = self.in_grid();
455+ let has_image = self.mode == Mode::Image && self.shown.is_some();
456+ let wrap = self.settings.wrap;
299457 let switch = |s: Switch, cur: bool| match s {
300458 Switch::Toggle => !cur,
301459 Switch::On => true,
@@ -327,6 +485,100 @@ impl AppState {
327485 Zoom::Percent(p) => self.view.zoom_to(p / 100.0, anchor, now, dur),
328486 }
329487 }
488+ Action::NextMark | Action::PrevMark => {
489+ let forward = action == Action::NextMark;
490+ match self.queue.find_mark(forward, 1, wrap) {
491+ Some(i) => fx.navigated = self.queue.goto(i),
492+ None => self.notify("no other marks", MsgKind::Info, now),
493+ }
494+ }
495+ Action::Mark(s) => {
496+ if let Some(range) = self.visual_range() {
497+ let on = match s {
498+ Switch::Toggle => !range.clone().all(|i| self.queue.is_marked(i)),
499+ Switch::On => true,
500+ Switch::Off => false,
501+ };
502+ for i in range {
503+ self.queue.set_mark(i, on);
504+ }
505+ fx.mode = self.set_mode(Mode::Grid, now);
506+ } else {
507+ let i = self.queue.index();
508+ let cur = self.queue.is_marked(i);
509+ self.queue.set_mark(i, switch(s, cur));
510+ }
511+ }
512+ Action::MarkAll => self.queue.mark_all(),
513+ Action::UnmarkAll => self.queue.unmark_all(),
514+ Action::InvertMarks => self.queue.invert_marks(),
515+ Action::Move(dir, n) if in_grid => {
516+ let l = self.grid_layout();
517+ fx.navigated = self.queue.goto(l.step(self.queue.index(), dir, n));
518+ }
519+ Action::Page(page) if in_grid => {
520+ let l = self.grid_layout();
521+ let full = l.page_rows() as isize;
522+ let half = ((l.viewport[1] / l.row_height() / 2.0).round() as isize).max(1);
523+ let rows = match page {
524+ Page::Down => full,
525+ Page::Up => -full,
526+ Page::HalfDown => half,
527+ Page::HalfUp => -half,
528+ };
529+ let to = l.clamp_scroll(self.grid.target(&l) + rows as f32 * l.row_height());
530+ self.grid.scroll_to(to, now, dur);
531+ fx.navigated = self.queue.goto(l.step_rows(self.queue.index(), rows));
532+ }
533+ Action::RowStart | Action::RowEnd if in_grid => {
534+ let l = self.grid_layout();
535+ let i = self.queue.index();
536+ let to = if action == Action::RowStart {
537+ l.row_start(i)
538+ } else {
539+ l.row_end(i)
540+ };
541+ fx.navigated = self.queue.goto(to);
542+ }
543+ Action::Scroll(dir, n) if in_grid => match dir {
544+ Dir::Up => fx.navigated = self.grid_scroll(-(n as f32), now),
545+ Dir::Down => fx.navigated = self.grid_scroll(n as f32, now),
546+ Dir::Left | Dir::Right => fx.redraw = false,
547+ },
548+ Action::Scroll(dir, n) if has_image => {
549+ self.view.pan_step(dir, 0.1 * n as f32, now, dur);
550+ }
551+ Action::Mode(m) => {
552+ let target = match m {
553+ ModeArg::Image => Mode::Image,
554+ ModeArg::Grid => Mode::Grid,
555+ ModeArg::Toggle if in_grid => Mode::Image,
556+ ModeArg::Toggle => Mode::Grid,
557+ };
558+ fx.mode = self.set_mode(target, now);
559+ fx.redraw = fx.mode;
560+ }
561+ Action::Visual => {
562+ if self.mode == Mode::Visual {
563+ fx.mode = self.set_mode(Mode::Grid, now);
564+ } else {
565+ self.set_mode(Mode::Grid, now);
566+ self.grid.anchor = Some(self.queue.index());
567+ self.mode = Mode::Visual;
568+ fx.mode = true;
569+ }
570+ }
571+ Action::GridSize(d) => {
572+ let size = grid::next_size(&self.settings.grid_sizes, self.grid.size, d);
573+ if size == self.grid.size {
574+ fx.redraw = false;
575+ } else {
576+ self.grid.size = size;
577+ if in_grid {
578+ self.grid_follow(now, false);
579+ }
580+ }
581+ }
330582 Action::Fit(fit) if has_image => self.view.set_fit(fit, now, dur),
331583 Action::Pan(dir) if has_image => self.view.pan_step(dir, 0.2, now, dur),
332584 Action::PanEdge(dir) if has_image => self.view.pan_edge(dir, now, dur),
@@ -352,7 +604,6 @@ impl AppState {
352604 fx.layout = true;
353605 }
354606 }
355− Action::Mode(ModeArg::Image) if self.mode == Mode::Image => fx.redraw = false,
356607 Action::Sort(key) => fx.sort = Some(key),
357608 Action::Reverse => {
358609 self.queue.reverse();
@@ -375,27 +626,23 @@ impl AppState {
375626 | Action::PanEdge(_)
376627 | Action::Center
377628 | Action::Rotate(_)
378− | Action::Flip(_) => fx.redraw = false,
629+ | Action::Flip(_)
630+ | Action::Move(..)
631+ | Action::Page(_)
632+ | Action::RowStart
633+ | Action::RowEnd
634+ | Action::Scroll(..) => fx.redraw = false,
379635 // Parsed and bindable now; they arrive with later milestones.
380− other @ (Action::NextMark
381− | Action::PrevMark
382− | Action::NextFrame
636+ other @ (Action::NextFrame
383637 | Action::PrevFrame
384− | Action::Mark(_)
385− | Action::MarkAll
386− | Action::UnmarkAll
387− | Action::InvertMarks
388638 | Action::Adjust(..)
389639 | Action::Play(_)
390640 | Action::Slideshow(_)
391641 | Action::SlideshowDelay { .. }
392− | Action::Mode(_)
393− | Action::Visual
394642 | Action::Info(_)
395643 | Action::Help
396644 | Action::Palette
397645 | Action::Search
398− | Action::GridSize(_)
399646 | Action::Filter(_)
400647 | Action::FilterClear
401648 | Action::Refresh
@@ -408,6 +655,9 @@ impl AppState {
408655 );
409656 }
410657 }
658+ if fx.navigated && self.in_grid() {
659+ self.grid_follow(now, true);
660+ }
411661 fx
412662 }
413663
@@ -538,7 +788,12 @@ impl AppState {
538788
539789 /// Whether frames must keep coming (animations, held keys).
540790 pub fn is_animating(&self, now: Instant) -> bool {
541− self.view.is_animating(now)
791+ let grid = self.in_grid()
792+ && (self.grid.is_animating(now)
793+ || self
794+ .last_thumb
795+ .is_some_and(|t| now < t + self.settings.thumb_fade));
796+ grid || self.view.is_animating(now)
542797 || self.fade.as_ref().is_some_and(|f| f.alpha.is_active(now))
543798 || self
544799 .theme_fade
@@ -670,6 +925,76 @@ mod tests {
670925 assert_eq!(s.queue.index(), 1);
671926 }
672927
928+ #[test]
929+ fn grid_mode_moves_the_queue_cursor() {
930+ let (mut s, now) = state(40);
931+ assert!(s.apply(Action::Mode(ModeArg::Toggle), now).mode);
932+ assert_eq!(s.mode, Mode::Grid);
933+ // 800 wide, 160 cells, 16 gaps: 4 columns.
934+ assert_eq!(s.grid_layout().cols, 4);
935+ assert!(s.apply(Action::Move(Dir::Down, 2), now).navigated);
936+ assert_eq!(s.queue.index(), 8);
937+ s.apply(Action::RowEnd, now);
938+ assert_eq!(s.queue.index(), 11);
939+ s.apply(Action::Last, now);
940+ let l = s.grid_layout();
941+ assert_eq!(s.grid.target(&l), l.max_scroll());
942+ s.apply(Action::Page(Page::Up), now);
943+ assert_eq!(s.queue.index(), 39 - 4 * l.page_rows());
944+ // Image-only actions do nothing in the grid.
945+ assert!(!s.apply(Action::Zoom(Zoom::In(1)), now).redraw);
946+ assert!(s.apply(Action::Mode(ModeArg::Toggle), now).mode);
947+ assert_eq!(s.mode, Mode::Image);
948+ assert!(!s.apply(Action::Move(Dir::Down, 1), now).redraw);
949+ }
950+
951+ #[test]
952+ fn visual_mode_marks_a_range() {
953+ let (mut s, now) = state(10);
954+ s.apply(Action::Goto(2), now);
955+ s.apply(Action::Visual, now);
956+ assert_eq!(s.mode, Mode::Visual);
957+ s.apply(Action::Move(Dir::Right, 3), now);
958+ assert_eq!(s.visual_range(), Some(1..5));
959+ s.apply(Action::Mark(Switch::On), now);
960+ assert_eq!(s.mode, Mode::Grid);
961+ assert_eq!(s.queue.marked_count(), 4);
962+ // Toggling a fully marked range unmarks it.
963+ s.apply(Action::Visual, now);
964+ s.apply(Action::Move(Dir::Left, 3), now);
965+ s.apply(Action::Mark(Switch::Toggle), now);
966+ assert_eq!(s.queue.marked_count(), 0);
967+ }
968+
969+ #[test]
970+ fn marks_and_mark_navigation() {
971+ let (mut s, now) = state(5);
972+ s.apply(Action::Mark(Switch::Toggle), now);
973+ s.apply(Action::Goto(4), now);
974+ s.apply(Action::Mark(Switch::Toggle), now);
975+ assert_eq!(s.queue.marked_count(), 2);
976+ assert!(s.apply(Action::NextMark, now).navigated);
977+ assert_eq!(s.queue.index(), 0);
978+ s.apply(Action::InvertMarks, now);
979+ assert_eq!(s.queue.marked_count(), 3);
980+ s.apply(Action::UnmarkAll, now);
981+ assert!(!s.apply(Action::NextMark, now).navigated);
982+ assert!(s.message.is_some());
983+ s.apply(Action::MarkAll, now);
984+ assert_eq!(s.queue.marked_count(), 5);
985+ }
986+
987+ #[test]
988+ fn grid_size_steps_through_sizes() {
989+ let (mut s, now) = state(3);
990+ s.apply(Action::GridSize(1), now);
991+ assert_eq!(s.grid.size, 224);
992+ s.apply(Action::GridSize(5), now);
993+ assert_eq!(s.grid.size, 320);
994+ assert!(!s.apply(Action::GridSize(1), now).redraw);
995+ assert_eq!(s.cell_px(), 320);
996+ }
997+
673998 #[test]
674999 fn tapped_pan_moves_a_minimum_distance() {
6751000 let (mut s, now) = state(1);
M src/state/queue.rs View
@@ -29,6 +29,8 @@ pub enum SortKey {
2929 pub struct Queue {
3030 files: Vec<PathBuf>,
3131 index: usize,
32+ /// Marked files, by path so reordering keeps them.
33+ marks: HashSet<PathBuf>,
3234 }
3335
3436 impl Queue {
@@ -37,7 +39,11 @@ impl Queue {
3739 let index = start
3840 .and_then(|s| files.iter().position(|f| f == s))
3941 .unwrap_or(start_index);
40− let mut q = Self { files, index: 0 };
42+ let mut q = Self {
43+ files,
44+ index: 0,
45+ marks: HashSet::new(),
46+ };
4147 q.index = q.clamp(index);
4248 q
4349 }
@@ -90,7 +96,8 @@ impl Queue {
9096 let Some(i) = self.files.iter().position(|f| f == path) else {
9197 return false;
9298 };
93− self.files.remove(i);
99+ let removed = self.files.remove(i);
100+ self.marks.remove(&removed);
94101 if i < self.index {
95102 self.index -= 1;
96103 }
@@ -171,6 +178,85 @@ impl Queue {
171178 }
172179 }
173180
181+ pub fn is_marked(&self, i: usize) -> bool {
182+ self.files.get(i).is_some_and(|f| self.marks.contains(f))
183+ }
184+
185+ pub fn marked_count(&self) -> usize {
186+ self.marks.len()
187+ }
188+
189+ /// Mark or unmark file `i`. Returns whether anything changed.
190+ pub fn set_mark(&mut self, i: usize, on: bool) -> bool {
191+ let Some(f) = self.files.get(i) else {
192+ return false;
193+ };
194+ if on {
195+ self.marks.insert(f.clone())
196+ } else {
197+ self.marks.remove(f)
198+ }
199+ }
200+
201+ pub fn mark_all(&mut self) {
202+ self.marks = self.files.iter().cloned().collect();
203+ }
204+
205+ pub fn unmark_all(&mut self) {
206+ self.marks.clear();
207+ }
208+
209+ pub fn invert_marks(&mut self) {
210+ self.marks = self
211+ .files
212+ .iter()
213+ .filter(|f| !self.marks.contains(*f))
214+ .cloned()
215+ .collect();
216+ }
217+
218+ /// Marked files in queue order.
219+ #[allow(dead_code)] // `-o` and command targets arrive in M4
220+ pub fn marked(&self) -> Vec<&Path> {
221+ self.files
222+ .iter()
223+ .filter(|f| self.marks.contains(*f))
224+ .map(PathBuf::as_path)
225+ .collect()
226+ }
227+
228+ /// The `n`th marked file after (`forward`) or before the current one.
229+ pub fn find_mark(&self, forward: bool, n: usize, wrap: bool) -> Option<usize> {
230+ let len = self.files.len();
231+ if self.marks.is_empty() || len == 0 {
232+ return None;
233+ }
234+ let mut found = None;
235+ let mut left = n.max(1);
236+ for k in 1..len {
237+ let i = if forward {
238+ let i = self.index + k;
239+ if i >= len && !wrap {
240+ break;
241+ }
242+ i % len
243+ } else {
244+ if k > self.index && !wrap {
245+ break;
246+ }
247+ (self.index + len - k) % len
248+ };
249+ if self.is_marked(i) {
250+ found = Some(i);
251+ left -= 1;
252+ if left == 0 {
253+ break;
254+ }
255+ }
256+ }
257+ found
258+ }
259+
174260 fn clamp(&self, i: usize) -> usize {
175261 i.min(self.files.len().saturating_sub(1))
176262 }
@@ -351,6 +437,45 @@ mod tests {
351437 assert!(empty.is_empty());
352438 }
353439
440+ #[test]
441+ fn marks_follow_files() {
442+ let mut q = Queue::new(paths(&["a", "b", "c", "d"]), None, 0);
443+ assert!(q.set_mark(1, true));
444+ assert!(q.set_mark(3, true));
445+ assert!(!q.set_mark(3, true));
446+ assert_eq!(q.marked_count(), 2);
447+ q.reverse();
448+ assert!(q.is_marked(0) && q.is_marked(2));
449+ assert_eq!(q.marked(), [Path::new("d"), Path::new("b")]);
450+ q.invert_marks();
451+ assert_eq!(q.marked(), [Path::new("c"), Path::new("a")]);
452+ q.remove(Path::new("c"));
453+ assert_eq!(q.marked_count(), 1);
454+ q.mark_all();
455+ assert_eq!(q.marked_count(), 3);
456+ q.unmark_all();
457+ assert_eq!(q.marked_count(), 0);
458+ }
459+
460+ #[test]
461+ fn finding_marks() {
462+ let mut q = Queue::new(paths(&["a", "b", "c", "d", "e"]), None, 2);
463+ assert_eq!(q.find_mark(true, 1, true), None);
464+ q.set_mark(0, true);
465+ q.set_mark(4, true);
466+ assert_eq!(q.find_mark(true, 1, true), Some(4));
467+ assert_eq!(q.find_mark(true, 2, true), Some(0));
468+ assert_eq!(q.find_mark(false, 1, true), Some(0));
469+ assert_eq!(q.find_mark(false, 2, false), Some(0));
470+ q.goto(4);
471+ assert_eq!(q.find_mark(true, 1, false), None);
472+ assert_eq!(q.find_mark(true, 1, true), Some(0));
473+ // The current file alone is not a next mark.
474+ q.unmark_all();
475+ q.set_mark(4, true);
476+ assert_eq!(q.find_mark(true, 1, true), None);
477+ }
478+
354479 #[test]
355480 fn random_sort_is_a_permutation() {
356481 let mut v = paths(&["a", "b", "c", "d", "e"]);
M src/thumbs.rs View
@@ -1 +1,611 @@
11 //! freedesktop thumbnail cache: lookup, validation, generation and atomic writes.
2+//!
3+//! Entries live in `$XDG_CACHE_HOME/thumbnails/<bucket>/<md5(uri)>.png`, validated by their
4+//! `Thumb::URI` and `Thumb::MTime` text chunks. The cache is shared with file managers, so
5+//! the URI must be spelled exactly as GLib spells it.
6+
7+use std::fs::{self, File};
8+use std::io::{BufReader, BufWriter, Write};
9+use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
10+use std::path::{Component, Path, PathBuf};
11+use std::time::UNIX_EPOCH;
12+
13+use anyhow::{Context, anyhow, bail};
14+use fast_image_resize as fir;
15+use image::metadata::Orientation;
16+use md5::{Digest, Md5};
17+
18+use crate::decode::{self, DecodedImage};
19+
20+/// The freedesktop size buckets, smallest first.
21+pub const BUCKETS: [(&str, u32); 4] = [
22+ ("normal", 128),
23+ ("large", 256),
24+ ("x-large", 512),
25+ ("xx-large", 1024),
26+];
27+
28+/// The folder failed entries go in, under `fail/`.
29+const APP: &str = "remage";
30+
31+/// A thumbnail ready for the atlas: straight-alpha RGBA8, at most `px` on its longer side.
32+pub struct Thumb {
33+ pub size: [u32; 2],
34+ pub pixels: Vec<u8>,
35+}
36+
37+impl std::fmt::Debug for Thumb {
38+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39+ f.debug_struct("Thumb").field("size", &self.size).finish()
40+ }
41+}
42+
43+#[derive(Debug, thiserror::Error)]
44+pub enum ThumbError {
45+ /// A `fail/remage` entry says this file can't be thumbnailed; don't retry.
46+ #[error("thumbnailing failed before")]
47+ KnownFailure,
48+ #[error("{0:#}")]
49+ Failed(anyhow::Error),
50+}
51+
52+/// How a job uses the shared cache.
53+#[derive(Clone, Debug)]
54+pub struct CacheOptions {
55+ /// `$XDG_CACHE_HOME/thumbnails`; `None` disables the cache entirely.
56+ pub root: Option<PathBuf>,
57+ /// Read but never write (`-p`).
58+ pub private: bool,
59+}
60+
61+impl CacheOptions {
62+ pub fn new(enabled: bool, private: bool) -> Self {
63+ Self {
64+ root: enabled
65+ .then(|| dirs::cache_dir().map(|d| d.join("thumbnails")))
66+ .flatten(),
67+ private,
68+ }
69+ }
70+}
71+
72+/// The smallest bucket at least `px` wide, else the largest.
73+pub fn bucket_for(px: u32) -> (&'static str, u32) {
74+ BUCKETS
75+ .iter()
76+ .copied()
77+ .find(|(_, s)| *s >= px)
78+ .unwrap_or(BUCKETS[BUCKETS.len() - 1])
79+}
80+
81+/// Make `path` absolute and drop `.` and `..` without resolving symlinks, as GLib does.
82+pub fn normalize(path: &Path) -> PathBuf {
83+ let abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
84+ let mut out = PathBuf::new();
85+ for c in abs.components() {
86+ match c {
87+ Component::CurDir => {}
88+ Component::ParentDir => {
89+ out.pop();
90+ }
91+ other => out.push(other),
92+ }
93+ }
94+ out
95+}
96+
97+/// `file://` URI of an absolute path, escaped like GLib's `g_filename_to_uri`.
98+pub fn file_uri(path: &Path) -> String {
99+ use std::os::unix::ffi::OsStrExt;
100+ const HEX: &[u8; 16] = b"0123456789ABCDEF";
101+ let mut uri = String::from("file://");
102+ for &b in path.as_os_str().as_bytes() {
103+ let keep = b.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:@/".contains(&b);
104+ if keep {
105+ uri.push(b as char);
106+ } else {
107+ uri.push('%');
108+ uri.push(HEX[(b >> 4) as usize] as char);
109+ uri.push(HEX[(b & 15) as usize] as char);
110+ }
111+ }
112+ uri
113+}
114+
115+/// The cache file name for a URI: its MD5 in lowercase hex, plus `.png`.
116+pub fn cache_name(uri: &str) -> String {
117+ let digest = Md5::digest(uri.as_bytes());
118+ let mut s = String::with_capacity(36);
119+ for b in digest {
120+ s.push_str(&format!("{b:02x}"));
121+ }
122+ s.push_str(".png");
123+ s
124+}
125+
126+/// Seconds since the epoch, as `Thumb::MTime` stores it.
127+fn mtime_secs(meta: &fs::Metadata) -> Option<u64> {
128+ meta.modified()
129+ .ok()?
130+ .duration_since(UNIX_EPOCH)
131+ .ok()
132+ .map(|d| d.as_secs())
133+}
134+
135+/// PNG text chunks as keyword–text pairs.
136+type Text = Vec<(String, String)>;
137+
138+/// Text chunks of a PNG, and its pixels as RGBA8 if `pixels` is set.
139+fn read_png(path: &Path, pixels: bool) -> anyhow::Result<(Text, Option<Thumb>)> {
140+ let file = File::open(path)?;
141+ let mut dec = png::Decoder::new(BufReader::new(file));
142+ dec.set_transformations(png::Transformations::normalize_to_color8());
143+ let mut reader = dec.read_info()?;
144+ let mut thumb = None;
145+ if pixels {
146+ let mut buf = vec![0; reader.output_buffer_size().context("thumbnail too large")?];
147+ let out = reader.next_frame(&mut buf)?;
148+ buf.truncate(out.buffer_size());
149+ let rgba = to_rgba(buf, out.color_type).context("unsupported thumbnail colour type")?;
150+ thumb = Some(Thumb {
151+ size: [out.width, out.height],
152+ pixels: rgba,
153+ });
154+ // Text chunks after the image data.
155+ let _ = reader.finish();
156+ }
157+ let info = reader.info();
158+ let mut text: Text = info
159+ .uncompressed_latin1_text
160+ .iter()
161+ .map(|c| (c.keyword.clone(), c.text.clone()))
162+ .collect();
163+ text.extend(
164+ info.utf8_text
165+ .iter()
166+ .filter_map(|c| Some((c.keyword.clone(), c.get_text().ok()?))),
167+ );
168+ text.extend(
169+ info.compressed_latin1_text
170+ .iter()
171+ .filter_map(|c| Some((c.keyword.clone(), c.get_text().ok()?))),
172+ );
173+ Ok((text, thumb))
174+}
175+
176+fn to_rgba(buf: Vec<u8>, color: png::ColorType) -> Option<Vec<u8>> {
177+ use png::ColorType::*;
178+ Some(match color {
179+ Rgba => buf,
180+ Rgb => buf
181+ .chunks_exact(3)
182+ .flat_map(|p| [p[0], p[1], p[2], 255])
183+ .collect(),
184+ Grayscale => buf.iter().flat_map(|&g| [g, g, g, 255]).collect(),
185+ GrayscaleAlpha => buf
186+ .chunks_exact(2)
187+ .flat_map(|p| [p[0], p[0], p[0], p[1]])
188+ .collect(),
189+ Indexed => return None,
190+ })
191+}
192+
193+/// Whether the text chunks describe `uri` at `mtime`. Some writers (ffmpegthumbnailer) store
194+/// a bare path as the URI; that counts too.
195+fn is_valid(text: &[(String, String)], uri: &str, mtime: u64) -> bool {
196+ let get = |k: &str| text.iter().find(|(key, _)| key == k).map(|(_, v)| v.trim());
197+ // Some writers store fractional seconds.
198+ let mtime_ok = get("Thumb::MTime")
199+ .and_then(|m| m.parse::<f64>().ok())
200+ .is_some_and(|m| m.trunc() as u64 == mtime);
201+ let path = uri.strip_prefix("file://").map(|p| {
202+ let bytes = percent_decode(p);
203+ String::from_utf8_lossy(&bytes).into_owned()
204+ });
205+ let uri_ok = get("Thumb::URI").is_some_and(|u| u == uri || Some(u) == path.as_deref());
206+ uri_ok && mtime_ok
207+}
208+
209+fn percent_decode(s: &str) -> Vec<u8> {
210+ let b = s.as_bytes();
211+ let mut out = Vec::with_capacity(b.len());
212+ let mut i = 0;
213+ while i < b.len() {
214+ let hex = |c: u8| (c as char).to_digit(16);
215+ if b[i] == b'%'
216+ && let (Some(h), Some(l)) = (
217+ b.get(i + 1).and_then(|&c| hex(c)),
218+ b.get(i + 2).and_then(|&c| hex(c)),
219+ )
220+ {
221+ out.push((h * 16 + l) as u8);
222+ i += 3;
223+ } else {
224+ out.push(b[i]);
225+ i += 1;
226+ }
227+ }
228+ out
229+}
230+
231+/// Write a PNG atomically (temporary file, then rename) with mode 0600.
232+fn write_png(path: &Path, thumb: &Thumb, text: &[(&str, String)]) -> anyhow::Result<()> {
233+ let dir = path.parent().context("cache path has no parent")?;
234+ fs::DirBuilder::new()
235+ .recursive(true)
236+ .mode(0o700)
237+ .create(dir)?;
238+ let name = path.file_name().context("cache path has no name")?;
239+ let tmp = dir.join(format!(
240+ ".{}.remage-{}.tmp",
241+ name.to_string_lossy(),
242+ std::process::id()
243+ ));
244+ let result = (|| -> anyhow::Result<()> {
245+ let file = fs::OpenOptions::new()
246+ .write(true)
247+ .create(true)
248+ .truncate(true)
249+ .mode(0o600)
250+ .open(&tmp)?;
251+ let mut w = BufWriter::new(file);
252+ let mut enc = png::Encoder::new(&mut w, thumb.size[0], thumb.size[1]);
253+ enc.set_color(png::ColorType::Rgba);
254+ enc.set_depth(png::BitDepth::Eight);
255+ enc.set_compression(png::Compression::Fast);
256+ for (k, v) in text {
257+ enc.add_text_chunk(k.to_string(), v.clone())?;
258+ }
259+ let mut writer = enc.write_header()?;
260+ writer.write_image_data(&thumb.pixels)?;
261+ writer.finish()?;
262+ w.flush()?;
263+ drop(w);
264+ fs::rename(&tmp, path)?;
265+ Ok(())
266+ })();
267+ if result.is_err() {
268+ let _ = fs::remove_file(&tmp);
269+ }
270+ result
271+}
272+
273+/// Shrink `src` so its longer side is at most `max` (never enlarging).
274+pub fn resize(src: Thumb, max: u32) -> anyhow::Result<Thumb> {
275+ let [w, h] = src.size;
276+ if w <= max && h <= max {
277+ return Ok(src);
278+ }
279+ let k = max as f64 / w.max(h) as f64;
280+ let nw = ((w as f64 * k).round() as u32).clamp(1, max);
281+ let nh = ((h as f64 * k).round() as u32).clamp(1, max);
282+ let mut img = fir::images::Image::from_vec_u8(w, h, src.pixels, fir::PixelType::U8x4)
283+ .context("resize source")?;
284+ // Resample in premultiplied alpha so transparent edges don't bleed dark fringes.
285+ let mul = fir::MulDiv::default();
286+ mul.multiply_alpha_inplace(&mut img)
287+ .context("premultiply")?;
288+ let mut dst = fir::images::Image::new(nw, nh, fir::PixelType::U8x4);
289+ let opts = fir::ResizeOptions::new()
290+ .resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::Lanczos3))
291+ .use_alpha(false);
292+ fir::Resizer::new()
293+ .resize(&img, &mut dst, &opts)
294+ .context("resize")?;
295+ mul.divide_alpha_inplace(&mut dst)
296+ .context("unpremultiply")?;
297+ Ok(Thumb {
298+ size: [nw, nh],
299+ pixels: dst.into_vec(),
300+ })
301+}
302+
303+/// Load a thumbnail of `path` at most `px` on its longer side: from the shared cache when a
304+/// valid entry is big enough, otherwise generated (and written back unless private).
305+pub fn load(path: &Path, px: u32, cache: &CacheOptions) -> Result<Thumb, ThumbError> {
306+ let fail = |e: anyhow::Error| ThumbError::Failed(e);
307+ let meta = fs::metadata(path).map_err(|e| fail(e.into()))?;
308+ let mtime = mtime_secs(&meta).unwrap_or(0);
309+ let abs = normalize(path);
310+ let uri = file_uri(&abs);
311+ let name = cache_name(&uri);
312+ let (bucket, bucket_px) = bucket_for(px);
313+
314+ if let Some(root) = &cache.root {
315+ // Our bucket, or any larger one.
316+ for (dir, _) in BUCKETS.iter().filter(|(_, s)| *s >= bucket_px) {
317+ let p = root.join(dir).join(&name);
318+ if let Ok((text, Some(t))) = read_png(&p, true)
319+ && is_valid(&text, &uri, mtime)
320+ {
321+ return resize(t, px).map_err(fail);
322+ }
323+ }
324+ // Only trust failures this version recorded: a later one may decode more formats.
325+ let failed = root.join("fail").join(APP).join(&name);
326+ if let Ok((text, _)) = read_png(&failed, false)
327+ && is_valid(&text, &uri, mtime)
328+ && text
329+ .iter()
330+ .any(|(k, v)| k == "Software" && *v == software())
331+ {
332+ return Err(ThumbError::KnownFailure);
333+ }
334+ }
335+
336+ let generated = match generate(path, bucket_px) {
337+ Ok(t) => t,
338+ Err(e) => {
339+ if let Some(root) = cache.root.as_ref().filter(|_| !cache.private) {
340+ let failed = root.join("fail").join(APP).join(&name);
341+ let empty = Thumb {
342+ size: [1, 1],
343+ pixels: vec![0; 4],
344+ };
345+ let text = [
346+ ("Thumb::URI", uri.clone()),
347+ ("Thumb::MTime", mtime.to_string()),
348+ ("Software", software()),
349+ ];
350+ if let Err(e) = write_png(&failed, &empty, &text) {
351+ log::debug!("{}: {e:#}", failed.display());
352+ }
353+ }
354+ return Err(fail(e));
355+ }
356+ };
357+
358+ // Images no bigger than the bucket gain nothing from a cache entry.
359+ let worth_caching = generated.source_size.iter().any(|&s| s > bucket_px);
360+ let thumb = Thumb {
361+ size: generated.size,
362+ pixels: generated.pixels,
363+ };
364+ if let Some(root) = cache.root.as_ref().filter(|_| !cache.private)
365+ && worth_caching
366+ && !abs.starts_with(root)
367+ {
368+ let dest = root.join(bucket).join(&name);
369+ let [sw, sh] = generated.source_size;
370+ let text = [
371+ ("Thumb::URI", uri.clone()),
372+ ("Thumb::MTime", mtime.to_string()),
373+ ("Thumb::Size", meta.len().to_string()),
374+ ("Thumb::Image::Width", sw.to_string()),
375+ ("Thumb::Image::Height", sh.to_string()),
376+ ("Software", software()),
377+ ];
378+ if let Err(e) = write_png(&dest, &thumb, &text) {
379+ log::debug!("{}: {e:#}", dest.display());
380+ }
381+ }
382+ resize(thumb, px).map_err(fail)
383+}
384+
385+fn software() -> String {
386+ format!("remage {}", env!("CARGO_PKG_VERSION"))
387+}
388+
389+/// A freshly made thumbnail at most `max` on its longer side.
390+struct Generated {
391+ size: [u32; 2],
392+ pixels: Vec<u8>,
393+ /// The original's upright size.
394+ source_size: [u32; 2],
395+}
396+
397+fn generate(path: &Path, max: u32) -> anyhow::Result<Generated> {
398+ // JPEG: decode at 1/2, 1/4 or 1/8 straight from the DCT.
399+ if let Some(g) = jpeg_scaled(path, max)? {
400+ return Ok(g);
401+ }
402+ let img = decode::decode(path, u32::MAX)?;
403+ let source_size = img.source_size;
404+ let DecodedImage { size, pixels, .. } = img;
405+ let t = resize(Thumb { size, pixels }, max)?;
406+ Ok(Generated {
407+ size: t.size,
408+ pixels: t.pixels,
409+ source_size,
410+ })
411+}
412+
413+/// DCT-scaled JPEG decode, or `None` when the file isn't a plain 8-bit RGB/grey JPEG.
414+fn jpeg_scaled(path: &Path, max: u32) -> anyhow::Result<Option<Generated>> {
415+ let mut magic = [0u8; 3];
416+ {
417+ use std::io::Read;
418+ let mut f = File::open(path)?;
419+ if f.read_exact(&mut magic).is_err() || magic != [0xFF, 0xD8, 0xFF] {
420+ return Ok(None);
421+ }
422+ }
423+ let mut dec = jpeg_decoder::Decoder::new(BufReader::new(File::open(path)?));
424+ dec.read_info()?;
425+ let info = dec.info().ok_or_else(|| anyhow!("no JPEG header"))?;
426+ use jpeg_decoder::PixelFormat::*;
427+ if !matches!(info.pixel_format, L8 | RGB24) {
428+ return Ok(None);
429+ }
430+ let (w, h) = (info.width as u32, info.height as u32);
431+ let k = (max as f64 / w.max(h) as f64).min(1.0);
432+ let req = |v: u32| ((v as f64 * k).ceil() as u32).clamp(1, u16::MAX as u32) as u16;
433+ let (sw, sh) = dec.scale(req(w), req(h))?;
434+ let data = dec.decode()?;
435+ let pixels: Vec<u8> = match info.pixel_format {
436+ L8 => data.iter().flat_map(|&g| [g, g, g, 255]).collect(),
437+ _ => data
438+ .chunks_exact(3)
439+ .flat_map(|p| [p[0], p[1], p[2], 255])
440+ .collect(),
441+ };
442+ let (sw, sh) = (sw as u32, sh as u32);
443+ if pixels.len() != (sw * sh * 4) as usize {
444+ bail!("JPEG decoded to an unexpected size");
445+ }
446+ let orientation = dec
447+ .exif_data()
448+ .and_then(Orientation::from_exif_chunk)
449+ .unwrap_or(Orientation::NoTransforms);
450+ let mut img = image::DynamicImage::ImageRgba8(
451+ image::RgbaImage::from_raw(sw, sh, pixels).context("JPEG buffer size")?,
452+ );
453+ img.apply_orientation(orientation);
454+ let source_size = if img.width() == sw { [w, h] } else { [h, w] };
455+ let rgba = img.into_rgba8();
456+ let size = [rgba.width(), rgba.height()];
457+ let t = resize(
458+ Thumb {
459+ size,
460+ pixels: rgba.into_raw(),
461+ },
462+ max,
463+ )?;
464+ Ok(Some(Generated {
465+ size: t.size,
466+ pixels: t.pixels,
467+ source_size,
468+ }))
469+}
470+
471+#[cfg(test)]
472+mod tests {
473+ use super::*;
474+
475+ fn tmp(name: &str) -> PathBuf {
476+ let dir = std::env::temp_dir().join(format!("remage-thumbs-{}-{name}", std::process::id()));
477+ let _ = fs::remove_dir_all(&dir);
478+ fs::create_dir_all(&dir).unwrap();
479+ dir
480+ }
481+
482+ #[test]
483+ fn uris_are_escaped_like_glib() {
484+ assert_eq!(
485+ file_uri(Path::new("/home/u/a b/ü#?%.png")),
486+ "file:///home/u/a%20b/%C3%BC%23%3F%25.png"
487+ );
488+ assert_eq!(
489+ file_uri(Path::new("/x/(1)!$&'*+,;=:@~-_.png")),
490+ "file:///x/(1)!$&'*+,;=:@~-_.png"
491+ );
492+ }
493+
494+ #[test]
495+ fn names_are_md5_of_the_uri() {
496+ // The spec's own example.
497+ assert_eq!(
498+ cache_name("file:///home/jens/photos/me.png"),
499+ "c6ee772d9e49320e97ec29a7eb5b1697.png"
500+ );
501+ }
502+
503+ #[test]
504+ fn normalize_is_lexical() {
505+ assert_eq!(
506+ normalize(Path::new("/a/./b/../c.png")),
507+ Path::new("/a/c.png")
508+ );
509+ assert!(normalize(Path::new("x.png")).is_absolute());
510+ }
511+
512+ #[test]
513+ fn buckets() {
514+ assert_eq!(bucket_for(96), ("normal", 128));
515+ assert_eq!(bucket_for(128), ("normal", 128));
516+ assert_eq!(bucket_for(160), ("large", 256));
517+ assert_eq!(bucket_for(320), ("x-large", 512));
518+ assert_eq!(bucket_for(2000), ("xx-large", 1024));
519+ }
520+
521+ #[test]
522+ fn generates_writes_and_rereads() {
523+ let dir = tmp("cache");
524+ let img = dir.join("pic.png");
525+ image::RgbaImage::from_fn(600, 300, |x, _| image::Rgba([(x % 256) as u8, 0, 0, 255]))
526+ .save(&img)
527+ .unwrap();
528+ let cache = CacheOptions {
529+ root: Some(dir.join("thumbnails")),
530+ private: false,
531+ };
532+ let t = load(&img, 160, &cache).unwrap();
533+ assert_eq!(t.size, [160, 80]);
534+ let entry = dir
535+ .join("thumbnails/large")
536+ .join(cache_name(&file_uri(&normalize(&img))));
537+ let (text, stored) = read_png(&entry, true).unwrap();
538+ assert_eq!(stored.unwrap().size, [256, 128]);
539+ assert!(
540+ text.iter()
541+ .any(|(k, v)| k == "Thumb::Image::Width" && v == "600")
542+ );
543+ {
544+ use std::os::unix::fs::PermissionsExt;
545+ let mode = fs::metadata(&entry).unwrap().permissions().mode();
546+ assert_eq!(mode & 0o777, 0o600);
547+ }
548+ // A second load reads the entry (same result), a smaller size downscales it.
549+ assert_eq!(load(&img, 160, &cache).unwrap().size, [160, 80]);
550+ assert_eq!(load(&img, 96, &cache).unwrap().size, [96, 48]);
551+ // A stale entry (different mtime) is ignored and replaced.
552+ let text = vec![
553+ ("Thumb::URI".to_string(), file_uri(&normalize(&img))),
554+ ("Thumb::MTime".to_string(), "1".to_string()),
555+ ];
556+ let meta = fs::metadata(&img).unwrap();
557+ assert!(!is_valid(&text, &text[0].1, mtime_secs(&meta).unwrap()));
558+ assert!(is_valid(&text, &text[0].1, 1));
559+ // A bare path as the URI is accepted too.
560+ let bare = vec![
561+ ("Thumb::URI".to_string(), "/a b/c.png".to_string()),
562+ ("Thumb::MTime".to_string(), "1".to_string()),
563+ ];
564+ assert!(is_valid(&bare, "file:///a%20b/c.png", 1));
565+ fs::remove_dir_all(&dir).unwrap();
566+ }
567+
568+ #[test]
569+ fn failures_are_remembered_and_private_writes_nothing() {
570+ let dir = tmp("fail");
571+ let bad = dir.join("bad.png");
572+ fs::write(&bad, b"not a png").unwrap();
573+ let cache = CacheOptions {
574+ root: Some(dir.join("thumbnails")),
575+ private: true,
576+ };
577+ assert!(matches!(
578+ load(&bad, 128, &cache),
579+ Err(ThumbError::Failed(_))
580+ ));
581+ assert!(!dir.join("thumbnails").exists());
582+ let cache = CacheOptions {
583+ private: false,
584+ ..cache
585+ };
586+ assert!(matches!(
587+ load(&bad, 128, &cache),
588+ Err(ThumbError::Failed(_))
589+ ));
590+ assert!(matches!(
591+ load(&bad, 128, &cache),
592+ Err(ThumbError::KnownFailure)
593+ ));
594+ fs::remove_dir_all(&dir).unwrap();
595+ }
596+
597+ #[test]
598+ fn jpeg_is_scaled_and_oriented() {
599+ let dir = tmp("jpeg");
600+ let p = dir.join("a.jpg");
601+ image::RgbImage::from_pixel(800, 400, image::Rgb([10, 200, 30]))
602+ .save(&p)
603+ .unwrap();
604+ let g = jpeg_scaled(&p, 128).unwrap().unwrap();
605+ assert_eq!(g.source_size, [800, 400]);
606+ assert_eq!(g.size, [128, 64]);
607+ let px = &g.pixels[..4];
608+ assert!(px[1] > 150 && px[0] < 60, "{px:?}");
609+ fs::remove_dir_all(&dir).unwrap();
610+ }
611+}
M src/ui/mod.rs View
@@ -6,11 +6,12 @@ use std::path::Path;
66 use std::time::Instant;
77
88 use crate::config::theme::{Rgba, Theme};
9+use crate::render::atlas::ThumbDraw;
910 use crate::render::image::AlphaStyle;
1011 use crate::render::prims::Prim;
1112 use crate::render::text::{Align, FontRole, TextItem};
1213 use crate::render::{ImageDraw, Scene};
13−use crate::state::{AlphaBg, AppState, MsgKind};
14+use crate::state::{AlphaBg, AppState, GRID_NAME_SIZE, Mode, MsgKind, ThumbState};
1415
1516 /// Space Mono label tracking.
1617 const LABEL_TRACKING: f32 = 0.08;
@@ -33,6 +34,7 @@ fn label(text: &str, color: Rgba, sf: f32, x: f32, y: f32, align: Align) -> Text
3334 y,
3435 align,
3536 max_width: None,
37+ clip: None,
3638 }
3739 }
3840
@@ -54,10 +56,28 @@ pub fn scene<'a>(
5456 image_clip: [0.0, 0.0, w, h - bar_h],
5557 alpha: alpha_style(state.alpha_bg, theme, sf),
5658 images: Vec::new(),
59+ thumbs: Vec::new(),
60+ under: Vec::new(),
5761 prims: Vec::new(),
5862 texts: Vec::new(),
5963 };
6064
65+ if state.in_grid() {
66+ grid(&mut scene, state, theme, now);
67+ } else {
68+ image(&mut scene, state, now);
69+ }
70+ if state.queue.is_empty() {
71+ empty_state(&mut scene, theme, sf, [w, h - bar_h]);
72+ }
73+ if bar_h > 0.0 {
74+ status_bar(&mut scene, state, theme, now, [w, h], bar_h, measure);
75+ }
76+ scene
77+}
78+
79+/// The current image, and the previous one fading out under it.
80+fn image<'a>(scene: &mut Scene<'a>, state: &'a AppState, now: Instant) {
6181 let nearest = state.effective_nearest(now);
6282 if let Some(fade) = &state.fade {
6383 scene.images.push(ImageDraw {
@@ -77,14 +97,102 @@ pub fn scene<'a>(
7797 nearest,
7898 });
7999 }
100+}
80101
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);
102+/// `r` grown by `d` on every side.
103+fn expand(r: [f32; 4], d: f32) -> [f32; 4] {
104+ [r[0] - d, r[1] - d, r[2] + 2.0 * d, r[3] + 2.0 * d]
105+}
106+
107+/// Thumbnails, the cursor, marks and the visual range.
108+fn grid<'a>(scene: &mut Scene<'a>, state: &'a AppState, theme: &Theme, now: Instant) {
109+ let sf = state.ui_scale();
110+ let l = state.grid_layout();
111+ // Whole pixels keep 1:1 thumbnails crisp while scrolling.
112+ let scroll = state.grid.scroll(&l, now).round();
113+ let cursor = state.queue.index();
114+ let range = state.visual_range();
115+ let line = sf.round().max(1.0);
116+ let area = [0.0, 0.0, l.viewport[0], l.viewport[1]];
117+ for i in l.visible(scroll) {
118+ let Some(path) = state.queue.get(i) else {
119+ continue;
120+ };
121+ let [x, y] = l.origin(i);
122+ let cell = [x, y - scroll, l.cell, l.cell];
123+ let thumb = state.thumbs.get(path);
124+ let rect = match thumb {
125+ Some(ThumbState { size, .. }) => {
126+ let k = (l.cell / size[0].max(size[1]) as f32).min(1.0);
127+ let (tw, th) = ((size[0] as f32 * k).round(), (size[1] as f32 * k).round());
128+ [
129+ cell[0] + ((l.cell - tw) * 0.5).floor(),
130+ cell[1] + ((l.cell - th) * 0.5).floor(),
131+ tw,
132+ th,
133+ ]
134+ }
135+ _ => cell,
136+ };
137+ if range.as_ref().is_some_and(|r| r.contains(&i)) {
138+ scene.under.push(Prim::rounded(
139+ expand(cell, 6.0 * sf),
140+ theme.surface_raised.linear(),
141+ 8.0 * sf,
142+ ));
143+ }
144+ let (cx, cy) = (cell[0] + l.cell * 0.5, cell[1] + l.cell * 0.5);
145+ match thumb {
146+ Some(t) => scene.thumbs.push(ThumbDraw {
147+ path,
148+ rect,
149+ alpha: state.thumb_alpha(t.at, now),
150+ }),
151+ // Pending: one dim dot, no skeleton.
152+ None => scene.prims.push(Prim::dot(
153+ [cx, cy],
154+ 2.0 * sf,
155+ theme.text_disabled.linear(),
156+ None,
157+ )),
158+ }
159+ if state.queue.is_marked(i) {
160+ let r = 4.0 * sf;
161+ scene.prims.push(Prim::dot(
162+ [rect[0] + rect[2] - 2.0 * r, rect[1] + 2.0 * r],
163+ r,
164+ theme.accent.linear(),
165+ Some((theme.background.linear(), line)),
166+ ));
167+ }
168+ if i == cursor {
169+ scene.prims.push(Prim::outline(
170+ expand(rect, 4.0 * sf),
171+ theme.text_display.linear(),
172+ 4.0 * sf,
173+ line,
174+ ));
175+ }
176+ if l.label > 0.0 {
177+ let color = if i == cursor {
178+ theme.text_primary
179+ } else {
180+ theme.text_secondary
181+ };
182+ scene.texts.push(TextItem {
183+ spans: vec![(file_name(path), color)],
184+ role: FontRole::Ui,
185+ size: (GRID_NAME_SIZE * sf).round(),
186+ weight: 400,
187+ tracking: 0.0,
188+ x: cx,
189+ y: cell[1] + l.cell + l.label * 0.5,
190+ align: Align::Center,
191+ max_width: Some(l.cell),
192+ clip: Some(area),
193+ });
194+ }
86195 }
87− scene
88196 }
89197
90198 fn alpha_style(bg: AlphaBg, theme: &Theme, sf: f32) -> AlphaStyle {
@@ -117,6 +225,7 @@ fn empty_state(scene: &mut Scene, theme: &Theme, sf: f32, area: [f32; 2]) {
117225 y: cy - 12.0 * sf,
118226 align: Align::Center,
119227 max_width: None,
228+ clip: None,
120229 });
121230 let hint = "remage <file|dir>…";
122231 scene.texts.push(label(
@@ -169,11 +278,14 @@ fn status_bar(
169278 if state.skipped > 0 {
170279 spans.push((format!("[SKIPPED {}]", state.skipped), theme.text_secondary));
171280 }
281+ if let Some(r) = state.visual_range() {
282+ spans.push((format!("[VISUAL {}]", r.len()), theme.text_secondary));
283+ }
172284 let mut info = Vec::new();
173285 if let Some(s) = state
174286 .shown
175287 .as_ref()
176− .filter(|s| Some(s.path.as_path()) == state.queue.current())
288+ .filter(|s| state.mode == Mode::Image && Some(s.path.as_path()) == state.queue.current())
177289 {
178290 info.push(s.format.to_string());
179291 info.push(format!("{}×{}", s.size[0], s.size[1]));
@@ -201,6 +313,37 @@ fn status_bar(
201313 scene.texts.push(item);
202314 }
203315
316+ // Marks: an accent dot and the count, filled when the current file is marked.
317+ let marks = state.queue.marked_count();
318+ if marks > 0 {
319+ let gap = if right_w > 0.0 { 16.0 * sf } else { 0.0 };
320+ let item = label(
321+ &marks.to_string(),
322+ theme.text_secondary,
323+ sf,
324+ w - pad - right_w - gap,
325+ y,
326+ Align::Right,
327+ );
328+ let count_w = measure(&item);
329+ scene.texts.push(item);
330+ let r = 3.0 * sf;
331+ let cx = w - pad - right_w - gap - count_w - 6.0 * sf - r;
332+ let accent = theme.accent.linear();
333+ let dot = if state.queue.is_marked(state.queue.index()) {
334+ Prim::dot([cx, y], r, accent, None)
335+ } else {
336+ Prim::outline(
337+ [cx - r, y - r, 2.0 * r, 2.0 * r],
338+ accent,
339+ r,
340+ sf.round().max(1.0),
341+ )
342+ };
343+ scene.prims.push(dot);
344+ right_w += gap + count_w + 6.0 * sf + 2.0 * r;
345+ }
346+
204347 // The filename takes whatever the right side leaves, ellipsised in the middle.
205348 if let Some(cur) = state.queue.current() {
206349 let room = (w - 2.0 * pad - right_w - 32.0 * sf).max(0.0);
@@ -214,6 +357,7 @@ fn status_bar(
214357 y,
215358 align: Align::Left,
216359 max_width: Some(room),
360+ clip: None,
217361 });
218362 }
219363 }
M src/worker.rs View
@@ -17,10 +17,8 @@ pub enum Priority {
1717 /// Neighbours of the current image.
1818 Preload = 1,
1919 /// Thumbnails in view.
20− #[allow(dead_code)] // thumbnails arrive in M3
2120 ThumbVisible = 2,
2221 /// Other thumbnails.
23− #[allow(dead_code)]
2422 Thumb = 3,
2523 }
2624
@@ -33,6 +31,7 @@ impl Priority {
3331 #[derive(Clone, Debug, PartialEq, Eq, Hash)]
3432 pub enum JobKey {
3533 Decode(PathBuf),
34+ Thumb(PathBuf),
3635 }
3736
3837 type Work = Box<dyn FnOnce() + Send>;
@@ -161,6 +160,11 @@ impl Pool {
161160 self.shared.cv.notify_one();
162161 }
163162
163+ /// Drop `key` if it is still queued (a running job finishes regardless).
164+ pub fn cancel(&self, key: &JobKey) {
165+ self.shared.lock().live.remove(key);
166+ }
167+
164168 /// Invalidate every queued current/preload job.
165169 pub fn bump_epoch(&self) {
166170 self.shared.lock().epoch += 1;
@@ -253,4 +257,24 @@ mod tests {
253257 assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), "new");
254258 assert!(rx.recv_timeout(Duration::from_millis(100)).is_err());
255259 }
260+
261+ #[test]
262+ fn cancelled_jobs_never_run() {
263+ let pool = Pool::with_threads(1);
264+ let (gate_tx, gate_rx) = unbounded::<()>();
265+ let (tx, rx) = unbounded();
266+ pool.submit(key("gate"), Priority::Current, move || {
267+ let _ = gate_rx.recv();
268+ });
269+ std::thread::sleep(Duration::from_millis(50));
270+ for k in ["a", "b"] {
271+ let tx = tx.clone();
272+ let job = JobKey::Thumb(PathBuf::from(k));
273+ pool.submit(job, Priority::Thumb, move || tx.send(k).unwrap());
274+ }
275+ pool.cancel(&JobKey::Thumb(PathBuf::from("a")));
276+ gate_tx.send(()).unwrap();
277+ assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), "b");
278+ assert!(rx.recv_timeout(Duration::from_millis(100)).is_err());
279+ }
256280 }