Krolyxon
Commit 9f6e071

Branches

Tags

Refs →

remagesrcaction.rs

1010 lines · 35.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 /// The help section this action is listed under.
651 pub fn group(&self) -> &'static str {
652 match self {
653 Action::Next(_)
654 | Action::Prev(_)
655 | Action::First
656 | Action::Last
657 | Action::Goto(_)
658 | Action::Random
659 | Action::NextMark
660 | Action::PrevMark => "Navigation",
661 Action::Mark(_) | Action::MarkAll | Action::UnmarkAll | Action::InvertMarks => "Marks",
662 Action::Move(..)
663 | Action::Page(_)
664 | Action::RowStart
665 | Action::RowEnd
666 | Action::Scroll(..)
667 | Action::GridSize(_)
668 | Action::Visual => "Grid",
669 Action::Zoom(_)
670 | Action::Fit(_)
671 | Action::Pan(_)
672 | Action::PanEdge(_)
673 | Action::Center
674 | Action::Rotate(_)
675 | Action::Flip(_)
676 | Action::FilterToggle
677 | Action::AlphaBgCycle
678 | Action::Adjust(..) => "View",
679 Action::Play(_)
680 | Action::NextFrame
681 | Action::PrevFrame
682 | Action::Slideshow(_)
683 | Action::SlideshowDelay { .. } => "Playback",
684 Action::Mode(_)
685 | Action::Fullscreen(_)
686 | Action::Bar(_)
687 | Action::Info(_)
688 | Action::Help
689 | Action::Palette
690 | Action::Search => "Interface",
691 Action::Sort(_)
692 | Action::Reverse
693 | Action::Filter(_)
694 | Action::FilterClear
695 | Action::Remove
696 | Action::Reload
697 | Action::Refresh
698 | Action::Open(_) => "Queue",
699 Action::Run(_)
700 | Action::ReloadConfig
701 | Action::Echo(_)
702 | Action::Cancel
703 | Action::Quit
704 | Action::QuitNoOutput => "App",
705 }
706 }
707
708 /// A one-line description of this exact action, for which-key, help and the palette.
709 pub fn description(&self) -> String {
710 let toggle = |s: &Switch, what: &str| match s {
711 Switch::Toggle => format!("Toggle {what}"),
712 Switch::On => format!("Show {what}"),
713 Switch::Off => format!("Hide {what}"),
714 };
715 match self {
716 Action::Next(1) => "Next image".into(),
717 Action::Prev(1) => "Previous image".into(),
718 Action::Next(n) => format!("Forward {n} images"),
719 Action::Prev(n) => format!("Back {n} images"),
720 Action::Goto(n) => format!("Go to image {n}"),
721 Action::Mark(Switch::Toggle) => "Toggle mark".into(),
722 Action::Mark(Switch::On) => "Mark".into(),
723 Action::Mark(Switch::Off) => "Unmark".into(),
724 Action::Zoom(Zoom::In(_)) => "Zoom in".into(),
725 Action::Zoom(Zoom::Out(_)) => "Zoom out".into(),
726 Action::Zoom(Zoom::Percent(p)) => format!("Zoom to {p}%"),
727 Action::Fit(m) => match m {
728 FitMode::Down => "Fit, never enlarge".into(),
729 FitMode::Fit => "Fit to window".into(),
730 FitMode::Fill => "Fill window".into(),
731 FitMode::Width => "Fit width".into(),
732 FitMode::Height => "Fit height".into(),
733 FitMode::Original => "Original size".into(),
734 },
735 Action::Pan(d) => format!("Pan {}", dir_name(*d)),
736 Action::PanEdge(d) => format!(
737 "Jump to {} edge",
738 match d {
739 Dir::Up => "top",
740 Dir::Down => "bottom",
741 other => dir_name(*other),
742 }
743 ),
744 Action::Move(d, _) => format!("Move {}", dir_name(*d)),
745 Action::Scroll(d, _) => format!("Scroll {}", dir_name(*d)),
746 Action::Page(p) => match p {
747 Page::Down => "Page down".into(),
748 Page::Up => "Page up".into(),
749 Page::HalfDown => "Half a page down".into(),
750 Page::HalfUp => "Half a page up".into(),
751 },
752 Action::Rotate(1) => "Rotate clockwise".into(),
753 Action::Rotate(-1) => "Rotate counter-clockwise".into(),
754 Action::Rotate(q) => format!("Rotate {}°", q * 90),
755 Action::Flip(Axis::Horizontal) => "Flip horizontally".into(),
756 Action::Flip(Axis::Vertical) => "Flip vertically".into(),
757 Action::Adjust(a, s) => {
758 let what = match a {
759 Adjust::Gamma => "gamma",
760 Adjust::Brightness => "brightness",
761 Adjust::Contrast => "contrast",
762 };
763 match s {
764 Step::Up => format!("Increase {what}"),
765 Step::Down => format!("Decrease {what}"),
766 Step::Reset => format!("Reset {what}"),
767 }
768 }
769 Action::Play(s) => match s {
770 Switch::Toggle => "Play / pause".into(),
771 Switch::On => "Play".into(),
772 Switch::Off => "Pause".into(),
773 },
774 Action::Slideshow(s) => toggle(s, "slideshow"),
775 Action::SlideshowDelay { secs, relative } if *relative => {
776 format!("Slideshow delay {secs:+}s")
777 }
778 Action::SlideshowDelay { secs, .. } => format!("Slideshow delay {secs}s"),
779 Action::Mode(ModeArg::Image) => "Image mode".into(),
780 Action::Mode(ModeArg::Grid) => "Grid mode".into(),
781 Action::Mode(ModeArg::Toggle) => "Toggle image / grid".into(),
782 Action::Fullscreen(s) => toggle(s, "fullscreen"),
783 Action::Bar(s) => toggle(s, "status bar"),
784 Action::Info(s) => toggle(s, "info panel"),
785 Action::GridSize(d) if *d > 0 => "Larger thumbnails".into(),
786 Action::GridSize(_) => "Smaller thumbnails".into(),
787 Action::Sort(k) => format!("Sort by {}", value_name(k)),
788 Action::Filter(p) => format!("Filter: {p}"),
789 Action::Open(p) => format!("Open {p}"),
790 Action::Run(c) => format!("Run {c}"),
791 Action::Echo(m) => format!("Echo: {m}"),
792 other => CATALOG
793 .iter()
794 .find(|e| e.name == other.name())
795 .map_or_else(|| other.to_string(), |e| e.desc.to_string()),
796 }
797 }
798
799 /// Apply a count prefix: `5n` moves five images, `12G` goes to image 12, `3+` zooms three
800 /// steps. Actions without a meaningful count ignore it.
801 pub fn with_count(self, n: usize) -> Action {
802 let n = n.max(1);
803 let n32 = n.min(i32::MAX as usize) as i32;
804 match self {
805 Action::Next(k) => Action::Next(k.saturating_mul(n)),
806 Action::Prev(k) => Action::Prev(k.saturating_mul(n)),
807 Action::First | Action::Last => Action::Goto(n),
808 Action::Zoom(Zoom::In(k)) => Action::Zoom(Zoom::In(k.saturating_mul(n32 as u32))),
809 Action::Zoom(Zoom::Out(k)) => Action::Zoom(Zoom::Out(k.saturating_mul(n32 as u32))),
810 Action::Rotate(q) => {
811 // Keep at most one full turn so the animation stays sane.
812 let total = q.saturating_mul(n32);
813 Action::Rotate(total.signum() * ((total.abs() - 1) % 4 + 1))
814 }
815 Action::GridSize(d) => Action::GridSize(d.saturating_mul(n32)),
816 Action::Move(d, k) => Action::Move(d, k.saturating_mul(n)),
817 Action::Scroll(d, k) => Action::Scroll(d, k.saturating_mul(n)),
818 other => other,
819 }
820 }
821
822 /// Whether holding the key should repeat the action.
823 pub fn repeats(&self) -> bool {
824 matches!(
825 self,
826 Action::Next(_)
827 | Action::Prev(_)
828 | Action::NextMark
829 | Action::PrevMark
830 | Action::NextFrame
831 | Action::PrevFrame
832 | Action::Zoom(Zoom::In(_) | Zoom::Out(_))
833 | Action::GridSize(_)
834 | Action::Move(..)
835 | Action::Scroll(..)
836 | Action::Page(_)
837 | Action::Adjust(_, Step::Up | Step::Down)
838 )
839 }
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845
846 fn p(s: &str) -> Action {
847 s.parse().unwrap_or_else(|e| panic!("{s}: {e}"))
848 }
849
850 #[test]
851 fn parses_the_grammar() {
852 assert_eq!(p("next"), Action::Next(1));
853 assert_eq!(p(" next 5 "), Action::Next(5));
854 assert_eq!(p("goto 12"), Action::Goto(12));
855 assert_eq!(p("zoom in"), Action::Zoom(Zoom::In(1)));
856 assert_eq!(p("zoom 150%"), Action::Zoom(Zoom::Percent(150.0)));
857 assert_eq!(p("zoom 50"), Action::Zoom(Zoom::Percent(50.0)));
858 assert_eq!(p("fit width"), Action::Fit(FitMode::Width));
859 assert_eq!(p("rotate 180"), Action::Rotate(2));
860 assert_eq!(p("rotate ccw"), Action::Rotate(-1));
861 assert_eq!(p("flip v"), Action::Flip(Axis::Vertical));
862 assert_eq!(p("filter toggle"), Action::FilterToggle);
863 assert_eq!(p("filter *.png"), Action::Filter("*.png".into()));
864 assert_eq!(p("filter \"toggle\""), Action::Filter("toggle".into()));
865 assert_eq!(p("bar toggle"), Action::Bar(Switch::Toggle));
866 assert_eq!(p("bar off"), Action::Bar(Switch::Off));
867 assert_eq!(p("mark"), Action::Mark(Switch::Toggle));
868 assert_eq!(p("gamma +"), Action::Adjust(Adjust::Gamma, Step::Up));
869 assert_eq!(
870 p("slideshow-delay -1.5"),
871 Action::SlideshowDelay {
872 secs: -1.5,
873 relative: true
874 }
875 );
876 assert_eq!(p("sort mtime"), Action::Sort(SortKey::Mtime));
877 assert_eq!(p("run wallpaper"), Action::Run("wallpaper".into()));
878 assert_eq!(p("echo hello world"), Action::Echo("hello world".into()));
879 assert_eq!(p("quit-no-output"), Action::QuitNoOutput);
880 }
881
882 #[test]
883 fn errors_are_helpful() {
884 let e = |s: &str| s.parse::<Action>().unwrap_err().0;
885 assert_eq!(e("nxt"), "unknown action `nxt`, did you mean `next`?");
886 assert!(
887 e("fit wdth").contains("did you mean `width`"),
888 "{}",
889 e("fit wdth")
890 );
891 assert!(e("quit now").contains("takes no arguments"));
892 assert!(e("goto 0").contains("positive"));
893 assert!(e("next -1").contains("positive"));
894 assert!(e("rotate 45").contains("cw, ccw"));
895 assert!(e("run two words").contains("no spaces"));
896 assert!(e("").contains("empty"));
897 }
898
899 #[test]
900 fn display_round_trips() {
901 let all = [
902 "next",
903 "next 5",
904 "prev",
905 "prev 3",
906 "first",
907 "last",
908 "goto 7",
909 "random",
910 "next-mark",
911 "prev-mark",
912 "next-frame",
913 "prev-frame",
914 "mark",
915 "mark on",
916 "mark off",
917 "mark-all",
918 "unmark-all",
919 "invert-marks",
920 "zoom in",
921 "zoom out 3",
922 "zoom 100%",
923 "zoom 12.5%",
924 "fit down",
925 "fit original",
926 "pan left",
927 "pan-edge down",
928 "center",
929 "move left",
930 "move down 3",
931 "page half-down",
932 "page up",
933 "row-start",
934 "row-end",
935 "scroll up",
936 "scroll down 2",
937 "rotate cw",
938 "rotate ccw",
939 "rotate 180",
940 "flip h",
941 "filter toggle",
942 "alpha-bg cycle",
943 "gamma +",
944 "brightness -",
945 "contrast reset",
946 "play",
947 "slideshow on",
948 "slideshow-delay +1",
949 "slideshow-delay 5",
950 "mode toggle",
951 "mode grid",
952 "visual",
953 "fullscreen",
954 "bar",
955 "info off",
956 "help",
957 "palette",
958 "search",
959 "grid-size +",
960 "grid-size -",
961 "sort natural",
962 "reverse",
963 "filter cat",
964 "filter \"toggle\"",
965 "filter-clear",
966 "remove",
967 "reload",
968 "refresh",
969 "open ~/Pictures/a b.png",
970 "run copy",
971 "reload-config",
972 "echo hi there",
973 "echo \" padded \"",
974 "cancel",
975 "quit",
976 "quit-no-output",
977 ];
978 for s in all {
979 let a = p(s);
980 assert_eq!(a.to_string(), s, "display of {a:?}");
981 assert_eq!(p(&a.to_string()), a);
982 assert!(!a.description().is_empty());
983 }
984 // Every catalog entry is reachable from the list above.
985 for entry in CATALOG {
986 assert!(
987 all.iter().any(|s| p(s).name() == entry.name),
988 "{} untested",
989 entry.name
990 );
991 }
992 }
993
994 #[test]
995 fn counts() {
996 assert_eq!(Action::Next(10).with_count(3), Action::Next(30));
997 assert_eq!(Action::First.with_count(12), Action::Goto(12));
998 assert_eq!(Action::Last.with_count(5), Action::Goto(5));
999 assert_eq!(
1000 Action::Zoom(Zoom::In(1)).with_count(3),
1001 Action::Zoom(Zoom::In(3))
1002 );
1003 assert_eq!(Action::GridSize(-1).with_count(2), Action::GridSize(-2));
1004 assert_eq!(
1005 Action::Move(Dir::Down, 1).with_count(5),
1006 Action::Move(Dir::Down, 5)
1007 );
1008 assert_eq!(Action::Quit.with_count(4), Action::Quit);
1009 }
1010}