Krolyxon
Commit ab1dc3b

Branches

Tags

Refs →

remagesrcstatemod.rs

1038 lines · 35.7 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/// The previous image fading out under the new one.
167#[derive(Clone, Debug)]
168pub struct Fade {
169 pub path: PathBuf,
170 pub affine: Affine,
171 pub alpha: Tween<f32>,
172}
173
174/// A thumbnail in the atlas, as far as drawing it is concerned.
175#[derive(Clone, Copy, Debug, PartialEq)]
176pub struct ThumbState {
177 pub size: [u32; 2],
178 /// When it arrived, for the fade-in.
179 pub at: Instant,
180}
181
182#[derive(Clone, Copy, Debug)]
183struct Hold {
184 dir: Dir,
185 pressed: Instant,
186 released: Option<Instant>,
187}
188
189/// What the event loop must do after an action.
190#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
191pub struct Effects {
192 pub redraw: bool,
193 /// The queue's current file changed: load it and refresh the preload window.
194 pub navigated: bool,
195 /// The queue order changed around the current file: refresh the preload window.
196 pub reordered: bool,
197 /// The status bar appeared or vanished, so the image area changed.
198 pub layout: bool,
199 /// Image ⇄ grid (or visual) switched.
200 pub mode: bool,
201 pub fullscreen: Option<Switch>,
202 pub reload: bool,
203 pub reload_config: bool,
204 /// Re-check files on disk: drop missing ones, re-decode changed ones.
205 pub refresh: bool,
206 /// Re-sort the queue (stat-based keys need the filesystem, so the loop does it).
207 pub sort: Option<SortKey>,
208 pub quit: bool,
209}
210
211#[derive(Debug)]
212pub struct AppState {
213 pub queue: Queue,
214 pub view: View,
215 pub settings: Settings,
216 pub mode: Mode,
217 pub grid: Grid,
218 /// Thumbnails in the atlas (or known to fail), by path.
219 pub thumbs: HashMap<PathBuf, ThumbState>,
220 last_thumb: Option<Instant>,
221 pub theme: Theme,
222 /// The previous theme fading out after a reload.
223 theme_fade: Option<(Theme, Tween<f32>)>,
224 pub bar: bool,
225 /// `None` = automatic (nearest above 300%), otherwise forced nearest/linear.
226 pub nearest: Option<bool>,
227 pub alpha_bg: AlphaBg,
228 pub shown: Option<Shown>,
229 pub fade: Option<Fade>,
230 pub loading_since: Option<Instant>,
231 pub skipped: usize,
232 pub message: Option<Message>,
233 /// Keys typed so far of an incomplete sequence.
234 pub pending: String,
235 /// An inline yes/no question (`DELETE 3 FILES?`) waiting for `y`.
236 pub prompt: Option<String>,
237 /// User commands still running, oldest first: (job id, name).
238 pub running: Vec<(u64, String)>,
239 window: [f32; 2],
240 scale_factor: f32,
241 holds: Vec<Hold>,
242 last_tick: Option<Instant>,
243}
244
245impl AppState {
246 pub fn new(queue: Queue, settings: Settings, bar: bool, now: Instant) -> Self {
247 Self {
248 queue,
249 view: View::new(now),
250 grid: Grid::new(settings.grid_size, now),
251 thumbs: HashMap::new(),
252 last_thumb: None,
253 settings,
254 mode: Mode::Image,
255 theme: Theme::nothing_dark(),
256 theme_fade: None,
257 bar,
258 nearest: None,
259 alpha_bg: AlphaBg::Dots,
260 shown: None,
261 fade: None,
262 loading_since: None,
263 skipped: 0,
264 message: None,
265 pending: String::new(),
266 prompt: None,
267 running: Vec::new(),
268 window: [1.0, 1.0],
269 scale_factor: 1.0,
270 holds: Vec::new(),
271 last_tick: None,
272 }
273 }
274
275 /// Window size in physical pixels.
276 pub fn set_window(&mut self, w: f32, h: f32, scale_factor: f32) {
277 self.window = [w, h];
278 self.scale_factor = scale_factor;
279 self.update_viewport();
280 if self.in_grid() {
281 self.grid_follow(Instant::now(), false);
282 }
283 }
284
285 pub fn window(&self) -> [f32; 2] {
286 self.window
287 }
288
289 pub fn scale_factor(&self) -> f32 {
290 self.scale_factor
291 }
292
293 /// Display scale times `ui.scale`: the factor for every UI size.
294 pub fn ui_scale(&self) -> f32 {
295 self.scale_factor * self.settings.ui_scale
296 }
297
298 /// Physical height of the status bar, 0 when hidden.
299 pub fn bar_height(&self) -> f32 {
300 if self.bar {
301 (BAR_HEIGHT * self.ui_scale()).round()
302 } else {
303 0.0
304 }
305 }
306
307 pub fn in_grid(&self) -> bool {
308 matches!(self.mode, Mode::Grid | Mode::Visual)
309 }
310
311 /// Side of a thumbnail cell in physical pixels.
312 pub fn cell_px(&self) -> u32 {
313 (self.grid.size as f32 * self.ui_scale()).round().max(16.0) as u32
314 }
315
316 /// This frame's grid geometry.
317 pub fn grid_layout(&self) -> Layout {
318 let sf = self.ui_scale();
319 let label = if self.settings.grid_names {
320 ((GRID_NAME_SIZE * 1.25).ceil() + 8.0) * sf
321 } else {
322 0.0
323 };
324 Layout::new(
325 self.queue.len(),
326 [
327 self.window[0],
328 (self.window[1] - self.bar_height()).max(1.0),
329 ],
330 self.cell_px() as f32,
331 (self.settings.grid_gap as f32 * sf).round(),
332 label.round(),
333 )
334 }
335
336 /// Scroll so the cursor's row is on screen.
337 fn grid_follow(&mut self, now: Instant, animate: bool) {
338 let l = self.grid_layout();
339 let to = l.follow(self.queue.index(), self.grid.target(&l));
340 if animate {
341 self.grid.scroll_to(to, now, self.settings.anim);
342 } else {
343 self.grid.jump_to(to);
344 }
345 }
346
347 /// Scroll the grid by `rows` (fractional for touchpads), pulling the cursor along so it
348 /// stays on screen. Returns whether the cursor moved.
349 pub fn grid_scroll(&mut self, rows: f32, now: Instant) -> bool {
350 let l = self.grid_layout();
351 let to = l.clamp_scroll(self.grid.target(&l) + rows * l.row_height());
352 self.grid.scroll_to(to, now, self.settings.anim);
353 self.queue.goto(l.pull_into_view(self.queue.index(), to))
354 }
355
356 /// Drag the grid content by `dy` pixels. Returns whether the cursor moved.
357 pub fn grid_drag(&mut self, dy: f32, now: Instant) -> bool {
358 let l = self.grid_layout();
359 let to = l.clamp_scroll(self.grid.scroll(&l, now) - dy);
360 self.grid.jump_to(to);
361 self.queue.goto(l.pull_into_view(self.queue.index(), to))
362 }
363
364 /// The thumbnail under a window point.
365 pub fn grid_hit(&self, p: [f32; 2], now: Instant) -> Option<usize> {
366 let l = self.grid_layout();
367 if p[1] >= l.viewport[1] {
368 return None;
369 }
370 l.hit(p, self.grid.scroll(&l, now))
371 }
372
373 /// The selected range in visual mode.
374 pub fn visual_range(&self) -> Option<std::ops::Range<usize>> {
375 if self.mode != Mode::Visual {
376 return None;
377 }
378 let r = self.grid.range(self.queue.index())?;
379 Some(r.start.min(self.queue.len())..r.end.min(self.queue.len()))
380 }
381
382 /// Switch modes. Returns whether the mode changed.
383 pub fn set_mode(&mut self, mode: Mode, now: Instant) -> bool {
384 if mode == self.mode {
385 return false;
386 }
387 let was_grid = self.in_grid();
388 self.mode = mode;
389 if mode != Mode::Visual {
390 self.grid.anchor = None;
391 }
392 self.holds.clear();
393 match mode {
394 Mode::Image => {
395 // Don't crossfade from whatever was open before the grid.
396 if self.shown.as_ref().map(|s| s.path.as_path()) != self.queue.current() {
397 self.clear_shown();
398 }
399 }
400 Mode::Grid | Mode::Visual if !was_grid => self.grid_follow(now, false),
401 _ => {}
402 }
403 true
404 }
405
406 /// A thumbnail landed in the atlas.
407 pub fn thumb_ready(&mut self, path: PathBuf, size: [u32; 2], now: Instant) {
408 self.thumbs.insert(path, ThumbState { size, at: now });
409 self.last_thumb = Some(now);
410 }
411
412 /// Opacity of a thumbnail fading in.
413 pub fn thumb_alpha(&self, at: Instant, now: Instant) -> f32 {
414 let d = self.settings.thumb_fade;
415 if d.is_zero() {
416 return 1.0;
417 }
418 let t = now.saturating_duration_since(at).as_secs_f32() / d.as_secs_f32();
419 crate::anim::ease(t.min(1.0))
420 }
421
422 fn update_viewport(&mut self) {
423 let h = (self.window[1] - self.bar_height()).max(1.0);
424 self.view.set_viewport(self.window[0], h);
425 }
426
427 fn dur(&self) -> Duration {
428 self.settings.anim
429 }
430
431 /// Apply settings from a (re)loaded config. The view keeps its zoom until the next image.
432 pub fn set_settings(&mut self, settings: Settings) {
433 self.settings = settings;
434 self.update_viewport();
435 }
436
437 /// Switch to a new theme, crossfading from whatever is on screen now.
438 pub fn set_theme(&mut self, theme: Theme, now: Instant) {
439 if theme == self.theme {
440 return;
441 }
442 let shown = self.theme_at(now);
443 self.theme = theme;
444 if self.settings.theme_fade.is_zero() {
445 self.theme_fade = None;
446 return;
447 }
448 let mut t = Tween::new(0.0, now);
449 t.retarget(1.0, now, self.settings.theme_fade);
450 self.theme_fade = Some((shown, t));
451 }
452
453 /// The theme for the frame at `now`.
454 pub fn theme_at(&self, now: Instant) -> Theme {
455 match &self.theme_fade {
456 Some((from, t)) if t.is_active(now) => from.mix(&self.theme, t.value(now)),
457 _ => self.theme.clone(),
458 }
459 }
460
461 pub fn apply(&mut self, action: Action, now: Instant) -> Effects {
462 self.apply_at(action, None, now)
463 }
464
465 /// Apply an action; `anchor` (viewport pixels) is where mouse-driven zoom centres.
466 pub fn apply_at(&mut self, action: Action, anchor: Option<[f32; 2]>, now: Instant) -> Effects {
467 let mut fx = Effects {
468 redraw: true,
469 ..Effects::default()
470 };
471 let dur = self.dur();
472 let in_grid = self.in_grid();
473 let has_image = self.mode == Mode::Image && self.shown.is_some();
474 let wrap = self.settings.wrap;
475 let switch = |s: Switch, cur: bool| match s {
476 Switch::Toggle => !cur,
477 Switch::On => true,
478 Switch::Off => false,
479 };
480 match action {
481 Action::Next(n) => fx.navigated = self.queue.step(n as isize, self.settings.wrap),
482 Action::Prev(n) => fx.navigated = self.queue.step(-(n as isize), self.settings.wrap),
483 Action::First => fx.navigated = self.queue.goto(0),
484 Action::Last => fx.navigated = self.queue.goto(usize::MAX),
485 Action::Goto(n) => fx.navigated = self.queue.goto(n.saturating_sub(1)),
486 Action::Random => {
487 let len = self.queue.len();
488 if len > 1 {
489 let r = std::hash::BuildHasher::hash_one(
490 &std::collections::hash_map::RandomState::new(),
491 now,
492 ) as usize;
493 // Any image but the current one.
494 let i = (self.queue.index() + 1 + r % (len - 1)) % len;
495 fx.navigated = self.queue.goto(i);
496 }
497 }
498 Action::Zoom(z) if has_image => {
499 let step = self.settings.zoom_step;
500 match z {
501 Zoom::In(n) => self.view.zoom_by(step.powi(n as i32), anchor, now, dur),
502 Zoom::Out(n) => self.view.zoom_by(step.powi(-(n as i32)), anchor, now, dur),
503 Zoom::Percent(p) => self.view.zoom_to(p / 100.0, anchor, now, dur),
504 }
505 }
506 Action::NextMark | Action::PrevMark => {
507 let forward = action == Action::NextMark;
508 match self.queue.find_mark(forward, 1, wrap) {
509 Some(i) => fx.navigated = self.queue.goto(i),
510 None => self.notify("no other marks", MsgKind::Info, now),
511 }
512 }
513 Action::Mark(s) => {
514 if let Some(range) = self.visual_range() {
515 let on = match s {
516 Switch::Toggle => !range.clone().all(|i| self.queue.is_marked(i)),
517 Switch::On => true,
518 Switch::Off => false,
519 };
520 for i in range {
521 self.queue.set_mark(i, on);
522 }
523 fx.mode = self.set_mode(Mode::Grid, now);
524 } else {
525 let i = self.queue.index();
526 let cur = self.queue.is_marked(i);
527 self.queue.set_mark(i, switch(s, cur));
528 }
529 }
530 Action::MarkAll => self.queue.mark_all(),
531 Action::UnmarkAll => self.queue.unmark_all(),
532 Action::InvertMarks => self.queue.invert_marks(),
533 Action::Move(dir, n) if in_grid => {
534 let l = self.grid_layout();
535 fx.navigated = self.queue.goto(l.step(self.queue.index(), dir, n));
536 }
537 Action::Page(page) if in_grid => {
538 let l = self.grid_layout();
539 let full = l.page_rows() as isize;
540 let half = ((l.viewport[1] / l.row_height() / 2.0).round() as isize).max(1);
541 let rows = match page {
542 Page::Down => full,
543 Page::Up => -full,
544 Page::HalfDown => half,
545 Page::HalfUp => -half,
546 };
547 let to = l.clamp_scroll(self.grid.target(&l) + rows as f32 * l.row_height());
548 self.grid.scroll_to(to, now, dur);
549 fx.navigated = self.queue.goto(l.step_rows(self.queue.index(), rows));
550 }
551 Action::RowStart | Action::RowEnd if in_grid => {
552 let l = self.grid_layout();
553 let i = self.queue.index();
554 let to = if action == Action::RowStart {
555 l.row_start(i)
556 } else {
557 l.row_end(i)
558 };
559 fx.navigated = self.queue.goto(to);
560 }
561 Action::Scroll(dir, n) if in_grid => match dir {
562 Dir::Up => fx.navigated = self.grid_scroll(-(n as f32), now),
563 Dir::Down => fx.navigated = self.grid_scroll(n as f32, now),
564 Dir::Left | Dir::Right => fx.redraw = false,
565 },
566 Action::Scroll(dir, n) if has_image => {
567 self.view.pan_step(dir, 0.1 * n as f32, now, dur);
568 }
569 Action::Mode(m) => {
570 let target = match m {
571 ModeArg::Image => Mode::Image,
572 ModeArg::Grid => Mode::Grid,
573 ModeArg::Toggle if in_grid => Mode::Image,
574 ModeArg::Toggle => Mode::Grid,
575 };
576 fx.mode = self.set_mode(target, now);
577 fx.redraw = fx.mode;
578 }
579 Action::Visual => {
580 if self.mode == Mode::Visual {
581 fx.mode = self.set_mode(Mode::Grid, now);
582 } else {
583 self.set_mode(Mode::Grid, now);
584 self.grid.anchor = Some(self.queue.index());
585 self.mode = Mode::Visual;
586 fx.mode = true;
587 }
588 }
589 Action::GridSize(d) => {
590 let size = grid::next_size(&self.settings.grid_sizes, self.grid.size, d);
591 if size == self.grid.size {
592 fx.redraw = false;
593 } else {
594 self.grid.size = size;
595 if in_grid {
596 self.grid_follow(now, false);
597 }
598 }
599 }
600 Action::Fit(fit) if has_image => self.view.set_fit(fit, now, dur),
601 Action::Pan(dir) if has_image => self.view.pan_step(dir, 0.2, now, dur),
602 Action::PanEdge(dir) if has_image => self.view.pan_edge(dir, now, dur),
603 Action::Center if has_image => self.view.recenter(now, dur),
604 Action::Rotate(q) if has_image => self.view.rotate(q, now, dur),
605 Action::Flip(axis) if has_image => self.view.flip(axis, now, dur),
606 Action::FilterToggle => {
607 let nearest = !self.effective_nearest(now);
608 self.nearest = Some(nearest);
609 self.notify(
610 if nearest { "NEAREST" } else { "LINEAR" },
611 MsgKind::Info,
612 now,
613 );
614 }
615 Action::AlphaBgCycle => self.alpha_bg = self.alpha_bg.next(),
616 Action::Fullscreen(s) => fx.fullscreen = Some(s),
617 Action::Bar(s) => {
618 let bar = switch(s, self.bar);
619 if bar != self.bar {
620 self.bar = bar;
621 self.update_viewport();
622 fx.layout = true;
623 }
624 }
625 Action::Sort(key) => fx.sort = Some(key),
626 Action::Reverse => {
627 self.queue.reverse();
628 fx.reordered = true;
629 }
630 Action::Reload => fx.reload = true,
631 Action::Remove => {
632 if let Some(cur) = self.queue.current().map(Path::to_path_buf) {
633 self.queue.remove(&cur);
634 fx.navigated = true;
635 }
636 }
637 Action::ReloadConfig => fx.reload_config = true,
638 Action::Refresh => fx.refresh = true,
639 // The event loop runs commands: they spawn processes and may wait for a `y`.
640 Action::Run(_) => fx.redraw = false,
641 Action::Echo(msg) => self.notify(msg, MsgKind::Info, now),
642 Action::Cancel => self.message = None,
643 Action::Quit | Action::QuitNoOutput => fx.quit = true,
644 Action::Zoom(_)
645 | Action::Fit(_)
646 | Action::Pan(_)
647 | Action::PanEdge(_)
648 | Action::Center
649 | Action::Rotate(_)
650 | Action::Flip(_)
651 | Action::Move(..)
652 | Action::Page(_)
653 | Action::RowStart
654 | Action::RowEnd
655 | Action::Scroll(..) => fx.redraw = false,
656 // Parsed and bindable now; they arrive with later milestones.
657 other @ (Action::NextFrame
658 | Action::PrevFrame
659 | Action::Adjust(..)
660 | Action::Play(_)
661 | Action::Slideshow(_)
662 | Action::SlideshowDelay { .. }
663 | Action::Info(_)
664 | Action::Help
665 | Action::Palette
666 | Action::Search
667 | Action::Filter(_)
668 | Action::FilterClear
669 | Action::Open(_)) => {
670 self.notify(
671 format!("{} not available yet", other.name()),
672 MsgKind::Info,
673 now,
674 );
675 }
676 }
677 if fx.navigated && self.in_grid() {
678 self.grid_follow(now, true);
679 }
680 fx
681 }
682
683 /// Start or stop a held-key pan.
684 pub fn hold_pan(&mut self, dir: Dir, held: bool, now: Instant) {
685 if held {
686 if !self
687 .holds
688 .iter()
689 .any(|h| h.dir == dir && h.released.is_none())
690 {
691 self.holds.retain(|h| h.dir != dir);
692 self.holds.push(Hold {
693 dir,
694 pressed: now,
695 released: None,
696 });
697 }
698 } else {
699 for h in self.holds.iter_mut().filter(|h| h.dir == dir) {
700 h.released.get_or_insert(now);
701 }
702 }
703 }
704
705 pub fn stop_all_pans(&mut self) {
706 self.holds.clear();
707 }
708
709 /// Advance time-driven state (held pans). Call once per frame before drawing.
710 pub fn advance(&mut self, now: Instant) {
711 let last = self.last_tick.replace(now).unwrap_or(now);
712 if self.holds.is_empty() || self.shown.is_none() {
713 self.holds.retain(|h| h.released.is_none());
714 return;
715 }
716 let vp = self.view.viewport();
717 let mut delta = [0.0f32; 2];
718 for h in &self.holds {
719 let end = h.released.map(|r| r.max(h.pressed + MIN_PAN_HOLD));
720 let from = last.max(h.pressed);
721 let to = end.map_or(now, |e| e.min(now));
722 let dt = to.saturating_duration_since(from).as_secs_f32();
723 let v = h.dir.vector();
724 delta[0] -= v[0] * vp[0] * PAN_SPEED * dt;
725 delta[1] -= v[1] * vp[1] * PAN_SPEED * dt;
726 }
727 self.holds.retain(|h| {
728 h.released
729 .is_none_or(|r| now < r.max(h.pressed + MIN_PAN_HOLD))
730 });
731 if delta != [0.0, 0.0] {
732 self.view.pan_pixels(delta);
733 }
734 }
735
736 /// The image for the current file is ready to show.
737 /// `size` is the original pixel size; the texture may be smaller (huge images), but the
738 /// view works in original pixels so zoom percentages stay true.
739 pub fn show(&mut self, path: &Path, size: [u32; 2], format: &'static str, now: Instant) {
740 if let Some(prev) = self.shown.take()
741 && prev.path != path
742 && !self.settings.crossfade.is_zero()
743 {
744 let mut alpha = Tween::new(0.0, now);
745 alpha.retarget(1.0, now, self.settings.crossfade);
746 let affine = self.view.transform(now);
747 self.fade = Some(Fade {
748 path: prev.path,
749 affine,
750 alpha,
751 });
752 }
753 self.shown = Some(Shown {
754 path: path.to_path_buf(),
755 size,
756 format,
757 });
758 self.view
759 .reset(size, self.settings.fit, self.settings.zoom, now);
760 self.loading_since = None;
761 self.holds.clear();
762 }
763
764 /// The current file can't be shown (or the queue emptied).
765 pub fn clear_shown(&mut self) {
766 self.shown = None;
767 self.fade = None;
768 self.loading_since = None;
769 }
770
771 /// Zoom by `steps` zoom steps (fractional for touchpads) around a viewport point.
772 pub fn zoom_at(&mut self, steps: f32, anchor: [f32; 2], now: Instant) {
773 if self.shown.is_some() {
774 let factor = self.settings.zoom_step.powf(steps);
775 self.view
776 .zoom_by(factor, Some(anchor), now, self.settings.anim);
777 }
778 }
779
780 pub fn notify(&mut self, text: impl Into<String>, kind: MsgKind, now: Instant) {
781 let secs = if kind == MsgKind::Error { 5 } else { 2 };
782 self.notify_for(text, kind, now, Duration::from_secs(secs));
783 }
784
785 pub fn notify_for(
786 &mut self,
787 text: impl Into<String>,
788 kind: MsgKind,
789 now: Instant,
790 d: Duration,
791 ) {
792 self.message = Some(Message {
793 text: text.into(),
794 kind,
795 until: now + d,
796 });
797 }
798
799 pub fn effective_nearest(&self, now: Instant) -> bool {
800 self.nearest
801 .unwrap_or_else(|| self.view.display_scale(now) > view::NEAREST_ABOVE)
802 }
803
804 pub fn loading_visible(&self, now: Instant) -> bool {
805 self.loading_since.is_some_and(|t| now >= t + LOADING_DELAY)
806 }
807
808 /// Whether frames must keep coming (animations, held keys).
809 pub fn is_animating(&self, now: Instant) -> bool {
810 let grid = self.in_grid()
811 && (self.grid.is_animating(now)
812 || self
813 .last_thumb
814 .is_some_and(|t| now < t + self.settings.thumb_fade));
815 grid || self.view.is_animating(now)
816 || self.fade.as_ref().is_some_and(|f| f.alpha.is_active(now))
817 || self
818 .theme_fade
819 .as_ref()
820 .is_some_and(|(_, t)| t.is_active(now))
821 || !self.holds.is_empty()
822 }
823
824 /// Expire timed state. Returns whether a redraw is due and the next wake-up time.
825 pub fn tick(&mut self, now: Instant) -> (bool, Option<Instant>) {
826 let mut redraw = false;
827 let mut next: Option<Instant> = None;
828 let mut wake = |t: Instant| next = Some(next.map_or(t, |n| n.min(t)));
829 if let Some(m) = &self.message {
830 if now >= m.until {
831 self.message = None;
832 redraw = true;
833 } else {
834 wake(m.until);
835 }
836 }
837 if let Some(t) = self.loading_since {
838 let at = t + LOADING_DELAY;
839 if now < at {
840 wake(at);
841 }
842 }
843 if self.fade.as_ref().is_some_and(|f| !f.alpha.is_active(now)) {
844 self.fade = None;
845 redraw = true;
846 }
847 if self
848 .theme_fade
849 .as_ref()
850 .is_some_and(|(_, t)| !t.is_active(now))
851 {
852 self.theme_fade = None;
853 redraw = true;
854 }
855 (redraw, next)
856 }
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862
863 fn state(n: usize) -> (AppState, Instant) {
864 let now = Instant::now();
865 let files = (0..n).map(|i| PathBuf::from(format!("{i}.png"))).collect();
866 let mut s = AppState::new(Queue::new(files, None, 0), Settings::default(), true, now);
867 s.set_window(800.0, 632.0, 1.0);
868 (s, now)
869 }
870
871 #[test]
872 fn navigation_reports_changes() {
873 let (mut s, now) = state(3);
874 assert!(s.apply(Action::Next(1), now).navigated);
875 assert!(s.apply(Action::Last, now).navigated);
876 assert!(!s.apply(Action::Last, now).navigated);
877 assert!(s.apply(Action::Goto(1), now).navigated);
878 assert_eq!(s.queue.index(), 0);
879 }
880
881 #[test]
882 fn bar_shrinks_the_image_area() {
883 let (mut s, now) = state(1);
884 assert_eq!(s.view.viewport(), [800.0, 600.0]);
885 assert!(!s.apply(Action::Bar(Switch::On), now).layout);
886 assert!(s.apply(Action::Bar(Switch::Toggle), now).layout);
887 assert_eq!(s.view.viewport(), [800.0, 632.0]);
888 assert!(!s.apply(Action::Bar(Switch::Off), now).layout);
889 s.settings.ui_scale = 2.0;
890 s.apply(Action::Bar(Switch::On), now);
891 assert_eq!(s.view.viewport(), [800.0, 568.0]);
892 s.apply(Action::Bar(Switch::Off), now);
893 assert_eq!(s.view.viewport(), [800.0, 632.0]);
894 }
895
896 #[test]
897 fn showing_a_second_image_crossfades() {
898 let (mut s, now) = state(2);
899 s.show(Path::new("0.png"), [10, 10], "PNG", now);
900 assert!(s.fade.is_none());
901 s.show(Path::new("1.png"), [10, 10], "PNG", now);
902 assert!(s.fade.is_some());
903 assert!(s.is_animating(now));
904 let later = now + Duration::from_secs(1);
905 assert!(s.tick(later).0);
906 assert!(s.fade.is_none());
907 }
908
909 #[test]
910 fn theme_changes_crossfade() {
911 use crate::config::theme::{Rgba, Theme};
912 let (mut s, now) = state(1);
913 let light = Theme::derive(
914 Rgba::rgb(0xFFFFFF),
915 Rgba::rgb(0x000000),
916 Rgba::rgb(0xD71921),
917 );
918 s.set_theme(light.clone(), now);
919 assert!(s.is_animating(now));
920 let mid = s.theme_at(now + THEME_FADE / 2).background;
921 assert!(mid.0[0] > 0 && mid.0[0] < 255, "{mid:?}");
922 let later = now + THEME_FADE * 2;
923 assert_eq!(s.theme_at(later), light);
924 assert!(s.tick(later).0);
925 assert!(!s.is_animating(later));
926 // With animation off the switch is instant.
927 s.settings.theme_fade = Duration::ZERO;
928 s.set_theme(Theme::nothing_dark(), later);
929 assert_eq!(s.theme_at(later), Theme::nothing_dark());
930 }
931
932 #[test]
933 fn unfinished_actions_say_so() {
934 let (mut s, now) = state(2);
935 let fx = s.apply(Action::Palette, now);
936 assert!(!fx.navigated);
937 assert_eq!(
938 s.message.as_ref().map(|m| m.text.as_str()),
939 Some("palette not available yet")
940 );
941 assert!(s.apply(Action::Refresh, now).refresh);
942 assert!(!s.apply(Action::Run("x".into()), now).redraw);
943 s.apply(Action::Echo("hi".into()), now);
944 assert_eq!(s.message.as_ref().map(|m| m.text.as_str()), Some("hi"));
945 assert!(s.apply(Action::Random, now).navigated);
946 assert_eq!(s.queue.index(), 1);
947 }
948
949 #[test]
950 fn grid_mode_moves_the_queue_cursor() {
951 let (mut s, now) = state(40);
952 assert!(s.apply(Action::Mode(ModeArg::Toggle), now).mode);
953 assert_eq!(s.mode, Mode::Grid);
954 // 800 wide, 160 cells, 16 gaps: 4 columns.
955 assert_eq!(s.grid_layout().cols, 4);
956 assert!(s.apply(Action::Move(Dir::Down, 2), now).navigated);
957 assert_eq!(s.queue.index(), 8);
958 s.apply(Action::RowEnd, now);
959 assert_eq!(s.queue.index(), 11);
960 s.apply(Action::Last, now);
961 let l = s.grid_layout();
962 assert_eq!(s.grid.target(&l), l.max_scroll());
963 s.apply(Action::Page(Page::Up), now);
964 assert_eq!(s.queue.index(), 39 - 4 * l.page_rows());
965 // Image-only actions do nothing in the grid.
966 assert!(!s.apply(Action::Zoom(Zoom::In(1)), now).redraw);
967 assert!(s.apply(Action::Mode(ModeArg::Toggle), now).mode);
968 assert_eq!(s.mode, Mode::Image);
969 assert!(!s.apply(Action::Move(Dir::Down, 1), now).redraw);
970 }
971
972 #[test]
973 fn visual_mode_marks_a_range() {
974 let (mut s, now) = state(10);
975 s.apply(Action::Goto(2), now);
976 s.apply(Action::Visual, now);
977 assert_eq!(s.mode, Mode::Visual);
978 s.apply(Action::Move(Dir::Right, 3), now);
979 assert_eq!(s.visual_range(), Some(1..5));
980 s.apply(Action::Mark(Switch::On), now);
981 assert_eq!(s.mode, Mode::Grid);
982 assert_eq!(s.queue.marked_count(), 4);
983 // Toggling a fully marked range unmarks it.
984 s.apply(Action::Visual, now);
985 s.apply(Action::Move(Dir::Left, 3), now);
986 s.apply(Action::Mark(Switch::Toggle), now);
987 assert_eq!(s.queue.marked_count(), 0);
988 }
989
990 #[test]
991 fn marks_and_mark_navigation() {
992 let (mut s, now) = state(5);
993 s.apply(Action::Mark(Switch::Toggle), now);
994 s.apply(Action::Goto(4), now);
995 s.apply(Action::Mark(Switch::Toggle), now);
996 assert_eq!(s.queue.marked_count(), 2);
997 assert!(s.apply(Action::NextMark, now).navigated);
998 assert_eq!(s.queue.index(), 0);
999 s.apply(Action::InvertMarks, now);
1000 assert_eq!(s.queue.marked_count(), 3);
1001 s.apply(Action::UnmarkAll, now);
1002 assert!(!s.apply(Action::NextMark, now).navigated);
1003 assert!(s.message.is_some());
1004 s.apply(Action::MarkAll, now);
1005 assert_eq!(s.queue.marked_count(), 5);
1006 }
1007
1008 #[test]
1009 fn grid_size_steps_through_sizes() {
1010 let (mut s, now) = state(3);
1011 s.apply(Action::GridSize(1), now);
1012 assert_eq!(s.grid.size, 224);
1013 s.apply(Action::GridSize(5), now);
1014 assert_eq!(s.grid.size, 320);
1015 assert!(!s.apply(Action::GridSize(1), now).redraw);
1016 assert_eq!(s.cell_px(), 320);
1017 }
1018
1019 #[test]
1020 fn tapped_pan_moves_a_minimum_distance() {
1021 let (mut s, now) = state(1);
1022 s.show(Path::new("0.png"), [4000, 4000], "PNG", now);
1023 s.apply(Action::Fit(FitMode::Original), now);
1024 s.advance(now);
1025 let before = s.view.transform(now + Duration::from_secs(1)).t;
1026 s.hold_pan(Dir::Right, true, now);
1027 s.hold_pan(Dir::Right, false, now + Duration::from_millis(10));
1028 s.advance(now + Duration::from_millis(500));
1029 assert!(!s.is_animating(now + Duration::from_secs(1)));
1030 let after = s.view.transform(now + Duration::from_secs(1)).t;
1031 let moved = before[0] - after[0];
1032 let expected = 800.0 * PAN_SPEED * MIN_PAN_HOLD.as_secs_f32();
1033 assert!(
1034 (moved - expected).abs() < 1.0,
1035 "moved {moved}, expected {expected}"
1036 );
1037 }
1038}