Krolyxon
Commit dbc22bf

Branches

Tags

Refs →

remagesrcstatemod.rs

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