Krolyxon
Commit 92928dd

Branches

Tags

Refs →

remagesrcaction.rs

858 lines · 30.1 KB · Rust

1//! Every user-facing operation, with one grammar (`name [args]`) shared by key bindings, the
2//! palette, IPC and command output. `parse(display(a)) == a` for every action.
3
4use std::fmt;
5use std::str::FromStr;
6
7use clap::ValueEnum;
8
9use crate::config::suggest;
10use crate::state::queue::SortKey;
11use crate::state::view::{Axis, Dir, FitMode};
12
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub enum Zoom {
15 /// Zoom steps.
16 In(u32),
17 Out(u32),
18 /// Absolute zoom, 100 = one image pixel per screen pixel.
19 Percent(f32),
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Switch {
24 Toggle,
25 On,
26 Off,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum ModeArg {
31 Image,
32 Grid,
33 Toggle,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum Adjust {
38 Gamma,
39 Brightness,
40 Contrast,
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum Step {
45 Up,
46 Down,
47 Reset,
48}
49
50#[derive(Clone, Debug, PartialEq)]
51pub enum Action {
52 Next(usize),
53 Prev(usize),
54 First,
55 Last,
56 /// 1-based, like the status bar.
57 Goto(usize),
58 Random,
59 NextMark,
60 PrevMark,
61 NextFrame,
62 PrevFrame,
63 Mark(Switch),
64 MarkAll,
65 UnmarkAll,
66 InvertMarks,
67 Zoom(Zoom),
68 Fit(FitMode),
69 /// From a key this pans for as long as the key is held; otherwise one step.
70 Pan(Dir),
71 PanEdge(Dir),
72 Center,
73 /// Quarter turns, positive = clockwise.
74 Rotate(i32),
75 Flip(Axis),
76 FilterToggle,
77 AlphaBgCycle,
78 Adjust(Adjust, Step),
79 Play(Switch),
80 Slideshow(Switch),
81 SlideshowDelay {
82 secs: f32,
83 relative: bool,
84 },
85 Mode(ModeArg),
86 Visual,
87 Fullscreen(Switch),
88 Bar(Switch),
89 Info(Switch),
90 Help,
91 Palette,
92 Search,
93 /// Steps through `grid.sizes`.
94 GridSize(i32),
95 Sort(SortKey),
96 Reverse,
97 Filter(String),
98 FilterClear,
99 Remove,
100 Reload,
101 Refresh,
102 Open(String),
103 Run(String),
104 ReloadConfig,
105 Echo(String),
106 Cancel,
107 Quit,
108 QuitNoOutput,
109}
110
111/// One row of the action reference: palette, help and `--dump-config`.
112pub struct Entry {
113 pub name: &'static str,
114 pub args: &'static str,
115 pub desc: &'static str,
116}
117
118const fn e(name: &'static str, args: &'static str, desc: &'static str) -> Entry {
119 Entry { name, args, desc }
120}
121
122pub const CATALOG: &[Entry] = &[
123 e("next", "[n]", "Next image"),
124 e("prev", "[n]", "Previous image"),
125 e("first", "", "First image"),
126 e("last", "", "Last image"),
127 e("goto", "N", "Go to image N"),
128 e("random", "", "Random image"),
129 e("next-mark", "", "Next marked image"),
130 e("prev-mark", "", "Previous marked image"),
131 e("next-frame", "", "Next frame or page"),
132 e("prev-frame", "", "Previous frame or page"),
133 e("mark", "[toggle|on|off]", "Mark the current image"),
134 e("mark-all", "", "Mark every image"),
135 e("unmark-all", "", "Clear all marks"),
136 e("invert-marks", "", "Invert marks"),
137 e("zoom", "in [n]|out [n]|N%", "Zoom"),
138 e("fit", "down|fit|fill|width|height|original", "Fit mode"),
139 e("pan", "left|right|up|down", "Pan (held keys pan smoothly)"),
140 e("pan-edge", "left|right|up|down", "Jump to an edge"),
141 e("center", "", "Centre the image"),
142 e("rotate", "cw|ccw|180", "Rotate the view"),
143 e("flip", "h|v", "Flip the view"),
144 e("filter", "toggle", "Toggle nearest/linear filtering"),
145 e("alpha-bg", "cycle", "Cycle the transparency background"),
146 e("gamma", "+|-|reset", "Adjust gamma"),
147 e("brightness", "+|-|reset", "Adjust brightness"),
148 e("contrast", "+|-|reset", "Adjust contrast"),
149 e("play", "[toggle|on|off]", "Play or pause animation"),
150 e("slideshow", "[toggle|on|off]", "Slideshow"),
151 e("slideshow-delay", "±N|N", "Slideshow delay in seconds"),
152 e("mode", "image|grid|toggle", "Switch between image and grid"),
153 e("visual", "", "Visual range selection"),
154 e("fullscreen", "[toggle|on|off]", "Fullscreen"),
155 e("bar", "[toggle|on|off]", "Status bar"),
156 e("info", "[toggle|on|off]", "Info panel"),
157 e("help", "", "Key reference"),
158 e("palette", "", "Command palette"),
159 e("search", "", "Search filenames"),
160 e("grid-size", "+|-", "Thumbnail size"),
161 e("sort", "natural|name|mtime|size|random", "Sort the queue"),
162 e("reverse", "", "Reverse the queue order"),
163 e("filter", "<pattern>", "Show only matching files"),
164 e("filter-clear", "", "Clear the filter"),
165 e("remove", "", "Remove from the queue"),
166 e("reload", "", "Reload the image from disk"),
167 e("refresh", "", "Re-check targets on disk"),
168 e("open", "<path>", "Open a file or directory"),
169 e("run", "<command>", "Run a user command"),
170 e("reload-config", "", "Reload the config"),
171 e("echo", "<message>", "Show a message"),
172 e("cancel", "", "Cancel pending keys and messages"),
173 e("quit", "", "Quit"),
174 e("quit-no-output", "", "Quit without printing marks"),
175];
176
177fn names() -> impl Iterator<Item = &'static str> {
178 CATALOG.iter().map(|e| e.name)
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct ParseError(pub String);
183
184impl fmt::Display for ParseError {
185 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186 f.write_str(&self.0)
187 }
188}
189
190impl std::error::Error for ParseError {}
191
192fn err<T>(msg: impl Into<String>) -> Result<T, ParseError> {
193 Err(ParseError(msg.into()))
194}
195
196/// Match one word of a fixed set, with a "did you mean" on failure.
197fn one_of<T: Copy>(name: &str, arg: &str, opts: &[(&str, T)]) -> Result<T, ParseError> {
198 if let Some((_, v)) = opts.iter().find(|(k, _)| k.eq_ignore_ascii_case(arg)) {
199 return Ok(*v);
200 }
201 let expected: Vec<&str> = opts.iter().map(|(k, _)| *k).collect();
202 let hint = suggest(arg, expected.iter().copied())
203 .map(|s| format!(", did you mean `{s}`?"))
204 .unwrap_or_default();
205 if arg.is_empty() {
206 err(format!("`{name}` needs one of: {}", expected.join(", ")))
207 } else {
208 err(format!("`{name}`: unknown argument `{arg}`{hint}"))
209 }
210}
211
212fn no_args(name: &str, rest: &str) -> Result<(), ParseError> {
213 if rest.is_empty() {
214 Ok(())
215 } else {
216 err(format!("`{name}` takes no arguments"))
217 }
218}
219
220fn count(name: &str, rest: &str) -> Result<usize, ParseError> {
221 if rest.is_empty() {
222 return Ok(1);
223 }
224 match rest.parse::<usize>() {
225 Ok(n) if n > 0 => Ok(n),
226 _ => err(format!("`{name}`: expected a positive count, got `{rest}`")),
227 }
228}
229
230fn switch(name: &str, rest: &str) -> Result<Switch, ParseError> {
231 if rest.is_empty() {
232 return Ok(Switch::Toggle);
233 }
234 one_of(
235 name,
236 rest,
237 &[
238 ("toggle", Switch::Toggle),
239 ("on", Switch::On),
240 ("off", Switch::Off),
241 ],
242 )
243}
244
245fn dir(name: &str, rest: &str) -> Result<Dir, ParseError> {
246 one_of(
247 name,
248 rest,
249 &[
250 ("left", Dir::Left),
251 ("right", Dir::Right),
252 ("up", Dir::Up),
253 ("down", Dir::Down),
254 ],
255 )
256}
257
258fn step(name: &str, rest: &str) -> Result<Step, ParseError> {
259 one_of(
260 name,
261 rest,
262 &[("+", Step::Up), ("-", Step::Down), ("reset", Step::Reset)],
263 )
264}
265
266/// A free-form argument, optionally quoted.
267fn text(name: &str, rest: &str) -> Result<String, ParseError> {
268 if rest.is_empty() {
269 return err(format!("`{name}` needs an argument"));
270 }
271 for q in ['"', '\''] {
272 if rest.len() >= 2 && rest.starts_with(q) && rest.ends_with(q) {
273 return Ok(rest[1..rest.len() - 1].to_string());
274 }
275 }
276 Ok(rest.to_string())
277}
278
279/// Quote a free-form argument when it would not parse back as itself.
280fn quoted(s: &str, reserved: &[&str]) -> String {
281 let plain =
282 !s.is_empty() && s.trim() == s && !s.starts_with(['"', '\'']) && !reserved.contains(&s);
283 if plain {
284 s.into()
285 } else if s.contains('"') {
286 format!("'{s}'")
287 } else {
288 format!("\"{s}\"")
289 }
290}
291
292fn value_enum<T: ValueEnum>(name: &str, rest: &str) -> Result<T, ParseError> {
293 if let Ok(v) = T::from_str(rest, true) {
294 return Ok(v);
295 }
296 let all: Vec<String> = T::value_variants()
297 .iter()
298 .filter_map(|v| v.to_possible_value().map(|p| p.get_name().to_string()))
299 .collect();
300 let hint = suggest(rest, all.iter().map(String::as_str))
301 .map(|s| format!(", did you mean `{s}`?"))
302 .unwrap_or_default();
303 err(format!(
304 "`{name}`: expected one of {}, got `{rest}`{hint}",
305 all.join(", ")
306 ))
307}
308
309fn value_name<T: ValueEnum>(v: &T) -> String {
310 v.to_possible_value()
311 .map(|p| p.get_name().to_string())
312 .unwrap_or_default()
313}
314
315impl FromStr for Action {
316 type Err = ParseError;
317
318 fn from_str(s: &str) -> Result<Self, ParseError> {
319 let s = s.trim();
320 let (name, rest) = s
321 .split_once(char::is_whitespace)
322 .map_or((s, ""), |(n, r)| (n, r.trim()));
323 let a = match name {
324 "next" => Action::Next(count(name, rest)?),
325 "prev" => Action::Prev(count(name, rest)?),
326 "goto" => match rest.parse::<usize>() {
327 Ok(n) if n > 0 => Action::Goto(n),
328 _ => return err(format!("`goto` needs a positive index, got `{rest}`")),
329 },
330 "mark" => Action::Mark(switch(name, rest)?),
331 "zoom" => match rest.split_once(' ').unwrap_or((rest, "")) {
332 ("in", n) => Action::Zoom(Zoom::In(count(name, n.trim())? as u32)),
333 ("out", n) => Action::Zoom(Zoom::Out(count(name, n.trim())? as u32)),
334 _ => match rest.trim_end_matches('%').trim().parse::<f32>() {
335 Ok(p) if p.is_finite() && p > 0.0 => Action::Zoom(Zoom::Percent(p)),
336 _ => return err(format!("`zoom` needs in, out or N%, got `{rest}`")),
337 },
338 },
339 "fit" => Action::Fit(value_enum(name, rest)?),
340 "pan" => Action::Pan(dir(name, rest)?),
341 "pan-edge" => Action::PanEdge(dir(name, rest)?),
342 "rotate" => match rest {
343 "cw" => Action::Rotate(1),
344 "ccw" => Action::Rotate(-1),
345 _ => match rest.parse::<i32>() {
346 Ok(d) if d != 0 && d % 90 == 0 => Action::Rotate(d / 90),
347 _ => return err(format!("`rotate` needs cw, ccw or 180, got `{rest}`")),
348 },
349 },
350 "flip" => Action::Flip(one_of(
351 name,
352 rest,
353 &[
354 ("h", Axis::Horizontal),
355 ("v", Axis::Vertical),
356 ("horizontal", Axis::Horizontal),
357 ("vertical", Axis::Vertical),
358 ],
359 )?),
360 "filter" => match rest {
361 "toggle" => Action::FilterToggle,
362 _ => Action::Filter(text(name, rest)?),
363 },
364 "alpha-bg" => {
365 one_of(name, rest, &[("cycle", ())])?;
366 Action::AlphaBgCycle
367 }
368 "gamma" => Action::Adjust(Adjust::Gamma, step(name, rest)?),
369 "brightness" => Action::Adjust(Adjust::Brightness, step(name, rest)?),
370 "contrast" => Action::Adjust(Adjust::Contrast, step(name, rest)?),
371 "play" => Action::Play(switch(name, rest)?),
372 "slideshow" => Action::Slideshow(switch(name, rest)?),
373 "slideshow-delay" => {
374 let relative = rest.starts_with(['+', '-']);
375 match rest.parse::<f32>() {
376 Ok(secs) if secs.is_finite() && (relative || secs > 0.0) => {
377 Action::SlideshowDelay { secs, relative }
378 }
379 _ => return err(format!("`slideshow-delay` needs ±N or N, got `{rest}`")),
380 }
381 }
382 "mode" => Action::Mode(one_of(
383 name,
384 rest,
385 &[
386 ("image", ModeArg::Image),
387 ("grid", ModeArg::Grid),
388 ("toggle", ModeArg::Toggle),
389 ],
390 )?),
391 "fullscreen" => Action::Fullscreen(switch(name, rest)?),
392 "bar" => Action::Bar(switch(name, rest)?),
393 "info" => Action::Info(switch(name, rest)?),
394 "grid-size" => Action::GridSize(one_of(name, rest, &[("+", 1), ("-", -1)])?),
395 "sort" => Action::Sort(value_enum(name, rest)?),
396 "open" => Action::Open(text(name, rest)?),
397 "run" => match rest {
398 "" => return err("`run` needs a command name"),
399 r if r.contains(char::is_whitespace) => {
400 return err(format!("`run`: command names have no spaces, got `{r}`"));
401 }
402 r => Action::Run(r.to_string()),
403 },
404 "echo" => Action::Echo(text(name, rest)?),
405 _ => {
406 let a = match name {
407 "first" => Action::First,
408 "last" => Action::Last,
409 "random" => Action::Random,
410 "next-mark" => Action::NextMark,
411 "prev-mark" => Action::PrevMark,
412 "next-frame" => Action::NextFrame,
413 "prev-frame" => Action::PrevFrame,
414 "mark-all" => Action::MarkAll,
415 "unmark-all" => Action::UnmarkAll,
416 "invert-marks" => Action::InvertMarks,
417 "center" | "centre" => Action::Center,
418 "visual" => Action::Visual,
419 "help" => Action::Help,
420 "palette" => Action::Palette,
421 "search" => Action::Search,
422 "reverse" => Action::Reverse,
423 "filter-clear" => Action::FilterClear,
424 "remove" => Action::Remove,
425 "reload" => Action::Reload,
426 "refresh" => Action::Refresh,
427 "reload-config" => Action::ReloadConfig,
428 "cancel" => Action::Cancel,
429 "quit" => Action::Quit,
430 "quit-no-output" => Action::QuitNoOutput,
431 "" => return err("empty action"),
432 _ => {
433 let hint = suggest(name, names())
434 .map(|s| format!(", did you mean `{s}`?"))
435 .unwrap_or_default();
436 return err(format!("unknown action `{name}`{hint}"));
437 }
438 };
439 no_args(name, rest)?;
440 a
441 }
442 };
443 Ok(a)
444 }
445}
446
447fn switch_suffix(s: Switch) -> &'static str {
448 match s {
449 Switch::Toggle => "",
450 Switch::On => " on",
451 Switch::Off => " off",
452 }
453}
454
455fn dir_name(d: Dir) -> &'static str {
456 match d {
457 Dir::Left => "left",
458 Dir::Right => "right",
459 Dir::Up => "up",
460 Dir::Down => "down",
461 }
462}
463
464fn step_name(s: Step) -> &'static str {
465 match s {
466 Step::Up => "+",
467 Step::Down => "-",
468 Step::Reset => "reset",
469 }
470}
471
472impl fmt::Display for Action {
473 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
474 let n = self.name();
475 match self {
476 Action::Next(1) | Action::Prev(1) => f.write_str(n),
477 Action::Next(c) | Action::Prev(c) => write!(f, "{n} {c}"),
478 Action::Goto(i) => write!(f, "goto {i}"),
479 Action::Mark(s)
480 | Action::Play(s)
481 | Action::Slideshow(s)
482 | Action::Fullscreen(s)
483 | Action::Bar(s)
484 | Action::Info(s) => write!(f, "{n}{}", switch_suffix(*s)),
485 Action::Zoom(Zoom::In(1)) => f.write_str("zoom in"),
486 Action::Zoom(Zoom::Out(1)) => f.write_str("zoom out"),
487 Action::Zoom(Zoom::In(c)) => write!(f, "zoom in {c}"),
488 Action::Zoom(Zoom::Out(c)) => write!(f, "zoom out {c}"),
489 Action::Zoom(Zoom::Percent(p)) => write!(f, "zoom {p}%"),
490 Action::Fit(m) => write!(f, "fit {}", value_name(m)),
491 Action::Pan(d) | Action::PanEdge(d) => write!(f, "{n} {}", dir_name(*d)),
492 Action::Rotate(1) => f.write_str("rotate cw"),
493 Action::Rotate(-1) => f.write_str("rotate ccw"),
494 Action::Rotate(q) => write!(f, "rotate {}", q * 90),
495 Action::Flip(Axis::Horizontal) => f.write_str("flip h"),
496 Action::Flip(Axis::Vertical) => f.write_str("flip v"),
497 Action::FilterToggle => f.write_str("filter toggle"),
498 Action::AlphaBgCycle => f.write_str("alpha-bg cycle"),
499 Action::Adjust(_, s) => write!(f, "{n} {}", step_name(*s)),
500 Action::SlideshowDelay { secs, relative } => {
501 if *relative {
502 write!(f, "{n} {secs:+}")
503 } else {
504 write!(f, "{n} {secs}")
505 }
506 }
507 Action::Mode(m) => write!(
508 f,
509 "mode {}",
510 match m {
511 ModeArg::Image => "image",
512 ModeArg::Grid => "grid",
513 ModeArg::Toggle => "toggle",
514 }
515 ),
516 Action::GridSize(d) if *d > 0 => f.write_str("grid-size +"),
517 Action::GridSize(_) => f.write_str("grid-size -"),
518 Action::Sort(k) => write!(f, "sort {}", value_name(k)),
519 Action::Filter(p) => write!(f, "filter {}", quoted(p, &["toggle"])),
520 Action::Open(p) => write!(f, "open {}", quoted(p, &[])),
521 Action::Echo(m) => write!(f, "echo {}", quoted(m, &[])),
522 Action::Run(c) => write!(f, "run {c}"),
523 _ => f.write_str(n),
524 }
525 }
526}
527
528impl Action {
529 /// The grammar's head word.
530 pub fn name(&self) -> &'static str {
531 match self {
532 Action::Next(_) => "next",
533 Action::Prev(_) => "prev",
534 Action::First => "first",
535 Action::Last => "last",
536 Action::Goto(_) => "goto",
537 Action::Random => "random",
538 Action::NextMark => "next-mark",
539 Action::PrevMark => "prev-mark",
540 Action::NextFrame => "next-frame",
541 Action::PrevFrame => "prev-frame",
542 Action::Mark(_) => "mark",
543 Action::MarkAll => "mark-all",
544 Action::UnmarkAll => "unmark-all",
545 Action::InvertMarks => "invert-marks",
546 Action::Zoom(_) => "zoom",
547 Action::Fit(_) => "fit",
548 Action::Pan(_) => "pan",
549 Action::PanEdge(_) => "pan-edge",
550 Action::Center => "center",
551 Action::Rotate(_) => "rotate",
552 Action::Flip(_) => "flip",
553 Action::FilterToggle => "filter",
554 Action::AlphaBgCycle => "alpha-bg",
555 Action::Adjust(Adjust::Gamma, _) => "gamma",
556 Action::Adjust(Adjust::Brightness, _) => "brightness",
557 Action::Adjust(Adjust::Contrast, _) => "contrast",
558 Action::Play(_) => "play",
559 Action::Slideshow(_) => "slideshow",
560 Action::SlideshowDelay { .. } => "slideshow-delay",
561 Action::Mode(_) => "mode",
562 Action::Visual => "visual",
563 Action::Fullscreen(_) => "fullscreen",
564 Action::Bar(_) => "bar",
565 Action::Info(_) => "info",
566 Action::Help => "help",
567 Action::Palette => "palette",
568 Action::Search => "search",
569 Action::GridSize(_) => "grid-size",
570 Action::Sort(_) => "sort",
571 Action::Reverse => "reverse",
572 Action::Filter(_) => "filter",
573 Action::FilterClear => "filter-clear",
574 Action::Remove => "remove",
575 Action::Reload => "reload",
576 Action::Refresh => "refresh",
577 Action::Open(_) => "open",
578 Action::Run(_) => "run",
579 Action::ReloadConfig => "reload-config",
580 Action::Echo(_) => "echo",
581 Action::Cancel => "cancel",
582 Action::Quit => "quit",
583 Action::QuitNoOutput => "quit-no-output",
584 }
585 }
586
587 /// A one-line description of this exact action, for which-key, help and the palette.
588 #[allow(dead_code)] // which-key, help and the palette arrive in M6
589 pub fn description(&self) -> String {
590 let toggle = |s: &Switch, what: &str| match s {
591 Switch::Toggle => format!("Toggle {what}"),
592 Switch::On => format!("Show {what}"),
593 Switch::Off => format!("Hide {what}"),
594 };
595 match self {
596 Action::Next(1) => "Next image".into(),
597 Action::Prev(1) => "Previous image".into(),
598 Action::Next(n) => format!("Forward {n} images"),
599 Action::Prev(n) => format!("Back {n} images"),
600 Action::Goto(n) => format!("Go to image {n}"),
601 Action::Mark(Switch::Toggle) => "Toggle mark".into(),
602 Action::Mark(Switch::On) => "Mark".into(),
603 Action::Mark(Switch::Off) => "Unmark".into(),
604 Action::Zoom(Zoom::In(_)) => "Zoom in".into(),
605 Action::Zoom(Zoom::Out(_)) => "Zoom out".into(),
606 Action::Zoom(Zoom::Percent(p)) => format!("Zoom to {p}%"),
607 Action::Fit(m) => match m {
608 FitMode::Down => "Fit, never enlarge".into(),
609 FitMode::Fit => "Fit to window".into(),
610 FitMode::Fill => "Fill window".into(),
611 FitMode::Width => "Fit width".into(),
612 FitMode::Height => "Fit height".into(),
613 FitMode::Original => "Original size".into(),
614 },
615 Action::Pan(d) => format!("Pan {}", dir_name(*d)),
616 Action::PanEdge(d) => format!("Jump to {} edge", dir_name(*d)),
617 Action::Rotate(1) => "Rotate clockwise".into(),
618 Action::Rotate(-1) => "Rotate counter-clockwise".into(),
619 Action::Rotate(q) => format!("Rotate {}°", q * 90),
620 Action::Flip(Axis::Horizontal) => "Flip horizontally".into(),
621 Action::Flip(Axis::Vertical) => "Flip vertically".into(),
622 Action::Adjust(a, s) => {
623 let what = match a {
624 Adjust::Gamma => "gamma",
625 Adjust::Brightness => "brightness",
626 Adjust::Contrast => "contrast",
627 };
628 match s {
629 Step::Up => format!("Increase {what}"),
630 Step::Down => format!("Decrease {what}"),
631 Step::Reset => format!("Reset {what}"),
632 }
633 }
634 Action::Play(s) => match s {
635 Switch::Toggle => "Play / pause".into(),
636 Switch::On => "Play".into(),
637 Switch::Off => "Pause".into(),
638 },
639 Action::Slideshow(s) => toggle(s, "slideshow"),
640 Action::SlideshowDelay { secs, relative } if *relative => {
641 format!("Slideshow delay {secs:+}s")
642 }
643 Action::SlideshowDelay { secs, .. } => format!("Slideshow delay {secs}s"),
644 Action::Mode(ModeArg::Image) => "Image mode".into(),
645 Action::Mode(ModeArg::Grid) => "Grid mode".into(),
646 Action::Mode(ModeArg::Toggle) => "Toggle image / grid".into(),
647 Action::Fullscreen(s) => toggle(s, "fullscreen"),
648 Action::Bar(s) => toggle(s, "status bar"),
649 Action::Info(s) => toggle(s, "info panel"),
650 Action::GridSize(d) if *d > 0 => "Larger thumbnails".into(),
651 Action::GridSize(_) => "Smaller thumbnails".into(),
652 Action::Sort(k) => format!("Sort by {}", value_name(k)),
653 Action::Filter(p) => format!("Filter: {p}"),
654 Action::Open(p) => format!("Open {p}"),
655 Action::Run(c) => format!("Run {c}"),
656 Action::Echo(m) => format!("Echo: {m}"),
657 other => CATALOG
658 .iter()
659 .find(|e| e.name == other.name())
660 .map_or_else(|| other.to_string(), |e| e.desc.to_string()),
661 }
662 }
663
664 /// Apply a count prefix: `5n` moves five images, `12G` goes to image 12, `3+` zooms three
665 /// steps. Actions without a meaningful count ignore it.
666 pub fn with_count(self, n: usize) -> Action {
667 let n = n.max(1);
668 let n32 = n.min(i32::MAX as usize) as i32;
669 match self {
670 Action::Next(k) => Action::Next(k.saturating_mul(n)),
671 Action::Prev(k) => Action::Prev(k.saturating_mul(n)),
672 Action::First | Action::Last => Action::Goto(n),
673 Action::Zoom(Zoom::In(k)) => Action::Zoom(Zoom::In(k.saturating_mul(n32 as u32))),
674 Action::Zoom(Zoom::Out(k)) => Action::Zoom(Zoom::Out(k.saturating_mul(n32 as u32))),
675 Action::Rotate(q) => {
676 // Keep at most one full turn so the animation stays sane.
677 let total = q.saturating_mul(n32);
678 Action::Rotate(total.signum() * ((total.abs() - 1) % 4 + 1))
679 }
680 Action::GridSize(d) => Action::GridSize(d.saturating_mul(n32)),
681 other => other,
682 }
683 }
684
685 /// Whether holding the key should repeat the action.
686 pub fn repeats(&self) -> bool {
687 matches!(
688 self,
689 Action::Next(_)
690 | Action::Prev(_)
691 | Action::NextMark
692 | Action::PrevMark
693 | Action::NextFrame
694 | Action::PrevFrame
695 | Action::Zoom(Zoom::In(_) | Zoom::Out(_))
696 | Action::GridSize(_)
697 | Action::Adjust(_, Step::Up | Step::Down)
698 )
699 }
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705
706 fn p(s: &str) -> Action {
707 s.parse().unwrap_or_else(|e| panic!("{s}: {e}"))
708 }
709
710 #[test]
711 fn parses_the_grammar() {
712 assert_eq!(p("next"), Action::Next(1));
713 assert_eq!(p(" next 5 "), Action::Next(5));
714 assert_eq!(p("goto 12"), Action::Goto(12));
715 assert_eq!(p("zoom in"), Action::Zoom(Zoom::In(1)));
716 assert_eq!(p("zoom 150%"), Action::Zoom(Zoom::Percent(150.0)));
717 assert_eq!(p("zoom 50"), Action::Zoom(Zoom::Percent(50.0)));
718 assert_eq!(p("fit width"), Action::Fit(FitMode::Width));
719 assert_eq!(p("rotate 180"), Action::Rotate(2));
720 assert_eq!(p("rotate ccw"), Action::Rotate(-1));
721 assert_eq!(p("flip v"), Action::Flip(Axis::Vertical));
722 assert_eq!(p("filter toggle"), Action::FilterToggle);
723 assert_eq!(p("filter *.png"), Action::Filter("*.png".into()));
724 assert_eq!(p("filter \"toggle\""), Action::Filter("toggle".into()));
725 assert_eq!(p("bar toggle"), Action::Bar(Switch::Toggle));
726 assert_eq!(p("bar off"), Action::Bar(Switch::Off));
727 assert_eq!(p("mark"), Action::Mark(Switch::Toggle));
728 assert_eq!(p("gamma +"), Action::Adjust(Adjust::Gamma, Step::Up));
729 assert_eq!(
730 p("slideshow-delay -1.5"),
731 Action::SlideshowDelay {
732 secs: -1.5,
733 relative: true
734 }
735 );
736 assert_eq!(p("sort mtime"), Action::Sort(SortKey::Mtime));
737 assert_eq!(p("run wallpaper"), Action::Run("wallpaper".into()));
738 assert_eq!(p("echo hello world"), Action::Echo("hello world".into()));
739 assert_eq!(p("quit-no-output"), Action::QuitNoOutput);
740 }
741
742 #[test]
743 fn errors_are_helpful() {
744 let e = |s: &str| s.parse::<Action>().unwrap_err().0;
745 assert_eq!(e("nxt"), "unknown action `nxt`, did you mean `next`?");
746 assert!(
747 e("fit wdth").contains("did you mean `width`"),
748 "{}",
749 e("fit wdth")
750 );
751 assert!(e("quit now").contains("takes no arguments"));
752 assert!(e("goto 0").contains("positive"));
753 assert!(e("next -1").contains("positive"));
754 assert!(e("rotate 45").contains("cw, ccw"));
755 assert!(e("run two words").contains("no spaces"));
756 assert!(e("").contains("empty"));
757 }
758
759 #[test]
760 fn display_round_trips() {
761 let all = [
762 "next",
763 "next 5",
764 "prev",
765 "prev 3",
766 "first",
767 "last",
768 "goto 7",
769 "random",
770 "next-mark",
771 "prev-mark",
772 "next-frame",
773 "prev-frame",
774 "mark",
775 "mark on",
776 "mark off",
777 "mark-all",
778 "unmark-all",
779 "invert-marks",
780 "zoom in",
781 "zoom out 3",
782 "zoom 100%",
783 "zoom 12.5%",
784 "fit down",
785 "fit original",
786 "pan left",
787 "pan-edge down",
788 "center",
789 "rotate cw",
790 "rotate ccw",
791 "rotate 180",
792 "flip h",
793 "filter toggle",
794 "alpha-bg cycle",
795 "gamma +",
796 "brightness -",
797 "contrast reset",
798 "play",
799 "slideshow on",
800 "slideshow-delay +1",
801 "slideshow-delay 5",
802 "mode toggle",
803 "mode grid",
804 "visual",
805 "fullscreen",
806 "bar",
807 "info off",
808 "help",
809 "palette",
810 "search",
811 "grid-size +",
812 "grid-size -",
813 "sort natural",
814 "reverse",
815 "filter cat",
816 "filter \"toggle\"",
817 "filter-clear",
818 "remove",
819 "reload",
820 "refresh",
821 "open ~/Pictures/a b.png",
822 "run copy",
823 "reload-config",
824 "echo hi there",
825 "echo \" padded \"",
826 "cancel",
827 "quit",
828 "quit-no-output",
829 ];
830 for s in all {
831 let a = p(s);
832 assert_eq!(a.to_string(), s, "display of {a:?}");
833 assert_eq!(p(&a.to_string()), a);
834 assert!(!a.description().is_empty());
835 }
836 // Every catalog entry is reachable from the list above.
837 for entry in CATALOG {
838 assert!(
839 all.iter().any(|s| p(s).name() == entry.name),
840 "{} untested",
841 entry.name
842 );
843 }
844 }
845
846 #[test]
847 fn counts() {
848 assert_eq!(Action::Next(10).with_count(3), Action::Next(30));
849 assert_eq!(Action::First.with_count(12), Action::Goto(12));
850 assert_eq!(Action::Last.with_count(5), Action::Goto(5));
851 assert_eq!(
852 Action::Zoom(Zoom::In(1)).with_count(3),
853 Action::Zoom(Zoom::In(3))
854 );
855 assert_eq!(Action::GridSize(-1).with_count(2), Action::GridSize(-2));
856 assert_eq!(Action::Quit.with_count(4), Action::Quit);
857 }
858}