Krolyxon
Commit ab1dc3b

Branches

Tags

Refs →

remagesrcapp.rs

1644 lines · 59.4 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, SystemTime};
8
9use serde_json::{Value, json};
10
11use winit::application::ApplicationHandler;
12use winit::dpi::LogicalSize;
13use winit::event::{ElementState, MouseButton, MouseScrollDelta, StartCause, WindowEvent};
14use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy};
15use winit::keyboard::{ModifiersState, PhysicalKey};
16use winit::window::{Fullscreen, Window, WindowId};
17
18use crate::action::{Action, ModeArg, Switch, Zoom};
19use crate::cache::ImageCache;
20use crate::config::watch::ConfigWatch;
21use crate::config::{Config, LoadError, Loaded, Output, Targets, suggest};
22use crate::decode::{self, DecodedImage};
23use crate::exec;
24use crate::input::keymap::{KeyTables, Keymap};
25use crate::input::keys::{Key, Scroll};
26use crate::input::{self};
27use crate::ipc::{self, Query, Request, Topic};
28use crate::render::{Renderer, text};
29use crate::scan;
30use crate::state::view::{Dir, FitMode};
31use crate::state::{AppState, Effects, Mode, MsgKind, Settings};
32use crate::thumbs::{self, CacheOptions, Thumb, ThumbError};
33use crate::ui;
34use crate::worker::{JobKey, Pool, Priority};
35
36/// GPU upload budget per event-loop turn, so preloads never cause a hitch.
37const UPLOAD_BUDGET: usize = 32 << 20;
38/// Show the window once the first image is ready, or after this long regardless.
39const SHOW_TIMEOUT: Duration = Duration::from_millis(300);
40/// Wheel pixels per zoom step for touchpads.
41const PIXELS_PER_STEP: f32 = 60.0;
42/// An ambiguous key sequence (`g` bound, and `gg` too) resolves after this long, like vim's
43/// `timeoutlen`.
44const SEQUENCE_TIMEOUT: Duration = Duration::from_millis(1000);
45/// A press and release closer than this is a click, not a drag.
46const CLICK_SLOP: f32 = 4.0;
47/// Config errors stay on screen until fixed (or dismissed with `<Esc>`).
48const CONFIG_ERROR_TIME: Duration = Duration::from_secs(3600);
49/// In the grid, the selected image decodes once the cursor has rested this long, so `<CR>`
50/// opens it at once without decoding everything the cursor passes over.
51const GRID_REST: Duration = Duration::from_millis(250);
52/// Two clicks on the same thumbnail this close together open it.
53const DOUBLE_CLICK: Duration = Duration::from_millis(400);
54
55pub enum UserEvent {
56 Decoded {
57 path: PathBuf,
58 /// The file's mtime when decoding started, for `refresh`.
59 mtime: Option<SystemTime>,
60 result: Result<Arc<DecodedImage>, String>,
61 },
62 /// Unknown-extension files that turned out to be images.
63 Found(Vec<PathBuf>),
64 /// Embedded plus system fonts, loaded off-thread after the first frame.
65 Fonts(glyphon::fontdb::Database),
66 /// The config was reloaded (file change, SIGUSR1 or `reload-config`).
67 Config(Box<Result<Loaded, LoadError>>),
68 /// A re-sort finished: the queue in its new order.
69 Sorted(Vec<PathBuf>),
70 /// A thumbnail for cells of `px` pixels.
71 Thumb {
72 path: PathBuf,
73 px: u32,
74 mtime: Option<SystemTime>,
75 result: Result<Thumb, ThumbError>,
76 },
77 /// Output or the exit of a user command.
78 Command { id: u64, event: exec::Event },
79 /// `refresh` re-checked these files: their mtime, or `None` if they are gone.
80 Refreshed(Vec<(PathBuf, Option<SystemTime>)>),
81 /// A request on the IPC socket.
82 Ipc(ipc::Incoming),
83}
84
85/// Command-line settings that beat the config, across reloads too.
86#[derive(Clone, Debug, Default)]
87pub struct Overrides {
88 pub fit: Option<FitMode>,
89 pub zoom: Option<f32>,
90 pub bar: Option<bool>,
91}
92
93impl Overrides {
94 pub fn apply(&self, s: &mut Settings) {
95 if let Some(fit) = self.fit {
96 s.fit = fit;
97 }
98 if self.zoom.is_some() {
99 s.zoom = self.zoom;
100 }
101 }
102}
103
104pub struct Options {
105 pub fullscreen: bool,
106 pub geometry: Option<(u32, u32)>,
107 pub class: String,
108 pub overrides: Overrides,
109 /// Never write the shared thumbnail cache (`-p`).
110 pub private: bool,
111 /// The IPC socket, passed to commands as `$REMAGE_SOCKET`.
112 pub socket: Option<PathBuf>,
113}
114
115/// Where a chain of actions came from, which decides what some of them act on.
116#[derive(Clone, Debug, Default)]
117struct Ctx {
118 /// The cursor, for mouse-triggered zoom.
119 anchor: Option<[f32; 2]>,
120 /// A command's targets, which its `refresh` re-checks (otherwise the whole queue).
121 targets: Option<Arc<[PathBuf]>>,
122}
123
124/// A user command from spawn to exit.
125struct Job {
126 name: String,
127 output: Output,
128 targets: Arc<[PathBuf]>,
129 /// The command's `after` actions, then whatever followed `run` in its binding: they wait
130 /// for a successful exit.
131 then: Vec<Action>,
132 /// stdout lines run as actions so far.
133 lines: usize,
134}
135
136/// An IPC connection streaming events.
137struct Subscriber {
138 topics: Vec<Topic>,
139 tx: crossbeam_channel::Sender<String>,
140}
141
142/// What subscribers last heard about.
143#[derive(Clone, Debug, PartialEq)]
144struct Published {
145 image: (Option<PathBuf>, usize, usize),
146 marks: u64,
147 mode: Mode,
148}
149
150/// Thumbnail jobs for the current cell size.
151#[derive(Default)]
152struct ThumbJobs {
153 /// Cell size the atlas and requests are for (0 = none yet).
154 cell: u32,
155 requested: HashMap<PathBuf, Priority>,
156 /// Paths near the view; the atlas never evicts these.
157 wanted: HashSet<PathBuf>,
158 /// Finished thumbnails waiting for the atlas.
159 arrivals: VecDeque<(PathBuf, Thumb)>,
160}
161
162struct Gfx {
163 renderer: Renderer,
164 // Dropped after the renderer, whose surface borrows the window.
165 window: Arc<Window>,
166}
167
168pub struct App {
169 opts: Options,
170 state: AppState,
171 config: Config,
172 keymap: Keymap,
173 watch: ConfigWatch,
174 /// A config error is on the status line.
175 config_error: bool,
176 /// `reload-config` ran, so the next reload is confirmed on screen.
177 reload_requested: bool,
178 /// When the pending key sequence became ambiguous.
179 ambiguous_since: Option<Instant>,
180 /// Actions a held key repeats.
181 repeat: Option<(PhysicalKey, Vec<Action>)>,
182 pool: Pool,
183 proxy: EventLoopProxy<UserEvent>,
184 cache: ImageCache,
185 max_dim: Arc<AtomicU32>,
186 /// Decoded images whose result event is still in the loop's queue.
187 arriving: Arc<Mutex<HashSet<PathBuf>>>,
188 gfx: Option<Gfx>,
189 uploads: VecDeque<(PathBuf, Arc<DecodedImage>)>,
190 thumbs: ThumbJobs,
191 thumb_cache: CacheOptions,
192 /// When the grid cursor last moved, until the selected image is requested.
193 grid_rest: Option<Instant>,
194 /// The last click in the grid, for double-clicks.
195 last_click: Option<(Instant, usize)>,
196 modifiers: ModifiersState,
197 cursor: [f32; 2],
198 dragging: bool,
199 /// Where the left button went down, while it is a click candidate.
200 press: Option<[f32; 2]>,
201 /// Wheel motion not yet turned into whole `<ScrollUp>`/`<ScrollDown>` presses.
202 scroll: [f32; 2],
203 held: HashMap<PhysicalKey, Dir>,
204 started: Instant,
205 shown_window: bool,
206 /// File mtimes when they were last decoded or thumbnailed, so `refresh` spots changes.
207 stamps: HashMap<PathBuf, SystemTime>,
208 jobs: HashMap<u64, Job>,
209 next_job: u64,
210 /// A command waiting for `y` at its confirm prompt.
211 confirm: Option<(Job, exec::Prepared)>,
212 subscribers: Vec<Subscriber>,
213 published: Option<Published>,
214 /// `quit-no-output` ran: `-o` prints nothing.
215 no_output: bool,
216 pub error: Option<anyhow::Error>,
217}
218
219impl App {
220 pub fn new(
221 opts: Options,
222 state: AppState,
223 proxy: EventLoopProxy<UserEvent>,
224 loaded: Loaded,
225 watch: ConfigWatch,
226 ) -> Self {
227 let cache = ImageCache::new(loaded.config.image.cache_mb << 20);
228 let thumb_cache = CacheOptions::new(loaded.config.grid.thumbnail_cache, opts.private);
229 let mut app = Self {
230 opts,
231 state,
232 keymap: keymap(&loaded.keys),
233 config: loaded.config,
234 watch,
235 config_error: false,
236 reload_requested: false,
237 ambiguous_since: None,
238 repeat: None,
239 pool: Pool::new(),
240 proxy,
241 cache,
242 // Until the GPU reports its limit; uploads re-check.
243 max_dim: Arc::new(AtomicU32::new(16384)),
244 arriving: Arc::default(),
245 gfx: None,
246 uploads: VecDeque::new(),
247 thumbs: ThumbJobs::default(),
248 thumb_cache,
249 grid_rest: None,
250 last_click: None,
251 modifiers: ModifiersState::empty(),
252 cursor: [0.0, 0.0],
253 dragging: false,
254 press: None,
255 scroll: [0.0, 0.0],
256 held: HashMap::new(),
257 started: Instant::now(),
258 shown_window: false,
259 stamps: HashMap::new(),
260 jobs: HashMap::new(),
261 next_job: 1,
262 confirm: None,
263 subscribers: Vec::new(),
264 published: None,
265 no_output: false,
266 error: None,
267 };
268 // Decode the first image while the window and GPU come up.
269 app.sync(true);
270 app
271 }
272
273 fn request_redraw(&self) {
274 if let Some(g) = &self.gfx {
275 g.window.request_redraw();
276 }
277 }
278
279 fn decode(&self, path: &Path, prio: Priority) {
280 if lock(&self.arriving).contains(path) {
281 return;
282 }
283 let proxy = self.proxy.clone();
284 let max_dim = self.max_dim.clone();
285 let arriving = self.arriving.clone();
286 let path = path.to_path_buf();
287 self.pool
288 .submit(JobKey::Decode(path.clone()), prio, move || {
289 let t = Instant::now();
290 let mtime = mtime(&path);
291 let result = decode::decode(&path, max_dim.load(Ordering::Relaxed))
292 .map(Arc::new)
293 .map_err(|e| e.to_string());
294 log::debug!("decoded {} in {:.1?}", path.display(), t.elapsed());
295 lock(&arriving).insert(path.clone());
296 let _ = proxy.send_event(UserEvent::Decoded {
297 path,
298 mtime,
299 result,
300 });
301 });
302 }
303
304 /// The current file and its preload neighbours, current first.
305 fn window_paths(&self) -> Vec<PathBuf> {
306 let q = &self.state.queue;
307 let Some(cur) = q.current() else {
308 return Vec::new();
309 };
310 let mut out = vec![cur.to_path_buf()];
311 for i in q.neighbours(self.state.settings.preload, self.state.settings.wrap) {
312 if let Some(p) = q.get(i) {
313 out.push(p.to_path_buf());
314 }
315 }
316 out
317 }
318
319 /// Make the GPU and decode queue match the current position.
320 fn sync(&mut self, navigated: bool) {
321 let now = Instant::now();
322 if navigated {
323 self.pool.bump_epoch();
324 }
325 let wanted = self.window_paths();
326 self.update_title();
327 let Some(cur) = wanted.first().cloned() else {
328 self.state.clear_shown();
329 self.uploads.clear();
330 if let Some(g) = &mut self.gfx {
331 g.renderer.retain(|_| false);
332 }
333 self.request_redraw();
334 return;
335 };
336 if self.state.in_grid() {
337 if navigated || !self.cache_has(&cur) {
338 self.grid_rest = Some(now);
339 }
340 self.sync_thumbs();
341 self.request_redraw();
342 return;
343 }
344
345 let keep = |p: &Path| {
346 wanted.iter().any(|w| w == p)
347 || self.state.shown.as_ref().is_some_and(|s| s.path == p)
348 || self.state.fade.as_ref().is_some_and(|f| f.path == p)
349 };
350 if let Some(g) = &mut self.gfx {
351 g.renderer.retain(keep);
352 }
353 self.uploads.retain(|(p, _)| wanted.contains(p));
354
355 let showing_cur = self.state.shown.as_ref().is_some_and(|s| s.path == cur);
356 if !showing_cur {
357 match self.cache.get(&cur) {
358 Some(img) => self.show(&cur, img, now),
359 None => {
360 self.state.loading_since.get_or_insert(now);
361 self.decode(&cur, Priority::Current);
362 }
363 }
364 }
365 for p in &wanted[1..] {
366 match self.cache.get(p) {
367 Some(img) => {
368 let ready = self.gfx.as_ref().is_some_and(|g| g.renderer.is_ready(p));
369 if !ready && !self.uploads.iter().any(|(u, _)| u == p) {
370 self.uploads.push_back((p.clone(), img));
371 }
372 }
373 None => self.decode(p, Priority::Preload),
374 }
375 }
376 self.request_redraw();
377 }
378
379 fn cache_has(&mut self, path: &Path) -> bool {
380 self.cache.get(path).is_some()
381 }
382
383 /// Request thumbnails around the grid view: visible ones first, then a page above and
384 /// two below. Requests that scrolled out of range are cancelled.
385 fn sync_thumbs(&mut self) {
386 if !self.state.in_grid() {
387 return;
388 }
389 let Some(g) = &mut self.gfx else { return };
390 let px = self.state.cell_px();
391 if px != self.thumbs.cell {
392 for p in self.thumbs.requested.drain().map(|(p, _)| p) {
393 self.pool.cancel(&JobKey::Thumb(p));
394 }
395 self.thumbs.arrivals.clear();
396 self.state.thumbs.clear();
397 g.renderer.reset_thumbs(px);
398 self.thumbs.cell = px;
399 }
400 let l = self.state.grid_layout();
401 let visible = l.visible(self.state.grid.target(&l));
402 let span = l.page_rows() * l.cols;
403 let near = visible.start.saturating_sub(span)..(visible.end + 2 * span).min(l.count);
404 let q = &self.state.queue;
405 let mut wanted = HashSet::with_capacity(near.len());
406 let mut visible_paths = HashSet::with_capacity(visible.len());
407 let mut submit = Vec::new();
408 for i in near {
409 let Some(p) = q.get(i) else { continue };
410 wanted.insert(p.to_path_buf());
411 let prio = if visible.contains(&i) {
412 visible_paths.insert(p.to_path_buf());
413 Priority::ThumbVisible
414 } else {
415 Priority::Thumb
416 };
417 if !self.state.thumbs.contains_key(p) && self.thumbs.requested.get(p) != Some(&prio) {
418 submit.push((p.to_path_buf(), prio));
419 }
420 }
421 // Visible first, in order.
422 submit.sort_by_key(|(_, prio)| *prio);
423 for (p, prio) in submit {
424 self.request_thumb(p.clone(), prio);
425 self.thumbs.requested.insert(p, prio);
426 }
427 let mut demote = Vec::new();
428 self.thumbs.requested.retain(|p, prio| {
429 if !wanted.contains(p) {
430 self.pool.cancel(&JobKey::Thumb(p.clone()));
431 return false;
432 }
433 if *prio == Priority::ThumbVisible && !visible_paths.contains(p) {
434 *prio = Priority::Thumb;
435 demote.push(p.clone());
436 }
437 true
438 });
439 for p in demote {
440 self.request_thumb(p, Priority::Thumb);
441 }
442 self.thumbs.wanted = wanted;
443 }
444
445 fn request_thumb(&self, path: PathBuf, prio: Priority) {
446 let proxy = self.proxy.clone();
447 let cache = self.thumb_cache.clone();
448 let px = self.thumbs.cell;
449 self.pool
450 .submit(JobKey::Thumb(path.clone()), prio, move || {
451 let t = Instant::now();
452 let mtime = mtime(&path);
453 let result = thumbs::load(&path, px, &cache);
454 log::trace!("thumbnail {} in {:.1?}", path.display(), t.elapsed());
455 let _ = proxy.send_event(UserEvent::Thumb {
456 path,
457 px,
458 mtime,
459 result,
460 });
461 });
462 }
463
464 fn on_thumb(
465 &mut self,
466 path: PathBuf,
467 px: u32,
468 mtime: Option<SystemTime>,
469 result: Result<Thumb, ThumbError>,
470 ) {
471 self.thumbs.requested.remove(&path);
472 if px != self.thumbs.cell {
473 return;
474 }
475 match result {
476 Ok(t) => {
477 if let Some(m) = mtime {
478 self.stamps.insert(path.clone(), m);
479 }
480 self.thumbs.arrivals.push_back((path, t));
481 }
482 Err(e) => {
483 // Undecodable: it leaves the queue, as in image mode.
484 log::warn!("{}: {e}", path.display());
485 let was_current = self.state.queue.current() == Some(path.as_path());
486 if self.state.queue.remove(&path) {
487 self.state.skipped += 1;
488 }
489 self.sync(was_current);
490 self.request_redraw();
491 }
492 }
493 }
494
495 /// Every thumbnail on screen is in the atlas.
496 fn grid_settled(&self) -> bool {
497 let l = self.state.grid_layout();
498 let scroll = self.state.grid.scroll(&l, Instant::now());
499 l.visible(scroll).all(|i| {
500 self.state
501 .queue
502 .get(i)
503 .is_some_and(|p| self.state.thumbs.contains_key(p))
504 })
505 }
506
507 /// Upload `img` in full and put it on screen.
508 fn show(&mut self, path: &Path, img: Arc<DecodedImage>, now: Instant) {
509 let Some(g) = &mut self.gfx else {
510 // No GPU yet: keep it cached; `resumed` syncs again.
511 self.state.loading_since.get_or_insert(now);
512 return;
513 };
514 let img = fit_gpu(img, g.renderer.max_texture_dim());
515 g.renderer.upload(path, &img, usize::MAX);
516 self.uploads.retain(|(p, _)| p != path);
517 self.state.show(path, img.source_size, img.format, now);
518 self.request_redraw();
519 }
520
521 fn update_title(&self) {
522 if let Some(g) = &self.gfx {
523 let q = &self.state.queue;
524 let title = match q.current() {
525 Some(p) => self
526 .config
527 .window
528 .title
529 .replace("{name}", &ui::file_name(p))
530 .replace("{index}", &(q.index() + 1).to_string())
531 .replace("{count}", &q.len().to_string()),
532 None => "remage".into(),
533 };
534 g.window.set_title(&title);
535 }
536 }
537
538 /// Show a config error until the config is fixed.
539 pub fn config_failed(&mut self, message: &str) {
540 self.state.notify_for(
541 format!("config error: {}", short_error(message)),
542 MsgKind::Error,
543 Instant::now(),
544 CONFIG_ERROR_TIME,
545 );
546 self.config_error = true;
547 self.request_redraw();
548 }
549
550 /// Adopt a reloaded config. Runtime toggles (bar, filter, alpha background) only change
551 /// when their config value did, so a reload doesn't undo what the user just toggled.
552 fn apply_config(&mut self, loaded: Loaded) {
553 let now = Instant::now();
554 for w in &loaded.warnings {
555 log::warn!("{w}");
556 }
557 let old = std::mem::replace(&mut self.config, loaded.config);
558 let new = &self.config;
559 let mut settings = Settings::from_config(new);
560 self.opts.overrides.apply(&mut settings);
561 let preload_changed = settings.preload != self.state.settings.preload;
562 if old.grid.size != new.grid.size {
563 self.state.grid.size = new.grid.size;
564 }
565 if old.grid.thumbnail_cache != new.grid.thumbnail_cache {
566 self.thumb_cache = CacheOptions::new(new.grid.thumbnail_cache, self.opts.private);
567 }
568 self.state.set_settings(settings);
569 self.state.set_theme(loaded.theme, now);
570 self.keymap = keymap(&loaded.keys);
571 self.ambiguous_since = None;
572 self.state.pending.clear();
573 self.cache.set_budget(new.image.cache_mb << 20);
574 if old.image.filter != new.image.filter {
575 self.state.nearest = new.image.filter.nearest();
576 }
577 if old.image.alpha_background != new.image.alpha_background {
578 self.state.alpha_bg = new.image.alpha_background;
579 }
580 if old.ui.statusbar != new.ui.statusbar && self.opts.overrides.bar.is_none() {
581 let s = if new.ui.statusbar {
582 Switch::On
583 } else {
584 Switch::Off
585 };
586 self.state.apply(Action::Bar(s), now);
587 }
588 if let Some(g) = &mut self.gfx {
589 if old.window.decorations != new.window.decorations {
590 g.window.set_decorations(new.window.decorations);
591 }
592 g.renderer.set_font_families(font_families(new));
593 }
594 self.update_title();
595 // Silent on routine changes (a new wallpaper palette); confirm fixes and manual reloads.
596 if std::mem::take(&mut self.config_error) || std::mem::take(&mut self.reload_requested) {
597 self.state.notify("config reloaded", MsgKind::Info, now);
598 }
599 if preload_changed {
600 self.sync(false);
601 }
602 self.sync_thumbs();
603 self.request_redraw();
604 }
605
606 fn on_decoded(
607 &mut self,
608 path: PathBuf,
609 mtime: Option<SystemTime>,
610 result: Result<Arc<DecodedImage>, String>,
611 ) {
612 lock(&self.arriving).remove(&path);
613 let now = Instant::now();
614 let is_current = self.state.queue.current() == Some(path.as_path());
615 let in_grid = self.state.in_grid();
616 match result {
617 Ok(img) => {
618 if let Some(m) = mtime {
619 self.stamps.insert(path.clone(), m);
620 }
621 let wanted = self.window_paths();
622 let keep: Vec<&Path> = wanted.iter().map(PathBuf::as_path).collect();
623 self.cache.insert(path.clone(), img.clone(), &keep);
624 if in_grid {
625 // Cached for when the grid opens it.
626 } else if is_current {
627 // Already on screen means this was a reload: replace the texture.
628 let reload = self.state.shown.as_ref().is_some_and(|s| s.path == path);
629 if reload && let Some(g) = &mut self.gfx {
630 g.renderer.retain(|p| p != path);
631 }
632 self.show(&path, img, now);
633 } else if wanted.contains(&path) && !self.uploads.iter().any(|(p, _)| *p == path) {
634 self.uploads.push_back((path, img));
635 }
636 }
637 Err(e) => {
638 log::warn!("{}: {e}", path.display());
639 self.state.queue.remove(&path);
640 self.state.skipped += 1;
641 if is_current {
642 self.state.loading_since = None;
643 let msg = format!("error: {}: {e}", ui::file_name(&path));
644 self.state.notify(msg, MsgKind::Error, now);
645 self.sync(true);
646 }
647 self.request_redraw();
648 }
649 }
650 }
651
652 fn reload(&mut self) {
653 if let Some(cur) = self.state.queue.current().map(Path::to_path_buf) {
654 self.cache.remove(&cur);
655 self.decode(&cur, Priority::Current);
656 }
657 }
658
659 /// Run actions in order. A `run` suspends the rest of the chain until its command exits
660 /// successfully. Errors are shown on the status line; the first is also returned for IPC.
661 fn run(&mut self, actions: Vec<Action>, ctx: &Ctx, el: &ActiveEventLoop) -> Result<(), String> {
662 let mut rest = actions.into_iter();
663 while let Some(action) = rest.next() {
664 match &action {
665 Action::Run(name) => return self.start_command(name, rest.collect()),
666 Action::Cancel => {
667 self.keymap.cancel();
668 self.state.pending.clear();
669 self.config_error = false;
670 self.answer(false);
671 }
672 Action::QuitNoOutput => self.no_output = true,
673 _ => {}
674 }
675 let fx = self.state.apply_at(action, ctx.anchor, Instant::now());
676 self.effects(fx, ctx, el);
677 if fx.quit {
678 break;
679 }
680 }
681 Ok(())
682 }
683
684 /// Start `[commands.<name>]`, or ask first when it has a `confirm`.
685 fn start_command(&mut self, name: &str, then: Vec<Action>) -> Result<(), String> {
686 let now = Instant::now();
687 let fail = |state: &mut AppState, msg: String| {
688 state.notify(format!("error: {msg}"), MsgKind::Error, now);
689 Err(msg)
690 };
691 let Some(cmd) = self.config.commands.get(name).cloned() else {
692 let hint = suggest(name, self.config.commands.keys().map(String::as_str))
693 .map(|s| format!(", did you mean `{s}`?"))
694 .unwrap_or_default();
695 self.request_redraw();
696 return fail(&mut self.state, format!("no command named `{name}`{hint}"));
697 };
698 let q = &self.state.queue;
699 let targets: Arc<[PathBuf]> =
700 exec::targets(cmd.targets, q.current(), &q.marked(), q.files()).into();
701 if targets.is_empty() && exec::needs_files(&cmd) {
702 let why = if cmd.targets == Targets::Marked {
703 "no marked files"
704 } else {
705 "no images"
706 };
707 self.request_redraw();
708 return fail(&mut self.state, format!("{name}: {why}"));
709 }
710 let size = self
711 .state
712 .shown
713 .as_ref()
714 .filter(|s| Some(s.path.as_path()) == q.current())
715 .map(|s| s.size);
716 let ctx = exec::Context {
717 current: q.current(),
718 index: q.index(),
719 targets: &targets,
720 size,
721 mode: self.state.mode.name(),
722 socket: self.opts.socket.as_deref(),
723 };
724 let prepared = exec::prepare(&cmd, &ctx);
725 let question = cmd.confirm.as_deref().map(|c| exec::expand_text(c, &ctx));
726 let mut chain = cmd.after.0.unwrap_or_default();
727 chain.extend(then);
728 let job = Job {
729 name: name.to_string(),
730 output: cmd.output,
731 targets,
732 then: chain,
733 lines: 0,
734 };
735 match question {
736 Some(q) => {
737 self.answer(false);
738 self.state.prompt = Some(q);
739 self.confirm = Some((job, prepared));
740 self.request_redraw();
741 }
742 None => self.spawn_job(job, prepared),
743 }
744 Ok(())
745 }
746
747 /// Settle a confirm prompt: `y` runs the command, anything else drops it.
748 fn answer(&mut self, yes: bool) {
749 let Some((job, prepared)) = self.confirm.take() else {
750 return;
751 };
752 self.state.prompt = None;
753 if yes {
754 self.spawn_job(job, prepared);
755 }
756 self.request_redraw();
757 }
758
759 fn spawn_job(&mut self, job: Job, prepared: exec::Prepared) {
760 let id = self.next_job;
761 self.next_job += 1;
762 log::info!(
763 "run {}: {} {:?}",
764 job.name,
765 prepared.program.to_string_lossy(),
766 prepared.args
767 );
768 let proxy = self.proxy.clone();
769 exec::spawn(&job.name, prepared, job.output, move |event| {
770 let _ = proxy.send_event(UserEvent::Command { id, event });
771 });
772 self.state.running.push((id, job.name.clone()));
773 self.jobs.insert(id, job);
774 self.request_redraw();
775 }
776
777 fn on_command(&mut self, id: u64, event: exec::Event, el: &ActiveEventLoop) {
778 let now = Instant::now();
779 match event {
780 exec::Event::Line(line) => {
781 let Some(job) = self.jobs.get_mut(&id) else {
782 return;
783 };
784 job.lines += 1;
785 let name = job.name.clone();
786 let ctx = Ctx {
787 anchor: None,
788 targets: Some(job.targets.clone()),
789 };
790 match line.parse::<Action>() {
791 Ok(a) => {
792 let _ = self.run(vec![a], &ctx, el);
793 }
794 Err(e) => {
795 log::warn!("{name}: {line}: {e}");
796 self.state
797 .notify(format!("error: {name}: {e}"), MsgKind::Error, now);
798 self.request_redraw();
799 }
800 }
801 }
802 exec::Event::Done(outcome) => {
803 let Some(job) = self.jobs.remove(&id) else {
804 return;
805 };
806 self.state.running.retain(|(j, _)| *j != id);
807 self.request_redraw();
808 if !outcome.success() {
809 let msg = format!("error: {}", outcome.error());
810 log::warn!("{}: {msg}", job.name);
811 self.state.notify(msg, MsgKind::Error, now);
812 return;
813 }
814 log::debug!("{}: done", job.name);
815 match (job.output, outcome.last_line) {
816 (Output::Status, Some(line)) => self.state.notify(line, MsgKind::Info, now),
817 // Actions speak for themselves.
818 (Output::Actions, _) if job.lines > 0 => {}
819 _ => self.state.notify("done", MsgKind::Info, now),
820 }
821 let ctx = Ctx {
822 anchor: None,
823 targets: Some(job.targets),
824 };
825 let _ = self.run(job.then, &ctx, el);
826 }
827 }
828 }
829
830 /// Re-check `targets` (default: the whole queue, plus the current file) on disk, off-thread.
831 fn refresh(&mut self, targets: Option<Arc<[PathBuf]>>) {
832 let mut files: Vec<PathBuf> = match targets {
833 Some(t) => t.to_vec(),
834 None => self.state.queue.files().to_vec(),
835 };
836 if let Some(cur) = self.state.queue.current()
837 && !files.iter().any(|f| f == cur)
838 {
839 files.push(cur.to_path_buf());
840 }
841 let proxy = self.proxy.clone();
842 std::thread::spawn(move || {
843 let found = files
844 .into_iter()
845 .map(|p| {
846 let m = std::fs::metadata(&p)
847 .ok()
848 .map(|m| m.modified().unwrap_or(SystemTime::UNIX_EPOCH));
849 (p, m)
850 })
851 .collect();
852 let _ = proxy.send_event(UserEvent::Refreshed(found));
853 });
854 }
855
856 /// Drop files that vanished and reload ones that changed since they were loaded.
857 fn on_refreshed(&mut self, found: Vec<(PathBuf, Option<SystemTime>)>) {
858 let cur = self.state.queue.current().map(Path::to_path_buf);
859 let showing = |s: &AppState, p: &Path| {
860 s.mode == Mode::Image && s.shown.as_ref().is_some_and(|s| s.path == p)
861 };
862 let mut navigated = false;
863 let mut stale = Vec::new();
864 let mut gone = 0;
865 for (path, mtime) in found {
866 match mtime {
867 None => {
868 if self.state.queue.remove(&path) {
869 gone += 1;
870 navigated |= cur.as_ref() == Some(&path);
871 }
872 self.cache.remove(&path);
873 self.stamps.remove(&path);
874 self.state.thumbs.remove(&path);
875 }
876 Some(t) if self.stamps.get(&path).is_some_and(|s| *s != t) => stale.push(path),
877 Some(_) => {}
878 }
879 }
880 let mut reload_current = false;
881 for path in &stale {
882 self.cache.remove(path);
883 self.stamps.remove(path);
884 self.state.thumbs.remove(path);
885 self.uploads.retain(|(p, _)| p != path);
886 if cur.as_ref() == Some(path) && showing(&self.state, path) {
887 // Keep it on screen until the new version replaces it.
888 reload_current = true;
889 } else if let Some(g) = &mut self.gfx {
890 g.renderer.retain(|p| p != path);
891 }
892 }
893 if gone + stale.len() > 0 {
894 log::debug!("refresh: {gone} gone, {} changed", stale.len());
895 }
896 if reload_current && let Some(cur) = &cur {
897 self.decode(cur, Priority::Current);
898 }
899 if gone + stale.len() > 0 {
900 self.sync(navigated);
901 self.request_redraw();
902 }
903 }
904
905 fn on_ipc(&mut self, inc: ipc::Incoming, el: &ActiveEventLoop) {
906 let reply = match inc.request {
907 Request::Actions(actions) => match self.run(actions, &Ctx::default(), el) {
908 Ok(()) => ipc::ok(Value::Null),
909 Err(e) => ipc::error(&e),
910 },
911 Request::Query(q) => ipc::ok(self.query(q)),
912 Request::Subscribe(topics) => match inc.events {
913 Some(tx) => {
914 if self.published.is_none() {
915 self.published = Some(self.snapshot());
916 }
917 self.subscribers.push(Subscriber { topics, tx });
918 ipc::ok(Value::Null)
919 }
920 None => ipc::error("no event channel"),
921 },
922 };
923 let _ = inc.reply.send(reply);
924 }
925
926 fn query(&self, q: Query) -> Value {
927 let s = &self.state;
928 let paths = |ps: &mut dyn Iterator<Item = &Path>| -> Value {
929 ps.map(ipc::path_json).collect::<Vec<_>>().into()
930 };
931 match q {
932 Query::Current => s.queue.current().map_or(Value::Null, ipc::path_json),
933 Query::Marked => paths(&mut s.queue.marked().into_iter()),
934 Query::Queue => paths(&mut s.queue.files().iter().map(PathBuf::as_path)),
935 Query::State => {
936 let shown = s
937 .shown
938 .as_ref()
939 .filter(|sh| Some(sh.path.as_path()) == s.queue.current());
940 let fullscreen = self
941 .gfx
942 .as_ref()
943 .is_some_and(|g| g.window.fullscreen().is_some());
944 json!({
945 "mode": s.mode.name(),
946 "current": s.queue.current().map(ipc::path_json),
947 "index": (!s.queue.is_empty()).then(|| s.queue.index() + 1),
948 "count": s.queue.len(),
949 "marked": s.queue.marked_count(),
950 "width": shown.map(|sh| sh.size[0]),
951 "height": shown.map(|sh| sh.size[1]),
952 "format": shown.map(|sh| sh.format),
953 "zoom": shown.map(|_| (s.view.scale() * 1000.0).round() / 10.0),
954 "fit": s.settings.fit,
955 "bar": s.bar,
956 "fullscreen": fullscreen,
957 "skipped": s.skipped,
958 "running": s.running.iter().map(|(_, n)| n.as_str()).collect::<Vec<_>>(),
959 "pid": std::process::id(),
960 })
961 }
962 }
963 }
964
965 fn snapshot(&self) -> Published {
966 let q = &self.state.queue;
967 Published {
968 image: (q.current().map(Path::to_path_buf), q.index(), q.len()),
969 marks: q.marks_version(),
970 mode: self.state.mode,
971 }
972 }
973
974 /// Tell subscribers what changed since they last heard.
975 fn publish(&mut self) {
976 if self.subscribers.is_empty() {
977 self.published = None;
978 return;
979 }
980 let now = self.snapshot();
981 let Some(before) = self.published.replace(now.clone()) else {
982 return;
983 };
984 let q = &self.state.queue;
985 let mut events = Vec::new();
986 if before.image != now.image {
987 let data = json!({
988 "path": q.current().map(ipc::path_json),
989 "index": (!q.is_empty()).then(|| q.index() + 1),
990 "count": q.len(),
991 });
992 events.push((Topic::Image, data));
993 }
994 if before.marks != now.marks {
995 let files: Vec<Value> = q.marked().into_iter().map(ipc::path_json).collect();
996 events.push((
997 Topic::Marks,
998 json!({ "count": files.len(), "files": files }),
999 ));
1000 }
1001 if before.mode != now.mode {
1002 events.push((Topic::Mode, json!(now.mode.name())));
1003 }
1004 for (topic, data) in events {
1005 let line = ipc::event(topic, data);
1006 self.subscribers
1007 .retain(|s| !s.topics.contains(&topic) || s.tx.send(line.clone()).is_ok());
1008 }
1009 }
1010
1011 /// Marked files for `-o`, unless the user quit with `quit-no-output`.
1012 pub fn marked_output(&self) -> Option<Vec<PathBuf>> {
1013 (!self.no_output).then(|| {
1014 self.state
1015 .queue
1016 .marked()
1017 .into_iter()
1018 .map(Path::to_path_buf)
1019 .collect()
1020 })
1021 }
1022
1023 fn effects(&mut self, fx: Effects, ctx: &Ctx, event_loop: &ActiveEventLoop) {
1024 if fx.quit {
1025 event_loop.exit();
1026 return;
1027 }
1028 if let Some(s) = fx.fullscreen
1029 && let Some(g) = &self.gfx
1030 {
1031 let on = g.window.fullscreen().is_some();
1032 let want = match s {
1033 Switch::Toggle => !on,
1034 Switch::On => true,
1035 Switch::Off => false,
1036 };
1037 if want != on {
1038 g.window
1039 .set_fullscreen(want.then_some(Fullscreen::Borderless(None)));
1040 }
1041 }
1042 if fx.reload {
1043 self.reload();
1044 }
1045 if fx.reload_config {
1046 self.reload_requested = true;
1047 self.watch.reload();
1048 }
1049 if fx.refresh {
1050 self.refresh(ctx.targets.clone());
1051 }
1052 if let Some(key) = fx.sort {
1053 let mut files = self.state.queue.files().to_vec();
1054 let proxy = self.proxy.clone();
1055 std::thread::spawn(move || {
1056 scan::sort_paths(&mut files, key, false);
1057 let _ = proxy.send_event(UserEvent::Sorted(files));
1058 });
1059 }
1060 if fx.mode {
1061 // Opening an image from the grid loads it like navigating to it.
1062 self.sync(self.state.mode == Mode::Image || fx.navigated);
1063 } else if fx.navigated {
1064 self.sync(true);
1065 } else if fx.reordered {
1066 self.sync(false);
1067 }
1068 // Scrolling, a new size or the bar can bring new thumbnails into view.
1069 self.sync_thumbs();
1070 if fx.redraw || fx.layout {
1071 self.request_redraw();
1072 }
1073 }
1074
1075 /// Mirror the keymap's pending keys on the status line and arm the ambiguity timeout.
1076 fn update_pending(&mut self, now: Instant) {
1077 let pending = self.keymap.pending_display();
1078 if pending != self.state.pending {
1079 self.state.pending = pending;
1080 self.request_redraw();
1081 }
1082 self.ambiguous_since = self
1083 .keymap
1084 .is_ambiguous(self.state.mode)
1085 .then(|| self.ambiguous_since.unwrap_or(now));
1086 }
1087
1088 fn on_key(&mut self, event: winit::event::KeyEvent, event_loop: &ActiveEventLoop) {
1089 let now = Instant::now();
1090 if event.state == ElementState::Released {
1091 if let Some(dir) = self.held.remove(&event.physical_key) {
1092 self.state.hold_pan(dir, false, now);
1093 self.request_redraw();
1094 }
1095 if self
1096 .repeat
1097 .as_ref()
1098 .is_some_and(|(k, _)| *k == event.physical_key)
1099 {
1100 self.repeat = None;
1101 }
1102 return;
1103 }
1104 if self.confirm.is_some() {
1105 // The prompt takes the next key: `y` runs the command, anything else cancels.
1106 if !event.repeat
1107 && let Some(key) = input::key(&event.logical_key, self.modifiers)
1108 {
1109 self.answer(key == Key::plain('y') || key == Key::plain('Y'));
1110 }
1111 return;
1112 }
1113 if event.repeat {
1114 // Repeats bypass the trie: only a completed, repeatable binding repeats.
1115 if let Some((k, actions)) = &self.repeat
1116 && *k == event.physical_key
1117 {
1118 let actions = actions.clone();
1119 let _ = self.run(actions, &Ctx::default(), event_loop);
1120 }
1121 return;
1122 }
1123 let Some(key) = input::key(&event.logical_key, self.modifiers) else {
1124 return;
1125 };
1126 let runs = self.keymap.feed(self.state.mode, key);
1127 self.update_pending(now);
1128 self.repeat = None;
1129 for run in runs {
1130 let actions = run.counted();
1131 if let [Action::Pan(dir)] = actions[..]
1132 && self.state.mode == Mode::Image
1133 {
1134 if self.state.shown.is_some() {
1135 self.held.insert(event.physical_key, dir);
1136 self.state.hold_pan(dir, true, now);
1137 self.request_redraw();
1138 }
1139 continue;
1140 }
1141 if !actions.is_empty() && actions.iter().all(Action::repeats) {
1142 self.repeat = Some((event.physical_key, actions.clone()));
1143 }
1144 let _ = self.run(actions, &Ctx::default(), event_loop);
1145 }
1146 }
1147
1148 /// A mouse button or wheel notch: bound actions run at the cursor, bypassing sequences.
1149 fn on_mouse_key(&mut self, key: Key, event_loop: &ActiveEventLoop) {
1150 self.keymap.cancel();
1151 self.update_pending(Instant::now());
1152 if let Some(actions) = self.keymap.lookup(self.state.mode, &[key]) {
1153 let actions = actions.to_vec();
1154 let ctx = Ctx {
1155 anchor: Some(self.cursor),
1156 ..Ctx::default()
1157 };
1158 let _ = self.run(actions, &ctx, event_loop);
1159 }
1160 }
1161
1162 /// `pixels` is set for touchpads, whose deltas are pixels divided by [`PIXELS_PER_STEP`].
1163 fn on_wheel(&mut self, delta: [f32; 2], pixels: bool, event_loop: &ActiveEventLoop) {
1164 let now = Instant::now();
1165 let [dx, dy] = delta;
1166 if dy != 0.0 {
1167 let dir = if dy > 0.0 { Scroll::Up } else { Scroll::Down };
1168 let key = input::scroll(dir, self.modifiers);
1169 // Wheel zoom and grid scrolling stay continuous for touchpads rather than stepping
1170 // per notch.
1171 let in_grid = self.state.in_grid();
1172 match self.keymap.lookup(self.state.mode, &[key]) {
1173 Some([Action::Scroll(d @ (Dir::Up | Dir::Down), n)]) if in_grid => {
1174 let sign = if *d == Dir::Up { -1.0 } else { 1.0 };
1175 let amount = dy.abs() * *n as f32 * sign;
1176 let moved = if pixels {
1177 self.state.grid_drag(-amount * PIXELS_PER_STEP, now)
1178 } else {
1179 self.state.grid_scroll(amount, now)
1180 };
1181 self.after_grid_scroll(moved);
1182 return;
1183 }
1184 Some([Action::Zoom(Zoom::In(n))]) => {
1185 let steps = dy.abs() * *n as f32;
1186 self.state.zoom_at(steps, self.cursor, now);
1187 self.request_redraw();
1188 return;
1189 }
1190 Some([Action::Zoom(Zoom::Out(n))]) => {
1191 let steps = -dy.abs() * *n as f32;
1192 self.state.zoom_at(steps, self.cursor, now);
1193 self.request_redraw();
1194 return;
1195 }
1196 _ => {}
1197 }
1198 }
1199 for (axis, d, pos, neg) in [
1200 (0, dx, Scroll::Left, Scroll::Right),
1201 (1, dy, Scroll::Up, Scroll::Down),
1202 ] {
1203 if d == 0.0 {
1204 continue;
1205 }
1206 // Reversing direction drops the leftover fraction.
1207 if self.scroll[axis] * d < 0.0 {
1208 self.scroll[axis] = 0.0;
1209 }
1210 self.scroll[axis] += d;
1211 while self.scroll[axis].abs() >= 1.0 {
1212 let up = self.scroll[axis] > 0.0;
1213 self.scroll[axis] -= self.scroll[axis].signum();
1214 let key = input::scroll(if up { pos } else { neg }, self.modifiers);
1215 self.on_mouse_key(key, event_loop);
1216 }
1217 }
1218 }
1219
1220 /// The grid scrolled; `moved` says whether it pulled the cursor along.
1221 fn after_grid_scroll(&mut self, moved: bool) {
1222 if moved {
1223 self.sync(true);
1224 } else {
1225 self.sync_thumbs();
1226 }
1227 self.request_redraw();
1228 }
1229
1230 /// A click (press and release in place) in the grid selects; a second one opens.
1231 fn grid_click(&mut self, event_loop: &ActiveEventLoop) {
1232 let now = Instant::now();
1233 let Some(i) = self.state.grid_hit(self.cursor, now) else {
1234 return;
1235 };
1236 let double = self
1237 .last_click
1238 .is_some_and(|(t, j)| j == i && now < t + DOUBLE_CLICK);
1239 self.last_click = (!double).then_some((now, i));
1240 let mut actions = vec![Action::Goto(i + 1)];
1241 if double {
1242 actions.push(Action::Mode(ModeArg::Image));
1243 }
1244 let _ = self.run(actions, &Ctx::default(), event_loop);
1245 }
1246
1247 fn redraw(&mut self) {
1248 let now = Instant::now();
1249 let ready = self.state.shown.is_some()
1250 || self.state.queue.is_empty()
1251 || (self.state.in_grid() && self.grid_settled())
1252 || now >= self.started + SHOW_TIMEOUT;
1253 if !self.shown_window && !ready {
1254 return; // about_to_wait wakes us at the timeout
1255 }
1256 self.state.advance(now);
1257 self.state.tick(now);
1258 let settled = if self.state.in_grid() {
1259 self.thumbs.arrivals.is_empty() && self.grid_settled()
1260 } else {
1261 self.state.loading_since.is_none()
1262 };
1263 let Some(g) = &mut self.gfx else { return };
1264 if !self.shown_window {
1265 g.window.set_visible(true);
1266 }
1267 let theme = self.state.theme_at(now);
1268 let scene = ui::scene(&self.state, &theme, now, &mut |t| g.renderer.measure(t));
1269 let result = g.renderer.render(&scene);
1270 if let Some(path) = std::env::var_os("REMAGE_SNAPSHOT")
1271 && !self.state.is_animating(now)
1272 && settled
1273 {
1274 match g.renderer.snapshot(&scene, Path::new(&path)) {
1275 Ok(()) => log::info!("snapshot written to {}", Path::new(&path).display()),
1276 Err(e) => log::error!("snapshot: {e:#}"),
1277 }
1278 }
1279 drop(scene);
1280 if let Err(e) = result {
1281 log::error!("render: {e:#}");
1282 self.state
1283 .notify(format!("error: {e}"), MsgKind::Error, now);
1284 }
1285 if !self.shown_window {
1286 self.shown_window = true;
1287 log::debug!("first frame after {:.1?}", self.started.elapsed());
1288 let proxy = self.proxy.clone();
1289 std::thread::spawn(move || {
1290 let _ = proxy.send_event(UserEvent::Fonts(text::all_fonts()));
1291 });
1292 }
1293 if self.state.is_animating(Instant::now()) {
1294 g.window.request_redraw();
1295 }
1296 }
1297
1298 /// Upload thumbnails, then preloaded images a slice at a time. Returns whether work remains.
1299 fn pump_uploads(&mut self) -> bool {
1300 let Some(g) = &mut self.gfx else { return false };
1301 let now = Instant::now();
1302 let mut bytes = 0;
1303 while bytes < UPLOAD_BUDGET
1304 && let Some((path, t)) = self.thumbs.arrivals.pop_front()
1305 {
1306 bytes += t.pixels.len();
1307 let wanted = &self.thumbs.wanted;
1308 for gone in g.renderer.insert_thumb(&path, &t, |p| wanted.contains(p)) {
1309 self.state.thumbs.remove(&gone);
1310 }
1311 if g.renderer.has_thumb(&path) {
1312 self.state.thumb_ready(path, t.size, now);
1313 }
1314 g.window.request_redraw();
1315 }
1316 if !self.thumbs.arrivals.is_empty() {
1317 return true;
1318 }
1319 if let Some((path, img)) = self.uploads.front() {
1320 let img = fit_gpu(img.clone(), g.renderer.max_texture_dim());
1321 if g.renderer.upload(path, &img, UPLOAD_BUDGET) {
1322 self.uploads.pop_front();
1323 }
1324 }
1325 !self.uploads.is_empty()
1326 }
1327}
1328
1329/// A config error trimmed for the status line: `config.toml:2:1: unknown field `fitt`; did you
1330/// mean `fit`?` without the directory or the list of every valid field.
1331fn short_error(message: &str) -> String {
1332 let mut s = match message.split_once(": ") {
1333 Some((loc, rest)) if loc.contains('/') => {
1334 format!("{}: {rest}", loc.rsplit('/').next().unwrap_or(loc))
1335 }
1336 _ => message.to_string(),
1337 };
1338 if let Some(start) = s.find(", expected one of") {
1339 let end = s[start..]
1340 .find("; did you mean")
1341 .map_or(s.len(), |i| start + i);
1342 s.replace_range(start..end, "");
1343 }
1344 s
1345}
1346
1347fn keymap(layers: &[KeyTables]) -> Keymap {
1348 let refs: Vec<&KeyTables> = layers.iter().collect();
1349 Keymap::new(&refs)
1350}
1351
1352fn font_families(c: &Config) -> [String; 3] {
1353 [
1354 c.ui.font_ui.clone(),
1355 c.ui.font_mono.clone(),
1356 c.ui.font_display.clone(),
1357 ]
1358}
1359
1360fn mtime(path: &Path) -> Option<SystemTime> {
1361 std::fs::metadata(path).and_then(|m| m.modified()).ok()
1362}
1363
1364fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1365 m.lock().unwrap_or_else(|e| e.into_inner())
1366}
1367
1368/// Shrink an image that exceeds the GPU's texture limit (decoded before the limit was known).
1369fn fit_gpu(img: Arc<DecodedImage>, max_dim: u32) -> Arc<DecodedImage> {
1370 if img.size[0] <= max_dim && img.size[1] <= max_dim {
1371 return img;
1372 }
1373 let owned = Arc::try_unwrap(img).unwrap_or_else(|a| DecodedImage {
1374 pixels: a.pixels.clone(),
1375 ..*a
1376 });
1377 match decode::downscale(owned, max_dim) {
1378 Ok(small) => Arc::new(small),
1379 Err(e) => {
1380 log::error!("downscale: {e:#}");
1381 Arc::new(DecodedImage {
1382 size: [1, 1],
1383 pixels: vec![0; 4],
1384 format: "?",
1385 source_size: [1, 1],
1386 })
1387 }
1388 }
1389}
1390
1391impl ApplicationHandler<UserEvent> for App {
1392 fn resumed(&mut self, event_loop: &ActiveEventLoop) {
1393 if self.gfx.is_some() {
1394 return;
1395 }
1396 let (w, h) = self.opts.geometry.unwrap_or((1280, 800));
1397 let mut attrs = Window::default_attributes()
1398 .with_title("remage")
1399 .with_inner_size(LogicalSize::new(w, h))
1400 .with_decorations(self.config.window.decorations)
1401 .with_visible(false);
1402 if self.opts.fullscreen {
1403 attrs = attrs.with_fullscreen(Some(Fullscreen::Borderless(None)));
1404 }
1405 let class = self.opts.class.clone();
1406 attrs = winit::platform::wayland::WindowAttributesExtWayland::with_name(
1407 attrs,
1408 class.clone(),
1409 class.clone(),
1410 );
1411 attrs =
1412 winit::platform::x11::WindowAttributesExtX11::with_name(attrs, class.clone(), class);
1413 let window = match event_loop.create_window(attrs) {
1414 Ok(w) => Arc::new(w),
1415 Err(e) => {
1416 self.error = Some(anyhow::anyhow!("create window: {e}"));
1417 event_loop.exit();
1418 return;
1419 }
1420 };
1421 let renderer = match Renderer::new(window.clone(), event_loop.owned_display_handle()) {
1422 Ok(mut r) => {
1423 r.set_font_families(font_families(&self.config));
1424 r
1425 }
1426 Err(e) => {
1427 self.error = Some(e.context("initialise GPU"));
1428 event_loop.exit();
1429 return;
1430 }
1431 };
1432 log::debug!("GPU ready after {:.1?}", self.started.elapsed());
1433 self.max_dim
1434 .store(renderer.max_texture_dim(), Ordering::Relaxed);
1435 let size = window.inner_size();
1436 self.state.set_window(
1437 size.width as f32,
1438 size.height as f32,
1439 window.scale_factor() as f32,
1440 );
1441 self.gfx = Some(Gfx { renderer, window });
1442 self.sync(false);
1443 }
1444
1445 fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
1446 match event {
1447 UserEvent::Decoded {
1448 path,
1449 mtime,
1450 result,
1451 } => self.on_decoded(path, mtime, result),
1452 UserEvent::Found(paths) => {
1453 log::debug!("sniffing found {} more images", paths.len());
1454 let was_empty = self.state.queue.is_empty();
1455 self.state.queue.insert_sorted(paths);
1456 self.sync(was_empty);
1457 }
1458 UserEvent::Fonts(db) => {
1459 if let Some(g) = &mut self.gfx {
1460 g.renderer.set_fonts(db);
1461 g.window.request_redraw();
1462 }
1463 }
1464 UserEvent::Config(result) => match *result {
1465 Ok(loaded) => {
1466 log::info!("config reloaded");
1467 self.apply_config(loaded);
1468 }
1469 Err(e) => {
1470 log::error!("{e}");
1471 self.config_failed(&e.message);
1472 }
1473 },
1474 UserEvent::Sorted(files) => {
1475 self.state.queue.reorder(files);
1476 self.sync(false);
1477 }
1478 UserEvent::Thumb {
1479 path,
1480 px,
1481 mtime,
1482 result,
1483 } => self.on_thumb(path, px, mtime, result),
1484 UserEvent::Command { id, event } => self.on_command(id, event, event_loop),
1485 UserEvent::Refreshed(found) => self.on_refreshed(found),
1486 UserEvent::Ipc(inc) => self.on_ipc(inc, event_loop),
1487 }
1488 }
1489
1490 fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
1491 match event {
1492 WindowEvent::CloseRequested => event_loop.exit(),
1493 WindowEvent::Resized(size) => {
1494 let Some(g) = &mut self.gfx else { return };
1495 g.renderer.resize(size.width, size.height);
1496 let sf = g.window.scale_factor() as f32;
1497 self.state
1498 .set_window(size.width as f32, size.height as f32, sf);
1499 self.sync_thumbs();
1500 self.request_redraw();
1501 }
1502 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
1503 let [w, h] = self.state.window();
1504 self.state.set_window(w, h, scale_factor as f32);
1505 self.sync_thumbs();
1506 self.request_redraw();
1507 }
1508 WindowEvent::RedrawRequested => self.redraw(),
1509 WindowEvent::ModifiersChanged(m) => self.modifiers = m.state(),
1510 WindowEvent::Focused(false) => {
1511 self.held.clear();
1512 self.state.stop_all_pans();
1513 self.dragging = false;
1514 }
1515 WindowEvent::KeyboardInput {
1516 event,
1517 is_synthetic: false,
1518 ..
1519 } => {
1520 self.on_key(event, event_loop);
1521 }
1522 WindowEvent::CursorMoved { position, .. } => {
1523 let p = [position.x as f32, position.y as f32];
1524 let delta = [p[0] - self.cursor[0], p[1] - self.cursor[1]];
1525 self.cursor = p;
1526 if self.dragging && self.state.in_grid() {
1527 let moved = self.state.grid_drag(delta[1], Instant::now());
1528 self.after_grid_scroll(moved);
1529 } else if self.dragging && self.state.shown.is_some() {
1530 self.state.view.pan_pixels(delta);
1531 self.request_redraw();
1532 }
1533 }
1534 WindowEvent::MouseInput { state, button, .. } => {
1535 let pressed = state == ElementState::Pressed;
1536 if button == MouseButton::Left {
1537 // Drag pans; a press and release in place is a `<LeftMouse>` click.
1538 self.dragging = pressed;
1539 if pressed {
1540 self.press = Some(self.cursor);
1541 return;
1542 }
1543 let Some(p) = self.press.take() else { return };
1544 let moved = (self.cursor[0] - p[0]).hypot(self.cursor[1] - p[1]);
1545 if moved > CLICK_SLOP * self.state.scale_factor() {
1546 return;
1547 }
1548 if self.state.in_grid() {
1549 self.grid_click(event_loop);
1550 }
1551 } else if !pressed {
1552 return;
1553 }
1554 if let Some(key) = input::button(button, self.modifiers) {
1555 self.on_mouse_key(key, event_loop);
1556 }
1557 }
1558 WindowEvent::MouseWheel { delta, .. } => {
1559 let (d, pixels) = match delta {
1560 MouseScrollDelta::LineDelta(x, y) => ([x, y], false),
1561 MouseScrollDelta::PixelDelta(p) => (
1562 [p.x as f32 / PIXELS_PER_STEP, p.y as f32 / PIXELS_PER_STEP],
1563 true,
1564 ),
1565 };
1566 self.on_wheel(d, pixels, event_loop);
1567 }
1568 _ => {}
1569 }
1570 }
1571
1572 fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) {
1573 if matches!(cause, StartCause::ResumeTimeReached { .. }) {
1574 self.request_redraw();
1575 }
1576 }
1577
1578 fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
1579 let now = Instant::now();
1580 let busy = self.pump_uploads();
1581 if let Some(t) = self.ambiguous_since
1582 && now >= t + SEQUENCE_TIMEOUT
1583 {
1584 let runs = self.keymap.timeout(self.state.mode);
1585 self.update_pending(now);
1586 for run in runs {
1587 let _ = self.run(run.counted(), &Ctx::default(), event_loop);
1588 }
1589 }
1590 if let Some(t) = self.grid_rest
1591 && now >= t + GRID_REST
1592 {
1593 self.grid_rest = None;
1594 if self.state.in_grid()
1595 && let Some(cur) = self.state.queue.current().map(Path::to_path_buf)
1596 && !self.cache_has(&cur)
1597 {
1598 self.decode(&cur, Priority::Preload);
1599 }
1600 }
1601 self.publish();
1602 let (redraw, mut next) = self.state.tick(now);
1603 if let Some(t) = self.grid_rest {
1604 let at = t + GRID_REST;
1605 next = Some(next.map_or(at, |n| n.min(at)));
1606 }
1607 if let Some(t) = self.ambiguous_since {
1608 let at = t + SEQUENCE_TIMEOUT;
1609 next = Some(next.map_or(at, |n| n.min(at)));
1610 }
1611 if redraw {
1612 self.request_redraw();
1613 }
1614 if !self.shown_window {
1615 let at = self.started + SHOW_TIMEOUT;
1616 next = Some(next.map_or(at, |n| n.min(at)));
1617 }
1618 event_loop.set_control_flow(match (busy, next) {
1619 (true, _) => ControlFlow::Poll,
1620 (false, Some(t)) => ControlFlow::WaitUntil(t),
1621 (false, None) => ControlFlow::Wait,
1622 });
1623 }
1624}
1625
1626#[cfg(test)]
1627mod tests {
1628 use super::*;
1629
1630 #[test]
1631 fn config_errors_are_shortened_for_the_bar() {
1632 assert_eq!(
1633 short_error(
1634 "/home/u/.config/remage/config.toml:2:1: unknown field `fitt`, expected one of \
1635 `fit`, `zoom_step`; did you mean `fit`?"
1636 ),
1637 "config.toml:2:1: unknown field `fitt`; did you mean `fit`?"
1638 );
1639 assert_eq!(
1640 short_error("a.toml:1:1: unknown field `x`, expected `y`"),
1641 "a.toml:1:1: unknown field `x`, expected `y`"
1642 );
1643 }
1644}