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.
+2581 −64
20 files changed
- M CLAUDE.md +4 −3
- M Cargo.lock +10 −0
- M Cargo.toml +1 −0
- M src/action.rs +88 −0
- M src/anim.rs +5 −0
- M src/app.rs +282 −20
- M src/cli.rs +8 −0
- M src/config/default.toml +21 −0
- M src/main.rs +4 −0
- A src/render/atlas.rs +404 −0
- M src/render/mod.rs +41 −3
- M src/render/prims.rs +49 −5
- A src/render/shaders/thumbs.wgsl +43 −0
- M src/render/text.rs +18 −4
- M src/state/grid.rs +346 −0
- M src/state/mod.rs +342 −17
- M src/state/queue.rs +127 −2
- M src/thumbs.rs +610 −0
- M src/ui/mod.rs +152 −8
- M src/worker.rs +26 −2
| @@ -41,8 +41,8 @@ and must stay mutually compatible. | |||
| 41 | 41 | | Errors / logs | `anyhow`, `thiserror`, `log` + `env_logger` (`RUST_LOG=remage=debug`) | | |
| 42 | 42 | ||
| 43 | 43 | **Locked pairings (M0, 2026-11):** glyphon 0.12 ↔ wgpu 30 (cosmic-text 0.19). winit stays on | |
| 44 | − | 0.30 until 0.31 leaves beta. Bump glyphon and wgpu together, never one alone. Crates still to add | |
| 45 | − | when their milestone needs them: `jpeg-decoder` (M3 scaled thumbnails) and `clap_mangen` (M7). | |
| 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). | |
| 46 | 46 | ||
| 47 | 47 | Cargo features are `default = ["jxl", "svg", "avif", "heif", "raw"]`. The only C dependencies | |
| 48 | 48 | (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 | |||
| 211 | 211 | |---|---| | |
| 212 | 212 | | Navigation | `next [n]`, `prev [n]`, `first`, `last`, `goto N`, `random`, `next-mark`, `prev-mark`, `next-frame`, `prev-frame` | | |
| 213 | 213 | | Marks | `mark [toggle\|on\|off]`, `mark-all`, `unmark-all`, `invert-marks` | | |
| 214 | + | | Grid | `move <dir> [n]`, `page down\|up\|half-down\|half-up`, `row-start`, `row-end`, `scroll <dir> [n]` (pans a step in image mode) | | |
| 214 | 215 | | View | `zoom in\|out\|N%`, `fit <mode>`, `pan <dir>`, `pan-edge <dir>`, `center`, `rotate cw\|ccw\|180`, `flip h\|v`, `filter toggle`, `alpha-bg cycle`, `gamma\|brightness\|contrast +\|-\|reset` | | |
| 215 | 216 | | Playback | `play toggle`, `slideshow toggle`, `slideshow-delay ±N` | | |
| 216 | 217 | | 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. | |||
| 434 | 435 | - Action grammar, and a keymap trie with sequences, counts and mouse bindings. | |
| 435 | 436 | - TOML defaults, include and live reload, plus `--dump-config` and `--check-config`. | |
| 436 | 437 | - 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. | |
| 438 | 439 | - Freedesktop thumbnails, worker priorities and the GPU atlas. | |
| 439 | 440 | - Grid navigation, visual mode, marks and smooth scrolling. | |
| 440 | 441 | - **M4 Commands + IPC** | |
| @@ -1525,6 +1525,15 @@ dependencies = [ | |||
| 1525 | 1525 | "libc", | |
| 1526 | 1526 | ] | |
| 1527 | 1527 | ||
| 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 | + | ||
| 1528 | 1537 | [[package]] | |
| 1529 | 1538 | name = "js-sys" | |
| 1530 | 1539 | version = "0.3.107" | |
| @@ -3049,6 +3058,7 @@ dependencies = [ | |||
| 3049 | 3058 | "fast_image_resize", | |
| 3050 | 3059 | "glyphon", | |
| 3051 | 3060 | "image", | |
| 3061 | + | "jpeg-decoder", | |
| 3052 | 3062 | "jxl-oxide", | |
| 3053 | 3063 | "kamadak-exif", | |
| 3054 | 3064 | "libheif-rs", | |
| @@ -16,6 +16,7 @@ env_logger = "0.11.11" | |||
| 16 | 16 | fast_image_resize = "6.1.0" | |
| 17 | 17 | glyphon = "0.12.0" | |
| 18 | 18 | 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" | |
| 19 | 20 | jxl-oxide = { version = "0.12.6", optional = true } | |
| 20 | 21 | kamadak-exif = "0.6.1" | |
| 21 | 22 | libheif-rs = { version = "3.0.0", optional = true } | |
| @@ -40,6 +40,15 @@ pub enum Adjust { | |||
| 40 | 40 | Contrast, | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 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 | + | ||
| 43 | 52 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 44 | 53 | pub enum Step { | |
| 45 | 54 | Up, | |
| @@ -70,6 +79,13 @@ pub enum Action { | |||
| 70 | 79 | Pan(Dir), | |
| 71 | 80 | PanEdge(Dir), | |
| 72 | 81 | 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), | |
| 73 | 89 | /// Quarter turns, positive = clockwise. | |
| 74 | 90 | Rotate(i32), | |
| 75 | 91 | Flip(Axis), | |
| @@ -139,6 +155,15 @@ pub const CATALOG: &[Entry] = &[ | |||
| 139 | 155 | e("pan", "left|right|up|down", "Pan (held keys pan smoothly)"), | |
| 140 | 156 | e("pan-edge", "left|right|up|down", "Jump to an edge"), | |
| 141 | 157 | 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 | + | ), | |
| 142 | 167 | e("rotate", "cw|ccw|180", "Rotate the view"), | |
| 143 | 168 | e("flip", "h|v", "Flip the view"), | |
| 144 | 169 | e("filter", "toggle", "Toggle nearest/linear filtering"), | |
| @@ -339,6 +364,25 @@ impl FromStr for Action { | |||
| 339 | 364 | "fit" => Action::Fit(value_enum(name, rest)?), | |
| 340 | 365 | "pan" => Action::Pan(dir(name, rest)?), | |
| 341 | 366 | "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 | + | )?), | |
| 342 | 386 | "rotate" => match rest { | |
| 343 | 387 | "cw" => Action::Rotate(1), | |
| 344 | 388 | "ccw" => Action::Rotate(-1), | |
| @@ -415,6 +459,8 @@ impl FromStr for Action { | |||
| 415 | 459 | "unmark-all" => Action::UnmarkAll, | |
| 416 | 460 | "invert-marks" => Action::InvertMarks, | |
| 417 | 461 | "center" | "centre" => Action::Center, | |
| 462 | + | "row-start" => Action::RowStart, | |
| 463 | + | "row-end" => Action::RowEnd, | |
| 418 | 464 | "visual" => Action::Visual, | |
| 419 | 465 | "help" => Action::Help, | |
| 420 | 466 | "palette" => Action::Palette, | |
| @@ -461,6 +507,15 @@ fn dir_name(d: Dir) -> &'static str { | |||
| 461 | 507 | } | |
| 462 | 508 | } | |
| 463 | 509 | ||
| 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 | + | ||
| 464 | 519 | fn step_name(s: Step) -> &'static str { | |
| 465 | 520 | match s { | |
| 466 | 521 | Step::Up => "+", | |
| @@ -489,6 +544,9 @@ impl fmt::Display for Action { | |||
| 489 | 544 | Action::Zoom(Zoom::Percent(p)) => write!(f, "zoom {p}%"), | |
| 490 | 545 | Action::Fit(m) => write!(f, "fit {}", value_name(m)), | |
| 491 | 546 | 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)), | |
| 492 | 550 | Action::Rotate(1) => f.write_str("rotate cw"), | |
| 493 | 551 | Action::Rotate(-1) => f.write_str("rotate ccw"), | |
| 494 | 552 | Action::Rotate(q) => write!(f, "rotate {}", q * 90), | |
| @@ -548,6 +606,11 @@ impl Action { | |||
| 548 | 606 | Action::Pan(_) => "pan", | |
| 549 | 607 | Action::PanEdge(_) => "pan-edge", | |
| 550 | 608 | 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", | |
| 551 | 614 | Action::Rotate(_) => "rotate", | |
| 552 | 615 | Action::Flip(_) => "flip", | |
| 553 | 616 | Action::FilterToggle => "filter", | |
| @@ -614,6 +677,14 @@ impl Action { | |||
| 614 | 677 | }, | |
| 615 | 678 | Action::Pan(d) => format!("Pan {}", dir_name(*d)), | |
| 616 | 679 | 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 | + | }, | |
| 617 | 688 | Action::Rotate(1) => "Rotate clockwise".into(), | |
| 618 | 689 | Action::Rotate(-1) => "Rotate counter-clockwise".into(), | |
| 619 | 690 | Action::Rotate(q) => format!("Rotate {}°", q * 90), | |
| @@ -678,6 +749,8 @@ impl Action { | |||
| 678 | 749 | Action::Rotate(total.signum() * ((total.abs() - 1) % 4 + 1)) | |
| 679 | 750 | } | |
| 680 | 751 | 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)), | |
| 681 | 754 | other => other, | |
| 682 | 755 | } | |
| 683 | 756 | } | |
| @@ -694,6 +767,9 @@ impl Action { | |||
| 694 | 767 | | Action::PrevFrame | |
| 695 | 768 | | Action::Zoom(Zoom::In(_) | Zoom::Out(_)) | |
| 696 | 769 | | Action::GridSize(_) | |
| 770 | + | | Action::Move(..) | |
| 771 | + | | Action::Scroll(..) | |
| 772 | + | | Action::Page(_) | |
| 697 | 773 | | Action::Adjust(_, Step::Up | Step::Down) | |
| 698 | 774 | ) | |
| 699 | 775 | } | |
| @@ -786,6 +862,14 @@ mod tests { | |||
| 786 | 862 | "pan left", | |
| 787 | 863 | "pan-edge down", | |
| 788 | 864 | "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", | |
| 789 | 873 | "rotate cw", | |
| 790 | 874 | "rotate ccw", | |
| 791 | 875 | "rotate 180", | |
| @@ -853,6 +937,10 @@ mod tests { | |||
| 853 | 937 | Action::Zoom(Zoom::In(3)) | |
| 854 | 938 | ); | |
| 855 | 939 | 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 | + | ); | |
| 856 | 944 | assert_eq!(Action::Quit.with_count(4), Action::Quit); | |
| 857 | 945 | } | |
| 858 | 946 | } | |
| @@ -86,6 +86,11 @@ impl<T: Lerp> Tween<T> { | |||
| 86 | 86 | } | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | + | /// Where the tween ends up. | |
| 90 | + | pub fn target(&self) -> T { | |
| 91 | + | self.to | |
| 92 | + | } | |
| 93 | + | ||
| 89 | 94 | pub fn is_active(&self, now: Instant) -> bool { | |
| 90 | 95 | !self.dur.is_zero() && now < self.start + self.dur | |
| 91 | 96 | } | |
| @@ -13,7 +13,7 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}; | |||
| 13 | 13 | use winit::keyboard::{ModifiersState, PhysicalKey}; | |
| 14 | 14 | use winit::window::{Fullscreen, Window, WindowId}; | |
| 15 | 15 | ||
| 16 | − | use crate::action::{Action, Switch, Zoom}; | |
| 16 | + | use crate::action::{Action, ModeArg, Switch, Zoom}; | |
| 17 | 17 | use crate::cache::ImageCache; | |
| 18 | 18 | use crate::config::watch::ConfigWatch; | |
| 19 | 19 | use crate::config::{Config, LoadError, Loaded}; | |
| @@ -24,7 +24,8 @@ use crate::input::{self}; | |||
| 24 | 24 | use crate::render::{Renderer, text}; | |
| 25 | 25 | use crate::scan; | |
| 26 | 26 | 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}; | |
| 28 | 29 | use crate::ui; | |
| 29 | 30 | use crate::worker::{JobKey, Pool, Priority}; | |
| 30 | 31 | ||
| @@ -41,6 +42,11 @@ const SEQUENCE_TIMEOUT: Duration = Duration::from_millis(1000); | |||
| 41 | 42 | const CLICK_SLOP: f32 = 4.0; | |
| 42 | 43 | /// Config errors stay on screen until fixed (or dismissed with `<Esc>`). | |
| 43 | 44 | 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); | |
| 44 | 50 | ||
| 45 | 51 | pub enum UserEvent { | |
| 46 | 52 | Decoded { | |
| @@ -55,6 +61,12 @@ pub enum UserEvent { | |||
| 55 | 61 | Config(Box<Result<Loaded, LoadError>>), | |
| 56 | 62 | /// A re-sort finished: the queue in its new order. | |
| 57 | 63 | 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 | + | }, | |
| 58 | 70 | } | |
| 59 | 71 | ||
| 60 | 72 | /// Command-line settings that beat the config, across reloads too. | |
| @@ -81,6 +93,20 @@ pub struct Options { | |||
| 81 | 93 | pub geometry: Option<(u32, u32)>, | |
| 82 | 94 | pub class: String, | |
| 83 | 95 | 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)>, | |
| 84 | 110 | } | |
| 85 | 111 | ||
| 86 | 112 | struct Gfx { | |
| @@ -111,6 +137,12 @@ pub struct App { | |||
| 111 | 137 | arriving: Arc<Mutex<HashSet<PathBuf>>>, | |
| 112 | 138 | gfx: Option<Gfx>, | |
| 113 | 139 | 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)>, | |
| 114 | 146 | modifiers: ModifiersState, | |
| 115 | 147 | cursor: [f32; 2], | |
| 116 | 148 | dragging: bool, | |
| @@ -133,6 +165,7 @@ impl App { | |||
| 133 | 165 | watch: ConfigWatch, | |
| 134 | 166 | ) -> Self { | |
| 135 | 167 | let cache = ImageCache::new(loaded.config.image.cache_mb << 20); | |
| 168 | + | let thumb_cache = CacheOptions::new(loaded.config.grid.thumbnail_cache, opts.private); | |
| 136 | 169 | let mut app = Self { | |
| 137 | 170 | opts, | |
| 138 | 171 | state, | |
| @@ -151,6 +184,10 @@ impl App { | |||
| 151 | 184 | arriving: Arc::default(), | |
| 152 | 185 | gfx: None, | |
| 153 | 186 | uploads: VecDeque::new(), | |
| 187 | + | thumbs: ThumbJobs::default(), | |
| 188 | + | thumb_cache, | |
| 189 | + | grid_rest: None, | |
| 190 | + | last_click: None, | |
| 154 | 191 | modifiers: ModifiersState::empty(), | |
| 155 | 192 | cursor: [0.0, 0.0], | |
| 156 | 193 | dragging: false, | |
| @@ -224,6 +261,14 @@ impl App { | |||
| 224 | 261 | self.request_redraw(); | |
| 225 | 262 | return; | |
| 226 | 263 | }; | |
| 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 | + | } | |
| 227 | 272 | ||
| 228 | 273 | let keep = |p: &Path| { | |
| 229 | 274 | wanted.iter().any(|w| w == p) | |
| @@ -259,6 +304,117 @@ impl App { | |||
| 259 | 304 | self.request_redraw(); | |
| 260 | 305 | } | |
| 261 | 306 | ||
| 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 | + | ||
| 262 | 418 | /// Upload `img` in full and put it on screen. | |
| 263 | 419 | fn show(&mut self, path: &Path, img: Arc<DecodedImage>, now: Instant) { | |
| 264 | 420 | let Some(g) = &mut self.gfx else { | |
| @@ -314,6 +470,12 @@ impl App { | |||
| 314 | 470 | let mut settings = Settings::from_config(new); | |
| 315 | 471 | self.opts.overrides.apply(&mut settings); | |
| 316 | 472 | 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 | + | } | |
| 317 | 479 | self.state.set_settings(settings); | |
| 318 | 480 | self.state.set_theme(loaded.theme, now); | |
| 319 | 481 | self.keymap = keymap(&loaded.keys); | |
| @@ -348,6 +510,7 @@ impl App { | |||
| 348 | 510 | if preload_changed { | |
| 349 | 511 | self.sync(false); | |
| 350 | 512 | } | |
| 513 | + | self.sync_thumbs(); | |
| 351 | 514 | self.request_redraw(); | |
| 352 | 515 | } | |
| 353 | 516 | ||
| @@ -355,12 +518,15 @@ impl App { | |||
| 355 | 518 | lock(&self.arriving).remove(&path); | |
| 356 | 519 | let now = Instant::now(); | |
| 357 | 520 | let is_current = self.state.queue.current() == Some(path.as_path()); | |
| 521 | + | let in_grid = self.state.in_grid(); | |
| 358 | 522 | match result { | |
| 359 | 523 | Ok(img) => { | |
| 360 | 524 | let wanted = self.window_paths(); | |
| 361 | 525 | let keep: Vec<&Path> = wanted.iter().map(PathBuf::as_path).collect(); | |
| 362 | 526 | 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 { | |
| 364 | 530 | // Already on screen means this was a reload: replace the texture. | |
| 365 | 531 | let reload = self.state.shown.as_ref().is_some_and(|s| s.path == path); | |
| 366 | 532 | if reload && let Some(g) = &mut self.gfx { | |
| @@ -443,11 +609,16 @@ impl App { | |||
| 443 | 609 | let _ = proxy.send_event(UserEvent::Sorted(files)); | |
| 444 | 610 | }); | |
| 445 | 611 | } | |
| 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 { | |
| 447 | 616 | self.sync(true); | |
| 448 | 617 | } else if fx.reordered { | |
| 449 | 618 | self.sync(false); | |
| 450 | 619 | } | |
| 620 | + | // Scrolling, a new size or the bar can bring new thumbnails into view. | |
| 621 | + | self.sync_thumbs(); | |
| 451 | 622 | if fx.redraw || fx.layout { | |
| 452 | 623 | self.request_redraw(); | |
| 453 | 624 | } | |
| @@ -500,7 +671,9 @@ impl App { | |||
| 500 | 671 | self.repeat = None; | |
| 501 | 672 | for run in runs { | |
| 502 | 673 | 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 | + | { | |
| 504 | 677 | if self.state.shown.is_some() { | |
| 505 | 678 | self.held.insert(event.physical_key, dir); | |
| 506 | 679 | self.state.hold_pan(dir, true, now); | |
| @@ -525,14 +698,28 @@ impl App { | |||
| 525 | 698 | } | |
| 526 | 699 | } | |
| 527 | 700 | ||
| 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) { | |
| 529 | 703 | let now = Instant::now(); | |
| 530 | 704 | let [dx, dy] = delta; | |
| 531 | 705 | if dy != 0.0 { | |
| 532 | 706 | let dir = if dy > 0.0 { Scroll::Up } else { Scroll::Down }; | |
| 533 | 707 | 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(); | |
| 535 | 711 | 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 | + | } | |
| 536 | 723 | Some([Action::Zoom(Zoom::In(n))]) => { | |
| 537 | 724 | let steps = dy.abs() * *n as f32; | |
| 538 | 725 | self.state.zoom_at(steps, self.cursor, now); | |
| @@ -569,16 +756,49 @@ impl App { | |||
| 569 | 756 | } | |
| 570 | 757 | } | |
| 571 | 758 | ||
| 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 | + | ||
| 572 | 786 | fn redraw(&mut self) { | |
| 573 | 787 | let now = Instant::now(); | |
| 574 | 788 | let ready = self.state.shown.is_some() | |
| 575 | 789 | || self.state.queue.is_empty() | |
| 790 | + | || (self.state.in_grid() && self.grid_settled()) | |
| 576 | 791 | || now >= self.started + SHOW_TIMEOUT; | |
| 577 | 792 | if !self.shown_window && !ready { | |
| 578 | 793 | return; // about_to_wait wakes us at the timeout | |
| 579 | 794 | } | |
| 580 | 795 | self.state.advance(now); | |
| 581 | 796 | 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 | + | }; | |
| 582 | 802 | let Some(g) = &mut self.gfx else { return }; | |
| 583 | 803 | if !self.shown_window { | |
| 584 | 804 | g.window.set_visible(true); | |
| @@ -588,7 +808,7 @@ impl App { | |||
| 588 | 808 | let result = g.renderer.render(&scene); | |
| 589 | 809 | if let Some(path) = std::env::var_os("REMAGE_SNAPSHOT") | |
| 590 | 810 | && !self.state.is_animating(now) | |
| 591 | − | && self.state.loading_since.is_none() | |
| 811 | + | && settled | |
| 592 | 812 | { | |
| 593 | 813 | match g.renderer.snapshot(&scene, Path::new(&path)) { | |
| 594 | 814 | Ok(()) => log::info!("snapshot written to {}", Path::new(&path).display()), | |
| @@ -614,9 +834,27 @@ impl App { | |||
| 614 | 834 | } | |
| 615 | 835 | } | |
| 616 | 836 | ||
| 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. | |
| 618 | 838 | fn pump_uploads(&mut self) -> bool { | |
| 619 | 839 | 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 | + | } | |
| 620 | 858 | if let Some((path, img)) = self.uploads.front() { | |
| 621 | 859 | let img = fit_gpu(img.clone(), g.renderer.max_texture_dim()); | |
| 622 | 860 | if g.renderer.upload(path, &img, UPLOAD_BUDGET) { | |
| @@ -768,6 +1006,7 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 768 | 1006 | self.state.queue.reorder(files); | |
| 769 | 1007 | self.sync(false); | |
| 770 | 1008 | } | |
| 1009 | + | UserEvent::Thumb { path, px, result } => self.on_thumb(path, px, result), | |
| 771 | 1010 | } | |
| 772 | 1011 | } | |
| 773 | 1012 | ||
| @@ -780,11 +1019,13 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 780 | 1019 | let sf = g.window.scale_factor() as f32; | |
| 781 | 1020 | self.state | |
| 782 | 1021 | .set_window(size.width as f32, size.height as f32, sf); | |
| 1022 | + | self.sync_thumbs(); | |
| 783 | 1023 | self.request_redraw(); | |
| 784 | 1024 | } | |
| 785 | 1025 | WindowEvent::ScaleFactorChanged { scale_factor, .. } => { | |
| 786 | 1026 | let [w, h] = self.state.window(); | |
| 787 | 1027 | self.state.set_window(w, h, scale_factor as f32); | |
| 1028 | + | self.sync_thumbs(); | |
| 788 | 1029 | self.request_redraw(); | |
| 789 | 1030 | } | |
| 790 | 1031 | WindowEvent::RedrawRequested => self.redraw(), | |
| @@ -803,13 +1044,15 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 803 | 1044 | } | |
| 804 | 1045 | WindowEvent::CursorMoved { position, .. } => { | |
| 805 | 1046 | 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); | |
| 810 | 1054 | self.request_redraw(); | |
| 811 | 1055 | } | |
| 812 | − | self.cursor = p; | |
| 813 | 1056 | } | |
| 814 | 1057 | WindowEvent::MouseInput { state, button, .. } => { | |
| 815 | 1058 | let pressed = state == ElementState::Pressed; | |
| @@ -825,6 +1068,9 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 825 | 1068 | if moved > CLICK_SLOP * self.state.scale_factor() { | |
| 826 | 1069 | return; | |
| 827 | 1070 | } | |
| 1071 | + | if self.state.in_grid() { | |
| 1072 | + | self.grid_click(event_loop); | |
| 1073 | + | } | |
| 828 | 1074 | } else if !pressed { | |
| 829 | 1075 | return; | |
| 830 | 1076 | } | |
| @@ -833,13 +1079,14 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 833 | 1079 | } | |
| 834 | 1080 | } | |
| 835 | 1081 | 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 | + | ), | |
| 841 | 1088 | }; | |
| 842 | − | self.on_wheel(d, event_loop); | |
| 1089 | + | self.on_wheel(d, pixels, event_loop); | |
| 843 | 1090 | } | |
| 844 | 1091 | _ => {} | |
| 845 | 1092 | } | |
| @@ -863,7 +1110,22 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 863 | 1110 | self.run(run.counted(), None, event_loop); | |
| 864 | 1111 | } | |
| 865 | 1112 | } | |
| 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 | + | } | |
| 866 | 1124 | 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 | + | } | |
| 867 | 1129 | if let Some(t) = self.ambiguous_since { | |
| 868 | 1130 | let at = t + SEQUENCE_TIMEOUT; | |
| 869 | 1131 | next = Some(next.map_or(at, |n| n.min(at))); | |
| @@ -17,6 +17,14 @@ pub struct Cli { | |||
| 17 | 17 | #[arg(short, long)] | |
| 18 | 18 | pub recursive: bool, | |
| 19 | 19 | ||
| 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 | + | ||
| 20 | 28 | /// Start at the Nth image (1-based) | |
| 21 | 29 | #[arg(short = 'n', long, value_name = "N")] | |
| 22 | 30 | pub start: Option<usize>, | |
| @@ -140,13 +140,34 @@ preset = "nothing-dark" # nothing-dark | nothing-light | |||
| 140 | 140 | "<ScrollUp>" = "zoom in" | |
| 141 | 141 | "<ScrollDown>" = "zoom out" | |
| 142 | 142 | ||
| 143 | + | # In the grid a click selects a thumbnail and a double-click opens it; dragging scrolls. | |
| 143 | 144 | [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" | |
| 144 | 162 | "+" = "grid-size +" | |
| 145 | 163 | "-" = "grid-size -" | |
| 146 | 164 | "v" = "visual" | |
| 165 | + | "<ScrollUp>" = "scroll up" | |
| 166 | + | "<ScrollDown>" = "scroll down" | |
| 147 | 167 | ||
| 148 | 168 | [keys.visual] | |
| 149 | 169 | "m" = "mark on" | |
| 170 | + | "v" = "mode grid" | |
| 150 | 171 | "<Esc>" = "mode grid" | |
| 151 | 172 | ||
| 152 | 173 | # User commands: bind them as "run <name>"; unbound ones still show in the : palette. | |
| @@ -82,6 +82,9 @@ fn main() -> anyhow::Result<()> { | |||
| 82 | 82 | let queue = Queue::new(scan.files, scan.start.as_deref(), start_index); | |
| 83 | 83 | let bar = overrides.bar.unwrap_or(cfg.ui.statusbar); | |
| 84 | 84 | 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 | + | } | |
| 85 | 88 | state.theme = loaded.theme.clone(); | |
| 86 | 89 | state.nearest = cfg.image.filter.nearest(); | |
| 87 | 90 | state.alpha_bg = cfg.image.alpha_background; | |
| @@ -113,6 +116,7 @@ fn main() -> anyhow::Result<()> { | |||
| 113 | 116 | geometry: cli.geometry, | |
| 114 | 117 | class: cli.class.unwrap_or_else(|| cfg.window.class.clone()), | |
| 115 | 118 | overrides, | |
| 119 | + | private: cli.private, | |
| 116 | 120 | }; | |
| 117 | 121 | let mut app = App::new(opts, state, proxy, loaded, watch); | |
| 118 | 122 | if let Some(e) = config_error { | |
| @@ -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 | + | } | |
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | //! wgpu device and surface setup, and per-frame orchestration. | |
| 2 | 2 | ||
| 3 | + | pub mod atlas; | |
| 3 | 4 | pub mod image; | |
| 4 | 5 | pub mod prims; | |
| 5 | 6 | pub mod text; | |
| @@ -14,6 +15,8 @@ use winit::window::Window; | |||
| 14 | 15 | use crate::config::theme::Rgba; | |
| 15 | 16 | use crate::decode::DecodedImage; | |
| 16 | 17 | use crate::state::view::Affine; | |
| 18 | + | use crate::thumbs::Thumb; | |
| 19 | + | use atlas::{Atlas, ThumbDraw}; | |
| 17 | 20 | use image::{AlphaStyle, GpuImage, ImagePipeline}; | |
| 18 | 21 | use prims::{Prim, PrimPipeline}; | |
| 19 | 22 | use text::{TextItem, TextSystem}; | |
| @@ -32,6 +35,10 @@ pub struct Scene<'a> { | |||
| 32 | 35 | pub image_clip: [f32; 4], | |
| 33 | 36 | pub alpha: AlphaStyle, | |
| 34 | 37 | 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>, | |
| 35 | 42 | pub prims: Vec<Prim>, | |
| 36 | 43 | pub texts: Vec<TextItem>, | |
| 37 | 44 | } | |
| @@ -46,6 +53,7 @@ pub struct Renderer { | |||
| 46 | 53 | view_format: wgpu::TextureFormat, | |
| 47 | 54 | images: ImagePipeline, | |
| 48 | 55 | prims: PrimPipeline, | |
| 56 | + | atlas: Atlas, | |
| 49 | 57 | text: TextSystem, | |
| 50 | 58 | textures: HashMap<PathBuf, GpuImage>, | |
| 51 | 59 | max_dim: u32, | |
| @@ -123,6 +131,7 @@ impl Renderer { | |||
| 123 | 131 | Ok(Self { | |
| 124 | 132 | images: ImagePipeline::new(&device, view_format), | |
| 125 | 133 | prims: PrimPipeline::new(&device, view_format), | |
| 134 | + | atlas: Atlas::new(&device, view_format, max_dim), | |
| 126 | 135 | text: TextSystem::new(&device, &queue, view_format), | |
| 127 | 136 | window, | |
| 128 | 137 | instance, | |
| @@ -178,6 +187,26 @@ impl Renderer { | |||
| 178 | 187 | .upload(&self.device, &self.queue, gpu, img, budget) | |
| 179 | 188 | } | |
| 180 | 189 | ||
| 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 | + | ||
| 181 | 210 | /// Drop textures that are no longer wanted. | |
| 182 | 211 | pub fn retain(&mut self, keep: impl Fn(&Path) -> bool) { | |
| 183 | 212 | self.textures.retain(|p, _| keep(p)); | |
| @@ -242,8 +271,15 @@ impl Renderer { | |||
| 242 | 271 | self.images | |
| 243 | 272 | .prepare(&self.queue, gpu, d.affine, viewport, d.alpha, &scene.alpha); | |
| 244 | 273 | } | |
| 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 | + | ); | |
| 247 | 283 | self.text | |
| 248 | 284 | .prepare(&self.device, &self.queue, &scene.texts, size)?; | |
| 249 | 285 | ||
| @@ -277,12 +313,14 @@ impl Renderer { | |||
| 277 | 313 | }); | |
| 278 | 314 | if let Some((x, y, w, h)) = clip(scene.image_clip, size) { | |
| 279 | 315 | pass.set_scissor_rect(x, y, w, h); | |
| 316 | + | self.prims.draw_under(&mut pass); | |
| 280 | 317 | for (d, gpu) in &draws { | |
| 281 | 318 | self.images.draw(&mut pass, gpu, d.nearest); | |
| 282 | 319 | } | |
| 320 | + | self.atlas.draw(&mut pass); | |
| 283 | 321 | pass.set_scissor_rect(0, 0, size[0], size[1]); | |
| 284 | 322 | } | |
| 285 | − | self.prims.draw(&mut pass); | |
| 323 | + | self.prims.draw_over(&mut pass); | |
| 286 | 324 | self.text.render(&mut pass)?; | |
| 287 | 325 | } | |
| 288 | 326 | Ok(encoder.finish()) | |
| @@ -23,6 +23,35 @@ impl Prim { | |||
| 23 | 23 | params: [0.0; 4], | |
| 24 | 24 | } | |
| 25 | 25 | } | |
| 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 | + | } | |
| 26 | 55 | } | |
| 27 | 56 | ||
| 28 | 57 | pub struct PrimPipeline { | |
| @@ -31,6 +60,8 @@ pub struct PrimPipeline { | |||
| 31 | 60 | bind_group: wgpu::BindGroup, | |
| 32 | 61 | instances: wgpu::Buffer, | |
| 33 | 62 | capacity: usize, | |
| 63 | + | /// Instances of the under layer, then the over layer. | |
| 64 | + | split: u32, | |
| 34 | 65 | count: u32, | |
| 35 | 66 | } | |
| 36 | 67 | ||
| @@ -116,17 +147,21 @@ impl PrimPipeline { | |||
| 116 | 147 | bind_group, | |
| 117 | 148 | instances: instance_buffer(device, capacity), | |
| 118 | 149 | capacity, | |
| 150 | + | split: 0, | |
| 119 | 151 | count: 0, | |
| 120 | 152 | } | |
| 121 | 153 | } | |
| 122 | 154 | ||
| 155 | + | /// Upload both layers: `under` draws before images and thumbnails, `over` after. | |
| 123 | 156 | pub fn prepare( | |
| 124 | 157 | &mut self, | |
| 125 | 158 | device: &wgpu::Device, | |
| 126 | 159 | queue: &wgpu::Queue, | |
| 127 | − | prims: &[Prim], | |
| 160 | + | under: &[Prim], | |
| 161 | + | over: &[Prim], | |
| 128 | 162 | viewport: [f32; 2], | |
| 129 | 163 | ) { | |
| 164 | + | let prims: Vec<Prim> = under.iter().chain(over).copied().collect(); | |
| 130 | 165 | queue.write_buffer( | |
| 131 | 166 | &self.globals, | |
| 132 | 167 | 0, | |
| @@ -137,18 +172,27 @@ impl PrimPipeline { | |||
| 137 | 172 | self.instances = instance_buffer(device, self.capacity); | |
| 138 | 173 | } | |
| 139 | 174 | 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)); | |
| 141 | 176 | } | |
| 177 | + | self.split = under.len() as u32; | |
| 142 | 178 | self.count = prims.len() as u32; | |
| 143 | 179 | } | |
| 144 | 180 | ||
| 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() { | |
| 147 | 183 | return; | |
| 148 | 184 | } | |
| 149 | 185 | pass.set_pipeline(&self.pipeline); | |
| 150 | 186 | pass.set_bind_group(0, &self.bind_group, &[]); | |
| 151 | 187 | 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); | |
| 153 | 197 | } | |
| 154 | 198 | } | |
| @@ -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 | + | } | |
| @@ -64,6 +64,8 @@ pub struct TextItem { | |||
| 64 | 64 | pub y: f32, | |
| 65 | 65 | pub align: Align, | |
| 66 | 66 | pub max_width: Option<f32>, | |
| 67 | + | /// Only the part inside this rect (x, y, w, h) shows. | |
| 68 | + | pub clip: Option<[f32; 4]>, | |
| 67 | 69 | } | |
| 68 | 70 | ||
| 69 | 71 | #[derive(Clone, PartialEq, Eq, Hash)] | |
| @@ -261,16 +263,28 @@ impl TextSystem { | |||
| 261 | 263 | }; | |
| 262 | 264 | let top = item.y - s.line_height * 0.5; | |
| 263 | 265 | 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 | + | } | |
| 264 | 278 | Some(TextArea { | |
| 265 | 279 | buffer: &s.buffer, | |
| 266 | 280 | left, | |
| 267 | 281 | top, | |
| 268 | 282 | scale: 1.0, | |
| 269 | 283 | 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, | |
| 274 | 288 | }, | |
| 275 | 289 | default_color: Color::rgba(color[0], color[1], color[2], color[3]), | |
| 276 | 290 | custom_glyphs: &[], | |
| @@ -1 +1,347 @@ | |||
| 1 | 1 | //! 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 | + | } | |
| @@ -4,13 +4,15 @@ pub mod grid; | |||
| 4 | 4 | pub mod queue; | |
| 5 | 5 | pub mod view; | |
| 6 | 6 | ||
| 7 | + | use std::collections::HashMap; | |
| 7 | 8 | use std::path::{Path, PathBuf}; | |
| 8 | 9 | use std::time::{Duration, Instant}; | |
| 9 | 10 | ||
| 10 | − | use crate::action::{Action, ModeArg, Switch, Zoom}; | |
| 11 | + | use crate::action::{Action, ModeArg, Page, Switch, Zoom}; | |
| 11 | 12 | use crate::anim::Tween; | |
| 12 | 13 | use crate::config::theme::Theme; | |
| 13 | 14 | use crate::config::{Config, Transition}; | |
| 15 | + | use grid::{Grid, Layout}; | |
| 14 | 16 | use queue::{Queue, SortKey}; | |
| 15 | 17 | use view::{Affine, Dir, FitMode, View}; | |
| 16 | 18 | ||
| @@ -26,9 +28,12 @@ pub const BAR_HEIGHT: f32 = 32.0; | |||
| 26 | 28 | const CROSSFADE: Duration = Duration::from_millis(100); | |
| 27 | 29 | /// Theme changes (a new wallpaper palette) crossfade this long. | |
| 28 | 30 | 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; | |
| 29 | 35 | ||
| 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. | |
| 32 | 37 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 33 | 38 | pub enum Mode { | |
| 34 | 39 | #[default] | |
| @@ -50,6 +55,12 @@ pub struct Settings { | |||
| 50 | 55 | /// UI size multiplier on top of the display scale. | |
| 51 | 56 | pub ui_scale: f32, | |
| 52 | 57 | 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, | |
| 53 | 64 | } | |
| 54 | 65 | ||
| 55 | 66 | impl Default for Settings { | |
| @@ -65,6 +76,11 @@ impl Default for Settings { | |||
| 65 | 76 | theme_fade: THEME_FADE, | |
| 66 | 77 | ui_scale: 1.0, | |
| 67 | 78 | 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, | |
| 68 | 84 | } | |
| 69 | 85 | } | |
| 70 | 86 | } | |
| @@ -87,6 +103,11 @@ impl Settings { | |||
| 87 | 103 | theme_fade: anim(THEME_FADE), | |
| 88 | 104 | ui_scale: c.ui.scale, | |
| 89 | 105 | 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, | |
| 90 | 111 | } | |
| 91 | 112 | } | |
| 92 | 113 | } | |
| @@ -140,6 +161,14 @@ pub struct Fade { | |||
| 140 | 161 | pub alpha: Tween<f32>, | |
| 141 | 162 | } | |
| 142 | 163 | ||
| 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 | + | ||
| 143 | 172 | #[derive(Clone, Copy, Debug)] | |
| 144 | 173 | struct Hold { | |
| 145 | 174 | dir: Dir, | |
| @@ -157,6 +186,8 @@ pub struct Effects { | |||
| 157 | 186 | pub reordered: bool, | |
| 158 | 187 | /// The status bar appeared or vanished, so the image area changed. | |
| 159 | 188 | pub layout: bool, | |
| 189 | + | /// Image ⇄ grid (or visual) switched. | |
| 190 | + | pub mode: bool, | |
| 160 | 191 | pub fullscreen: Option<Switch>, | |
| 161 | 192 | pub reload: bool, | |
| 162 | 193 | pub reload_config: bool, | |
| @@ -171,6 +202,10 @@ pub struct AppState { | |||
| 171 | 202 | pub view: View, | |
| 172 | 203 | pub settings: Settings, | |
| 173 | 204 | 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>, | |
| 174 | 209 | pub theme: Theme, | |
| 175 | 210 | /// The previous theme fading out after a reload. | |
| 176 | 211 | theme_fade: Option<(Theme, Tween<f32>)>, | |
| @@ -196,6 +231,9 @@ impl AppState { | |||
| 196 | 231 | Self { | |
| 197 | 232 | queue, | |
| 198 | 233 | view: View::new(now), | |
| 234 | + | grid: Grid::new(settings.grid_size, now), | |
| 235 | + | thumbs: HashMap::new(), | |
| 236 | + | last_thumb: None, | |
| 199 | 237 | settings, | |
| 200 | 238 | mode: Mode::Image, | |
| 201 | 239 | theme: Theme::nothing_dark(), | |
| @@ -221,6 +259,9 @@ impl AppState { | |||
| 221 | 259 | self.window = [w, h]; | |
| 222 | 260 | self.scale_factor = scale_factor; | |
| 223 | 261 | self.update_viewport(); | |
| 262 | + | if self.in_grid() { | |
| 263 | + | self.grid_follow(Instant::now(), false); | |
| 264 | + | } | |
| 224 | 265 | } | |
| 225 | 266 | ||
| 226 | 267 | pub fn window(&self) -> [f32; 2] { | |
| @@ -245,6 +286,121 @@ impl AppState { | |||
| 245 | 286 | } | |
| 246 | 287 | } | |
| 247 | 288 | ||
| 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 | + | ||
| 248 | 404 | fn update_viewport(&mut self) { | |
| 249 | 405 | let h = (self.window[1] - self.bar_height()).max(1.0); | |
| 250 | 406 | self.view.set_viewport(self.window[0], h); | |
| @@ -295,7 +451,9 @@ impl AppState { | |||
| 295 | 451 | ..Effects::default() | |
| 296 | 452 | }; | |
| 297 | 453 | 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; | |
| 299 | 457 | let switch = |s: Switch, cur: bool| match s { | |
| 300 | 458 | Switch::Toggle => !cur, | |
| 301 | 459 | Switch::On => true, | |
| @@ -327,6 +485,100 @@ impl AppState { | |||
| 327 | 485 | Zoom::Percent(p) => self.view.zoom_to(p / 100.0, anchor, now, dur), | |
| 328 | 486 | } | |
| 329 | 487 | } | |
| 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 | + | } | |
| 330 | 582 | Action::Fit(fit) if has_image => self.view.set_fit(fit, now, dur), | |
| 331 | 583 | Action::Pan(dir) if has_image => self.view.pan_step(dir, 0.2, now, dur), | |
| 332 | 584 | Action::PanEdge(dir) if has_image => self.view.pan_edge(dir, now, dur), | |
| @@ -352,7 +604,6 @@ impl AppState { | |||
| 352 | 604 | fx.layout = true; | |
| 353 | 605 | } | |
| 354 | 606 | } | |
| 355 | − | Action::Mode(ModeArg::Image) if self.mode == Mode::Image => fx.redraw = false, | |
| 356 | 607 | Action::Sort(key) => fx.sort = Some(key), | |
| 357 | 608 | Action::Reverse => { | |
| 358 | 609 | self.queue.reverse(); | |
| @@ -375,27 +626,23 @@ impl AppState { | |||
| 375 | 626 | | Action::PanEdge(_) | |
| 376 | 627 | | Action::Center | |
| 377 | 628 | | 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, | |
| 379 | 635 | // Parsed and bindable now; they arrive with later milestones. | |
| 380 | − | other @ (Action::NextMark | |
| 381 | − | | Action::PrevMark | |
| 382 | − | | Action::NextFrame | |
| 636 | + | other @ (Action::NextFrame | |
| 383 | 637 | | Action::PrevFrame | |
| 384 | − | | Action::Mark(_) | |
| 385 | − | | Action::MarkAll | |
| 386 | − | | Action::UnmarkAll | |
| 387 | − | | Action::InvertMarks | |
| 388 | 638 | | Action::Adjust(..) | |
| 389 | 639 | | Action::Play(_) | |
| 390 | 640 | | Action::Slideshow(_) | |
| 391 | 641 | | Action::SlideshowDelay { .. } | |
| 392 | − | | Action::Mode(_) | |
| 393 | − | | Action::Visual | |
| 394 | 642 | | Action::Info(_) | |
| 395 | 643 | | Action::Help | |
| 396 | 644 | | Action::Palette | |
| 397 | 645 | | Action::Search | |
| 398 | − | | Action::GridSize(_) | |
| 399 | 646 | | Action::Filter(_) | |
| 400 | 647 | | Action::FilterClear | |
| 401 | 648 | | Action::Refresh | |
| @@ -408,6 +655,9 @@ impl AppState { | |||
| 408 | 655 | ); | |
| 409 | 656 | } | |
| 410 | 657 | } | |
| 658 | + | if fx.navigated && self.in_grid() { | |
| 659 | + | self.grid_follow(now, true); | |
| 660 | + | } | |
| 411 | 661 | fx | |
| 412 | 662 | } | |
| 413 | 663 | ||
| @@ -538,7 +788,12 @@ impl AppState { | |||
| 538 | 788 | ||
| 539 | 789 | /// Whether frames must keep coming (animations, held keys). | |
| 540 | 790 | 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) | |
| 542 | 797 | || self.fade.as_ref().is_some_and(|f| f.alpha.is_active(now)) | |
| 543 | 798 | || self | |
| 544 | 799 | .theme_fade | |
| @@ -670,6 +925,76 @@ mod tests { | |||
| 670 | 925 | assert_eq!(s.queue.index(), 1); | |
| 671 | 926 | } | |
| 672 | 927 | ||
| 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 | + | ||
| 673 | 998 | #[test] | |
| 674 | 999 | fn tapped_pan_moves_a_minimum_distance() { | |
| 675 | 1000 | let (mut s, now) = state(1); | |
| @@ -29,6 +29,8 @@ pub enum SortKey { | |||
| 29 | 29 | pub struct Queue { | |
| 30 | 30 | files: Vec<PathBuf>, | |
| 31 | 31 | index: usize, | |
| 32 | + | /// Marked files, by path so reordering keeps them. | |
| 33 | + | marks: HashSet<PathBuf>, | |
| 32 | 34 | } | |
| 33 | 35 | ||
| 34 | 36 | impl Queue { | |
| @@ -37,7 +39,11 @@ impl Queue { | |||
| 37 | 39 | let index = start | |
| 38 | 40 | .and_then(|s| files.iter().position(|f| f == s)) | |
| 39 | 41 | .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 | + | }; | |
| 41 | 47 | q.index = q.clamp(index); | |
| 42 | 48 | q | |
| 43 | 49 | } | |
| @@ -90,7 +96,8 @@ impl Queue { | |||
| 90 | 96 | let Some(i) = self.files.iter().position(|f| f == path) else { | |
| 91 | 97 | return false; | |
| 92 | 98 | }; | |
| 93 | − | self.files.remove(i); | |
| 99 | + | let removed = self.files.remove(i); | |
| 100 | + | self.marks.remove(&removed); | |
| 94 | 101 | if i < self.index { | |
| 95 | 102 | self.index -= 1; | |
| 96 | 103 | } | |
| @@ -171,6 +178,85 @@ impl Queue { | |||
| 171 | 178 | } | |
| 172 | 179 | } | |
| 173 | 180 | ||
| 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 | + | ||
| 174 | 260 | fn clamp(&self, i: usize) -> usize { | |
| 175 | 261 | i.min(self.files.len().saturating_sub(1)) | |
| 176 | 262 | } | |
| @@ -351,6 +437,45 @@ mod tests { | |||
| 351 | 437 | assert!(empty.is_empty()); | |
| 352 | 438 | } | |
| 353 | 439 | ||
| 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 | + | ||
| 354 | 479 | #[test] | |
| 355 | 480 | fn random_sort_is_a_permutation() { | |
| 356 | 481 | let mut v = paths(&["a", "b", "c", "d", "e"]); | |
| @@ -1 +1,611 @@ | |||
| 1 | 1 | //! 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 | + | } | |
| @@ -6,11 +6,12 @@ use std::path::Path; | |||
| 6 | 6 | use std::time::Instant; | |
| 7 | 7 | ||
| 8 | 8 | use crate::config::theme::{Rgba, Theme}; | |
| 9 | + | use crate::render::atlas::ThumbDraw; | |
| 9 | 10 | use crate::render::image::AlphaStyle; | |
| 10 | 11 | use crate::render::prims::Prim; | |
| 11 | 12 | use crate::render::text::{Align, FontRole, TextItem}; | |
| 12 | 13 | use crate::render::{ImageDraw, Scene}; | |
| 13 | − | use crate::state::{AlphaBg, AppState, MsgKind}; | |
| 14 | + | use crate::state::{AlphaBg, AppState, GRID_NAME_SIZE, Mode, MsgKind, ThumbState}; | |
| 14 | 15 | ||
| 15 | 16 | /// Space Mono label tracking. | |
| 16 | 17 | 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 | |||
| 33 | 34 | y, | |
| 34 | 35 | align, | |
| 35 | 36 | max_width: None, | |
| 37 | + | clip: None, | |
| 36 | 38 | } | |
| 37 | 39 | } | |
| 38 | 40 | ||
| @@ -54,10 +56,28 @@ pub fn scene<'a>( | |||
| 54 | 56 | image_clip: [0.0, 0.0, w, h - bar_h], | |
| 55 | 57 | alpha: alpha_style(state.alpha_bg, theme, sf), | |
| 56 | 58 | images: Vec::new(), | |
| 59 | + | thumbs: Vec::new(), | |
| 60 | + | under: Vec::new(), | |
| 57 | 61 | prims: Vec::new(), | |
| 58 | 62 | texts: Vec::new(), | |
| 59 | 63 | }; | |
| 60 | 64 | ||
| 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) { | |
| 61 | 81 | let nearest = state.effective_nearest(now); | |
| 62 | 82 | if let Some(fade) = &state.fade { | |
| 63 | 83 | scene.images.push(ImageDraw { | |
| @@ -77,14 +97,102 @@ pub fn scene<'a>( | |||
| 77 | 97 | nearest, | |
| 78 | 98 | }); | |
| 79 | 99 | } | |
| 100 | + | } | |
| 80 | 101 | ||
| 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 | + | } | |
| 86 | 195 | } | |
| 87 | − | scene | |
| 88 | 196 | } | |
| 89 | 197 | ||
| 90 | 198 | 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]) { | |||
| 117 | 225 | y: cy - 12.0 * sf, | |
| 118 | 226 | align: Align::Center, | |
| 119 | 227 | max_width: None, | |
| 228 | + | clip: None, | |
| 120 | 229 | }); | |
| 121 | 230 | let hint = "remage <file|dir>…"; | |
| 122 | 231 | scene.texts.push(label( | |
| @@ -169,11 +278,14 @@ fn status_bar( | |||
| 169 | 278 | if state.skipped > 0 { | |
| 170 | 279 | spans.push((format!("[SKIPPED {}]", state.skipped), theme.text_secondary)); | |
| 171 | 280 | } | |
| 281 | + | if let Some(r) = state.visual_range() { | |
| 282 | + | spans.push((format!("[VISUAL {}]", r.len()), theme.text_secondary)); | |
| 283 | + | } | |
| 172 | 284 | let mut info = Vec::new(); | |
| 173 | 285 | if let Some(s) = state | |
| 174 | 286 | .shown | |
| 175 | 287 | .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()) | |
| 177 | 289 | { | |
| 178 | 290 | info.push(s.format.to_string()); | |
| 179 | 291 | info.push(format!("{}×{}", s.size[0], s.size[1])); | |
| @@ -201,6 +313,37 @@ fn status_bar( | |||
| 201 | 313 | scene.texts.push(item); | |
| 202 | 314 | } | |
| 203 | 315 | ||
| 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 | + | ||
| 204 | 347 | // The filename takes whatever the right side leaves, ellipsised in the middle. | |
| 205 | 348 | if let Some(cur) = state.queue.current() { | |
| 206 | 349 | let room = (w - 2.0 * pad - right_w - 32.0 * sf).max(0.0); | |
| @@ -214,6 +357,7 @@ fn status_bar( | |||
| 214 | 357 | y, | |
| 215 | 358 | align: Align::Left, | |
| 216 | 359 | max_width: Some(room), | |
| 360 | + | clip: None, | |
| 217 | 361 | }); | |
| 218 | 362 | } | |
| 219 | 363 | } | |
| @@ -17,10 +17,8 @@ pub enum Priority { | |||
| 17 | 17 | /// Neighbours of the current image. | |
| 18 | 18 | Preload = 1, | |
| 19 | 19 | /// Thumbnails in view. | |
| 20 | − | #[allow(dead_code)] // thumbnails arrive in M3 | |
| 21 | 20 | ThumbVisible = 2, | |
| 22 | 21 | /// Other thumbnails. | |
| 23 | − | #[allow(dead_code)] | |
| 24 | 22 | Thumb = 3, | |
| 25 | 23 | } | |
| 26 | 24 | ||
| @@ -33,6 +31,7 @@ impl Priority { | |||
| 33 | 31 | #[derive(Clone, Debug, PartialEq, Eq, Hash)] | |
| 34 | 32 | pub enum JobKey { | |
| 35 | 33 | Decode(PathBuf), | |
| 34 | + | Thumb(PathBuf), | |
| 36 | 35 | } | |
| 37 | 36 | ||
| 38 | 37 | type Work = Box<dyn FnOnce() + Send>; | |
| @@ -161,6 +160,11 @@ impl Pool { | |||
| 161 | 160 | self.shared.cv.notify_one(); | |
| 162 | 161 | } | |
| 163 | 162 | ||
| 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 | + | ||
| 164 | 168 | /// Invalidate every queued current/preload job. | |
| 165 | 169 | pub fn bump_epoch(&self) { | |
| 166 | 170 | self.shared.lock().epoch += 1; | |
| @@ -253,4 +257,24 @@ mod tests { | |||
| 253 | 257 | assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), "new"); | |
| 254 | 258 | assert!(rx.recv_timeout(Duration::from_millis(100)).is_err()); | |
| 255 | 259 | } | |
| 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 | + | } | |
| 256 | 280 | } | |