Krolyxon
Commit 92928dd

Branches

Tags

Refs →

remagesrcstatemod.rs

692 lines · 22.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::path::{Path, PathBuf};
8use std::time::{Duration, Instant};
9
10use crate::action::{Action, ModeArg, Switch, Zoom};
11use crate::anim::Tween;
12use crate::config::theme::Theme;
13use crate::config::{Config, Transition};
14use queue::{Queue, SortKey};
15use view::{Affine, Dir, FitMode, View};
16
17/// `[LOADING]` appears only once a load has taken this long, so it never flickers.
18pub const LOADING_DELAY: Duration = Duration::from_millis(120);
19/// A tap on a pan key moves for at least this long.
20const MIN_PAN_HOLD: Duration = Duration::from_millis(120);
21/// Held-key pan speed, in viewports per second.
22const PAN_SPEED: f32 = 1.25;
23/// Logical height of the status bar.
24pub const BAR_HEIGHT: f32 = 32.0;
25/// Image switch crossfade with `transition = "fade"`.
26const CROSSFADE: Duration = Duration::from_millis(100);
27/// Theme changes (a new wallpaper palette) crossfade this long.
28const THEME_FADE: Duration = Duration::from_millis(250);
29
30/// Which keymap applies, and (from M3) what is on screen.
31#[allow(dead_code)] // grid and visual arrive in M3
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33pub enum Mode {
34 #[default]
35 Image,
36 Grid,
37 Visual,
38}
39
40#[derive(Clone, Debug, PartialEq)]
41pub struct Settings {
42 pub fit: FitMode,
43 pub zoom: Option<f32>,
44 pub zoom_step: f32,
45 pub wrap: bool,
46 pub preload: usize,
47 pub anim: Duration,
48 pub crossfade: Duration,
49 pub theme_fade: Duration,
50 /// UI size multiplier on top of the display scale.
51 pub ui_scale: f32,
52 pub which_key_delay: Duration,
53}
54
55impl Default for Settings {
56 fn default() -> Self {
57 Self {
58 fit: FitMode::Down,
59 zoom: None,
60 zoom_step: 1.25,
61 wrap: true,
62 preload: 2,
63 anim: Duration::from_millis(180),
64 crossfade: CROSSFADE,
65 theme_fade: THEME_FADE,
66 ui_scale: 1.0,
67 which_key_delay: Duration::from_millis(300),
68 }
69 }
70}
71
72impl Settings {
73 pub fn from_config(c: &Config) -> Self {
74 let on = c.animation.enabled;
75 let anim = |d: Duration| if on { d } else { Duration::ZERO };
76 Self {
77 fit: c.image.fit,
78 zoom: None,
79 zoom_step: c.image.zoom_step,
80 wrap: c.general.wrap,
81 preload: c.image.preload,
82 anim: anim(Duration::from_millis(c.animation.duration_ms)),
83 crossfade: match c.animation.transition {
84 Transition::Fade => anim(CROSSFADE),
85 Transition::None => Duration::ZERO,
86 },
87 theme_fade: anim(THEME_FADE),
88 ui_scale: c.ui.scale,
89 which_key_delay: Duration::from_millis(c.ui.which_key_delay_ms),
90 }
91 }
92}
93
94#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
95#[serde(rename_all = "lowercase")]
96pub enum AlphaBg {
97 #[default]
98 Dots,
99 Checker,
100 Solid,
101}
102
103impl AlphaBg {
104 fn next(self) -> Self {
105 match self {
106 AlphaBg::Dots => AlphaBg::Checker,
107 AlphaBg::Checker => AlphaBg::Solid,
108 AlphaBg::Solid => AlphaBg::Dots,
109 }
110 }
111}
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub enum MsgKind {
115 Info,
116 Error,
117}
118
119#[derive(Clone, Debug)]
120pub struct Message {
121 pub text: String,
122 pub kind: MsgKind,
123 pub until: Instant,
124}
125
126/// The image currently on screen (which may lag the queue's current file while it loads).
127#[derive(Clone, Debug)]
128pub struct Shown {
129 pub path: PathBuf,
130 /// Pixel size before any downscale for the GPU.
131 pub size: [u32; 2],
132 pub format: &'static str,
133}
134
135/// The previous image fading out under the new one.
136#[derive(Clone, Debug)]
137pub struct Fade {
138 pub path: PathBuf,
139 pub affine: Affine,
140 pub alpha: Tween<f32>,
141}
142
143#[derive(Clone, Copy, Debug)]
144struct Hold {
145 dir: Dir,
146 pressed: Instant,
147 released: Option<Instant>,
148}
149
150/// What the event loop must do after an action.
151#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
152pub struct Effects {
153 pub redraw: bool,
154 /// The queue's current file changed: load it and refresh the preload window.
155 pub navigated: bool,
156 /// The queue order changed around the current file: refresh the preload window.
157 pub reordered: bool,
158 /// The status bar appeared or vanished, so the image area changed.
159 pub layout: bool,
160 pub fullscreen: Option<Switch>,
161 pub reload: bool,
162 pub reload_config: bool,
163 /// Re-sort the queue (stat-based keys need the filesystem, so the loop does it).
164 pub sort: Option<SortKey>,
165 pub quit: bool,
166}
167
168#[derive(Debug)]
169pub struct AppState {
170 pub queue: Queue,
171 pub view: View,
172 pub settings: Settings,
173 pub mode: Mode,
174 pub theme: Theme,
175 /// The previous theme fading out after a reload.
176 theme_fade: Option<(Theme, Tween<f32>)>,
177 pub bar: bool,
178 /// `None` = automatic (nearest above 300%), otherwise forced nearest/linear.
179 pub nearest: Option<bool>,
180 pub alpha_bg: AlphaBg,
181 pub shown: Option<Shown>,
182 pub fade: Option<Fade>,
183 pub loading_since: Option<Instant>,
184 pub skipped: usize,
185 pub message: Option<Message>,
186 /// Keys typed so far of an incomplete sequence.
187 pub pending: String,
188 window: [f32; 2],
189 scale_factor: f32,
190 holds: Vec<Hold>,
191 last_tick: Option<Instant>,
192}
193
194impl AppState {
195 pub fn new(queue: Queue, settings: Settings, bar: bool, now: Instant) -> Self {
196 Self {
197 queue,
198 view: View::new(now),
199 settings,
200 mode: Mode::Image,
201 theme: Theme::nothing_dark(),
202 theme_fade: None,
203 bar,
204 nearest: None,
205 alpha_bg: AlphaBg::Dots,
206 shown: None,
207 fade: None,
208 loading_since: None,
209 skipped: 0,
210 message: None,
211 pending: String::new(),
212 window: [1.0, 1.0],
213 scale_factor: 1.0,
214 holds: Vec::new(),
215 last_tick: None,
216 }
217 }
218
219 /// Window size in physical pixels.
220 pub fn set_window(&mut self, w: f32, h: f32, scale_factor: f32) {
221 self.window = [w, h];
222 self.scale_factor = scale_factor;
223 self.update_viewport();
224 }
225
226 pub fn window(&self) -> [f32; 2] {
227 self.window
228 }
229
230 pub fn scale_factor(&self) -> f32 {
231 self.scale_factor
232 }
233
234 /// Display scale times `ui.scale`: the factor for every UI size.
235 pub fn ui_scale(&self) -> f32 {
236 self.scale_factor * self.settings.ui_scale
237 }
238
239 /// Physical height of the status bar, 0 when hidden.
240 pub fn bar_height(&self) -> f32 {
241 if self.bar {
242 (BAR_HEIGHT * self.ui_scale()).round()
243 } else {
244 0.0
245 }
246 }
247
248 fn update_viewport(&mut self) {
249 let h = (self.window[1] - self.bar_height()).max(1.0);
250 self.view.set_viewport(self.window[0], h);
251 }
252
253 fn dur(&self) -> Duration {
254 self.settings.anim
255 }
256
257 /// Apply settings from a (re)loaded config. The view keeps its zoom until the next image.
258 pub fn set_settings(&mut self, settings: Settings) {
259 self.settings = settings;
260 self.update_viewport();
261 }
262
263 /// Switch to a new theme, crossfading from whatever is on screen now.
264 pub fn set_theme(&mut self, theme: Theme, now: Instant) {
265 if theme == self.theme {
266 return;
267 }
268 let shown = self.theme_at(now);
269 self.theme = theme;
270 if self.settings.theme_fade.is_zero() {
271 self.theme_fade = None;
272 return;
273 }
274 let mut t = Tween::new(0.0, now);
275 t.retarget(1.0, now, self.settings.theme_fade);
276 self.theme_fade = Some((shown, t));
277 }
278
279 /// The theme for the frame at `now`.
280 pub fn theme_at(&self, now: Instant) -> Theme {
281 match &self.theme_fade {
282 Some((from, t)) if t.is_active(now) => from.mix(&self.theme, t.value(now)),
283 _ => self.theme.clone(),
284 }
285 }
286
287 pub fn apply(&mut self, action: Action, now: Instant) -> Effects {
288 self.apply_at(action, None, now)
289 }
290
291 /// Apply an action; `anchor` (viewport pixels) is where mouse-driven zoom centres.
292 pub fn apply_at(&mut self, action: Action, anchor: Option<[f32; 2]>, now: Instant) -> Effects {
293 let mut fx = Effects {
294 redraw: true,
295 ..Effects::default()
296 };
297 let dur = self.dur();
298 let has_image = self.shown.is_some();
299 let switch = |s: Switch, cur: bool| match s {
300 Switch::Toggle => !cur,
301 Switch::On => true,
302 Switch::Off => false,
303 };
304 match action {
305 Action::Next(n) => fx.navigated = self.queue.step(n as isize, self.settings.wrap),
306 Action::Prev(n) => fx.navigated = self.queue.step(-(n as isize), self.settings.wrap),
307 Action::First => fx.navigated = self.queue.goto(0),
308 Action::Last => fx.navigated = self.queue.goto(usize::MAX),
309 Action::Goto(n) => fx.navigated = self.queue.goto(n.saturating_sub(1)),
310 Action::Random => {
311 let len = self.queue.len();
312 if len > 1 {
313 let r = std::hash::BuildHasher::hash_one(
314 &std::collections::hash_map::RandomState::new(),
315 now,
316 ) as usize;
317 // Any image but the current one.
318 let i = (self.queue.index() + 1 + r % (len - 1)) % len;
319 fx.navigated = self.queue.goto(i);
320 }
321 }
322 Action::Zoom(z) if has_image => {
323 let step = self.settings.zoom_step;
324 match z {
325 Zoom::In(n) => self.view.zoom_by(step.powi(n as i32), anchor, now, dur),
326 Zoom::Out(n) => self.view.zoom_by(step.powi(-(n as i32)), anchor, now, dur),
327 Zoom::Percent(p) => self.view.zoom_to(p / 100.0, anchor, now, dur),
328 }
329 }
330 Action::Fit(fit) if has_image => self.view.set_fit(fit, now, dur),
331 Action::Pan(dir) if has_image => self.view.pan_step(dir, 0.2, now, dur),
332 Action::PanEdge(dir) if has_image => self.view.pan_edge(dir, now, dur),
333 Action::Center if has_image => self.view.recenter(now, dur),
334 Action::Rotate(q) if has_image => self.view.rotate(q, now, dur),
335 Action::Flip(axis) if has_image => self.view.flip(axis, now, dur),
336 Action::FilterToggle => {
337 let nearest = !self.effective_nearest(now);
338 self.nearest = Some(nearest);
339 self.notify(
340 if nearest { "NEAREST" } else { "LINEAR" },
341 MsgKind::Info,
342 now,
343 );
344 }
345 Action::AlphaBgCycle => self.alpha_bg = self.alpha_bg.next(),
346 Action::Fullscreen(s) => fx.fullscreen = Some(s),
347 Action::Bar(s) => {
348 let bar = switch(s, self.bar);
349 if bar != self.bar {
350 self.bar = bar;
351 self.update_viewport();
352 fx.layout = true;
353 }
354 }
355 Action::Mode(ModeArg::Image) if self.mode == Mode::Image => fx.redraw = false,
356 Action::Sort(key) => fx.sort = Some(key),
357 Action::Reverse => {
358 self.queue.reverse();
359 fx.reordered = true;
360 }
361 Action::Reload => fx.reload = true,
362 Action::Remove => {
363 if let Some(cur) = self.queue.current().map(Path::to_path_buf) {
364 self.queue.remove(&cur);
365 fx.navigated = true;
366 }
367 }
368 Action::ReloadConfig => fx.reload_config = true,
369 Action::Echo(msg) => self.notify(msg, MsgKind::Info, now),
370 Action::Cancel => self.message = None,
371 Action::Quit | Action::QuitNoOutput => fx.quit = true,
372 Action::Zoom(_)
373 | Action::Fit(_)
374 | Action::Pan(_)
375 | Action::PanEdge(_)
376 | Action::Center
377 | Action::Rotate(_)
378 | Action::Flip(_) => fx.redraw = false,
379 // Parsed and bindable now; they arrive with later milestones.
380 other @ (Action::NextMark
381 | Action::PrevMark
382 | Action::NextFrame
383 | Action::PrevFrame
384 | Action::Mark(_)
385 | Action::MarkAll
386 | Action::UnmarkAll
387 | Action::InvertMarks
388 | Action::Adjust(..)
389 | Action::Play(_)
390 | Action::Slideshow(_)
391 | Action::SlideshowDelay { .. }
392 | Action::Mode(_)
393 | Action::Visual
394 | Action::Info(_)
395 | Action::Help
396 | Action::Palette
397 | Action::Search
398 | Action::GridSize(_)
399 | Action::Filter(_)
400 | Action::FilterClear
401 | Action::Refresh
402 | Action::Open(_)
403 | Action::Run(_)) => {
404 self.notify(
405 format!("{} not available yet", other.name()),
406 MsgKind::Info,
407 now,
408 );
409 }
410 }
411 fx
412 }
413
414 /// Start or stop a held-key pan.
415 pub fn hold_pan(&mut self, dir: Dir, held: bool, now: Instant) {
416 if held {
417 if !self
418 .holds
419 .iter()
420 .any(|h| h.dir == dir && h.released.is_none())
421 {
422 self.holds.retain(|h| h.dir != dir);
423 self.holds.push(Hold {
424 dir,
425 pressed: now,
426 released: None,
427 });
428 }
429 } else {
430 for h in self.holds.iter_mut().filter(|h| h.dir == dir) {
431 h.released.get_or_insert(now);
432 }
433 }
434 }
435
436 pub fn stop_all_pans(&mut self) {
437 self.holds.clear();
438 }
439
440 /// Advance time-driven state (held pans). Call once per frame before drawing.
441 pub fn advance(&mut self, now: Instant) {
442 let last = self.last_tick.replace(now).unwrap_or(now);
443 if self.holds.is_empty() || self.shown.is_none() {
444 self.holds.retain(|h| h.released.is_none());
445 return;
446 }
447 let vp = self.view.viewport();
448 let mut delta = [0.0f32; 2];
449 for h in &self.holds {
450 let end = h.released.map(|r| r.max(h.pressed + MIN_PAN_HOLD));
451 let from = last.max(h.pressed);
452 let to = end.map_or(now, |e| e.min(now));
453 let dt = to.saturating_duration_since(from).as_secs_f32();
454 let v = h.dir.vector();
455 delta[0] -= v[0] * vp[0] * PAN_SPEED * dt;
456 delta[1] -= v[1] * vp[1] * PAN_SPEED * dt;
457 }
458 self.holds.retain(|h| {
459 h.released
460 .is_none_or(|r| now < r.max(h.pressed + MIN_PAN_HOLD))
461 });
462 if delta != [0.0, 0.0] {
463 self.view.pan_pixels(delta);
464 }
465 }
466
467 /// The image for the current file is ready to show.
468 /// `size` is the original pixel size; the texture may be smaller (huge images), but the
469 /// view works in original pixels so zoom percentages stay true.
470 pub fn show(&mut self, path: &Path, size: [u32; 2], format: &'static str, now: Instant) {
471 if let Some(prev) = self.shown.take()
472 && prev.path != path
473 && !self.settings.crossfade.is_zero()
474 {
475 let mut alpha = Tween::new(0.0, now);
476 alpha.retarget(1.0, now, self.settings.crossfade);
477 let affine = self.view.transform(now);
478 self.fade = Some(Fade {
479 path: prev.path,
480 affine,
481 alpha,
482 });
483 }
484 self.shown = Some(Shown {
485 path: path.to_path_buf(),
486 size,
487 format,
488 });
489 self.view
490 .reset(size, self.settings.fit, self.settings.zoom, now);
491 self.loading_since = None;
492 self.holds.clear();
493 }
494
495 /// The current file can't be shown (or the queue emptied).
496 pub fn clear_shown(&mut self) {
497 self.shown = None;
498 self.fade = None;
499 self.loading_since = None;
500 }
501
502 /// Zoom by `steps` zoom steps (fractional for touchpads) around a viewport point.
503 pub fn zoom_at(&mut self, steps: f32, anchor: [f32; 2], now: Instant) {
504 if self.shown.is_some() {
505 let factor = self.settings.zoom_step.powf(steps);
506 self.view
507 .zoom_by(factor, Some(anchor), now, self.settings.anim);
508 }
509 }
510
511 pub fn notify(&mut self, text: impl Into<String>, kind: MsgKind, now: Instant) {
512 let secs = if kind == MsgKind::Error { 5 } else { 2 };
513 self.notify_for(text, kind, now, Duration::from_secs(secs));
514 }
515
516 pub fn notify_for(
517 &mut self,
518 text: impl Into<String>,
519 kind: MsgKind,
520 now: Instant,
521 d: Duration,
522 ) {
523 self.message = Some(Message {
524 text: text.into(),
525 kind,
526 until: now + d,
527 });
528 }
529
530 pub fn effective_nearest(&self, now: Instant) -> bool {
531 self.nearest
532 .unwrap_or_else(|| self.view.display_scale(now) > view::NEAREST_ABOVE)
533 }
534
535 pub fn loading_visible(&self, now: Instant) -> bool {
536 self.loading_since.is_some_and(|t| now >= t + LOADING_DELAY)
537 }
538
539 /// Whether frames must keep coming (animations, held keys).
540 pub fn is_animating(&self, now: Instant) -> bool {
541 self.view.is_animating(now)
542 || self.fade.as_ref().is_some_and(|f| f.alpha.is_active(now))
543 || self
544 .theme_fade
545 .as_ref()
546 .is_some_and(|(_, t)| t.is_active(now))
547 || !self.holds.is_empty()
548 }
549
550 /// Expire timed state. Returns whether a redraw is due and the next wake-up time.
551 pub fn tick(&mut self, now: Instant) -> (bool, Option<Instant>) {
552 let mut redraw = false;
553 let mut next: Option<Instant> = None;
554 let mut wake = |t: Instant| next = Some(next.map_or(t, |n| n.min(t)));
555 if let Some(m) = &self.message {
556 if now >= m.until {
557 self.message = None;
558 redraw = true;
559 } else {
560 wake(m.until);
561 }
562 }
563 if let Some(t) = self.loading_since {
564 let at = t + LOADING_DELAY;
565 if now < at {
566 wake(at);
567 }
568 }
569 if self.fade.as_ref().is_some_and(|f| !f.alpha.is_active(now)) {
570 self.fade = None;
571 redraw = true;
572 }
573 if self
574 .theme_fade
575 .as_ref()
576 .is_some_and(|(_, t)| !t.is_active(now))
577 {
578 self.theme_fade = None;
579 redraw = true;
580 }
581 (redraw, next)
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 fn state(n: usize) -> (AppState, Instant) {
590 let now = Instant::now();
591 let files = (0..n).map(|i| PathBuf::from(format!("{i}.png"))).collect();
592 let mut s = AppState::new(Queue::new(files, None, 0), Settings::default(), true, now);
593 s.set_window(800.0, 632.0, 1.0);
594 (s, now)
595 }
596
597 #[test]
598 fn navigation_reports_changes() {
599 let (mut s, now) = state(3);
600 assert!(s.apply(Action::Next(1), now).navigated);
601 assert!(s.apply(Action::Last, now).navigated);
602 assert!(!s.apply(Action::Last, now).navigated);
603 assert!(s.apply(Action::Goto(1), now).navigated);
604 assert_eq!(s.queue.index(), 0);
605 }
606
607 #[test]
608 fn bar_shrinks_the_image_area() {
609 let (mut s, now) = state(1);
610 assert_eq!(s.view.viewport(), [800.0, 600.0]);
611 assert!(!s.apply(Action::Bar(Switch::On), now).layout);
612 assert!(s.apply(Action::Bar(Switch::Toggle), now).layout);
613 assert_eq!(s.view.viewport(), [800.0, 632.0]);
614 assert!(!s.apply(Action::Bar(Switch::Off), now).layout);
615 s.settings.ui_scale = 2.0;
616 s.apply(Action::Bar(Switch::On), now);
617 assert_eq!(s.view.viewport(), [800.0, 568.0]);
618 s.apply(Action::Bar(Switch::Off), now);
619 assert_eq!(s.view.viewport(), [800.0, 632.0]);
620 }
621
622 #[test]
623 fn showing_a_second_image_crossfades() {
624 let (mut s, now) = state(2);
625 s.show(Path::new("0.png"), [10, 10], "PNG", now);
626 assert!(s.fade.is_none());
627 s.show(Path::new("1.png"), [10, 10], "PNG", now);
628 assert!(s.fade.is_some());
629 assert!(s.is_animating(now));
630 let later = now + Duration::from_secs(1);
631 assert!(s.tick(later).0);
632 assert!(s.fade.is_none());
633 }
634
635 #[test]
636 fn theme_changes_crossfade() {
637 use crate::config::theme::{Rgba, Theme};
638 let (mut s, now) = state(1);
639 let light = Theme::derive(
640 Rgba::rgb(0xFFFFFF),
641 Rgba::rgb(0x000000),
642 Rgba::rgb(0xD71921),
643 );
644 s.set_theme(light.clone(), now);
645 assert!(s.is_animating(now));
646 let mid = s.theme_at(now + THEME_FADE / 2).background;
647 assert!(mid.0[0] > 0 && mid.0[0] < 255, "{mid:?}");
648 let later = now + THEME_FADE * 2;
649 assert_eq!(s.theme_at(later), light);
650 assert!(s.tick(later).0);
651 assert!(!s.is_animating(later));
652 // With animation off the switch is instant.
653 s.settings.theme_fade = Duration::ZERO;
654 s.set_theme(Theme::nothing_dark(), later);
655 assert_eq!(s.theme_at(later), Theme::nothing_dark());
656 }
657
658 #[test]
659 fn unfinished_actions_say_so() {
660 let (mut s, now) = state(2);
661 let fx = s.apply(Action::Palette, now);
662 assert!(!fx.navigated);
663 assert_eq!(
664 s.message.as_ref().map(|m| m.text.as_str()),
665 Some("palette not available yet")
666 );
667 s.apply(Action::Echo("hi".into()), now);
668 assert_eq!(s.message.as_ref().map(|m| m.text.as_str()), Some("hi"));
669 assert!(s.apply(Action::Random, now).navigated);
670 assert_eq!(s.queue.index(), 1);
671 }
672
673 #[test]
674 fn tapped_pan_moves_a_minimum_distance() {
675 let (mut s, now) = state(1);
676 s.show(Path::new("0.png"), [4000, 4000], "PNG", now);
677 s.apply(Action::Fit(FitMode::Original), now);
678 s.advance(now);
679 let before = s.view.transform(now + Duration::from_secs(1)).t;
680 s.hold_pan(Dir::Right, true, now);
681 s.hold_pan(Dir::Right, false, now + Duration::from_millis(10));
682 s.advance(now + Duration::from_millis(500));
683 assert!(!s.is_animating(now + Duration::from_secs(1)));
684 let after = s.view.transform(now + Duration::from_secs(1)).t;
685 let moved = before[0] - after[0];
686 let expected = 800.0 * PAN_SPEED * MIN_PAN_HOLD.as_secs_f32();
687 assert!(
688 (moved - expected).abs() < 1.0,
689 "moved {moved}, expected {expected}"
690 );
691 }
692}