Krolyxon
Commit f2bd31a

Branches

Tags

Refs →

remagesrcstatemod.rs

467 lines · 14.6 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, Zoom};
11use crate::anim::Tween;
12use queue::Queue;
13use view::{Affine, Dir, FitMode, View};
14
15/// `[LOADING]` appears only once a load has taken this long, so it never flickers.
16pub const LOADING_DELAY: Duration = Duration::from_millis(120);
17/// A tap on a pan key moves for at least this long.
18const MIN_PAN_HOLD: Duration = Duration::from_millis(120);
19/// Held-key pan speed, in viewports per second.
20const PAN_SPEED: f32 = 1.25;
21/// Logical height of the status bar.
22pub const BAR_HEIGHT: f32 = 32.0;
23
24#[derive(Clone, Debug)]
25pub struct Settings {
26 pub fit: FitMode,
27 pub zoom: Option<f32>,
28 pub zoom_step: f32,
29 pub wrap: bool,
30 pub preload: usize,
31 pub anim: Duration,
32 pub crossfade: Duration,
33}
34
35impl Default for Settings {
36 fn default() -> Self {
37 Self {
38 fit: FitMode::Down,
39 zoom: None,
40 zoom_step: 1.25,
41 wrap: true,
42 preload: 2,
43 anim: Duration::from_millis(180),
44 crossfade: Duration::from_millis(100),
45 }
46 }
47}
48
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum AlphaBg {
51 Dots,
52 Checker,
53 Solid,
54}
55
56impl AlphaBg {
57 fn next(self) -> Self {
58 match self {
59 AlphaBg::Dots => AlphaBg::Checker,
60 AlphaBg::Checker => AlphaBg::Solid,
61 AlphaBg::Solid => AlphaBg::Dots,
62 }
63 }
64}
65
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum MsgKind {
68 Info,
69 Error,
70}
71
72#[derive(Clone, Debug)]
73pub struct Message {
74 pub text: String,
75 pub kind: MsgKind,
76 pub until: Instant,
77}
78
79/// The image currently on screen (which may lag the queue's current file while it loads).
80#[derive(Clone, Debug)]
81pub struct Shown {
82 pub path: PathBuf,
83 /// Pixel size before any downscale for the GPU.
84 pub size: [u32; 2],
85 pub format: &'static str,
86}
87
88/// The previous image fading out under the new one.
89#[derive(Clone, Debug)]
90pub struct Fade {
91 pub path: PathBuf,
92 pub affine: Affine,
93 pub alpha: Tween<f32>,
94}
95
96#[derive(Clone, Copy, Debug)]
97struct Hold {
98 dir: Dir,
99 pressed: Instant,
100 released: Option<Instant>,
101}
102
103/// What the event loop must do after an action.
104#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
105pub struct Effects {
106 pub redraw: bool,
107 /// The queue's current file changed: load it and refresh the preload window.
108 pub navigated: bool,
109 /// The status bar appeared or vanished, so the image area changed.
110 pub layout: bool,
111 pub fullscreen: bool,
112 pub reload: bool,
113 pub quit: bool,
114}
115
116#[derive(Debug)]
117pub struct AppState {
118 pub queue: Queue,
119 pub view: View,
120 pub settings: Settings,
121 pub bar: bool,
122 /// `None` = automatic (nearest above 300%), otherwise forced nearest/linear.
123 pub nearest: Option<bool>,
124 pub alpha_bg: AlphaBg,
125 pub shown: Option<Shown>,
126 pub fade: Option<Fade>,
127 pub loading_since: Option<Instant>,
128 pub skipped: usize,
129 pub message: Option<Message>,
130 /// Keys typed so far of an incomplete sequence.
131 pub pending: String,
132 window: [f32; 2],
133 scale_factor: f32,
134 holds: Vec<Hold>,
135 last_tick: Option<Instant>,
136}
137
138impl AppState {
139 pub fn new(queue: Queue, settings: Settings, bar: bool, now: Instant) -> Self {
140 Self {
141 queue,
142 view: View::new(now),
143 settings,
144 bar,
145 nearest: None,
146 alpha_bg: AlphaBg::Dots,
147 shown: None,
148 fade: None,
149 loading_since: None,
150 skipped: 0,
151 message: None,
152 pending: String::new(),
153 window: [1.0, 1.0],
154 scale_factor: 1.0,
155 holds: Vec::new(),
156 last_tick: None,
157 }
158 }
159
160 /// Window size in physical pixels.
161 pub fn set_window(&mut self, w: f32, h: f32, scale_factor: f32) {
162 self.window = [w, h];
163 self.scale_factor = scale_factor;
164 self.update_viewport();
165 }
166
167 pub fn window(&self) -> [f32; 2] {
168 self.window
169 }
170
171 pub fn scale_factor(&self) -> f32 {
172 self.scale_factor
173 }
174
175 /// Physical height of the status bar, 0 when hidden.
176 pub fn bar_height(&self) -> f32 {
177 if self.bar {
178 (BAR_HEIGHT * self.scale_factor).round()
179 } else {
180 0.0
181 }
182 }
183
184 fn update_viewport(&mut self) {
185 let h = (self.window[1] - self.bar_height()).max(1.0);
186 self.view.set_viewport(self.window[0], h);
187 }
188
189 fn dur(&self) -> Duration {
190 self.settings.anim
191 }
192
193 pub fn apply(&mut self, action: Action, now: Instant) -> Effects {
194 let mut fx = Effects {
195 redraw: true,
196 ..Effects::default()
197 };
198 let dur = self.dur();
199 let has_image = self.shown.is_some();
200 match action {
201 Action::Next(n) => fx.navigated = self.queue.step(n as isize, self.settings.wrap),
202 Action::Prev(n) => fx.navigated = self.queue.step(-(n as isize), self.settings.wrap),
203 Action::First => fx.navigated = self.queue.goto(0),
204 Action::Last => fx.navigated = self.queue.goto(usize::MAX),
205 Action::Goto(n) => fx.navigated = self.queue.goto(n.saturating_sub(1)),
206 Action::Zoom(z) if has_image => {
207 let step = self.settings.zoom_step;
208 match z {
209 Zoom::In => self.view.zoom_by(step, None, now, dur),
210 Zoom::Out => self.view.zoom_by(1.0 / step, None, now, dur),
211 Zoom::Percent(p) => self.view.zoom_to(p / 100.0, None, now, dur),
212 }
213 }
214 Action::Fit(fit) if has_image => self.view.set_fit(fit, now, dur),
215 Action::Pan(dir) if has_image => self.view.pan_step(dir, 0.2, now, dur),
216 Action::PanEdge(dir) if has_image => self.view.pan_edge(dir, now, dur),
217 Action::Center if has_image => self.view.recenter(now, dur),
218 Action::Rotate(q) if has_image => self.view.rotate(q, now, dur),
219 Action::Flip(axis) if has_image => self.view.flip(axis, now, dur),
220 Action::FilterToggle => {
221 let nearest = !self.effective_nearest(now);
222 self.nearest = Some(nearest);
223 self.notify(
224 if nearest { "NEAREST" } else { "LINEAR" },
225 MsgKind::Info,
226 now,
227 );
228 }
229 Action::AlphaBgCycle => self.alpha_bg = self.alpha_bg.next(),
230 Action::Fullscreen => fx.fullscreen = true,
231 Action::BarToggle => {
232 self.bar = !self.bar;
233 self.update_viewport();
234 fx.layout = true;
235 }
236 Action::Reload => fx.reload = true,
237 Action::Remove => {
238 if let Some(cur) = self.queue.current().map(Path::to_path_buf) {
239 self.queue.remove(&cur);
240 fx.navigated = true;
241 }
242 }
243 Action::Cancel => self.message = None,
244 Action::Quit => fx.quit = true,
245 Action::Zoom(_)
246 | Action::Fit(_)
247 | Action::Pan(_)
248 | Action::PanEdge(_)
249 | Action::Center
250 | Action::Rotate(_)
251 | Action::Flip(_) => fx.redraw = false,
252 }
253 fx
254 }
255
256 /// Start or stop a held-key pan.
257 pub fn hold_pan(&mut self, dir: Dir, held: bool, now: Instant) {
258 if held {
259 if !self
260 .holds
261 .iter()
262 .any(|h| h.dir == dir && h.released.is_none())
263 {
264 self.holds.retain(|h| h.dir != dir);
265 self.holds.push(Hold {
266 dir,
267 pressed: now,
268 released: None,
269 });
270 }
271 } else {
272 for h in self.holds.iter_mut().filter(|h| h.dir == dir) {
273 h.released.get_or_insert(now);
274 }
275 }
276 }
277
278 pub fn stop_all_pans(&mut self) {
279 self.holds.clear();
280 }
281
282 /// Advance time-driven state (held pans). Call once per frame before drawing.
283 pub fn advance(&mut self, now: Instant) {
284 let last = self.last_tick.replace(now).unwrap_or(now);
285 if self.holds.is_empty() || self.shown.is_none() {
286 self.holds.retain(|h| h.released.is_none());
287 return;
288 }
289 let vp = self.view.viewport();
290 let mut delta = [0.0f32; 2];
291 for h in &self.holds {
292 let end = h.released.map(|r| r.max(h.pressed + MIN_PAN_HOLD));
293 let from = last.max(h.pressed);
294 let to = end.map_or(now, |e| e.min(now));
295 let dt = to.saturating_duration_since(from).as_secs_f32();
296 let v = h.dir.vector();
297 delta[0] -= v[0] * vp[0] * PAN_SPEED * dt;
298 delta[1] -= v[1] * vp[1] * PAN_SPEED * dt;
299 }
300 self.holds.retain(|h| {
301 h.released
302 .is_none_or(|r| now < r.max(h.pressed + MIN_PAN_HOLD))
303 });
304 if delta != [0.0, 0.0] {
305 self.view.pan_pixels(delta);
306 }
307 }
308
309 /// The image for the current file is ready to show.
310 /// `size` is the original pixel size; the texture may be smaller (huge images), but the
311 /// view works in original pixels so zoom percentages stay true.
312 pub fn show(&mut self, path: &Path, size: [u32; 2], format: &'static str, now: Instant) {
313 if let Some(prev) = self.shown.take()
314 && prev.path != path
315 && !self.settings.crossfade.is_zero()
316 {
317 let mut alpha = Tween::new(0.0, now);
318 alpha.retarget(1.0, now, self.settings.crossfade);
319 let affine = self.view.transform(now);
320 self.fade = Some(Fade {
321 path: prev.path,
322 affine,
323 alpha,
324 });
325 }
326 self.shown = Some(Shown {
327 path: path.to_path_buf(),
328 size,
329 format,
330 });
331 self.view
332 .reset(size, self.settings.fit, self.settings.zoom, now);
333 self.loading_since = None;
334 self.holds.clear();
335 }
336
337 /// The current file can't be shown (or the queue emptied).
338 pub fn clear_shown(&mut self) {
339 self.shown = None;
340 self.fade = None;
341 self.loading_since = None;
342 }
343
344 /// Zoom by `steps` zoom steps around a viewport point (mouse wheel).
345 pub fn zoom_at(&mut self, steps: f32, anchor: [f32; 2], now: Instant) {
346 if self.shown.is_some() {
347 let factor = self.settings.zoom_step.powf(steps);
348 self.view
349 .zoom_by(factor, Some(anchor), now, self.settings.anim);
350 }
351 }
352
353 pub fn notify(&mut self, text: impl Into<String>, kind: MsgKind, now: Instant) {
354 let secs = if kind == MsgKind::Error { 5 } else { 2 };
355 self.message = Some(Message {
356 text: text.into(),
357 kind,
358 until: now + Duration::from_secs(secs),
359 });
360 }
361
362 pub fn effective_nearest(&self, now: Instant) -> bool {
363 self.nearest
364 .unwrap_or_else(|| self.view.display_scale(now) > view::NEAREST_ABOVE)
365 }
366
367 pub fn loading_visible(&self, now: Instant) -> bool {
368 self.loading_since.is_some_and(|t| now >= t + LOADING_DELAY)
369 }
370
371 /// Whether frames must keep coming (animations, held keys).
372 pub fn is_animating(&self, now: Instant) -> bool {
373 self.view.is_animating(now)
374 || self.fade.as_ref().is_some_and(|f| f.alpha.is_active(now))
375 || !self.holds.is_empty()
376 }
377
378 /// Expire timed state. Returns whether a redraw is due and the next wake-up time.
379 pub fn tick(&mut self, now: Instant) -> (bool, Option<Instant>) {
380 let mut redraw = false;
381 let mut next: Option<Instant> = None;
382 let mut wake = |t: Instant| next = Some(next.map_or(t, |n| n.min(t)));
383 if let Some(m) = &self.message {
384 if now >= m.until {
385 self.message = None;
386 redraw = true;
387 } else {
388 wake(m.until);
389 }
390 }
391 if let Some(t) = self.loading_since {
392 let at = t + LOADING_DELAY;
393 if now < at {
394 wake(at);
395 }
396 }
397 if self.fade.as_ref().is_some_and(|f| !f.alpha.is_active(now)) {
398 self.fade = None;
399 redraw = true;
400 }
401 (redraw, next)
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 fn state(n: usize) -> (AppState, Instant) {
410 let now = Instant::now();
411 let files = (0..n).map(|i| PathBuf::from(format!("{i}.png"))).collect();
412 let mut s = AppState::new(Queue::new(files, None, 0), Settings::default(), true, now);
413 s.set_window(800.0, 632.0, 1.0);
414 (s, now)
415 }
416
417 #[test]
418 fn navigation_reports_changes() {
419 let (mut s, now) = state(3);
420 assert!(s.apply(Action::Next(1), now).navigated);
421 assert!(s.apply(Action::Last, now).navigated);
422 assert!(!s.apply(Action::Last, now).navigated);
423 assert!(s.apply(Action::Goto(1), now).navigated);
424 assert_eq!(s.queue.index(), 0);
425 }
426
427 #[test]
428 fn bar_shrinks_the_image_area() {
429 let (mut s, now) = state(1);
430 assert_eq!(s.view.viewport(), [800.0, 600.0]);
431 assert!(s.apply(Action::BarToggle, now).layout);
432 assert_eq!(s.view.viewport(), [800.0, 632.0]);
433 }
434
435 #[test]
436 fn showing_a_second_image_crossfades() {
437 let (mut s, now) = state(2);
438 s.show(Path::new("0.png"), [10, 10], "PNG", now);
439 assert!(s.fade.is_none());
440 s.show(Path::new("1.png"), [10, 10], "PNG", now);
441 assert!(s.fade.is_some());
442 assert!(s.is_animating(now));
443 let later = now + Duration::from_secs(1);
444 assert!(s.tick(later).0);
445 assert!(s.fade.is_none());
446 }
447
448 #[test]
449 fn tapped_pan_moves_a_minimum_distance() {
450 let (mut s, now) = state(1);
451 s.show(Path::new("0.png"), [4000, 4000], "PNG", now);
452 s.apply(Action::Fit(FitMode::Original), now);
453 s.advance(now);
454 let before = s.view.transform(now + Duration::from_secs(1)).t;
455 s.hold_pan(Dir::Right, true, now);
456 s.hold_pan(Dir::Right, false, now + Duration::from_millis(10));
457 s.advance(now + Duration::from_millis(500));
458 assert!(!s.is_animating(now + Duration::from_secs(1)));
459 let after = s.view.transform(now + Duration::from_secs(1)).t;
460 let moved = before[0] - after[0];
461 let expected = 800.0 * PAN_SPEED * MIN_PAN_HOLD.as_secs_f32();
462 assert!(
463 (moved - expected).abs() < 1.0,
464 "moved {moved}, expected {expected}"
465 );
466 }
467}