Krolyxon
Commit 4654f72

Branches

Tags

Refs →

remagesrcstatemod.rs

1679 lines · 59.8 KB · Rust

1//! Pure application state: no winit or wgpu types, so everything here is unit-testable.
2
3pub mod fuzzy;
4pub mod grid;
5pub mod overlay;
6pub mod queue;
7pub mod view;
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::time::{Duration, Instant};
12
13use crate::action::{Action, Adjust, ModeArg, Page, Step, Switch, Zoom};
14use crate::anim::Tween;
15use crate::config::theme::Theme;
16use crate::config::{Config, Transition};
17use grid::{Grid, Layout};
18use overlay::{Edit, Help, Info, Input, InputKind, PaletteItem, WhichRow};
19use queue::{Queue, SortKey};
20use view::{Affine, Dir, FitMode, View};
21
22/// `[LOADING]` appears only once a load has taken this long, so it never flickers.
23pub const LOADING_DELAY: Duration = Duration::from_millis(120);
24/// A tap on a pan key moves for at least this long.
25const MIN_PAN_HOLD: Duration = Duration::from_millis(120);
26/// Held-key pan speed, in viewports per second.
27const PAN_SPEED: f32 = 1.25;
28/// Logical height of the status bar.
29pub const BAR_HEIGHT: f32 = 32.0;
30/// Image switch crossfade with `transition = "fade"`.
31const CROSSFADE: Duration = Duration::from_millis(100);
32/// Theme changes (a new wallpaper palette) crossfade this long.
33const THEME_FADE: Duration = Duration::from_millis(250);
34/// Thumbnails fade in this long as they arrive.
35const THUMB_FADE: Duration = Duration::from_millis(150);
36/// Thumbnail names: Space Grotesk at this size.
37pub const GRID_NAME_SIZE: f32 = 12.0;
38/// Overlay panels: distance from the window edges, inner padding, row height (logical px).
39pub const PANEL_MARGIN: f32 = 16.0;
40pub const PANEL_PAD: f32 = 16.0;
41pub const PANEL_ROW: f32 = 28.0;
42/// Help's title line above its rows.
43pub const HELP_HEAD: f32 = 36.0;
44/// Slideshow delays stay within these bounds.
45const SLIDESHOW_MIN: f32 = 0.5;
46const SLIDESHOW_MAX: f32 = 3600.0;
47
48/// Which keymap applies and what is on screen.
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50pub enum Mode {
51 #[default]
52 Image,
53 Grid,
54 Visual,
55}
56
57impl Mode {
58 pub fn name(self) -> &'static str {
59 match self {
60 Mode::Image => "image",
61 Mode::Grid => "grid",
62 Mode::Visual => "visual",
63 }
64 }
65}
66
67#[derive(Clone, Debug, PartialEq)]
68pub struct Settings {
69 pub fit: FitMode,
70 pub zoom: Option<f32>,
71 pub zoom_step: f32,
72 pub wrap: bool,
73 pub preload: usize,
74 pub anim: Duration,
75 pub crossfade: Duration,
76 pub theme_fade: Duration,
77 /// UI size multiplier on top of the display scale.
78 pub ui_scale: f32,
79 pub which_key_delay: Duration,
80 pub thumb_fade: Duration,
81 /// Logical thumbnail sizes `grid-size` steps through, and the starting one.
82 pub grid_sizes: Vec<u32>,
83 pub grid_size: u32,
84 pub grid_gap: u32,
85 pub grid_names: bool,
86 pub slideshow_delay: Duration,
87 /// Status bar templates.
88 pub bar_left: String,
89 pub bar_right: String,
90}
91
92impl Default for Settings {
93 fn default() -> Self {
94 Self {
95 fit: FitMode::Down,
96 zoom: None,
97 zoom_step: 1.25,
98 wrap: true,
99 preload: 2,
100 anim: Duration::from_millis(180),
101 crossfade: CROSSFADE,
102 theme_fade: THEME_FADE,
103 ui_scale: 1.0,
104 which_key_delay: Duration::from_millis(300),
105 thumb_fade: THUMB_FADE,
106 grid_sizes: vec![96, 128, 160, 224, 320],
107 grid_size: 160,
108 grid_gap: 16,
109 grid_names: false,
110 slideshow_delay: Duration::from_secs(5),
111 bar_left: "{name}".into(),
112 bar_right: "{format} {width}×{height} {zoom}% {index}/{count}".into(),
113 }
114 }
115}
116
117impl Settings {
118 pub fn from_config(c: &Config) -> Self {
119 let on = c.animation.enabled;
120 let anim = |d: Duration| if on { d } else { Duration::ZERO };
121 Self {
122 fit: c.image.fit,
123 zoom: None,
124 zoom_step: c.image.zoom_step,
125 wrap: c.general.wrap,
126 preload: c.image.preload,
127 anim: anim(Duration::from_millis(c.animation.duration_ms)),
128 crossfade: match c.animation.transition {
129 Transition::Fade => anim(CROSSFADE),
130 Transition::None => Duration::ZERO,
131 },
132 theme_fade: anim(THEME_FADE),
133 ui_scale: c.ui.scale,
134 which_key_delay: Duration::from_millis(c.ui.which_key_delay_ms),
135 thumb_fade: anim(THUMB_FADE),
136 grid_sizes: c.grid.sizes.clone(),
137 grid_size: c.grid.size,
138 grid_gap: c.grid.gap,
139 grid_names: c.grid.names,
140 slideshow_delay: Duration::from_secs_f32(
141 c.image.slideshow_delay.clamp(SLIDESHOW_MIN, SLIDESHOW_MAX),
142 ),
143 bar_left: c.ui.statusbar_left.clone(),
144 bar_right: c.ui.statusbar_right.clone(),
145 }
146 }
147}
148
149#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
150#[serde(rename_all = "lowercase")]
151pub enum AlphaBg {
152 #[default]
153 Dots,
154 Checker,
155 Solid,
156}
157
158impl AlphaBg {
159 fn next(self) -> Self {
160 match self {
161 AlphaBg::Dots => AlphaBg::Checker,
162 AlphaBg::Checker => AlphaBg::Solid,
163 AlphaBg::Solid => AlphaBg::Dots,
164 }
165 }
166}
167
168/// Display adjustments, applied in the image shader.
169#[derive(Clone, Copy, Debug, PartialEq)]
170pub struct Adjustments {
171 pub gamma: f32,
172 /// Added to every channel, -1..1.
173 pub brightness: f32,
174 /// Scales around mid-grey.
175 pub contrast: f32,
176}
177
178impl Default for Adjustments {
179 fn default() -> Self {
180 Self {
181 gamma: 1.0,
182 brightness: 0.0,
183 contrast: 1.0,
184 }
185 }
186}
187
188impl Adjustments {
189 /// Apply one step; returns the new value for the status line.
190 fn step(&mut self, a: Adjust, s: Step) -> f32 {
191 let d = Self::default();
192 let (v, reset, up, down, lo, hi) = match a {
193 Adjust::Gamma => (&mut self.gamma, d.gamma, 1.1, 1.0 / 1.1, 0.1, 10.0),
194 Adjust::Brightness => (&mut self.brightness, d.brightness, 0.05, -0.05, -1.0, 1.0),
195 Adjust::Contrast => (&mut self.contrast, d.contrast, 0.1, -0.1, 0.0, 4.0),
196 };
197 let mul = a == Adjust::Gamma;
198 *v = match s {
199 Step::Reset => reset,
200 Step::Up if mul => *v * up,
201 Step::Down if mul => *v * down,
202 Step::Up => *v + up,
203 Step::Down => *v + down,
204 };
205 // Snap away float drift so stepping back lands exactly on the default.
206 *v = ((*v * 1000.0).round() / 1000.0).clamp(lo, hi);
207 *v
208 }
209}
210
211#[derive(Clone, Copy, Debug, PartialEq, Eq)]
212pub enum MsgKind {
213 Info,
214 Error,
215}
216
217#[derive(Clone, Debug)]
218pub struct Message {
219 pub text: String,
220 pub kind: MsgKind,
221 pub until: Instant,
222}
223
224/// The image currently on screen (which may lag the queue's current file while it loads).
225#[derive(Clone, Debug)]
226pub struct Shown {
227 pub path: PathBuf,
228 /// Pixel size before any downscale for the GPU.
229 pub size: [u32; 2],
230 pub format: &'static str,
231}
232
233/// Frames of the image on screen: a timed animation, or the pages of a document.
234#[derive(Clone, Debug)]
235pub struct Playback {
236 pub delays: Vec<Duration>,
237 pub animated: bool,
238 pub index: usize,
239 pub playing: bool,
240 /// When the next animation frame is due.
241 next: Option<Instant>,
242}
243
244impl Playback {
245 pub fn len(&self) -> usize {
246 self.delays.len()
247 }
248
249 fn step(&mut self, delta: isize) {
250 let n = self.len() as isize;
251 self.index = (self.index as isize + delta).rem_euclid(n) as usize;
252 }
253
254 fn delay(&self) -> Duration {
255 self.delays[self.index].max(Duration::from_millis(10))
256 }
257}
258
259/// The previous image fading out under the new one.
260#[derive(Clone, Debug)]
261pub struct Fade {
262 pub path: PathBuf,
263 pub affine: Affine,
264 pub alpha: Tween<f32>,
265}
266
267/// A thumbnail in the atlas, as far as drawing it is concerned.
268#[derive(Clone, Copy, Debug, PartialEq)]
269pub struct ThumbState {
270 pub size: [u32; 2],
271 /// When it arrived, for the fade-in.
272 pub at: Instant,
273}
274
275#[derive(Clone, Copy, Debug)]
276struct Hold {
277 dir: Dir,
278 pressed: Instant,
279 released: Option<Instant>,
280}
281
282/// What the event loop must do after an action.
283#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
284pub struct Effects {
285 pub redraw: bool,
286 /// The queue's current file changed: load it and refresh the preload window.
287 pub navigated: bool,
288 /// The queue order changed around the current file: refresh the preload window.
289 pub reordered: bool,
290 /// The status bar appeared or vanished, so the image area changed.
291 pub layout: bool,
292 /// Image ⇄ grid (or visual) switched.
293 pub mode: bool,
294 pub fullscreen: Option<Switch>,
295 pub reload: bool,
296 pub reload_config: bool,
297 /// Re-check files on disk: drop missing ones, re-decode changed ones.
298 pub refresh: bool,
299 /// Re-sort the queue (stat-based keys need the filesystem, so the loop does it).
300 pub sort: Option<SortKey>,
301 /// Open the palette or help: the loop supplies their rows from the live keymap.
302 pub palette: bool,
303 pub help: bool,
304 /// The info panel opened: load the current file's metadata.
305 pub info: bool,
306 /// `open <path>` ran: [`AppState::open`] holds the path to scan.
307 pub open: bool,
308 pub quit: bool,
309}
310
311#[derive(Debug)]
312pub struct AppState {
313 pub queue: Queue,
314 pub view: View,
315 pub settings: Settings,
316 pub mode: Mode,
317 pub grid: Grid,
318 /// Thumbnails in the atlas (or known to fail), by path.
319 pub thumbs: HashMap<PathBuf, ThumbState>,
320 last_thumb: Option<Instant>,
321 pub theme: Theme,
322 /// The previous theme fading out after a reload.
323 theme_fade: Option<(Theme, Tween<f32>)>,
324 pub bar: bool,
325 /// `None` = automatic (nearest above 300%), otherwise forced nearest/linear.
326 pub nearest: Option<bool>,
327 pub alpha_bg: AlphaBg,
328 pub shown: Option<Shown>,
329 /// Frames of the shown image, when it has more than one.
330 pub playback: Option<Playback>,
331 pub fade: Option<Fade>,
332 pub loading_since: Option<Instant>,
333 pub skipped: usize,
334 pub message: Option<Message>,
335 /// Keys typed so far of an incomplete sequence.
336 pub pending: String,
337 /// An inline yes/no question (`DELETE 3 FILES?`) waiting for `y`.
338 pub prompt: Option<String>,
339 /// User commands still running, oldest first: (job id, name).
340 pub running: Vec<(u64, String)>,
341 /// The `:` palette or `/` search line.
342 pub input: Option<Input>,
343 /// Keys that continue the pending sequence, once it has waited `which_key_delay`.
344 pub which_key: Option<Vec<WhichRow>>,
345 pub help: Option<Help>,
346 pub info_open: bool,
347 /// Metadata of the current file, once loaded.
348 pub info: Option<Info>,
349 /// Slideshow on: when the next image is due (`None` until the current one shows).
350 pub slideshow: Option<Option<Instant>>,
351 pub slideshow_delay: Duration,
352 pub adjust: Adjustments,
353 /// The path of the last `open`, for the loop to scan.
354 pub open: Option<String>,
355 /// `ui.info_command` output for a file: its last line fills `{info}`.
356 pub info_line: Option<(PathBuf, String)>,
357 window: [f32; 2],
358 scale_factor: f32,
359 holds: Vec<Hold>,
360 last_tick: Option<Instant>,
361}
362
363impl AppState {
364 pub fn new(queue: Queue, settings: Settings, bar: bool, now: Instant) -> Self {
365 let slideshow_delay = settings.slideshow_delay;
366 Self {
367 queue,
368 view: View::new(now),
369 grid: Grid::new(settings.grid_size, now),
370 thumbs: HashMap::new(),
371 last_thumb: None,
372 settings,
373 mode: Mode::Image,
374 theme: Theme::nothing_dark(),
375 theme_fade: None,
376 bar,
377 nearest: None,
378 alpha_bg: AlphaBg::Dots,
379 shown: None,
380 playback: None,
381 fade: None,
382 loading_since: None,
383 skipped: 0,
384 message: None,
385 pending: String::new(),
386 prompt: None,
387 running: Vec::new(),
388 input: None,
389 which_key: None,
390 help: None,
391 info_open: false,
392 info: None,
393 slideshow: None,
394 slideshow_delay,
395 adjust: Adjustments::default(),
396 open: None,
397 info_line: None,
398 window: [1.0, 1.0],
399 scale_factor: 1.0,
400 holds: Vec::new(),
401 last_tick: None,
402 }
403 }
404
405 /// Window size in physical pixels.
406 pub fn set_window(&mut self, w: f32, h: f32, scale_factor: f32) {
407 self.window = [w, h];
408 self.scale_factor = scale_factor;
409 self.update_viewport();
410 if self.in_grid() {
411 self.grid_follow(Instant::now(), false);
412 }
413 }
414
415 pub fn window(&self) -> [f32; 2] {
416 self.window
417 }
418
419 pub fn scale_factor(&self) -> f32 {
420 self.scale_factor
421 }
422
423 /// Display scale times `ui.scale`: the factor for every UI size.
424 pub fn ui_scale(&self) -> f32 {
425 self.scale_factor * self.settings.ui_scale
426 }
427
428 /// Physical height of the status bar, 0 when hidden.
429 pub fn bar_height(&self) -> f32 {
430 if self.bar {
431 (BAR_HEIGHT * self.ui_scale()).round()
432 } else {
433 0.0
434 }
435 }
436
437 /// Help rows that fit on screen at once.
438 pub fn help_page(&self) -> usize {
439 let sf = self.ui_scale();
440 let h = self.window[1] - self.bar_height() - 2.0 * PANEL_MARGIN * sf;
441 let rows = (h - (2.0 * PANEL_PAD + HELP_HEAD) * sf) / (PANEL_ROW * sf);
442 rows.floor().max(1.0) as usize
443 }
444
445 pub fn in_grid(&self) -> bool {
446 matches!(self.mode, Mode::Grid | Mode::Visual)
447 }
448
449 /// Side of a thumbnail cell in physical pixels.
450 pub fn cell_px(&self) -> u32 {
451 (self.grid.size as f32 * self.ui_scale()).round().max(16.0) as u32
452 }
453
454 /// This frame's grid geometry.
455 pub fn grid_layout(&self) -> Layout {
456 let sf = self.ui_scale();
457 let label = if self.settings.grid_names {
458 ((GRID_NAME_SIZE * 1.25).ceil() + 8.0) * sf
459 } else {
460 0.0
461 };
462 Layout::new(
463 self.queue.len(),
464 [
465 self.window[0],
466 (self.window[1] - self.bar_height()).max(1.0),
467 ],
468 self.cell_px() as f32,
469 (self.settings.grid_gap as f32 * sf).round(),
470 label.round(),
471 )
472 }
473
474 /// Scroll so the cursor's row is on screen.
475 fn grid_follow(&mut self, now: Instant, animate: bool) {
476 let l = self.grid_layout();
477 let to = l.follow(self.queue.index(), self.grid.target(&l));
478 if animate {
479 self.grid.scroll_to(to, now, self.settings.anim);
480 } else {
481 self.grid.jump_to(to);
482 }
483 }
484
485 /// Scroll the grid by `rows` (fractional for touchpads), pulling the cursor along so it
486 /// stays on screen. Returns whether the cursor moved.
487 pub fn grid_scroll(&mut self, rows: f32, now: Instant) -> bool {
488 let l = self.grid_layout();
489 let to = l.clamp_scroll(self.grid.target(&l) + rows * l.row_height());
490 self.grid.scroll_to(to, now, self.settings.anim);
491 self.queue.goto(l.pull_into_view(self.queue.index(), to))
492 }
493
494 /// Drag the grid content by `dy` pixels. Returns whether the cursor moved.
495 pub fn grid_drag(&mut self, dy: f32, now: Instant) -> bool {
496 let l = self.grid_layout();
497 let to = l.clamp_scroll(self.grid.scroll(&l, now) - dy);
498 self.grid.jump_to(to);
499 self.queue.goto(l.pull_into_view(self.queue.index(), to))
500 }
501
502 /// The thumbnail under a window point.
503 pub fn grid_hit(&self, p: [f32; 2], now: Instant) -> Option<usize> {
504 let l = self.grid_layout();
505 if p[1] >= l.viewport[1] {
506 return None;
507 }
508 l.hit(p, self.grid.scroll(&l, now))
509 }
510
511 /// The selected range in visual mode.
512 pub fn visual_range(&self) -> Option<std::ops::Range<usize>> {
513 if self.mode != Mode::Visual {
514 return None;
515 }
516 let r = self.grid.range(self.queue.index())?;
517 Some(r.start.min(self.queue.len())..r.end.min(self.queue.len()))
518 }
519
520 /// Switch modes. Returns whether the mode changed.
521 pub fn set_mode(&mut self, mode: Mode, now: Instant) -> bool {
522 if mode == self.mode {
523 return false;
524 }
525 let was_grid = self.in_grid();
526 self.mode = mode;
527 if mode != Mode::Visual {
528 self.grid.anchor = None;
529 }
530 self.holds.clear();
531 match mode {
532 Mode::Image => {
533 if let Some(pb) = self.playback.as_mut().filter(|p| p.playing) {
534 pb.next = Some(now + pb.delay());
535 }
536 // Don't crossfade from whatever was open before the grid.
537 if self.shown.as_ref().map(|s| s.path.as_path()) != self.queue.current() {
538 self.clear_shown();
539 }
540 }
541 Mode::Grid | Mode::Visual if !was_grid => self.grid_follow(now, false),
542 _ => {}
543 }
544 true
545 }
546
547 /// A thumbnail landed in the atlas.
548 pub fn thumb_ready(&mut self, path: PathBuf, size: [u32; 2], now: Instant) {
549 self.thumbs.insert(path, ThumbState { size, at: now });
550 self.last_thumb = Some(now);
551 }
552
553 /// Opacity of a thumbnail fading in.
554 pub fn thumb_alpha(&self, at: Instant, now: Instant) -> f32 {
555 let d = self.settings.thumb_fade;
556 if d.is_zero() {
557 return 1.0;
558 }
559 let t = now.saturating_duration_since(at).as_secs_f32() / d.as_secs_f32();
560 crate::anim::ease(t.min(1.0))
561 }
562
563 fn update_viewport(&mut self) {
564 let h = (self.window[1] - self.bar_height()).max(1.0);
565 self.view.set_viewport(self.window[0], h);
566 }
567
568 fn dur(&self) -> Duration {
569 self.settings.anim
570 }
571
572 /// Apply settings from a (re)loaded config. The view keeps its zoom until the next image.
573 pub fn set_settings(&mut self, settings: Settings) {
574 if settings.slideshow_delay != self.settings.slideshow_delay {
575 self.slideshow_delay = settings.slideshow_delay;
576 }
577 self.settings = settings;
578 self.update_viewport();
579 }
580
581 /// Switch to a new theme, crossfading from whatever is on screen now.
582 pub fn set_theme(&mut self, theme: Theme, now: Instant) {
583 if theme == self.theme {
584 return;
585 }
586 let shown = self.theme_at(now);
587 self.theme = theme;
588 if self.settings.theme_fade.is_zero() {
589 self.theme_fade = None;
590 return;
591 }
592 let mut t = Tween::new(0.0, now);
593 t.retarget(1.0, now, self.settings.theme_fade);
594 self.theme_fade = Some((shown, t));
595 }
596
597 /// The theme for the frame at `now`.
598 pub fn theme_at(&self, now: Instant) -> Theme {
599 match &self.theme_fade {
600 Some((from, t)) if t.is_active(now) => from.mix(&self.theme, t.value(now)),
601 _ => self.theme.clone(),
602 }
603 }
604
605 pub fn apply(&mut self, action: Action, now: Instant) -> Effects {
606 self.apply_at(action, None, now)
607 }
608
609 /// Apply an action; `anchor` (viewport pixels) is where mouse-driven zoom centres.
610 pub fn apply_at(&mut self, action: Action, anchor: Option<[f32; 2]>, now: Instant) -> Effects {
611 let mut fx = Effects {
612 redraw: true,
613 ..Effects::default()
614 };
615 let dur = self.dur();
616 let in_grid = self.in_grid();
617 let has_image = self.mode == Mode::Image && self.shown.is_some();
618 let wrap = self.settings.wrap;
619 let switch = |s: Switch, cur: bool| match s {
620 Switch::Toggle => !cur,
621 Switch::On => true,
622 Switch::Off => false,
623 };
624 match action {
625 Action::Next(n) => fx.navigated = self.queue.step(n as isize, self.settings.wrap),
626 Action::Prev(n) => fx.navigated = self.queue.step(-(n as isize), self.settings.wrap),
627 Action::First => fx.navigated = self.queue.goto(0),
628 Action::Last => fx.navigated = self.queue.goto(usize::MAX),
629 Action::Goto(n) => fx.navigated = self.queue.goto(n.saturating_sub(1)),
630 Action::Random => {
631 let len = self.queue.len();
632 if len > 1 {
633 let r = std::hash::BuildHasher::hash_one(
634 &std::collections::hash_map::RandomState::new(),
635 now,
636 ) as usize;
637 // Any image but the current one.
638 let i = (self.queue.index() + 1 + r % (len - 1)) % len;
639 fx.navigated = self.queue.goto(i);
640 }
641 }
642 Action::Zoom(z) if has_image => {
643 let step = self.settings.zoom_step;
644 match z {
645 Zoom::In(n) => self.view.zoom_by(step.powi(n as i32), anchor, now, dur),
646 Zoom::Out(n) => self.view.zoom_by(step.powi(-(n as i32)), anchor, now, dur),
647 Zoom::Percent(p) => self.view.zoom_to(p / 100.0, anchor, now, dur),
648 }
649 }
650 Action::NextMark | Action::PrevMark => {
651 let forward = action == Action::NextMark;
652 match self.queue.find_mark(forward, 1, wrap) {
653 Some(i) => fx.navigated = self.queue.goto(i),
654 None => self.notify("no other marks", MsgKind::Info, now),
655 }
656 }
657 Action::Mark(s) => {
658 if let Some(range) = self.visual_range() {
659 let on = match s {
660 Switch::Toggle => !range.clone().all(|i| self.queue.is_marked(i)),
661 Switch::On => true,
662 Switch::Off => false,
663 };
664 for i in range {
665 self.queue.set_mark(i, on);
666 }
667 fx.mode = self.set_mode(Mode::Grid, now);
668 } else {
669 let i = self.queue.index();
670 let cur = self.queue.is_marked(i);
671 self.queue.set_mark(i, switch(s, cur));
672 }
673 }
674 Action::MarkAll => self.queue.mark_all(),
675 Action::UnmarkAll => self.queue.unmark_all(),
676 Action::InvertMarks => self.queue.invert_marks(),
677 Action::Move(dir, n) if in_grid => {
678 let l = self.grid_layout();
679 fx.navigated = self.queue.goto(l.step(self.queue.index(), dir, n));
680 }
681 Action::Page(page) if in_grid => {
682 let l = self.grid_layout();
683 let full = l.page_rows() as isize;
684 let half = ((l.viewport[1] / l.row_height() / 2.0).round() as isize).max(1);
685 let rows = match page {
686 Page::Down => full,
687 Page::Up => -full,
688 Page::HalfDown => half,
689 Page::HalfUp => -half,
690 };
691 let to = l.clamp_scroll(self.grid.target(&l) + rows as f32 * l.row_height());
692 self.grid.scroll_to(to, now, dur);
693 fx.navigated = self.queue.goto(l.step_rows(self.queue.index(), rows));
694 }
695 Action::RowStart | Action::RowEnd if in_grid => {
696 let l = self.grid_layout();
697 let i = self.queue.index();
698 let to = if action == Action::RowStart {
699 l.row_start(i)
700 } else {
701 l.row_end(i)
702 };
703 fx.navigated = self.queue.goto(to);
704 }
705 Action::Scroll(dir, n) if in_grid => match dir {
706 Dir::Up => fx.navigated = self.grid_scroll(-(n as f32), now),
707 Dir::Down => fx.navigated = self.grid_scroll(n as f32, now),
708 Dir::Left | Dir::Right => fx.redraw = false,
709 },
710 Action::Scroll(dir, n) if has_image => {
711 self.view.pan_step(dir, 0.1 * n as f32, now, dur);
712 }
713 Action::Mode(m) => {
714 let target = match m {
715 ModeArg::Image => Mode::Image,
716 ModeArg::Grid => Mode::Grid,
717 ModeArg::Toggle if in_grid => Mode::Image,
718 ModeArg::Toggle => Mode::Grid,
719 };
720 fx.mode = self.set_mode(target, now);
721 fx.redraw = fx.mode;
722 }
723 Action::Visual => {
724 if self.mode == Mode::Visual {
725 fx.mode = self.set_mode(Mode::Grid, now);
726 } else {
727 self.set_mode(Mode::Grid, now);
728 self.grid.anchor = Some(self.queue.index());
729 self.mode = Mode::Visual;
730 fx.mode = true;
731 }
732 }
733 Action::GridSize(d) => {
734 let size = grid::next_size(&self.settings.grid_sizes, self.grid.size, d);
735 if size == self.grid.size {
736 fx.redraw = false;
737 } else {
738 self.grid.size = size;
739 if in_grid {
740 self.grid_follow(now, false);
741 }
742 }
743 }
744 Action::Fit(fit) if has_image => self.view.set_fit(fit, now, dur),
745 Action::Pan(dir) if has_image => self.view.pan_step(dir, 0.2, now, dur),
746 Action::PanEdge(dir) if has_image => self.view.pan_edge(dir, now, dur),
747 Action::Center if has_image => self.view.recenter(now, dur),
748 Action::Rotate(q) if has_image => self.view.rotate(q, now, dur),
749 Action::Flip(axis) if has_image => self.view.flip(axis, now, dur),
750 Action::FilterToggle => {
751 let nearest = !self.effective_nearest(now);
752 self.nearest = Some(nearest);
753 self.notify(
754 if nearest { "NEAREST" } else { "LINEAR" },
755 MsgKind::Info,
756 now,
757 );
758 }
759 Action::AlphaBgCycle => self.alpha_bg = self.alpha_bg.next(),
760 Action::Play(s) if has_image => match &mut self.playback {
761 Some(pb) if pb.animated => {
762 pb.playing = switch(s, pb.playing);
763 pb.next = pb.playing.then(|| now + pb.delay());
764 }
765 _ => self.notify("not animated", MsgKind::Info, now),
766 },
767 Action::NextFrame | Action::PrevFrame if has_image => match &mut self.playback {
768 Some(pb) => {
769 pb.step(if action == Action::NextFrame { 1 } else { -1 });
770 // Stepping an animation pauses it, so the frame stays put.
771 pb.playing = false;
772 pb.next = None;
773 }
774 None => self.notify("single frame", MsgKind::Info, now),
775 },
776 Action::Adjust(a, s) if has_image => {
777 let v = self.adjust.step(a, s);
778 let name = Action::Adjust(a, s).name();
779 self.notify(format!("{name} {v:.2}"), MsgKind::Info, now);
780 }
781 Action::Slideshow(s) => {
782 let on = switch(s, self.slideshow.is_some());
783 if on == self.slideshow.is_some() {
784 fx.redraw = false;
785 } else if on {
786 // It runs in image mode; the timer starts once the image shows.
787 fx.mode = self.set_mode(Mode::Image, now);
788 self.slideshow = Some(self.shown.is_some().then(|| now + self.slideshow_delay));
789 let secs = self.slideshow_delay.as_secs_f32();
790 self.notify(format!("slideshow {secs}s"), MsgKind::Info, now);
791 } else {
792 self.slideshow = None;
793 self.notify("slideshow off", MsgKind::Info, now);
794 }
795 }
796 Action::SlideshowDelay { secs, relative } => {
797 let base = if relative {
798 self.slideshow_delay.as_secs_f32()
799 } else {
800 0.0
801 };
802 let d = (base + secs).clamp(SLIDESHOW_MIN, SLIDESHOW_MAX);
803 self.slideshow_delay = Duration::from_secs_f32(d);
804 if let Some(Some(next)) = &mut self.slideshow {
805 // Restart the count for the image on screen.
806 *next = now + self.slideshow_delay;
807 }
808 self.notify(
809 format!("slideshow {}s", (d * 10.0).round() / 10.0),
810 MsgKind::Info,
811 now,
812 );
813 }
814 Action::Info(s) => {
815 let open = switch(s, self.info_open);
816 fx.info = open && !self.info_open;
817 fx.redraw = open != self.info_open;
818 self.info_open = open;
819 }
820 Action::Help => {
821 if self.help.take().is_none() {
822 fx.help = true;
823 }
824 }
825 Action::Palette => fx.palette = true,
826 Action::Search => {
827 self.which_key = None;
828 self.help = None;
829 let origin = self.queue.current().map(Path::to_path_buf);
830 self.input = Some(Input::new(InputKind::Search, Vec::new(), origin));
831 }
832 Action::Filter(p) => {
833 fx.navigated = self.queue.set_filter(Some(p));
834 fx.reordered = true;
835 if self.queue.is_empty() {
836 self.notify("no matches", MsgKind::Info, now);
837 }
838 }
839 Action::FilterClear => {
840 if self.queue.filter().is_some() {
841 fx.navigated = self.queue.set_filter(None);
842 fx.reordered = true;
843 } else {
844 fx.redraw = false;
845 }
846 }
847 Action::Open(p) => {
848 self.open = Some(p);
849 fx.open = true;
850 }
851 Action::Fullscreen(s) => fx.fullscreen = Some(s),
852 Action::Bar(s) => {
853 let bar = switch(s, self.bar);
854 if bar != self.bar {
855 self.bar = bar;
856 self.update_viewport();
857 fx.layout = true;
858 }
859 }
860 Action::Sort(key) => fx.sort = Some(key),
861 Action::Reverse => {
862 self.queue.reverse();
863 fx.reordered = true;
864 }
865 Action::Reload => fx.reload = true,
866 Action::Remove => {
867 if let Some(cur) = self.queue.current().map(Path::to_path_buf) {
868 self.queue.remove(&cur);
869 fx.navigated = true;
870 }
871 }
872 Action::ReloadConfig => fx.reload_config = true,
873 Action::Refresh => fx.refresh = true,
874 // The event loop runs commands: they spawn processes and may wait for a `y`.
875 Action::Run(_) => fx.redraw = false,
876 Action::Echo(msg) => self.notify(msg, MsgKind::Info, now),
877 Action::Cancel => {
878 self.message = None;
879 self.help = None;
880 self.which_key = None;
881 }
882 Action::Quit | Action::QuitNoOutput => fx.quit = true,
883 Action::Zoom(_)
884 | Action::Fit(_)
885 | Action::Pan(_)
886 | Action::PanEdge(_)
887 | Action::Center
888 | Action::Rotate(_)
889 | Action::Flip(_)
890 | Action::Move(..)
891 | Action::Page(_)
892 | Action::RowStart
893 | Action::RowEnd
894 | Action::Scroll(..)
895 | Action::Play(_)
896 | Action::NextFrame
897 | Action::PrevFrame
898 | Action::Adjust(..) => fx.redraw = false,
899 }
900 if fx.navigated && self.in_grid() {
901 self.grid_follow(now, true);
902 }
903 fx
904 }
905
906 /// Open the palette with its rows (built by the loop from the keymap and commands).
907 pub fn open_palette(&mut self, items: Vec<PaletteItem>) {
908 self.which_key = None;
909 self.help = None;
910 self.input = Some(Input::new(InputKind::Palette, items, None));
911 }
912
913 fn goto_path(&mut self, path: &Path) -> bool {
914 match self.queue.files().iter().position(|f| f == path) {
915 Some(i) => self.queue.goto(i),
916 None => false,
917 }
918 }
919
920 /// Re-run the search for the current text: the grid filters live, the image view lists
921 /// the best matches.
922 fn search_update(&mut self, now: Instant) -> Effects {
923 let mut fx = Effects {
924 redraw: true,
925 ..Effects::default()
926 };
927 let Some(input) = &mut self.input else {
928 return fx;
929 };
930 if self.mode == Mode::Image {
931 let names: Vec<String> = self.queue.files().iter().map(|p| file_name(p)).collect();
932 input.matches = fuzzy::rank(&input.text, names.iter().map(String::as_str));
933 input.selected = 0;
934 return fx;
935 }
936 let text = input.text.clone();
937 fx.navigated = self.queue.set_search(Some(text));
938 fx.reordered = true;
939 self.grid_follow(now, false);
940 fx
941 }
942
943 /// A key typed at the palette or search line.
944 pub fn input_edit(&mut self, e: Edit, now: Instant) -> Effects {
945 let mut fx = Effects {
946 redraw: true,
947 ..Effects::default()
948 };
949 let in_grid = self.in_grid();
950 let Some(input) = &mut self.input else {
951 return fx;
952 };
953 let kind = input.kind;
954 if input.edit_text(&e) {
955 return match kind {
956 InputKind::Palette => {
957 input.rank_items();
958 fx
959 }
960 InputKind::Search => self.search_update(now),
961 };
962 }
963 match (&e, kind, in_grid) {
964 (Edit::Up | Edit::Down, InputKind::Palette, _)
965 | (Edit::Up | Edit::Down, InputKind::Search, false) => input.select(e == Edit::Up),
966 (Edit::Left | Edit::Right | Edit::Up | Edit::Down, InputKind::Search, true) => {
967 let dir = match e {
968 Edit::Left => Dir::Left,
969 Edit::Right => Dir::Right,
970 Edit::Up => Dir::Up,
971 _ => Dir::Down,
972 };
973 return self.apply(Action::Move(dir, 1), now);
974 }
975 (Edit::Complete, InputKind::Palette, _) => {
976 if let Some(item) = input.selected_item() {
977 input.text = item.completion();
978 input.rank_items();
979 }
980 }
981 _ => fx.redraw = false,
982 }
983 fx
984 }
985
986 /// `<CR>` at the prompt. Returns the action text to run, if any.
987 pub fn input_submit(&mut self, now: Instant) -> (Effects, Option<String>) {
988 let mut fx = Effects {
989 redraw: true,
990 ..Effects::default()
991 };
992 let Some(input) = self.input.take() else {
993 return (fx, None);
994 };
995 match input.kind {
996 InputKind::Palette => {
997 let typed = input.text.trim();
998 let selected = input.selected_item().cloned();
999 let parses = typed.parse::<Action>().is_ok();
1000 let run = match selected {
1001 Some(sel) if input.moved || !parses => {
1002 if sel.template {
1003 // `goto N`: complete it and keep typing.
1004 let mut input = input;
1005 input.text = sel.completion();
1006 input.rank_items();
1007 self.input = Some(input);
1008 return (fx, None);
1009 }
1010 Some(sel.action)
1011 }
1012 _ if typed.is_empty() => None,
1013 _ => Some(typed.to_string()),
1014 };
1015 (fx, run)
1016 }
1017 InputKind::Search if self.mode == Mode::Image => {
1018 if let Some(&i) = input.matches.get(input.selected) {
1019 fx.navigated = self.queue.goto(i);
1020 }
1021 (fx, None)
1022 }
1023 InputKind::Search => {
1024 // Keep the file under the cursor, with the whole queue back around it.
1025 let cur = self.queue.current().map(Path::to_path_buf);
1026 self.queue.set_search(None);
1027 fx.reordered = true;
1028 fx.navigated = cur.as_deref() != input.origin.as_deref();
1029 if let Some(c) = cur {
1030 self.goto_path(&c);
1031 }
1032 self.grid_follow(now, false);
1033 (fx, None)
1034 }
1035 }
1036 }
1037
1038 /// `<Esc>` at the prompt: a search puts everything back as it was.
1039 pub fn input_cancel(&mut self, now: Instant) -> Effects {
1040 let mut fx = Effects {
1041 redraw: true,
1042 ..Effects::default()
1043 };
1044 let Some(input) = self.input.take() else {
1045 return fx;
1046 };
1047 if input.kind == InputKind::Search && self.in_grid() {
1048 self.queue.set_search(None);
1049 fx.reordered = true;
1050 if let Some(o) = &input.origin {
1051 self.goto_path(o);
1052 }
1053 fx.navigated = self.queue.current() != input.origin.as_deref();
1054 self.grid_follow(now, false);
1055 }
1056 fx
1057 }
1058
1059 /// The slideshow's next image is due.
1060 pub fn slideshow_due(&self, now: Instant) -> bool {
1061 self.mode == Mode::Image && matches!(self.slideshow, Some(Some(t)) if now >= t)
1062 }
1063
1064 /// The slideshow moved on (or couldn't): wait for the next image to show.
1065 pub fn slideshow_advanced(&mut self, moved: bool) {
1066 if self.slideshow.is_some() {
1067 self.slideshow = moved.then_some(None);
1068 }
1069 }
1070
1071 /// Start or stop a held-key pan.
1072 pub fn hold_pan(&mut self, dir: Dir, held: bool, now: Instant) {
1073 if held {
1074 if !self
1075 .holds
1076 .iter()
1077 .any(|h| h.dir == dir && h.released.is_none())
1078 {
1079 self.holds.retain(|h| h.dir != dir);
1080 self.holds.push(Hold {
1081 dir,
1082 pressed: now,
1083 released: None,
1084 });
1085 }
1086 } else {
1087 for h in self.holds.iter_mut().filter(|h| h.dir == dir) {
1088 h.released.get_or_insert(now);
1089 }
1090 }
1091 }
1092
1093 pub fn stop_all_pans(&mut self) {
1094 self.holds.clear();
1095 }
1096
1097 /// Advance time-driven state (held pans). Call once per frame before drawing.
1098 pub fn advance(&mut self, now: Instant) {
1099 let last = self.last_tick.replace(now).unwrap_or(now);
1100 if self.holds.is_empty() || self.shown.is_none() {
1101 self.holds.retain(|h| h.released.is_none());
1102 return;
1103 }
1104 let vp = self.view.viewport();
1105 let mut delta = [0.0f32; 2];
1106 for h in &self.holds {
1107 let end = h.released.map(|r| r.max(h.pressed + MIN_PAN_HOLD));
1108 let from = last.max(h.pressed);
1109 let to = end.map_or(now, |e| e.min(now));
1110 let dt = to.saturating_duration_since(from).as_secs_f32();
1111 let v = h.dir.vector();
1112 delta[0] -= v[0] * vp[0] * PAN_SPEED * dt;
1113 delta[1] -= v[1] * vp[1] * PAN_SPEED * dt;
1114 }
1115 self.holds.retain(|h| {
1116 h.released
1117 .is_none_or(|r| now < r.max(h.pressed + MIN_PAN_HOLD))
1118 });
1119 if delta != [0.0, 0.0] {
1120 self.view.pan_pixels(delta);
1121 }
1122 }
1123
1124 /// The image for the current file is ready to show.
1125 /// `size` is the original pixel size; the texture may be smaller (huge images), but the
1126 /// view works in original pixels so zoom percentages stay true.
1127 pub fn show(&mut self, path: &Path, size: [u32; 2], format: &'static str, now: Instant) {
1128 if let Some(prev) = self.shown.take()
1129 && prev.path != path
1130 && !self.settings.crossfade.is_zero()
1131 {
1132 let mut alpha = Tween::new(0.0, now);
1133 alpha.retarget(1.0, now, self.settings.crossfade);
1134 let affine = self.view.transform(now);
1135 self.fade = Some(Fade {
1136 path: prev.path,
1137 affine,
1138 alpha,
1139 });
1140 }
1141 self.shown = Some(Shown {
1142 path: path.to_path_buf(),
1143 size,
1144 format,
1145 });
1146 self.view
1147 .reset(size, self.settings.fit, self.settings.zoom, now);
1148 self.loading_since = None;
1149 self.holds.clear();
1150 self.playback = None;
1151 if let Some(next) = &mut self.slideshow {
1152 *next = Some(now + self.slideshow_delay);
1153 }
1154 }
1155
1156 /// The shown image has several frames: play an animation, or page through a document.
1157 pub fn set_frames(&mut self, delays: Vec<Duration>, animated: bool, now: Instant) {
1158 if delays.len() < 2 {
1159 self.playback = None;
1160 return;
1161 }
1162 let mut pb = Playback {
1163 delays,
1164 animated,
1165 index: 0,
1166 playing: animated,
1167 next: None,
1168 };
1169 pb.next = animated.then(|| now + pb.delay());
1170 self.playback = Some(pb);
1171 }
1172
1173 /// The frame of the shown image to draw.
1174 pub fn frame(&self) -> usize {
1175 self.playback.as_ref().map_or(0, |p| p.index)
1176 }
1177
1178 /// The current file can't be shown (or the queue emptied).
1179 pub fn clear_shown(&mut self) {
1180 self.shown = None;
1181 self.playback = None;
1182 self.fade = None;
1183 self.loading_since = None;
1184 }
1185
1186 /// Zoom by `steps` zoom steps (fractional for touchpads) around a viewport point.
1187 pub fn zoom_at(&mut self, steps: f32, anchor: [f32; 2], now: Instant) {
1188 if self.shown.is_some() {
1189 let factor = self.settings.zoom_step.powf(steps);
1190 self.view
1191 .zoom_by(factor, Some(anchor), now, self.settings.anim);
1192 }
1193 }
1194
1195 pub fn notify(&mut self, text: impl Into<String>, kind: MsgKind, now: Instant) {
1196 let secs = if kind == MsgKind::Error { 5 } else { 2 };
1197 self.notify_for(text, kind, now, Duration::from_secs(secs));
1198 }
1199
1200 pub fn notify_for(
1201 &mut self,
1202 text: impl Into<String>,
1203 kind: MsgKind,
1204 now: Instant,
1205 d: Duration,
1206 ) {
1207 self.message = Some(Message {
1208 text: text.into(),
1209 kind,
1210 until: now + d,
1211 });
1212 }
1213
1214 pub fn effective_nearest(&self, now: Instant) -> bool {
1215 self.nearest
1216 .unwrap_or_else(|| self.view.display_scale(now) > view::NEAREST_ABOVE)
1217 }
1218
1219 /// The grid never waits on a full decode, so a load left over from image mode stays quiet.
1220 pub fn loading_visible(&self, now: Instant) -> bool {
1221 !self.in_grid() && self.loading_since.is_some_and(|t| now >= t + LOADING_DELAY)
1222 }
1223
1224 /// Whether frames must keep coming (animations, held keys).
1225 pub fn is_animating(&self, now: Instant) -> bool {
1226 let grid = self.in_grid()
1227 && (self.grid.is_animating(now)
1228 || self
1229 .last_thumb
1230 .is_some_and(|t| now < t + self.settings.thumb_fade));
1231 grid || self.view.is_animating(now)
1232 || self.fade.as_ref().is_some_and(|f| f.alpha.is_active(now))
1233 || self
1234 .theme_fade
1235 .as_ref()
1236 .is_some_and(|(_, t)| t.is_active(now))
1237 || !self.holds.is_empty()
1238 }
1239
1240 /// Expire timed state. Returns whether a redraw is due and the next wake-up time.
1241 pub fn tick(&mut self, now: Instant) -> (bool, Option<Instant>) {
1242 let mut redraw = false;
1243 let mut next: Option<Instant> = None;
1244 let mut wake = |t: Instant| next = Some(next.map_or(t, |n| n.min(t)));
1245 if let Some(m) = &self.message {
1246 if now >= m.until {
1247 self.message = None;
1248 redraw = true;
1249 } else {
1250 wake(m.until);
1251 }
1252 }
1253 if let Some(t) = self.loading_since.filter(|_| !self.in_grid()) {
1254 let at = t + LOADING_DELAY;
1255 if now < at {
1256 wake(at);
1257 }
1258 }
1259 if self.mode == Mode::Image
1260 && let Some(Some(t)) = self.slideshow
1261 {
1262 wake(t);
1263 }
1264 // Animation frames: only while the image is on screen.
1265 if self.mode == Mode::Image
1266 && let Some(pb) = &mut self.playback
1267 && let Some(mut next) = pb.next
1268 {
1269 if now >= next {
1270 let before = pb.index;
1271 // Catch up on missed frames, but after a long stall just carry on from now.
1272 for _ in 0..pb.len() {
1273 if now < next {
1274 break;
1275 }
1276 pb.step(1);
1277 next += pb.delay();
1278 }
1279 if now >= next {
1280 next = now + pb.delay();
1281 }
1282 pb.next = Some(next);
1283 redraw |= pb.index != before;
1284 }
1285 wake(next);
1286 }
1287 if self.fade.as_ref().is_some_and(|f| !f.alpha.is_active(now)) {
1288 self.fade = None;
1289 redraw = true;
1290 }
1291 if self
1292 .theme_fade
1293 .as_ref()
1294 .is_some_and(|(_, t)| !t.is_active(now))
1295 {
1296 self.theme_fade = None;
1297 redraw = true;
1298 }
1299 (redraw, next)
1300 }
1301}
1302
1303pub fn file_name(path: &Path) -> String {
1304 path.file_name().map_or_else(
1305 || path.display().to_string(),
1306 |n| n.to_string_lossy().into(),
1307 )
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312 use super::*;
1313
1314 fn state(n: usize) -> (AppState, Instant) {
1315 let now = Instant::now();
1316 let files = (0..n).map(|i| PathBuf::from(format!("{i}.png"))).collect();
1317 let mut s = AppState::new(Queue::new(files, None, 0), Settings::default(), true, now);
1318 s.set_window(800.0, 632.0, 1.0);
1319 (s, now)
1320 }
1321
1322 #[test]
1323 fn navigation_reports_changes() {
1324 let (mut s, now) = state(3);
1325 assert!(s.apply(Action::Next(1), now).navigated);
1326 assert!(s.apply(Action::Last, now).navigated);
1327 assert!(!s.apply(Action::Last, now).navigated);
1328 assert!(s.apply(Action::Goto(1), now).navigated);
1329 assert_eq!(s.queue.index(), 0);
1330 }
1331
1332 #[test]
1333 fn grid_hides_a_pending_load() {
1334 let (mut s, now) = state(3);
1335 s.loading_since = Some(now);
1336 let later = now + LOADING_DELAY;
1337 assert!(s.loading_visible(later));
1338 s.set_mode(Mode::Grid, now);
1339 assert!(!s.loading_visible(later));
1340 }
1341
1342 #[test]
1343 fn bar_shrinks_the_image_area() {
1344 let (mut s, now) = state(1);
1345 assert_eq!(s.view.viewport(), [800.0, 600.0]);
1346 assert!(!s.apply(Action::Bar(Switch::On), now).layout);
1347 assert!(s.apply(Action::Bar(Switch::Toggle), now).layout);
1348 assert_eq!(s.view.viewport(), [800.0, 632.0]);
1349 assert!(!s.apply(Action::Bar(Switch::Off), now).layout);
1350 s.settings.ui_scale = 2.0;
1351 s.apply(Action::Bar(Switch::On), now);
1352 assert_eq!(s.view.viewport(), [800.0, 568.0]);
1353 s.apply(Action::Bar(Switch::Off), now);
1354 assert_eq!(s.view.viewport(), [800.0, 632.0]);
1355 }
1356
1357 #[test]
1358 fn showing_a_second_image_crossfades() {
1359 let (mut s, now) = state(2);
1360 s.show(Path::new("0.png"), [10, 10], "PNG", now);
1361 assert!(s.fade.is_none());
1362 s.show(Path::new("1.png"), [10, 10], "PNG", now);
1363 assert!(s.fade.is_some());
1364 assert!(s.is_animating(now));
1365 let later = now + Duration::from_secs(1);
1366 assert!(s.tick(later).0);
1367 assert!(s.fade.is_none());
1368 }
1369
1370 #[test]
1371 fn theme_changes_crossfade() {
1372 use crate::config::theme::{Rgba, Theme};
1373 let (mut s, now) = state(1);
1374 let light = Theme::derive(
1375 Rgba::rgb(0xFFFFFF),
1376 Rgba::rgb(0x000000),
1377 Rgba::rgb(0xD71921),
1378 );
1379 s.set_theme(light.clone(), now);
1380 assert!(s.is_animating(now));
1381 let mid = s.theme_at(now + THEME_FADE / 2).background;
1382 assert!(mid.0[0] > 0 && mid.0[0] < 255, "{mid:?}");
1383 let later = now + THEME_FADE * 2;
1384 assert_eq!(s.theme_at(later), light);
1385 assert!(s.tick(later).0);
1386 assert!(!s.is_animating(later));
1387 // With animation off the switch is instant.
1388 s.settings.theme_fade = Duration::ZERO;
1389 s.set_theme(Theme::nothing_dark(), later);
1390 assert_eq!(s.theme_at(later), Theme::nothing_dark());
1391 }
1392
1393 #[test]
1394 fn overlay_actions_ask_the_loop() {
1395 let (mut s, now) = state(2);
1396 assert!(s.apply(Action::Palette, now).palette);
1397 assert!(s.apply(Action::Help, now).help);
1398 s.help = Some(Help {
1399 rows: Vec::new(),
1400 scroll: 0,
1401 });
1402 assert!(!s.apply(Action::Help, now).help, "help toggles off");
1403 assert!(s.help.is_none());
1404 assert!(s.apply(Action::Info(Switch::Toggle), now).info);
1405 assert!(!s.apply(Action::Info(Switch::On), now).redraw);
1406 assert!(s.apply(Action::Open("x.png".into()), now).open);
1407 assert_eq!(s.open.as_deref(), Some("x.png"));
1408 assert!(s.apply(Action::Refresh, now).refresh);
1409 assert!(!s.apply(Action::Run("x".into()), now).redraw);
1410 s.apply(Action::Echo("hi".into()), now);
1411 assert_eq!(s.message.as_ref().map(|m| m.text.as_str()), Some("hi"));
1412 assert!(s.apply(Action::Random, now).navigated);
1413 assert_eq!(s.queue.index(), 1);
1414 }
1415
1416 fn palette_items() -> Vec<PaletteItem> {
1417 [("next", false), ("goto N", true), ("fit width", false)]
1418 .iter()
1419 .map(|(a, t)| PaletteItem {
1420 action: a.to_string(),
1421 desc: String::new(),
1422 keys: String::new(),
1423 template: *t,
1424 })
1425 .collect()
1426 }
1427
1428 #[test]
1429 fn palette_runs_typed_or_selected_actions() {
1430 let (mut s, now) = state(3);
1431 let typed = |s: &mut AppState, t: &str| {
1432 s.open_palette(palette_items());
1433 s.input_edit(Edit::Insert(t.into()), now);
1434 s.input_submit(now).1
1435 };
1436 assert_eq!(typed(&mut s, "fitw").as_deref(), Some("fit width"));
1437 // Typed text that parses wins over the best match...
1438 assert_eq!(typed(&mut s, "goto 3").as_deref(), Some("goto 3"));
1439 // ...unless the selection was moved by hand.
1440 s.open_palette(palette_items());
1441 s.input_edit(Edit::Up, now);
1442 assert!(s.input_submit(now).1.is_none());
1443 assert_eq!(
1444 s.input.as_ref().unwrap().text,
1445 "goto ",
1446 "a template completes"
1447 );
1448 s.input_edit(Edit::Insert("2".into()), now);
1449 assert_eq!(s.input_submit(now).1.as_deref(), Some("goto 2"));
1450 assert!(s.input.is_none());
1451 // No match: the text runs as typed (and the loop reports it if it doesn't parse).
1452 assert_eq!(typed(&mut s, "zzzz").as_deref(), Some("zzzz"));
1453 s.open_palette(palette_items());
1454 assert!(!s.input_cancel(now).navigated);
1455 assert!(s.input.is_none());
1456 }
1457
1458 #[test]
1459 fn search_filters_the_grid_live() {
1460 let now = Instant::now();
1461 let files = ["cat.png", "dog.png", "cow.png", "bird.png"]
1462 .iter()
1463 .map(PathBuf::from)
1464 .collect();
1465 let mut s = AppState::new(Queue::new(files, None, 1), Settings::default(), true, now);
1466 s.set_window(800.0, 632.0, 1.0);
1467 s.apply(Action::Mode(ModeArg::Grid), now);
1468 s.apply(Action::Search, now);
1469 let fx = s.input_edit(Edit::Insert("c".into()), now);
1470 assert!(fx.reordered);
1471 assert_eq!(s.queue.len(), 2);
1472 s.input_edit(Edit::Right, now);
1473 assert_eq!(s.queue.current(), Some(Path::new("cow.png")));
1474 let (fx, run) = s.input_submit(now);
1475 assert!(run.is_none() && fx.navigated);
1476 assert_eq!(s.queue.len(), 4);
1477 assert_eq!(s.queue.current(), Some(Path::new("cow.png")));
1478 // <Esc> puts back the file the search started on.
1479 s.apply(Action::Search, now);
1480 s.input_edit(Edit::Insert("bi".into()), now);
1481 assert_eq!(s.queue.current(), Some(Path::new("bird.png")));
1482 s.input_cancel(now);
1483 assert_eq!(s.queue.current(), Some(Path::new("cow.png")));
1484 assert_eq!(s.queue.len(), 4);
1485 // In image mode the search lists matches and jumps to the selected one.
1486 s.apply(Action::Mode(ModeArg::Image), now);
1487 s.apply(Action::Search, now);
1488 s.input_edit(Edit::Insert("dg".into()), now);
1489 assert_eq!(s.queue.len(), 4);
1490 assert!(s.input_submit(now).0.navigated);
1491 assert_eq!(s.queue.current(), Some(Path::new("dog.png")));
1492 }
1493
1494 #[test]
1495 fn filter_action_narrows_the_queue() {
1496 let (mut s, now) = state(12);
1497 assert!(s.apply(Action::Filter("1*".into()), now).reordered);
1498 assert_eq!(s.queue.len(), 3, "1, 10, 11");
1499 s.apply(Action::Filter("zzz".into()), now);
1500 assert!(s.queue.is_empty());
1501 assert_eq!(s.message.as_ref().unwrap().text, "no matches");
1502 assert!(s.apply(Action::FilterClear, now).reordered);
1503 assert_eq!(s.queue.len(), 12);
1504 assert!(!s.apply(Action::FilterClear, now).redraw);
1505 }
1506
1507 #[test]
1508 fn slideshow_advances_after_the_delay() {
1509 let (mut s, now) = state(2);
1510 s.apply(Action::Mode(ModeArg::Grid), now);
1511 s.apply(Action::Slideshow(Switch::On), now);
1512 assert_eq!(s.mode, Mode::Image, "slideshows run in image mode");
1513 assert!(
1514 !s.slideshow_due(now + Duration::from_secs(60)),
1515 "waits for an image"
1516 );
1517 s.show(Path::new("0.png"), [10, 10], "PNG", now);
1518 let d = s.slideshow_delay;
1519 s.message = None;
1520 assert_eq!(s.tick(now).1, Some(now + d));
1521 assert!(!s.slideshow_due(now + d / 2));
1522 assert!(s.slideshow_due(now + d));
1523 s.slideshow_advanced(true);
1524 assert!(
1525 !s.slideshow_due(now + d * 2),
1526 "re-armed when the next image shows"
1527 );
1528 s.apply(
1529 Action::SlideshowDelay {
1530 secs: -100.0,
1531 relative: true,
1532 },
1533 now,
1534 );
1535 assert_eq!(s.slideshow_delay, Duration::from_secs_f32(SLIDESHOW_MIN));
1536 s.apply(Action::Slideshow(Switch::Toggle), now);
1537 assert!(s.slideshow.is_none());
1538 }
1539
1540 #[test]
1541 fn adjustments_step_and_reset() {
1542 let (mut s, now) = state(1);
1543 s.show(Path::new("0.png"), [10, 10], "PNG", now);
1544 s.apply(Action::Adjust(Adjust::Gamma, Step::Up), now);
1545 s.apply(Action::Adjust(Adjust::Gamma, Step::Up), now);
1546 assert_eq!(s.adjust.gamma, 1.21);
1547 assert_eq!(s.message.as_ref().unwrap().text, "gamma 1.21");
1548 s.apply(Action::Adjust(Adjust::Gamma, Step::Down), now);
1549 s.apply(Action::Adjust(Adjust::Gamma, Step::Down), now);
1550 assert_eq!(s.adjust.gamma, 1.0);
1551 for _ in 0..30 {
1552 s.apply(Action::Adjust(Adjust::Brightness, Step::Up), now);
1553 }
1554 assert_eq!(s.adjust.brightness, 1.0);
1555 s.apply(Action::Adjust(Adjust::Brightness, Step::Reset), now);
1556 assert_eq!(s.adjust, Adjustments::default());
1557 }
1558
1559 #[test]
1560 fn grid_mode_moves_the_queue_cursor() {
1561 let (mut s, now) = state(40);
1562 assert!(s.apply(Action::Mode(ModeArg::Toggle), now).mode);
1563 assert_eq!(s.mode, Mode::Grid);
1564 // 800 wide, 160 cells, 16 gaps: 4 columns.
1565 assert_eq!(s.grid_layout().cols, 4);
1566 assert!(s.apply(Action::Move(Dir::Down, 2), now).navigated);
1567 assert_eq!(s.queue.index(), 8);
1568 s.apply(Action::RowEnd, now);
1569 assert_eq!(s.queue.index(), 11);
1570 s.apply(Action::Last, now);
1571 let l = s.grid_layout();
1572 assert_eq!(s.grid.target(&l), l.max_scroll());
1573 s.apply(Action::Page(Page::Up), now);
1574 assert_eq!(s.queue.index(), 39 - 4 * l.page_rows());
1575 // Image-only actions do nothing in the grid.
1576 assert!(!s.apply(Action::Zoom(Zoom::In(1)), now).redraw);
1577 assert!(s.apply(Action::Mode(ModeArg::Toggle), now).mode);
1578 assert_eq!(s.mode, Mode::Image);
1579 assert!(!s.apply(Action::Move(Dir::Down, 1), now).redraw);
1580 }
1581
1582 #[test]
1583 fn visual_mode_marks_a_range() {
1584 let (mut s, now) = state(10);
1585 s.apply(Action::Goto(2), now);
1586 s.apply(Action::Visual, now);
1587 assert_eq!(s.mode, Mode::Visual);
1588 s.apply(Action::Move(Dir::Right, 3), now);
1589 assert_eq!(s.visual_range(), Some(1..5));
1590 s.apply(Action::Mark(Switch::On), now);
1591 assert_eq!(s.mode, Mode::Grid);
1592 assert_eq!(s.queue.marked_count(), 4);
1593 // Toggling a fully marked range unmarks it.
1594 s.apply(Action::Visual, now);
1595 s.apply(Action::Move(Dir::Left, 3), now);
1596 s.apply(Action::Mark(Switch::Toggle), now);
1597 assert_eq!(s.queue.marked_count(), 0);
1598 }
1599
1600 #[test]
1601 fn marks_and_mark_navigation() {
1602 let (mut s, now) = state(5);
1603 s.apply(Action::Mark(Switch::Toggle), now);
1604 s.apply(Action::Goto(4), now);
1605 s.apply(Action::Mark(Switch::Toggle), now);
1606 assert_eq!(s.queue.marked_count(), 2);
1607 assert!(s.apply(Action::NextMark, now).navigated);
1608 assert_eq!(s.queue.index(), 0);
1609 s.apply(Action::InvertMarks, now);
1610 assert_eq!(s.queue.marked_count(), 3);
1611 s.apply(Action::UnmarkAll, now);
1612 assert!(!s.apply(Action::NextMark, now).navigated);
1613 assert!(s.message.is_some());
1614 s.apply(Action::MarkAll, now);
1615 assert_eq!(s.queue.marked_count(), 5);
1616 }
1617
1618 #[test]
1619 fn grid_size_steps_through_sizes() {
1620 let (mut s, now) = state(3);
1621 s.apply(Action::GridSize(1), now);
1622 assert_eq!(s.grid.size, 224);
1623 s.apply(Action::GridSize(5), now);
1624 assert_eq!(s.grid.size, 320);
1625 assert!(!s.apply(Action::GridSize(1), now).redraw);
1626 assert_eq!(s.cell_px(), 320);
1627 }
1628
1629 #[test]
1630 fn animation_plays_steps_and_pauses() {
1631 let (mut s, now) = state(1);
1632 s.show(Path::new("0.png"), [10, 10], "GIF", now);
1633 let ms = Duration::from_millis;
1634 s.set_frames(vec![ms(100), ms(50), ms(100)], true, now);
1635 assert_eq!(s.frame(), 0);
1636 assert_eq!(s.tick(now + ms(10)).1, Some(now + ms(100)));
1637 assert!(s.tick(now + ms(100)).0);
1638 assert_eq!(s.frame(), 1);
1639 // Late: catch up through frame 1's 50 ms into frame 2.
1640 s.tick(now + ms(160));
1641 assert_eq!(s.frame(), 2);
1642 s.apply(Action::Play(Switch::Toggle), now);
1643 assert!(!s.tick(now + ms(5000)).0, "paused");
1644 s.apply(Action::NextFrame, now);
1645 assert_eq!(s.frame(), 0);
1646 s.apply(Action::PrevFrame, now);
1647 assert_eq!(s.frame(), 2);
1648 // Pages step but never play.
1649 s.set_frames(vec![Duration::ZERO; 3], false, now);
1650 assert!(!s.playback.as_ref().unwrap().playing);
1651 s.apply(Action::Play(Switch::On), now);
1652 assert_eq!(s.message.as_ref().unwrap().text, "not animated");
1653 s.apply(Action::NextFrame, now);
1654 assert_eq!(s.frame(), 1);
1655 // A new image starts without frames.
1656 s.show(Path::new("1.png"), [10, 10], "PNG", now);
1657 assert!(s.playback.is_none());
1658 }
1659
1660 #[test]
1661 fn tapped_pan_moves_a_minimum_distance() {
1662 let (mut s, now) = state(1);
1663 s.show(Path::new("0.png"), [4000, 4000], "PNG", now);
1664 s.apply(Action::Fit(FitMode::Original), now);
1665 s.advance(now);
1666 let before = s.view.transform(now + Duration::from_secs(1)).t;
1667 s.hold_pan(Dir::Right, true, now);
1668 s.hold_pan(Dir::Right, false, now + Duration::from_millis(10));
1669 s.advance(now + Duration::from_millis(500));
1670 assert!(!s.is_animating(now + Duration::from_secs(1)));
1671 let after = s.view.transform(now + Duration::from_secs(1)).t;
1672 let moved = before[0] - after[0];
1673 let expected = 800.0 * PAN_SPEED * MIN_PAN_HOLD.as_secs_f32();
1674 assert!(
1675 (moved - expected).abs() < 1.0,
1676 "moved {moved}, expected {expected}"
1677 );
1678 }
1679}