Krolyxon
Commit dbc22bf

Branches

Tags

Refs →

remagesrcaction.rs

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