| 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 | |
| 4 | use std::fmt; |
| 5 | use std::str::FromStr; |
| 6 | |
| 7 | use clap::ValueEnum; |
| 8 | |
| 9 | use crate::config::suggest; |
| 10 | use crate::state::queue::SortKey; |
| 11 | use crate::state::view::{Axis, Dir, FitMode}; |
| 12 | |
| 13 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 14 | pub 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)] |
| 23 | pub enum Switch { |
| 24 | Toggle, |
| 25 | On, |
| 26 | Off, |
| 27 | } |
| 28 | |
| 29 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 30 | pub enum ModeArg { |
| 31 | Image, |
| 32 | Grid, |
| 33 | Toggle, |
| 34 | } |
| 35 | |
| 36 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 37 | pub 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)] |
| 45 | pub enum Page { |
| 46 | Down, |
| 47 | Up, |
| 48 | HalfDown, |
| 49 | HalfUp, |
| 50 | } |
| 51 | |
| 52 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 53 | pub enum Step { |
| 54 | Up, |
| 55 | Down, |
| 56 | Reset, |
| 57 | } |
| 58 | |
| 59 | #[derive(Clone, Debug, PartialEq)] |
| 60 | pub 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`. |
| 128 | pub struct Entry { |
| 129 | pub name: &'static str, |
| 130 | pub args: &'static str, |
| 131 | pub desc: &'static str, |
| 132 | } |
| 133 | |
| 134 | const fn e(name: &'static str, args: &'static str, desc: &'static str) -> Entry { |
| 135 | Entry { name, args, desc } |
| 136 | } |
| 137 | |
| 138 | pub 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 | |
| 202 | fn names() -> impl Iterator<Item = &'static str> { |
| 203 | CATALOG.iter().map(|e| e.name) |
| 204 | } |
| 205 | |
| 206 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 207 | pub struct ParseError(pub String); |
| 208 | |
| 209 | impl fmt::Display for ParseError { |
| 210 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 211 | f.write_str(&self.0) |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | impl std::error::Error for ParseError {} |
| 216 | |
| 217 | fn 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. |
| 222 | fn 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 | |
| 237 | fn 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 | |
| 245 | fn 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 | |
| 255 | fn 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 | |
| 270 | fn 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 | |
| 283 | fn 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. |
| 292 | fn 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. |
| 305 | fn 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 | |
| 317 | fn 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 | |
| 334 | fn 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 | |
| 340 | impl 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 | |
| 493 | fn switch_suffix(s: Switch) -> &'static str { |
| 494 | match s { |
| 495 | Switch::Toggle => "", |
| 496 | Switch::On => " on", |
| 497 | Switch::Off => " off", |
| 498 | } |
| 499 | } |
| 500 | |
| 501 | fn 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 | |
| 510 | fn 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 | |
| 519 | fn step_name(s: Step) -> &'static str { |
| 520 | match s { |
| 521 | Step::Up => "+", |
| 522 | Step::Down => "-", |
| 523 | Step::Reset => "reset", |
| 524 | } |
| 525 | } |
| 526 | |
| 527 | impl 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 | |
| 586 | impl 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)] |
| 779 | mod 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 | } |