Krolyxon
Commit e3295f9

Branches

Tags

Refs →

remagesrcstatemod.rs

1164 lines · 40.2 KB · Rust

1//! Pure application state: no winit or wgpu types, so everything here is unit-testable.
2
3pub mod grid;
4pub mod queue;
5pub mod view;
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, Instant};
10
11use crate::action::{Action, ModeArg, Page, Switch, Zoom};
12use crate::anim::Tween;
13use crate::config::theme::Theme;
14use crate::config::{Config, Transition};
15use grid::{Grid, Layout};
16use queue::{Queue, SortKey};
17use view::{Affine, Dir, FitMode, View};
18
19/// `[LOADING]` appears only once a load has taken this long, so it never flickers.
20pub const LOADING_DELAY: Duration = Duration::from_millis(120);
21/// A tap on a pan key moves for at least this long.
22const MIN_PAN_HOLD: Duration = Duration::from_millis(120);
23/// Held-key pan speed, in viewports per second.
24const PAN_SPEED: f32 = 1.25;
25/// Logical height of the status bar.
26pub const BAR_HEIGHT: f32 = 32.0;
27/// Image switch crossfade with `transition = "fade"`.
28const CROSSFADE: Duration = Duration::from_millis(100);
29/// Theme changes (a new wallpaper palette) crossfade this long.
30const THEME_FADE: Duration = Duration::from_millis(250);
31/// Thumbnails fade in this long as they arrive.
32const THUMB_FADE: Duration = Duration::from_millis(150);
33/// Thumbnail names: Space Grotesk at this size.
34pub const GRID_NAME_SIZE: f32 = 12.0;
35
36/// Which keymap applies and what is on screen.
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub enum Mode {
39 #[default]
40 Image,
41 Grid,
42 Visual,
43}
44
45impl Mode {
46 pub fn name(self) -> &'static str {
47 match self {
48 Mode::Image => "image",
49 Mode::Grid => "grid",
50 Mode::Visual => "visual",
51 }
52 }
53}
54
55#[derive(Clone, Debug, PartialEq)]
56pub struct Settings {
57 pub fit: FitMode,
58 pub zoom: Option<f32>,
59 pub zoom_step: f32,
60 pub wrap: bool,
61 pub preload: usize,
62 pub anim: Duration,
63 pub crossfade: Duration,
64 pub theme_fade: Duration,
65 /// UI size multiplier on top of the display scale.
66 pub ui_scale: f32,
67 pub which_key_delay: Duration,
68 pub thumb_fade: Duration,
69 /// Logical thumbnail sizes `grid-size` steps through, and the starting one.
70 pub grid_sizes: Vec<u32>,
71 pub grid_size: u32,
72 pub grid_gap: u32,
73 pub grid_names: bool,
74}
75
76impl Default for Settings {
77 fn default() -> Self {
78 Self {
79 fit: FitMode::Down,
80 zoom: None,
81 zoom_step: 1.25,
82 wrap: true,
83 preload: 2,
84 anim: Duration::from_millis(180),
85 crossfade: CROSSFADE,
86 theme_fade: THEME_FADE,
87 ui_scale: 1.0,
88 which_key_delay: Duration::from_millis(300),
89 thumb_fade: THUMB_FADE,
90 grid_sizes: vec![96, 128, 160, 224, 320],
91 grid_size: 160,
92 grid_gap: 16,
93 grid_names: false,
94 }
95 }
96}
97
98impl Settings {
99 pub fn from_config(c: &Config) -> Self {
100 let on = c.animation.enabled;
101 let anim = |d: Duration| if on { d } else { Duration::ZERO };
102 Self {
103 fit: c.image.fit,
104 zoom: None,
105 zoom_step: c.image.zoom_step,
106 wrap: c.general.wrap,
107 preload: c.image.preload,
108 anim: anim(Duration::from_millis(c.animation.duration_ms)),
109 crossfade: match c.animation.transition {
110 Transition::Fade => anim(CROSSFADE),
111 Transition::None => Duration::ZERO,
112 },
113 theme_fade: anim(THEME_FADE),
114 ui_scale: c.ui.scale,
115 which_key_delay: Duration::from_millis(c.ui.which_key_delay_ms),
116 thumb_fade: anim(THUMB_FADE),
117 grid_sizes: c.grid.sizes.clone(),
118 grid_size: c.grid.size,
119 grid_gap: c.grid.gap,
120 grid_names: c.grid.names,
121 }
122 }
123}
124
125#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
126#[serde(rename_all = "lowercase")]
127pub enum AlphaBg {
128 #[default]
129 Dots,
130 Checker,
131 Solid,
132}
133
134impl AlphaBg {
135 fn next(self) -> Self {
136 match self {
137 AlphaBg::Dots => AlphaBg::Checker,
138 AlphaBg::Checker => AlphaBg::Solid,
139 AlphaBg::Solid => AlphaBg::Dots,
140 }
141 }
142}
143
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub enum MsgKind {
146 Info,
147 Error,
148}
149
150#[derive(Clone, Debug)]
151pub struct Message {
152 pub text: String,
153 pub kind: MsgKind,
154 pub until: Instant,
155}
156
157/// The image currently on screen (which may lag the queue's current file while it loads).
158#[derive(Clone, Debug)]
159pub struct Shown {
160 pub path: PathBuf,
161 /// Pixel size before any downscale for the GPU.
162 pub size: [u32; 2],
163 pub format: &'static str,
164}
165
166/// Frames of the image on screen: a timed animation, or the pages of a document.
167#[derive(Clone, Debug)]
168pub struct Playback {
169 pub delays: Vec<Duration>,
170 pub animated: bool,
171 pub index: usize,
172 pub playing: bool,
173 /// When the next animation frame is due.
174 next: Option<Instant>,
175}
176
177impl Playback {
178 pub fn len(&self) -> usize {
179 self.delays.len()
180 }
181
182 fn step(&mut self, delta: isize) {
183 let n = self.len() as isize;
184 self.index = (self.index as isize + delta).rem_euclid(n) as usize;
185 }
186
187 fn delay(&self) -> Duration {
188 self.delays[self.index].max(Duration::from_millis(10))
189 }
190}
191
192/// The previous image fading out under the new one.
193#[derive(Clone, Debug)]
194pub struct Fade {
195 pub path: PathBuf,
196 pub affine: Affine,
197 pub alpha: Tween<f32>,
198}
199
200/// A thumbnail in the atlas, as far as drawing it is concerned.
201#[derive(Clone, Copy, Debug, PartialEq)]
202pub struct ThumbState {
203 pub size: [u32; 2],
204 /// When it arrived, for the fade-in.
205 pub at: Instant,
206}
207
208#[derive(Clone, Copy, Debug)]
209struct Hold {
210 dir: Dir,
211 pressed: Instant,
212 released: Option<Instant>,
213}
214
215/// What the event loop must do after an action.
216#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
217pub struct Effects {
218 pub redraw: bool,
219 /// The queue's current file changed: load it and refresh the preload window.
220 pub navigated: bool,
221 /// The queue order changed around the current file: refresh the preload window.
222 pub reordered: bool,
223 /// The status bar appeared or vanished, so the image area changed.
224 pub layout: bool,
225 /// Image ⇄ grid (or visual) switched.
226 pub mode: bool,
227 pub fullscreen: Option<Switch>,
228 pub reload: bool,
229 pub reload_config: bool,
230 /// Re-check files on disk: drop missing ones, re-decode changed ones.
231 pub refresh: bool,
232 /// Re-sort the queue (stat-based keys need the filesystem, so the loop does it).
233 pub sort: Option<SortKey>,
234 pub quit: bool,
235}
236
237#[derive(Debug)]
238pub struct AppState {
239 pub queue: Queue,
240 pub view: View,
241 pub settings: Settings,
242 pub mode: Mode,
243 pub grid: Grid,
244 /// Thumbnails in the atlas (or known to fail), by path.
245 pub thumbs: HashMap<PathBuf, ThumbState>,
246 last_thumb: Option<Instant>,
247 pub theme: Theme,
248 /// The previous theme fading out after a reload.
249 theme_fade: Option<(Theme, Tween<f32>)>,
250 pub bar: bool,
251 /// `None` = automatic (nearest above 300%), otherwise forced nearest/linear.
252 pub nearest: Option<bool>,
253 pub alpha_bg: AlphaBg,
254 pub shown: Option<Shown>,
255 /// Frames of the shown image, when it has more than one.
256 pub playback: Option<Playback>,
257 pub fade: Option<Fade>,
258 pub loading_since: Option<Instant>,
259 pub skipped: usize,
260 pub message: Option<Message>,
261 /// Keys typed so far of an incomplete sequence.
262 pub pending: String,
263 /// An inline yes/no question (`DELETE 3 FILES?`) waiting for `y`.
264 pub prompt: Option<String>,
265 /// User commands still running, oldest first: (job id, name).
266 pub running: Vec<(u64, String)>,
267 window: [f32; 2],
268 scale_factor: f32,
269 holds: Vec<Hold>,
270 last_tick: Option<Instant>,
271}
272
273impl AppState {
274 pub fn new(queue: Queue, settings: Settings, bar: bool, now: Instant) -> Self {
275 Self {
276 queue,
277 view: View::new(now),
278 grid: Grid::new(settings.grid_size, now),
279 thumbs: HashMap::new(),
280 last_thumb: None,
281 settings,
282 mode: Mode::Image,
283 theme: Theme::nothing_dark(),
284 theme_fade: None,
285 bar,
286 nearest: None,
287 alpha_bg: AlphaBg::Dots,
288 shown: None,
289 playback: None,
290 fade: None,
291 loading_since: None,
292 skipped: 0,
293 message: None,
294 pending: String::new(),
295 prompt: None,
296 running: Vec::new(),
297 window: [1.0, 1.0],
298 scale_factor: 1.0,
299 holds: Vec::new(),
300 last_tick: None,
301 }
302 }
303
304 /// Window size in physical pixels.
305 pub fn set_window(&mut self, w: f32, h: f32, scale_factor: f32) {
306 self.window = [w, h];
307 self.scale_factor = scale_factor;
308 self.update_viewport();
309 if self.in_grid() {
310 self.grid_follow(Instant::now(), false);
311 }
312 }
313
314 pub fn window(&self) -> [f32; 2] {
315 self.window
316 }
317
318 pub fn scale_factor(&self) -> f32 {
319 self.scale_factor
320 }
321
322 /// Display scale times `ui.scale`: the factor for every UI size.
323 pub fn ui_scale(&self) -> f32 {
324 self.scale_factor * self.settings.ui_scale
325 }
326
327 /// Physical height of the status bar, 0 when hidden.
328 pub fn bar_height(&self) -> f32 {
329 if self.bar {
330 (BAR_HEIGHT * self.ui_scale()).round()
331 } else {
332 0.0
333 }
334 }
335
336 pub fn in_grid(&self) -> bool {
337 matches!(self.mode, Mode::Grid | Mode::Visual)
338 }
339
340 /// Side of a thumbnail cell in physical pixels.
341 pub fn cell_px(&self) -> u32 {
342 (self.grid.size as f32 * self.ui_scale()).round().max(16.0) as u32
343 }
344
345 /// This frame's grid geometry.
346 pub fn grid_layout(&self) -> Layout {
347 let sf = self.ui_scale();
348 let label = if self.settings.grid_names {
349 ((GRID_NAME_SIZE * 1.25).ceil() + 8.0) * sf
350 } else {
351 0.0
352 };
353 Layout::new(
354 self.queue.len(),
355 [
356 self.window[0],
357 (self.window[1] - self.bar_height()).max(1.0),
358 ],
359 self.cell_px() as f32,
360 (self.settings.grid_gap as f32 * sf).round(),
361 label.round(),
362 )
363 }
364
365 /// Scroll so the cursor's row is on screen.
366 fn grid_follow(&mut self, now: Instant, animate: bool) {
367 let l = self.grid_layout();
368 let to = l.follow(self.queue.index(), self.grid.target(&l));
369 if animate {
370 self.grid.scroll_to(to, now, self.settings.anim);
371 } else {
372 self.grid.jump_to(to);
373 }
374 }
375
376 /// Scroll the grid by `rows` (fractional for touchpads), pulling the cursor along so it
377 /// stays on screen. Returns whether the cursor moved.
378 pub fn grid_scroll(&mut self, rows: f32, now: Instant) -> bool {
379 let l = self.grid_layout();
380 let to = l.clamp_scroll(self.grid.target(&l) + rows * l.row_height());
381 self.grid.scroll_to(to, now, self.settings.anim);
382 self.queue.goto(l.pull_into_view(self.queue.index(), to))
383 }
384
385 /// Drag the grid content by `dy` pixels. Returns whether the cursor moved.
386 pub fn grid_drag(&mut self, dy: f32, now: Instant) -> bool {
387 let l = self.grid_layout();
388 let to = l.clamp_scroll(self.grid.scroll(&l, now) - dy);
389 self.grid.jump_to(to);
390 self.queue.goto(l.pull_into_view(self.queue.index(), to))
391 }
392
393 /// The thumbnail under a window point.
394 pub fn grid_hit(&self, p: [f32; 2], now: Instant) -> Option<usize> {
395 let l = self.grid_layout();
396 if p[1] >= l.viewport[1] {
397 return None;
398 }
399 l.hit(p, self.grid.scroll(&l, now))
400 }
401
402 /// The selected range in visual mode.
403 pub fn visual_range(&self) -> Option<std::ops::Range<usize>> {
404 if self.mode != Mode::Visual {
405 return None;
406 }
407 let r = self.grid.range(self.queue.index())?;
408 Some(r.start.min(self.queue.len())..r.end.min(self.queue.len()))
409 }
410
411 /// Switch modes. Returns whether the mode changed.
412 pub fn set_mode(&mut self, mode: Mode, now: Instant) -> bool {
413 if mode == self.mode {
414 return false;
415 }
416 let was_grid = self.in_grid();
417 self.mode = mode;
418 if mode != Mode::Visual {
419 self.grid.anchor = None;
420 }
421 self.holds.clear();
422 match mode {
423 Mode::Image => {
424 if let Some(pb) = self.playback.as_mut().filter(|p| p.playing) {
425 pb.next = Some(now + pb.delay());
426 }
427 // Don't crossfade from whatever was open before the grid.
428 if self.shown.as_ref().map(|s| s.path.as_path()) != self.queue.current() {
429 self.clear_shown();
430 }
431 }
432 Mode::Grid | Mode::Visual if !was_grid => self.grid_follow(now, false),
433 _ => {}
434 }
435 true
436 }
437
438 /// A thumbnail landed in the atlas.
439 pub fn thumb_ready(&mut self, path: PathBuf, size: [u32; 2], now: Instant) {
440 self.thumbs.insert(path, ThumbState { size, at: now });
441 self.last_thumb = Some(now);
442 }
443
444 /// Opacity of a thumbnail fading in.
445 pub fn thumb_alpha(&self, at: Instant, now: Instant) -> f32 {
446 let d = self.settings.thumb_fade;
447 if d.is_zero() {
448 return 1.0;
449 }
450 let t = now.saturating_duration_since(at).as_secs_f32() / d.as_secs_f32();
451 crate::anim::ease(t.min(1.0))
452 }
453
454 fn update_viewport(&mut self) {
455 let h = (self.window[1] - self.bar_height()).max(1.0);
456 self.view.set_viewport(self.window[0], h);
457 }
458
459 fn dur(&self) -> Duration {
460 self.settings.anim
461 }
462
463 /// Apply settings from a (re)loaded config. The view keeps its zoom until the next image.
464 pub fn set_settings(&mut self, settings: Settings) {
465 self.settings = settings;
466 self.update_viewport();
467 }
468
469 /// Switch to a new theme, crossfading from whatever is on screen now.
470 pub fn set_theme(&mut self, theme: Theme, now: Instant) {
471 if theme == self.theme {
472 return;
473 }
474 let shown = self.theme_at(now);
475 self.theme = theme;
476 if self.settings.theme_fade.is_zero() {
477 self.theme_fade = None;
478 return;
479 }
480 let mut t = Tween::new(0.0, now);
481 t.retarget(1.0, now, self.settings.theme_fade);
482 self.theme_fade = Some((shown, t));
483 }
484
485 /// The theme for the frame at `now`.
486 pub fn theme_at(&self, now: Instant) -> Theme {
487 match &self.theme_fade {
488 Some((from, t)) if t.is_active(now) => from.mix(&self.theme, t.value(now)),
489 _ => self.theme.clone(),
490 }
491 }
492
493 pub fn apply(&mut self, action: Action, now: Instant) -> Effects {
494 self.apply_at(action, None, now)
495 }
496
497 /// Apply an action; `anchor` (viewport pixels) is where mouse-driven zoom centres.
498 pub fn apply_at(&mut self, action: Action, anchor: Option<[f32; 2]>, now: Instant) -> Effects {
499 let mut fx = Effects {
500 redraw: true,
501 ..Effects::default()
502 };
503 let dur = self.dur();
504 let in_grid = self.in_grid();
505 let has_image = self.mode == Mode::Image && self.shown.is_some();
506 let wrap = self.settings.wrap;
507 let switch = |s: Switch, cur: bool| match s {
508 Switch::Toggle => !cur,
509 Switch::On => true,
510 Switch::Off => false,
511 };
512 match action {
513 Action::Next(n) => fx.navigated = self.queue.step(n as isize, self.settings.wrap),
514 Action::Prev(n) => fx.navigated = self.queue.step(-(n as isize), self.settings.wrap),
515 Action::First => fx.navigated = self.queue.goto(0),
516 Action::Last => fx.navigated = self.queue.goto(usize::MAX),
517 Action::Goto(n) => fx.navigated = self.queue.goto(n.saturating_sub(1)),
518 Action::Random => {
519 let len = self.queue.len();
520 if len > 1 {
521 let r = std::hash::BuildHasher::hash_one(
522 &std::collections::hash_map::RandomState::new(),
523 now,
524 ) as usize;
525 // Any image but the current one.
526 let i = (self.queue.index() + 1 + r % (len - 1)) % len;
527 fx.navigated = self.queue.goto(i);
528 }
529 }
530 Action::Zoom(z) if has_image => {
531 let step = self.settings.zoom_step;
532 match z {
533 Zoom::In(n) => self.view.zoom_by(step.powi(n as i32), anchor, now, dur),
534 Zoom::Out(n) => self.view.zoom_by(step.powi(-(n as i32)), anchor, now, dur),
535 Zoom::Percent(p) => self.view.zoom_to(p / 100.0, anchor, now, dur),
536 }
537 }
538 Action::NextMark | Action::PrevMark => {
539 let forward = action == Action::NextMark;
540 match self.queue.find_mark(forward, 1, wrap) {
541 Some(i) => fx.navigated = self.queue.goto(i),
542 None => self.notify("no other marks", MsgKind::Info, now),
543 }
544 }
545 Action::Mark(s) => {
546 if let Some(range) = self.visual_range() {
547 let on = match s {
548 Switch::Toggle => !range.clone().all(|i| self.queue.is_marked(i)),
549 Switch::On => true,
550 Switch::Off => false,
551 };
552 for i in range {
553 self.queue.set_mark(i, on);
554 }
555 fx.mode = self.set_mode(Mode::Grid, now);
556 } else {
557 let i = self.queue.index();
558 let cur = self.queue.is_marked(i);
559 self.queue.set_mark(i, switch(s, cur));
560 }
561 }
562 Action::MarkAll => self.queue.mark_all(),
563 Action::UnmarkAll => self.queue.unmark_all(),
564 Action::InvertMarks => self.queue.invert_marks(),
565 Action::Move(dir, n) if in_grid => {
566 let l = self.grid_layout();
567 fx.navigated = self.queue.goto(l.step(self.queue.index(), dir, n));
568 }
569 Action::Page(page) if in_grid => {
570 let l = self.grid_layout();
571 let full = l.page_rows() as isize;
572 let half = ((l.viewport[1] / l.row_height() / 2.0).round() as isize).max(1);
573 let rows = match page {
574 Page::Down => full,
575 Page::Up => -full,
576 Page::HalfDown => half,
577 Page::HalfUp => -half,
578 };
579 let to = l.clamp_scroll(self.grid.target(&l) + rows as f32 * l.row_height());
580 self.grid.scroll_to(to, now, dur);
581 fx.navigated = self.queue.goto(l.step_rows(self.queue.index(), rows));
582 }
583 Action::RowStart | Action::RowEnd if in_grid => {
584 let l = self.grid_layout();
585 let i = self.queue.index();
586 let to = if action == Action::RowStart {
587 l.row_start(i)
588 } else {
589 l.row_end(i)
590 };
591 fx.navigated = self.queue.goto(to);
592 }
593 Action::Scroll(dir, n) if in_grid => match dir {
594 Dir::Up => fx.navigated = self.grid_scroll(-(n as f32), now),
595 Dir::Down => fx.navigated = self.grid_scroll(n as f32, now),
596 Dir::Left | Dir::Right => fx.redraw = false,
597 },
598 Action::Scroll(dir, n) if has_image => {
599 self.view.pan_step(dir, 0.1 * n as f32, now, dur);
600 }
601 Action::Mode(m) => {
602 let target = match m {
603 ModeArg::Image => Mode::Image,
604 ModeArg::Grid => Mode::Grid,
605 ModeArg::Toggle if in_grid => Mode::Image,
606 ModeArg::Toggle => Mode::Grid,
607 };
608 fx.mode = self.set_mode(target, now);
609 fx.redraw = fx.mode;
610 }
611 Action::Visual => {
612 if self.mode == Mode::Visual {
613 fx.mode = self.set_mode(Mode::Grid, now);
614 } else {
615 self.set_mode(Mode::Grid, now);
616 self.grid.anchor = Some(self.queue.index());
617 self.mode = Mode::Visual;
618 fx.mode = true;
619 }
620 }
621 Action::GridSize(d) => {
622 let size = grid::next_size(&self.settings.grid_sizes, self.grid.size, d);
623 if size == self.grid.size {
624 fx.redraw = false;
625 } else {
626 self.grid.size = size;
627 if in_grid {
628 self.grid_follow(now, false);
629 }
630 }
631 }
632 Action::Fit(fit) if has_image => self.view.set_fit(fit, now, dur),
633 Action::Pan(dir) if has_image => self.view.pan_step(dir, 0.2, now, dur),
634 Action::PanEdge(dir) if has_image => self.view.pan_edge(dir, now, dur),
635 Action::Center if has_image => self.view.recenter(now, dur),
636 Action::Rotate(q) if has_image => self.view.rotate(q, now, dur),
637 Action::Flip(axis) if has_image => self.view.flip(axis, now, dur),
638 Action::FilterToggle => {
639 let nearest = !self.effective_nearest(now);
640 self.nearest = Some(nearest);
641 self.notify(
642 if nearest { "NEAREST" } else { "LINEAR" },
643 MsgKind::Info,
644 now,
645 );
646 }
647 Action::AlphaBgCycle => self.alpha_bg = self.alpha_bg.next(),
648 Action::Play(s) if has_image => match &mut self.playback {
649 Some(pb) if pb.animated => {
650 pb.playing = switch(s, pb.playing);
651 pb.next = pb.playing.then(|| now + pb.delay());
652 }
653 _ => self.notify("not animated", MsgKind::Info, now),
654 },
655 Action::NextFrame | Action::PrevFrame if has_image => match &mut self.playback {
656 Some(pb) => {
657 pb.step(if action == Action::NextFrame { 1 } else { -1 });
658 // Stepping an animation pauses it, so the frame stays put.
659 pb.playing = false;
660 pb.next = None;
661 }
662 None => self.notify("single frame", MsgKind::Info, now),
663 },
664 Action::Fullscreen(s) => fx.fullscreen = Some(s),
665 Action::Bar(s) => {
666 let bar = switch(s, self.bar);
667 if bar != self.bar {
668 self.bar = bar;
669 self.update_viewport();
670 fx.layout = true;
671 }
672 }
673 Action::Sort(key) => fx.sort = Some(key),
674 Action::Reverse => {
675 self.queue.reverse();
676 fx.reordered = true;
677 }
678 Action::Reload => fx.reload = true,
679 Action::Remove => {
680 if let Some(cur) = self.queue.current().map(Path::to_path_buf) {
681 self.queue.remove(&cur);
682 fx.navigated = true;
683 }
684 }
685 Action::ReloadConfig => fx.reload_config = true,
686 Action::Refresh => fx.refresh = true,
687 // The event loop runs commands: they spawn processes and may wait for a `y`.
688 Action::Run(_) => fx.redraw = false,
689 Action::Echo(msg) => self.notify(msg, MsgKind::Info, now),
690 Action::Cancel => self.message = None,
691 Action::Quit | Action::QuitNoOutput => fx.quit = true,
692 Action::Zoom(_)
693 | Action::Fit(_)
694 | Action::Pan(_)
695 | Action::PanEdge(_)
696 | Action::Center
697 | Action::Rotate(_)
698 | Action::Flip(_)
699 | Action::Move(..)
700 | Action::Page(_)
701 | Action::RowStart
702 | Action::RowEnd
703 | Action::Scroll(..)
704 | Action::Play(_)
705 | Action::NextFrame
706 | Action::PrevFrame => fx.redraw = false,
707 // Parsed and bindable now; they arrive with later milestones.
708 other @ (Action::Adjust(..)
709 | Action::Slideshow(_)
710 | Action::SlideshowDelay { .. }
711 | Action::Info(_)
712 | Action::Help
713 | Action::Palette
714 | Action::Search
715 | Action::Filter(_)
716 | Action::FilterClear
717 | Action::Open(_)) => {
718 self.notify(
719 format!("{} not available yet", other.name()),
720 MsgKind::Info,
721 now,
722 );
723 }
724 }
725 if fx.navigated && self.in_grid() {
726 self.grid_follow(now, true);
727 }
728 fx
729 }
730
731 /// Start or stop a held-key pan.
732 pub fn hold_pan(&mut self, dir: Dir, held: bool, now: Instant) {
733 if held {
734 if !self
735 .holds
736 .iter()
737 .any(|h| h.dir == dir && h.released.is_none())
738 {
739 self.holds.retain(|h| h.dir != dir);
740 self.holds.push(Hold {
741 dir,
742 pressed: now,
743 released: None,
744 });
745 }
746 } else {
747 for h in self.holds.iter_mut().filter(|h| h.dir == dir) {
748 h.released.get_or_insert(now);
749 }
750 }
751 }
752
753 pub fn stop_all_pans(&mut self) {
754 self.holds.clear();
755 }
756
757 /// Advance time-driven state (held pans). Call once per frame before drawing.
758 pub fn advance(&mut self, now: Instant) {
759 let last = self.last_tick.replace(now).unwrap_or(now);
760 if self.holds.is_empty() || self.shown.is_none() {
761 self.holds.retain(|h| h.released.is_none());
762 return;
763 }
764 let vp = self.view.viewport();
765 let mut delta = [0.0f32; 2];
766 for h in &self.holds {
767 let end = h.released.map(|r| r.max(h.pressed + MIN_PAN_HOLD));
768 let from = last.max(h.pressed);
769 let to = end.map_or(now, |e| e.min(now));
770 let dt = to.saturating_duration_since(from).as_secs_f32();
771 let v = h.dir.vector();
772 delta[0] -= v[0] * vp[0] * PAN_SPEED * dt;
773 delta[1] -= v[1] * vp[1] * PAN_SPEED * dt;
774 }
775 self.holds.retain(|h| {
776 h.released
777 .is_none_or(|r| now < r.max(h.pressed + MIN_PAN_HOLD))
778 });
779 if delta != [0.0, 0.0] {
780 self.view.pan_pixels(delta);
781 }
782 }
783
784 /// The image for the current file is ready to show.
785 /// `size` is the original pixel size; the texture may be smaller (huge images), but the
786 /// view works in original pixels so zoom percentages stay true.
787 pub fn show(&mut self, path: &Path, size: [u32; 2], format: &'static str, now: Instant) {
788 if let Some(prev) = self.shown.take()
789 && prev.path != path
790 && !self.settings.crossfade.is_zero()
791 {
792 let mut alpha = Tween::new(0.0, now);
793 alpha.retarget(1.0, now, self.settings.crossfade);
794 let affine = self.view.transform(now);
795 self.fade = Some(Fade {
796 path: prev.path,
797 affine,
798 alpha,
799 });
800 }
801 self.shown = Some(Shown {
802 path: path.to_path_buf(),
803 size,
804 format,
805 });
806 self.view
807 .reset(size, self.settings.fit, self.settings.zoom, now);
808 self.loading_since = None;
809 self.holds.clear();
810 self.playback = None;
811 }
812
813 /// The shown image has several frames: play an animation, or page through a document.
814 pub fn set_frames(&mut self, delays: Vec<Duration>, animated: bool, now: Instant) {
815 if delays.len() < 2 {
816 self.playback = None;
817 return;
818 }
819 let mut pb = Playback {
820 delays,
821 animated,
822 index: 0,
823 playing: animated,
824 next: None,
825 };
826 pb.next = animated.then(|| now + pb.delay());
827 self.playback = Some(pb);
828 }
829
830 /// The frame of the shown image to draw.
831 pub fn frame(&self) -> usize {
832 self.playback.as_ref().map_or(0, |p| p.index)
833 }
834
835 /// The current file can't be shown (or the queue emptied).
836 pub fn clear_shown(&mut self) {
837 self.shown = None;
838 self.playback = None;
839 self.fade = None;
840 self.loading_since = None;
841 }
842
843 /// Zoom by `steps` zoom steps (fractional for touchpads) around a viewport point.
844 pub fn zoom_at(&mut self, steps: f32, anchor: [f32; 2], now: Instant) {
845 if self.shown.is_some() {
846 let factor = self.settings.zoom_step.powf(steps);
847 self.view
848 .zoom_by(factor, Some(anchor), now, self.settings.anim);
849 }
850 }
851
852 pub fn notify(&mut self, text: impl Into<String>, kind: MsgKind, now: Instant) {
853 let secs = if kind == MsgKind::Error { 5 } else { 2 };
854 self.notify_for(text, kind, now, Duration::from_secs(secs));
855 }
856
857 pub fn notify_for(
858 &mut self,
859 text: impl Into<String>,
860 kind: MsgKind,
861 now: Instant,
862 d: Duration,
863 ) {
864 self.message = Some(Message {
865 text: text.into(),
866 kind,
867 until: now + d,
868 });
869 }
870
871 pub fn effective_nearest(&self, now: Instant) -> bool {
872 self.nearest
873 .unwrap_or_else(|| self.view.display_scale(now) > view::NEAREST_ABOVE)
874 }
875
876 pub fn loading_visible(&self, now: Instant) -> bool {
877 self.loading_since.is_some_and(|t| now >= t + LOADING_DELAY)
878 }
879
880 /// Whether frames must keep coming (animations, held keys).
881 pub fn is_animating(&self, now: Instant) -> bool {
882 let grid = self.in_grid()
883 && (self.grid.is_animating(now)
884 || self
885 .last_thumb
886 .is_some_and(|t| now < t + self.settings.thumb_fade));
887 grid || self.view.is_animating(now)
888 || self.fade.as_ref().is_some_and(|f| f.alpha.is_active(now))
889 || self
890 .theme_fade
891 .as_ref()
892 .is_some_and(|(_, t)| t.is_active(now))
893 || !self.holds.is_empty()
894 }
895
896 /// Expire timed state. Returns whether a redraw is due and the next wake-up time.
897 pub fn tick(&mut self, now: Instant) -> (bool, Option<Instant>) {
898 let mut redraw = false;
899 let mut next: Option<Instant> = None;
900 let mut wake = |t: Instant| next = Some(next.map_or(t, |n| n.min(t)));
901 if let Some(m) = &self.message {
902 if now >= m.until {
903 self.message = None;
904 redraw = true;
905 } else {
906 wake(m.until);
907 }
908 }
909 if let Some(t) = self.loading_since {
910 let at = t + LOADING_DELAY;
911 if now < at {
912 wake(at);
913 }
914 }
915 // Animation frames: only while the image is on screen.
916 if self.mode == Mode::Image
917 && let Some(pb) = &mut self.playback
918 && let Some(mut next) = pb.next
919 {
920 if now >= next {
921 let before = pb.index;
922 // Catch up on missed frames, but after a long stall just carry on from now.
923 for _ in 0..pb.len() {
924 if now < next {
925 break;
926 }
927 pb.step(1);
928 next += pb.delay();
929 }
930 if now >= next {
931 next = now + pb.delay();
932 }
933 pb.next = Some(next);
934 redraw |= pb.index != before;
935 }
936 wake(next);
937 }
938 if self.fade.as_ref().is_some_and(|f| !f.alpha.is_active(now)) {
939 self.fade = None;
940 redraw = true;
941 }
942 if self
943 .theme_fade
944 .as_ref()
945 .is_some_and(|(_, t)| !t.is_active(now))
946 {
947 self.theme_fade = None;
948 redraw = true;
949 }
950 (redraw, next)
951 }
952}
953
954#[cfg(test)]
955mod tests {
956 use super::*;
957
958 fn state(n: usize) -> (AppState, Instant) {
959 let now = Instant::now();
960 let files = (0..n).map(|i| PathBuf::from(format!("{i}.png"))).collect();
961 let mut s = AppState::new(Queue::new(files, None, 0), Settings::default(), true, now);
962 s.set_window(800.0, 632.0, 1.0);
963 (s, now)
964 }
965
966 #[test]
967 fn navigation_reports_changes() {
968 let (mut s, now) = state(3);
969 assert!(s.apply(Action::Next(1), now).navigated);
970 assert!(s.apply(Action::Last, now).navigated);
971 assert!(!s.apply(Action::Last, now).navigated);
972 assert!(s.apply(Action::Goto(1), now).navigated);
973 assert_eq!(s.queue.index(), 0);
974 }
975
976 #[test]
977 fn bar_shrinks_the_image_area() {
978 let (mut s, now) = state(1);
979 assert_eq!(s.view.viewport(), [800.0, 600.0]);
980 assert!(!s.apply(Action::Bar(Switch::On), now).layout);
981 assert!(s.apply(Action::Bar(Switch::Toggle), now).layout);
982 assert_eq!(s.view.viewport(), [800.0, 632.0]);
983 assert!(!s.apply(Action::Bar(Switch::Off), now).layout);
984 s.settings.ui_scale = 2.0;
985 s.apply(Action::Bar(Switch::On), now);
986 assert_eq!(s.view.viewport(), [800.0, 568.0]);
987 s.apply(Action::Bar(Switch::Off), now);
988 assert_eq!(s.view.viewport(), [800.0, 632.0]);
989 }
990
991 #[test]
992 fn showing_a_second_image_crossfades() {
993 let (mut s, now) = state(2);
994 s.show(Path::new("0.png"), [10, 10], "PNG", now);
995 assert!(s.fade.is_none());
996 s.show(Path::new("1.png"), [10, 10], "PNG", now);
997 assert!(s.fade.is_some());
998 assert!(s.is_animating(now));
999 let later = now + Duration::from_secs(1);
1000 assert!(s.tick(later).0);
1001 assert!(s.fade.is_none());
1002 }
1003
1004 #[test]
1005 fn theme_changes_crossfade() {
1006 use crate::config::theme::{Rgba, Theme};
1007 let (mut s, now) = state(1);
1008 let light = Theme::derive(
1009 Rgba::rgb(0xFFFFFF),
1010 Rgba::rgb(0x000000),
1011 Rgba::rgb(0xD71921),
1012 );
1013 s.set_theme(light.clone(), now);
1014 assert!(s.is_animating(now));
1015 let mid = s.theme_at(now + THEME_FADE / 2).background;
1016 assert!(mid.0[0] > 0 && mid.0[0] < 255, "{mid:?}");
1017 let later = now + THEME_FADE * 2;
1018 assert_eq!(s.theme_at(later), light);
1019 assert!(s.tick(later).0);
1020 assert!(!s.is_animating(later));
1021 // With animation off the switch is instant.
1022 s.settings.theme_fade = Duration::ZERO;
1023 s.set_theme(Theme::nothing_dark(), later);
1024 assert_eq!(s.theme_at(later), Theme::nothing_dark());
1025 }
1026
1027 #[test]
1028 fn unfinished_actions_say_so() {
1029 let (mut s, now) = state(2);
1030 let fx = s.apply(Action::Palette, now);
1031 assert!(!fx.navigated);
1032 assert_eq!(
1033 s.message.as_ref().map(|m| m.text.as_str()),
1034 Some("palette not available yet")
1035 );
1036 assert!(s.apply(Action::Refresh, now).refresh);
1037 assert!(!s.apply(Action::Run("x".into()), now).redraw);
1038 s.apply(Action::Echo("hi".into()), now);
1039 assert_eq!(s.message.as_ref().map(|m| m.text.as_str()), Some("hi"));
1040 assert!(s.apply(Action::Random, now).navigated);
1041 assert_eq!(s.queue.index(), 1);
1042 }
1043
1044 #[test]
1045 fn grid_mode_moves_the_queue_cursor() {
1046 let (mut s, now) = state(40);
1047 assert!(s.apply(Action::Mode(ModeArg::Toggle), now).mode);
1048 assert_eq!(s.mode, Mode::Grid);
1049 // 800 wide, 160 cells, 16 gaps: 4 columns.
1050 assert_eq!(s.grid_layout().cols, 4);
1051 assert!(s.apply(Action::Move(Dir::Down, 2), now).navigated);
1052 assert_eq!(s.queue.index(), 8);
1053 s.apply(Action::RowEnd, now);
1054 assert_eq!(s.queue.index(), 11);
1055 s.apply(Action::Last, now);
1056 let l = s.grid_layout();
1057 assert_eq!(s.grid.target(&l), l.max_scroll());
1058 s.apply(Action::Page(Page::Up), now);
1059 assert_eq!(s.queue.index(), 39 - 4 * l.page_rows());
1060 // Image-only actions do nothing in the grid.
1061 assert!(!s.apply(Action::Zoom(Zoom::In(1)), now).redraw);
1062 assert!(s.apply(Action::Mode(ModeArg::Toggle), now).mode);
1063 assert_eq!(s.mode, Mode::Image);
1064 assert!(!s.apply(Action::Move(Dir::Down, 1), now).redraw);
1065 }
1066
1067 #[test]
1068 fn visual_mode_marks_a_range() {
1069 let (mut s, now) = state(10);
1070 s.apply(Action::Goto(2), now);
1071 s.apply(Action::Visual, now);
1072 assert_eq!(s.mode, Mode::Visual);
1073 s.apply(Action::Move(Dir::Right, 3), now);
1074 assert_eq!(s.visual_range(), Some(1..5));
1075 s.apply(Action::Mark(Switch::On), now);
1076 assert_eq!(s.mode, Mode::Grid);
1077 assert_eq!(s.queue.marked_count(), 4);
1078 // Toggling a fully marked range unmarks it.
1079 s.apply(Action::Visual, now);
1080 s.apply(Action::Move(Dir::Left, 3), now);
1081 s.apply(Action::Mark(Switch::Toggle), now);
1082 assert_eq!(s.queue.marked_count(), 0);
1083 }
1084
1085 #[test]
1086 fn marks_and_mark_navigation() {
1087 let (mut s, now) = state(5);
1088 s.apply(Action::Mark(Switch::Toggle), now);
1089 s.apply(Action::Goto(4), now);
1090 s.apply(Action::Mark(Switch::Toggle), now);
1091 assert_eq!(s.queue.marked_count(), 2);
1092 assert!(s.apply(Action::NextMark, now).navigated);
1093 assert_eq!(s.queue.index(), 0);
1094 s.apply(Action::InvertMarks, now);
1095 assert_eq!(s.queue.marked_count(), 3);
1096 s.apply(Action::UnmarkAll, now);
1097 assert!(!s.apply(Action::NextMark, now).navigated);
1098 assert!(s.message.is_some());
1099 s.apply(Action::MarkAll, now);
1100 assert_eq!(s.queue.marked_count(), 5);
1101 }
1102
1103 #[test]
1104 fn grid_size_steps_through_sizes() {
1105 let (mut s, now) = state(3);
1106 s.apply(Action::GridSize(1), now);
1107 assert_eq!(s.grid.size, 224);
1108 s.apply(Action::GridSize(5), now);
1109 assert_eq!(s.grid.size, 320);
1110 assert!(!s.apply(Action::GridSize(1), now).redraw);
1111 assert_eq!(s.cell_px(), 320);
1112 }
1113
1114 #[test]
1115 fn animation_plays_steps_and_pauses() {
1116 let (mut s, now) = state(1);
1117 s.show(Path::new("0.png"), [10, 10], "GIF", now);
1118 let ms = Duration::from_millis;
1119 s.set_frames(vec![ms(100), ms(50), ms(100)], true, now);
1120 assert_eq!(s.frame(), 0);
1121 assert_eq!(s.tick(now + ms(10)).1, Some(now + ms(100)));
1122 assert!(s.tick(now + ms(100)).0);
1123 assert_eq!(s.frame(), 1);
1124 // Late: catch up through frame 1's 50 ms into frame 2.
1125 s.tick(now + ms(160));
1126 assert_eq!(s.frame(), 2);
1127 s.apply(Action::Play(Switch::Toggle), now);
1128 assert!(!s.tick(now + ms(5000)).0, "paused");
1129 s.apply(Action::NextFrame, now);
1130 assert_eq!(s.frame(), 0);
1131 s.apply(Action::PrevFrame, now);
1132 assert_eq!(s.frame(), 2);
1133 // Pages step but never play.
1134 s.set_frames(vec![Duration::ZERO; 3], false, now);
1135 assert!(!s.playback.as_ref().unwrap().playing);
1136 s.apply(Action::Play(Switch::On), now);
1137 assert_eq!(s.message.as_ref().unwrap().text, "not animated");
1138 s.apply(Action::NextFrame, now);
1139 assert_eq!(s.frame(), 1);
1140 // A new image starts without frames.
1141 s.show(Path::new("1.png"), [10, 10], "PNG", now);
1142 assert!(s.playback.is_none());
1143 }
1144
1145 #[test]
1146 fn tapped_pan_moves_a_minimum_distance() {
1147 let (mut s, now) = state(1);
1148 s.show(Path::new("0.png"), [4000, 4000], "PNG", now);
1149 s.apply(Action::Fit(FitMode::Original), now);
1150 s.advance(now);
1151 let before = s.view.transform(now + Duration::from_secs(1)).t;
1152 s.hold_pan(Dir::Right, true, now);
1153 s.hold_pan(Dir::Right, false, now + Duration::from_millis(10));
1154 s.advance(now + Duration::from_millis(500));
1155 assert!(!s.is_animating(now + Duration::from_secs(1)));
1156 let after = s.view.transform(now + Duration::from_secs(1)).t;
1157 let moved = before[0] - after[0];
1158 let expected = 800.0 * PAN_SPEED * MIN_PAN_HOLD.as_secs_f32();
1159 assert!(
1160 (moved - expected).abs() < 1.0,
1161 "moved {moved}, expected {expected}"
1162 );
1163 }
1164}