Krolyxon
Commit c498905

Branches

Tags

Refs →

remagesrcapp.rs

1166 lines · 42.3 KB · Rust

1//! winit `ApplicationHandler`: routes events, owns the state and the renderer.
2
3use std::collections::{HashMap, HashSet, VecDeque};
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicU32, Ordering};
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, Instant};
8
9use winit::application::ApplicationHandler;
10use winit::dpi::LogicalSize;
11use winit::event::{ElementState, MouseButton, MouseScrollDelta, StartCause, WindowEvent};
12use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy};
13use winit::keyboard::{ModifiersState, PhysicalKey};
14use winit::window::{Fullscreen, Window, WindowId};
15
16use crate::action::{Action, ModeArg, Switch, Zoom};
17use crate::cache::ImageCache;
18use crate::config::watch::ConfigWatch;
19use crate::config::{Config, LoadError, Loaded};
20use crate::decode::{self, DecodedImage};
21use crate::input::keymap::{KeyTables, Keymap};
22use crate::input::keys::{Key, Scroll};
23use crate::input::{self};
24use crate::render::{Renderer, text};
25use crate::scan;
26use crate::state::view::{Dir, FitMode};
27use crate::state::{AppState, Effects, Mode, MsgKind, Settings};
28use crate::thumbs::{self, CacheOptions, Thumb, ThumbError};
29use crate::ui;
30use crate::worker::{JobKey, Pool, Priority};
31
32/// GPU upload budget per event-loop turn, so preloads never cause a hitch.
33const UPLOAD_BUDGET: usize = 32 << 20;
34/// Show the window once the first image is ready, or after this long regardless.
35const SHOW_TIMEOUT: Duration = Duration::from_millis(300);
36/// Wheel pixels per zoom step for touchpads.
37const PIXELS_PER_STEP: f32 = 60.0;
38/// An ambiguous key sequence (`g` bound, and `gg` too) resolves after this long, like vim's
39/// `timeoutlen`.
40const SEQUENCE_TIMEOUT: Duration = Duration::from_millis(1000);
41/// A press and release closer than this is a click, not a drag.
42const CLICK_SLOP: f32 = 4.0;
43/// Config errors stay on screen until fixed (or dismissed with `<Esc>`).
44const 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.
47const GRID_REST: Duration = Duration::from_millis(250);
48/// Two clicks on the same thumbnail this close together open it.
49const DOUBLE_CLICK: Duration = Duration::from_millis(400);
50
51pub enum UserEvent {
52 Decoded {
53 path: PathBuf,
54 result: Result<Arc<DecodedImage>, String>,
55 },
56 /// Unknown-extension files that turned out to be images.
57 Found(Vec<PathBuf>),
58 /// Embedded plus system fonts, loaded off-thread after the first frame.
59 Fonts(glyphon::fontdb::Database),
60 /// The config was reloaded (file change, SIGUSR1 or `reload-config`).
61 Config(Box<Result<Loaded, LoadError>>),
62 /// A re-sort finished: the queue in its new order.
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 },
70}
71
72/// Command-line settings that beat the config, across reloads too.
73#[derive(Clone, Debug, Default)]
74pub struct Overrides {
75 pub fit: Option<FitMode>,
76 pub zoom: Option<f32>,
77 pub bar: Option<bool>,
78}
79
80impl Overrides {
81 pub fn apply(&self, s: &mut Settings) {
82 if let Some(fit) = self.fit {
83 s.fit = fit;
84 }
85 if self.zoom.is_some() {
86 s.zoom = self.zoom;
87 }
88 }
89}
90
91pub struct Options {
92 pub fullscreen: bool,
93 pub geometry: Option<(u32, u32)>,
94 pub class: String,
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)]
102struct 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)>,
110}
111
112struct Gfx {
113 renderer: Renderer,
114 // Dropped after the renderer, whose surface borrows the window.
115 window: Arc<Window>,
116}
117
118pub struct App {
119 opts: Options,
120 state: AppState,
121 config: Config,
122 keymap: Keymap,
123 watch: ConfigWatch,
124 /// A config error is on the status line.
125 config_error: bool,
126 /// `reload-config` ran, so the next reload is confirmed on screen.
127 reload_requested: bool,
128 /// When the pending key sequence became ambiguous.
129 ambiguous_since: Option<Instant>,
130 /// Actions a held key repeats.
131 repeat: Option<(PhysicalKey, Vec<Action>)>,
132 pool: Pool,
133 proxy: EventLoopProxy<UserEvent>,
134 cache: ImageCache,
135 max_dim: Arc<AtomicU32>,
136 /// Decoded images whose result event is still in the loop's queue.
137 arriving: Arc<Mutex<HashSet<PathBuf>>>,
138 gfx: Option<Gfx>,
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)>,
146 modifiers: ModifiersState,
147 cursor: [f32; 2],
148 dragging: bool,
149 /// Where the left button went down, while it is a click candidate.
150 press: Option<[f32; 2]>,
151 /// Wheel motion not yet turned into whole `<ScrollUp>`/`<ScrollDown>` presses.
152 scroll: [f32; 2],
153 held: HashMap<PhysicalKey, Dir>,
154 started: Instant,
155 shown_window: bool,
156 pub error: Option<anyhow::Error>,
157}
158
159impl App {
160 pub fn new(
161 opts: Options,
162 state: AppState,
163 proxy: EventLoopProxy<UserEvent>,
164 loaded: Loaded,
165 watch: ConfigWatch,
166 ) -> Self {
167 let cache = ImageCache::new(loaded.config.image.cache_mb << 20);
168 let thumb_cache = CacheOptions::new(loaded.config.grid.thumbnail_cache, opts.private);
169 let mut app = Self {
170 opts,
171 state,
172 keymap: keymap(&loaded.keys),
173 config: loaded.config,
174 watch,
175 config_error: false,
176 reload_requested: false,
177 ambiguous_since: None,
178 repeat: None,
179 pool: Pool::new(),
180 proxy,
181 cache,
182 // Until the GPU reports its limit; uploads re-check.
183 max_dim: Arc::new(AtomicU32::new(16384)),
184 arriving: Arc::default(),
185 gfx: None,
186 uploads: VecDeque::new(),
187 thumbs: ThumbJobs::default(),
188 thumb_cache,
189 grid_rest: None,
190 last_click: None,
191 modifiers: ModifiersState::empty(),
192 cursor: [0.0, 0.0],
193 dragging: false,
194 press: None,
195 scroll: [0.0, 0.0],
196 held: HashMap::new(),
197 started: Instant::now(),
198 shown_window: false,
199 error: None,
200 };
201 // Decode the first image while the window and GPU come up.
202 app.sync(true);
203 app
204 }
205
206 fn request_redraw(&self) {
207 if let Some(g) = &self.gfx {
208 g.window.request_redraw();
209 }
210 }
211
212 fn decode(&self, path: &Path, prio: Priority) {
213 if lock(&self.arriving).contains(path) {
214 return;
215 }
216 let proxy = self.proxy.clone();
217 let max_dim = self.max_dim.clone();
218 let arriving = self.arriving.clone();
219 let path = path.to_path_buf();
220 self.pool
221 .submit(JobKey::Decode(path.clone()), prio, move || {
222 let t = Instant::now();
223 let result = decode::decode(&path, max_dim.load(Ordering::Relaxed))
224 .map(Arc::new)
225 .map_err(|e| e.to_string());
226 log::debug!("decoded {} in {:.1?}", path.display(), t.elapsed());
227 lock(&arriving).insert(path.clone());
228 let _ = proxy.send_event(UserEvent::Decoded { path, result });
229 });
230 }
231
232 /// The current file and its preload neighbours, current first.
233 fn window_paths(&self) -> Vec<PathBuf> {
234 let q = &self.state.queue;
235 let Some(cur) = q.current() else {
236 return Vec::new();
237 };
238 let mut out = vec![cur.to_path_buf()];
239 for i in q.neighbours(self.state.settings.preload, self.state.settings.wrap) {
240 if let Some(p) = q.get(i) {
241 out.push(p.to_path_buf());
242 }
243 }
244 out
245 }
246
247 /// Make the GPU and decode queue match the current position.
248 fn sync(&mut self, navigated: bool) {
249 let now = Instant::now();
250 if navigated {
251 self.pool.bump_epoch();
252 }
253 let wanted = self.window_paths();
254 self.update_title();
255 let Some(cur) = wanted.first().cloned() else {
256 self.state.clear_shown();
257 self.uploads.clear();
258 if let Some(g) = &mut self.gfx {
259 g.renderer.retain(|_| false);
260 }
261 self.request_redraw();
262 return;
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 }
272
273 let keep = |p: &Path| {
274 wanted.iter().any(|w| w == p)
275 || self.state.shown.as_ref().is_some_and(|s| s.path == p)
276 || self.state.fade.as_ref().is_some_and(|f| f.path == p)
277 };
278 if let Some(g) = &mut self.gfx {
279 g.renderer.retain(keep);
280 }
281 self.uploads.retain(|(p, _)| wanted.contains(p));
282
283 let showing_cur = self.state.shown.as_ref().is_some_and(|s| s.path == cur);
284 if !showing_cur {
285 match self.cache.get(&cur) {
286 Some(img) => self.show(&cur, img, now),
287 None => {
288 self.state.loading_since.get_or_insert(now);
289 self.decode(&cur, Priority::Current);
290 }
291 }
292 }
293 for p in &wanted[1..] {
294 match self.cache.get(p) {
295 Some(img) => {
296 let ready = self.gfx.as_ref().is_some_and(|g| g.renderer.is_ready(p));
297 if !ready && !self.uploads.iter().any(|(u, _)| u == p) {
298 self.uploads.push_back((p.clone(), img));
299 }
300 }
301 None => self.decode(p, Priority::Preload),
302 }
303 }
304 self.request_redraw();
305 }
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
418 /// Upload `img` in full and put it on screen.
419 fn show(&mut self, path: &Path, img: Arc<DecodedImage>, now: Instant) {
420 let Some(g) = &mut self.gfx else {
421 // No GPU yet: keep it cached; `resumed` syncs again.
422 self.state.loading_since.get_or_insert(now);
423 return;
424 };
425 let img = fit_gpu(img, g.renderer.max_texture_dim());
426 g.renderer.upload(path, &img, usize::MAX);
427 self.uploads.retain(|(p, _)| p != path);
428 self.state.show(path, img.source_size, img.format, now);
429 self.request_redraw();
430 }
431
432 fn update_title(&self) {
433 if let Some(g) = &self.gfx {
434 let q = &self.state.queue;
435 let title = match q.current() {
436 Some(p) => self
437 .config
438 .window
439 .title
440 .replace("{name}", &ui::file_name(p))
441 .replace("{index}", &(q.index() + 1).to_string())
442 .replace("{count}", &q.len().to_string()),
443 None => "remage".into(),
444 };
445 g.window.set_title(&title);
446 }
447 }
448
449 /// Show a config error until the config is fixed.
450 pub fn config_failed(&mut self, message: &str) {
451 self.state.notify_for(
452 format!("config error: {}", short_error(message)),
453 MsgKind::Error,
454 Instant::now(),
455 CONFIG_ERROR_TIME,
456 );
457 self.config_error = true;
458 self.request_redraw();
459 }
460
461 /// Adopt a reloaded config. Runtime toggles (bar, filter, alpha background) only change
462 /// when their config value did, so a reload doesn't undo what the user just toggled.
463 fn apply_config(&mut self, loaded: Loaded) {
464 let now = Instant::now();
465 for w in &loaded.warnings {
466 log::warn!("{w}");
467 }
468 let old = std::mem::replace(&mut self.config, loaded.config);
469 let new = &self.config;
470 let mut settings = Settings::from_config(new);
471 self.opts.overrides.apply(&mut settings);
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 }
479 self.state.set_settings(settings);
480 self.state.set_theme(loaded.theme, now);
481 self.keymap = keymap(&loaded.keys);
482 self.ambiguous_since = None;
483 self.state.pending.clear();
484 self.cache.set_budget(new.image.cache_mb << 20);
485 if old.image.filter != new.image.filter {
486 self.state.nearest = new.image.filter.nearest();
487 }
488 if old.image.alpha_background != new.image.alpha_background {
489 self.state.alpha_bg = new.image.alpha_background;
490 }
491 if old.ui.statusbar != new.ui.statusbar && self.opts.overrides.bar.is_none() {
492 let s = if new.ui.statusbar {
493 Switch::On
494 } else {
495 Switch::Off
496 };
497 self.state.apply(Action::Bar(s), now);
498 }
499 if let Some(g) = &mut self.gfx {
500 if old.window.decorations != new.window.decorations {
501 g.window.set_decorations(new.window.decorations);
502 }
503 g.renderer.set_font_families(font_families(new));
504 }
505 self.update_title();
506 // Silent on routine changes (a new wallpaper palette); confirm fixes and manual reloads.
507 if std::mem::take(&mut self.config_error) || std::mem::take(&mut self.reload_requested) {
508 self.state.notify("config reloaded", MsgKind::Info, now);
509 }
510 if preload_changed {
511 self.sync(false);
512 }
513 self.sync_thumbs();
514 self.request_redraw();
515 }
516
517 fn on_decoded(&mut self, path: PathBuf, result: Result<Arc<DecodedImage>, String>) {
518 lock(&self.arriving).remove(&path);
519 let now = Instant::now();
520 let is_current = self.state.queue.current() == Some(path.as_path());
521 let in_grid = self.state.in_grid();
522 match result {
523 Ok(img) => {
524 let wanted = self.window_paths();
525 let keep: Vec<&Path> = wanted.iter().map(PathBuf::as_path).collect();
526 self.cache.insert(path.clone(), img.clone(), &keep);
527 if in_grid {
528 // Cached for when the grid opens it.
529 } else if is_current {
530 // Already on screen means this was a reload: replace the texture.
531 let reload = self.state.shown.as_ref().is_some_and(|s| s.path == path);
532 if reload && let Some(g) = &mut self.gfx {
533 g.renderer.retain(|p| p != path);
534 }
535 self.show(&path, img, now);
536 } else if wanted.contains(&path) && !self.uploads.iter().any(|(p, _)| *p == path) {
537 self.uploads.push_back((path, img));
538 }
539 }
540 Err(e) => {
541 log::warn!("{}: {e}", path.display());
542 self.state.queue.remove(&path);
543 self.state.skipped += 1;
544 if is_current {
545 self.state.loading_since = None;
546 let msg = format!("error: {}: {e}", ui::file_name(&path));
547 self.state.notify(msg, MsgKind::Error, now);
548 self.sync(true);
549 }
550 self.request_redraw();
551 }
552 }
553 }
554
555 fn reload(&mut self) {
556 if let Some(cur) = self.state.queue.current().map(Path::to_path_buf) {
557 self.cache.remove(&cur);
558 self.decode(&cur, Priority::Current);
559 }
560 }
561
562 /// Run actions in order; `anchor` is the cursor for mouse-triggered ones.
563 fn run(&mut self, actions: Vec<Action>, anchor: Option<[f32; 2]>, el: &ActiveEventLoop) {
564 for action in actions {
565 if action == Action::Cancel {
566 self.keymap.cancel();
567 self.state.pending.clear();
568 self.config_error = false;
569 }
570 let fx = self.state.apply_at(action, anchor, Instant::now());
571 self.effects(fx, el);
572 if fx.quit {
573 return;
574 }
575 }
576 }
577
578 fn effects(&mut self, fx: Effects, event_loop: &ActiveEventLoop) {
579 if fx.quit {
580 event_loop.exit();
581 return;
582 }
583 if let Some(s) = fx.fullscreen
584 && let Some(g) = &self.gfx
585 {
586 let on = g.window.fullscreen().is_some();
587 let want = match s {
588 Switch::Toggle => !on,
589 Switch::On => true,
590 Switch::Off => false,
591 };
592 if want != on {
593 g.window
594 .set_fullscreen(want.then_some(Fullscreen::Borderless(None)));
595 }
596 }
597 if fx.reload {
598 self.reload();
599 }
600 if fx.reload_config {
601 self.reload_requested = true;
602 self.watch.reload();
603 }
604 if let Some(key) = fx.sort {
605 let mut files = self.state.queue.files().to_vec();
606 let proxy = self.proxy.clone();
607 std::thread::spawn(move || {
608 scan::sort_paths(&mut files, key, false);
609 let _ = proxy.send_event(UserEvent::Sorted(files));
610 });
611 }
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 {
616 self.sync(true);
617 } else if fx.reordered {
618 self.sync(false);
619 }
620 // Scrolling, a new size or the bar can bring new thumbnails into view.
621 self.sync_thumbs();
622 if fx.redraw || fx.layout {
623 self.request_redraw();
624 }
625 }
626
627 /// Mirror the keymap's pending keys on the status line and arm the ambiguity timeout.
628 fn update_pending(&mut self, now: Instant) {
629 let pending = self.keymap.pending_display();
630 if pending != self.state.pending {
631 self.state.pending = pending;
632 self.request_redraw();
633 }
634 self.ambiguous_since = self
635 .keymap
636 .is_ambiguous(self.state.mode)
637 .then(|| self.ambiguous_since.unwrap_or(now));
638 }
639
640 fn on_key(&mut self, event: winit::event::KeyEvent, event_loop: &ActiveEventLoop) {
641 let now = Instant::now();
642 if event.state == ElementState::Released {
643 if let Some(dir) = self.held.remove(&event.physical_key) {
644 self.state.hold_pan(dir, false, now);
645 self.request_redraw();
646 }
647 if self
648 .repeat
649 .as_ref()
650 .is_some_and(|(k, _)| *k == event.physical_key)
651 {
652 self.repeat = None;
653 }
654 return;
655 }
656 if event.repeat {
657 // Repeats bypass the trie: only a completed, repeatable binding repeats.
658 if let Some((k, actions)) = &self.repeat
659 && *k == event.physical_key
660 {
661 let actions = actions.clone();
662 self.run(actions, None, event_loop);
663 }
664 return;
665 }
666 let Some(key) = input::key(&event.logical_key, self.modifiers) else {
667 return;
668 };
669 let runs = self.keymap.feed(self.state.mode, key);
670 self.update_pending(now);
671 self.repeat = None;
672 for run in runs {
673 let actions = run.counted();
674 if let [Action::Pan(dir)] = actions[..]
675 && self.state.mode == Mode::Image
676 {
677 if self.state.shown.is_some() {
678 self.held.insert(event.physical_key, dir);
679 self.state.hold_pan(dir, true, now);
680 self.request_redraw();
681 }
682 continue;
683 }
684 if !actions.is_empty() && actions.iter().all(Action::repeats) {
685 self.repeat = Some((event.physical_key, actions.clone()));
686 }
687 self.run(actions, None, event_loop);
688 }
689 }
690
691 /// A mouse button or wheel notch: bound actions run at the cursor, bypassing sequences.
692 fn on_mouse_key(&mut self, key: Key, event_loop: &ActiveEventLoop) {
693 self.keymap.cancel();
694 self.update_pending(Instant::now());
695 if let Some(actions) = self.keymap.lookup(self.state.mode, &[key]) {
696 let actions = actions.to_vec();
697 self.run(actions, Some(self.cursor), event_loop);
698 }
699 }
700
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) {
703 let now = Instant::now();
704 let [dx, dy] = delta;
705 if dy != 0.0 {
706 let dir = if dy > 0.0 { Scroll::Up } else { Scroll::Down };
707 let key = input::scroll(dir, self.modifiers);
708 // Wheel zoom and grid scrolling stay continuous for touchpads rather than stepping
709 // per notch.
710 let in_grid = self.state.in_grid();
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 }
723 Some([Action::Zoom(Zoom::In(n))]) => {
724 let steps = dy.abs() * *n as f32;
725 self.state.zoom_at(steps, self.cursor, now);
726 self.request_redraw();
727 return;
728 }
729 Some([Action::Zoom(Zoom::Out(n))]) => {
730 let steps = -dy.abs() * *n as f32;
731 self.state.zoom_at(steps, self.cursor, now);
732 self.request_redraw();
733 return;
734 }
735 _ => {}
736 }
737 }
738 for (axis, d, pos, neg) in [
739 (0, dx, Scroll::Left, Scroll::Right),
740 (1, dy, Scroll::Up, Scroll::Down),
741 ] {
742 if d == 0.0 {
743 continue;
744 }
745 // Reversing direction drops the leftover fraction.
746 if self.scroll[axis] * d < 0.0 {
747 self.scroll[axis] = 0.0;
748 }
749 self.scroll[axis] += d;
750 while self.scroll[axis].abs() >= 1.0 {
751 let up = self.scroll[axis] > 0.0;
752 self.scroll[axis] -= self.scroll[axis].signum();
753 let key = input::scroll(if up { pos } else { neg }, self.modifiers);
754 self.on_mouse_key(key, event_loop);
755 }
756 }
757 }
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
786 fn redraw(&mut self) {
787 let now = Instant::now();
788 let ready = self.state.shown.is_some()
789 || self.state.queue.is_empty()
790 || (self.state.in_grid() && self.grid_settled())
791 || now >= self.started + SHOW_TIMEOUT;
792 if !self.shown_window && !ready {
793 return; // about_to_wait wakes us at the timeout
794 }
795 self.state.advance(now);
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 };
802 let Some(g) = &mut self.gfx else { return };
803 if !self.shown_window {
804 g.window.set_visible(true);
805 }
806 let theme = self.state.theme_at(now);
807 let scene = ui::scene(&self.state, &theme, now, &mut |t| g.renderer.measure(t));
808 let result = g.renderer.render(&scene);
809 if let Some(path) = std::env::var_os("REMAGE_SNAPSHOT")
810 && !self.state.is_animating(now)
811 && settled
812 {
813 match g.renderer.snapshot(&scene, Path::new(&path)) {
814 Ok(()) => log::info!("snapshot written to {}", Path::new(&path).display()),
815 Err(e) => log::error!("snapshot: {e:#}"),
816 }
817 }
818 drop(scene);
819 if let Err(e) = result {
820 log::error!("render: {e:#}");
821 self.state
822 .notify(format!("error: {e}"), MsgKind::Error, now);
823 }
824 if !self.shown_window {
825 self.shown_window = true;
826 log::debug!("first frame after {:.1?}", self.started.elapsed());
827 let proxy = self.proxy.clone();
828 std::thread::spawn(move || {
829 let _ = proxy.send_event(UserEvent::Fonts(text::all_fonts()));
830 });
831 }
832 if self.state.is_animating(Instant::now()) {
833 g.window.request_redraw();
834 }
835 }
836
837 /// Upload thumbnails, then preloaded images a slice at a time. Returns whether work remains.
838 fn pump_uploads(&mut self) -> bool {
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 }
858 if let Some((path, img)) = self.uploads.front() {
859 let img = fit_gpu(img.clone(), g.renderer.max_texture_dim());
860 if g.renderer.upload(path, &img, UPLOAD_BUDGET) {
861 self.uploads.pop_front();
862 }
863 }
864 !self.uploads.is_empty()
865 }
866}
867
868/// A config error trimmed for the status line: `config.toml:2:1: unknown field `fitt`; did you
869/// mean `fit`?` without the directory or the list of every valid field.
870fn short_error(message: &str) -> String {
871 let mut s = match message.split_once(": ") {
872 Some((loc, rest)) if loc.contains('/') => {
873 format!("{}: {rest}", loc.rsplit('/').next().unwrap_or(loc))
874 }
875 _ => message.to_string(),
876 };
877 if let Some(start) = s.find(", expected one of") {
878 let end = s[start..]
879 .find("; did you mean")
880 .map_or(s.len(), |i| start + i);
881 s.replace_range(start..end, "");
882 }
883 s
884}
885
886fn keymap(layers: &[KeyTables]) -> Keymap {
887 let refs: Vec<&KeyTables> = layers.iter().collect();
888 Keymap::new(&refs)
889}
890
891fn font_families(c: &Config) -> [String; 3] {
892 [
893 c.ui.font_ui.clone(),
894 c.ui.font_mono.clone(),
895 c.ui.font_display.clone(),
896 ]
897}
898
899fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
900 m.lock().unwrap_or_else(|e| e.into_inner())
901}
902
903/// Shrink an image that exceeds the GPU's texture limit (decoded before the limit was known).
904fn fit_gpu(img: Arc<DecodedImage>, max_dim: u32) -> Arc<DecodedImage> {
905 if img.size[0] <= max_dim && img.size[1] <= max_dim {
906 return img;
907 }
908 let owned = Arc::try_unwrap(img).unwrap_or_else(|a| DecodedImage {
909 pixels: a.pixels.clone(),
910 ..*a
911 });
912 match decode::downscale(owned, max_dim) {
913 Ok(small) => Arc::new(small),
914 Err(e) => {
915 log::error!("downscale: {e:#}");
916 Arc::new(DecodedImage {
917 size: [1, 1],
918 pixels: vec![0; 4],
919 format: "?",
920 source_size: [1, 1],
921 })
922 }
923 }
924}
925
926impl ApplicationHandler<UserEvent> for App {
927 fn resumed(&mut self, event_loop: &ActiveEventLoop) {
928 if self.gfx.is_some() {
929 return;
930 }
931 let (w, h) = self.opts.geometry.unwrap_or((1280, 800));
932 let mut attrs = Window::default_attributes()
933 .with_title("remage")
934 .with_inner_size(LogicalSize::new(w, h))
935 .with_decorations(self.config.window.decorations)
936 .with_visible(false);
937 if self.opts.fullscreen {
938 attrs = attrs.with_fullscreen(Some(Fullscreen::Borderless(None)));
939 }
940 let class = self.opts.class.clone();
941 attrs = winit::platform::wayland::WindowAttributesExtWayland::with_name(
942 attrs,
943 class.clone(),
944 class.clone(),
945 );
946 attrs =
947 winit::platform::x11::WindowAttributesExtX11::with_name(attrs, class.clone(), class);
948 let window = match event_loop.create_window(attrs) {
949 Ok(w) => Arc::new(w),
950 Err(e) => {
951 self.error = Some(anyhow::anyhow!("create window: {e}"));
952 event_loop.exit();
953 return;
954 }
955 };
956 let renderer = match Renderer::new(window.clone(), event_loop.owned_display_handle()) {
957 Ok(mut r) => {
958 r.set_font_families(font_families(&self.config));
959 r
960 }
961 Err(e) => {
962 self.error = Some(e.context("initialise GPU"));
963 event_loop.exit();
964 return;
965 }
966 };
967 log::debug!("GPU ready after {:.1?}", self.started.elapsed());
968 self.max_dim
969 .store(renderer.max_texture_dim(), Ordering::Relaxed);
970 let size = window.inner_size();
971 self.state.set_window(
972 size.width as f32,
973 size.height as f32,
974 window.scale_factor() as f32,
975 );
976 self.gfx = Some(Gfx { renderer, window });
977 self.sync(false);
978 }
979
980 fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: UserEvent) {
981 match event {
982 UserEvent::Decoded { path, result } => self.on_decoded(path, result),
983 UserEvent::Found(paths) => {
984 log::debug!("sniffing found {} more images", paths.len());
985 let was_empty = self.state.queue.is_empty();
986 self.state.queue.insert_sorted(paths);
987 self.sync(was_empty);
988 }
989 UserEvent::Fonts(db) => {
990 if let Some(g) = &mut self.gfx {
991 g.renderer.set_fonts(db);
992 g.window.request_redraw();
993 }
994 }
995 UserEvent::Config(result) => match *result {
996 Ok(loaded) => {
997 log::info!("config reloaded");
998 self.apply_config(loaded);
999 }
1000 Err(e) => {
1001 log::error!("{e}");
1002 self.config_failed(&e.message);
1003 }
1004 },
1005 UserEvent::Sorted(files) => {
1006 self.state.queue.reorder(files);
1007 self.sync(false);
1008 }
1009 UserEvent::Thumb { path, px, result } => self.on_thumb(path, px, result),
1010 }
1011 }
1012
1013 fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
1014 match event {
1015 WindowEvent::CloseRequested => event_loop.exit(),
1016 WindowEvent::Resized(size) => {
1017 let Some(g) = &mut self.gfx else { return };
1018 g.renderer.resize(size.width, size.height);
1019 let sf = g.window.scale_factor() as f32;
1020 self.state
1021 .set_window(size.width as f32, size.height as f32, sf);
1022 self.sync_thumbs();
1023 self.request_redraw();
1024 }
1025 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
1026 let [w, h] = self.state.window();
1027 self.state.set_window(w, h, scale_factor as f32);
1028 self.sync_thumbs();
1029 self.request_redraw();
1030 }
1031 WindowEvent::RedrawRequested => self.redraw(),
1032 WindowEvent::ModifiersChanged(m) => self.modifiers = m.state(),
1033 WindowEvent::Focused(false) => {
1034 self.held.clear();
1035 self.state.stop_all_pans();
1036 self.dragging = false;
1037 }
1038 WindowEvent::KeyboardInput {
1039 event,
1040 is_synthetic: false,
1041 ..
1042 } => {
1043 self.on_key(event, event_loop);
1044 }
1045 WindowEvent::CursorMoved { position, .. } => {
1046 let p = [position.x as f32, position.y as f32];
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);
1054 self.request_redraw();
1055 }
1056 }
1057 WindowEvent::MouseInput { state, button, .. } => {
1058 let pressed = state == ElementState::Pressed;
1059 if button == MouseButton::Left {
1060 // Drag pans; a press and release in place is a `<LeftMouse>` click.
1061 self.dragging = pressed;
1062 if pressed {
1063 self.press = Some(self.cursor);
1064 return;
1065 }
1066 let Some(p) = self.press.take() else { return };
1067 let moved = (self.cursor[0] - p[0]).hypot(self.cursor[1] - p[1]);
1068 if moved > CLICK_SLOP * self.state.scale_factor() {
1069 return;
1070 }
1071 if self.state.in_grid() {
1072 self.grid_click(event_loop);
1073 }
1074 } else if !pressed {
1075 return;
1076 }
1077 if let Some(key) = input::button(button, self.modifiers) {
1078 self.on_mouse_key(key, event_loop);
1079 }
1080 }
1081 WindowEvent::MouseWheel { delta, .. } => {
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 ),
1088 };
1089 self.on_wheel(d, pixels, event_loop);
1090 }
1091 _ => {}
1092 }
1093 }
1094
1095 fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) {
1096 if matches!(cause, StartCause::ResumeTimeReached { .. }) {
1097 self.request_redraw();
1098 }
1099 }
1100
1101 fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
1102 let now = Instant::now();
1103 let busy = self.pump_uploads();
1104 if let Some(t) = self.ambiguous_since
1105 && now >= t + SEQUENCE_TIMEOUT
1106 {
1107 let runs = self.keymap.timeout(self.state.mode);
1108 self.update_pending(now);
1109 for run in runs {
1110 self.run(run.counted(), None, event_loop);
1111 }
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 }
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 }
1129 if let Some(t) = self.ambiguous_since {
1130 let at = t + SEQUENCE_TIMEOUT;
1131 next = Some(next.map_or(at, |n| n.min(at)));
1132 }
1133 if redraw {
1134 self.request_redraw();
1135 }
1136 if !self.shown_window {
1137 let at = self.started + SHOW_TIMEOUT;
1138 next = Some(next.map_or(at, |n| n.min(at)));
1139 }
1140 event_loop.set_control_flow(match (busy, next) {
1141 (true, _) => ControlFlow::Poll,
1142 (false, Some(t)) => ControlFlow::WaitUntil(t),
1143 (false, None) => ControlFlow::Wait,
1144 });
1145 }
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150 use super::*;
1151
1152 #[test]
1153 fn config_errors_are_shortened_for_the_bar() {
1154 assert_eq!(
1155 short_error(
1156 "/home/u/.config/remage/config.toml:2:1: unknown field `fitt`, expected one of \
1157 `fit`, `zoom_step`; did you mean `fit`?"
1158 ),
1159 "config.toml:2:1: unknown field `fitt`; did you mean `fit`?"
1160 );
1161 assert_eq!(
1162 short_error("a.toml:1:1: unknown field `x`, expected `y`"),
1163 "a.toml:1:1: unknown field `x`, expected `y`"
1164 );
1165 }
1166}