remage92928dd
Add input and config: action grammar, keymap, live TOML config
Implement M2: one action grammar (parse and Display round-trip) shared by keys, palette, IPC and command output; vim key notation and a per-mode keymap trie with sequences, counts, ambiguity timeout and mouse/wheel bindings; a TOML config whose compiled-in default is default.toml, with includes, file:line:col errors with suggestions, --dump-config and --check-config; live reload on file change, SIGUSR1 and reload-config; theme presets, token derivation, pywal import and a 250 ms theme crossfade, plus matugen and pywal templates.
+4268 −289
22 files changed
- M CLAUDE.md +1 −1
- A contrib/matugen/remage.toml +16 −0
- A contrib/pywal/remage.toml +15 −0
- M src/action.rs +827 −8
- M src/app.rs +391 −55
- M src/cache.rs +5 −0
- M src/cli.rs +18 −6
- A src/config/default.toml +175 −0
- M src/config/mod.rs +1046 −1
- M src/config/theme.rs +237 −22
- A src/config/watch.rs +143 −0
- M src/input/keymap.rs +435 −0
- M src/input/keys.rs +437 −1
- M src/input/mod.rs +95 −148
- M src/main.rs +72 −13
- M src/render/mod.rs +4 −0
- M src/render/text.rs +19 −5
- M src/scan.rs +6 −1
- M src/state/mod.rs +244 −19
- M src/state/queue.rs +64 −1
- M src/state/view.rs +13 −1
- M src/ui/mod.rs +5 −7
| @@ -430,7 +430,7 @@ Each milestone should end with a usable binary. | |||
| 430 | 430 | - winit + wgpu window, decoding through the `image` crate. | |
| 431 | 431 | - Fit/zoom/pan/rotate/flip with animation, and `n`/`p`. | |
| 432 | 432 | - Preload + cache, a status bar with text, and the `nothing-dark` theme with hard-coded keys. | |
| 433 | − | - **M2 Input + config** | |
| 433 | + | - **M2 Input + config** ✓ (2026-11-04). Unbuilt actions parse and bind, then say "not available yet". | |
| 434 | 434 | - Action grammar, and a keymap trie with sequences, counts and mouse bindings. | |
| 435 | 435 | - TOML defaults, include and live reload, plus `--dump-config` and `--check-config`. | |
| 436 | 436 | - Theme tokens, presets and derivation; matugen + pywal support. | |
| @@ -0,0 +1,16 @@ | |||
| 1 | + | # matugen template for remage. | |
| 2 | + | # | |
| 3 | + | # 1. Copy to ~/.config/matugen/templates/remage.toml | |
| 4 | + | # 2. In ~/.config/matugen/config.toml: | |
| 5 | + | # [templates.remage] | |
| 6 | + | # input_path = '~/.config/matugen/templates/remage.toml' | |
| 7 | + | # output_path = '~/.cache/matugen/remage.toml' | |
| 8 | + | # 3. In ~/.config/remage/config.toml: | |
| 9 | + | # include = ["~/.cache/matugen/remage.toml"] | |
| 10 | + | # | |
| 11 | + | # remage watches the include, so every wallpaper change recolours open windows; no post_hook. | |
| 12 | + | # Only three colours are needed: every other token is derived from them. | |
| 13 | + | [theme] | |
| 14 | + | background = "{{colors.surface_container_lowest.default.hex}}" | |
| 15 | + | foreground = "{{colors.on_surface.default.hex}}" | |
| 16 | + | accent = "{{colors.primary.default.hex}}" | |
| @@ -0,0 +1,15 @@ | |||
| 1 | + | # pywal template for remage. | |
| 2 | + | # | |
| 3 | + | # remage can read pywal's colors.json directly, which needs no template: | |
| 4 | + | # [theme] | |
| 5 | + | # pywal = "~/.cache/wal/colors.json" | |
| 6 | + | # | |
| 7 | + | # To go through a template instead (to pick other colours), copy this file to | |
| 8 | + | # ~/.config/wal/templates/remage.toml and add to ~/.config/remage/config.toml: | |
| 9 | + | # include = ["~/.cache/wal/remage.toml"] | |
| 10 | + | # | |
| 11 | + | # remage watches the include, so `wal -i <image>` recolours open windows. | |
| 12 | + | [theme] | |
| 13 | + | background = "{background}" | |
| 14 | + | foreground = "{foreground}" | |
| 15 | + | accent = "{color1}" | |
| @@ -1,17 +1,53 @@ | |||
| 1 | − | //! Every user-facing operation. Keys produce these today; the palette, IPC and command | |
| 2 | − | //! output will share the same grammar (parser and `Display` arrive with the keymap in M2). | |
| 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 | 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; | |
| 4 | 11 | use crate::state::view::{Axis, Dir, FitMode}; | |
| 5 | 12 | ||
| 6 | 13 | #[derive(Clone, Copy, Debug, PartialEq)] | |
| 7 | 14 | pub enum Zoom { | |
| 8 | − | In, | |
| 9 | − | Out, | |
| 15 | + | /// Zoom steps. | |
| 16 | + | In(u32), | |
| 17 | + | Out(u32), | |
| 10 | 18 | /// Absolute zoom, 100 = one image pixel per screen pixel. | |
| 11 | 19 | Percent(f32), | |
| 12 | 20 | } | |
| 13 | 21 | ||
| 14 | − | #[derive(Clone, Copy, Debug, PartialEq)] | |
| 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 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 44 | + | pub enum Step { | |
| 45 | + | Up, | |
| 46 | + | Down, | |
| 47 | + | Reset, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | #[derive(Clone, Debug, PartialEq)] | |
| 15 | 51 | pub enum Action { | |
| 16 | 52 | Next(usize), | |
| 17 | 53 | Prev(usize), | |
| @@ -19,6 +55,15 @@ pub enum Action { | |||
| 19 | 55 | Last, | |
| 20 | 56 | /// 1-based, like the status bar. | |
| 21 | 57 | Goto(usize), | |
| 58 | + | Random, | |
| 59 | + | NextMark, | |
| 60 | + | PrevMark, | |
| 61 | + | NextFrame, | |
| 62 | + | PrevFrame, | |
| 63 | + | Mark(Switch), | |
| 64 | + | MarkAll, | |
| 65 | + | UnmarkAll, | |
| 66 | + | InvertMarks, | |
| 22 | 67 | Zoom(Zoom), | |
| 23 | 68 | Fit(FitMode), | |
| 24 | 69 | /// From a key this pans for as long as the key is held; otherwise one step. | |
| @@ -30,10 +75,784 @@ pub enum Action { | |||
| 30 | 75 | Flip(Axis), | |
| 31 | 76 | FilterToggle, | |
| 32 | 77 | AlphaBgCycle, | |
| 33 | − | Fullscreen, | |
| 34 | − | BarToggle, | |
| 35 | − | Reload, | |
| 78 | + | Adjust(Adjust, Step), | |
| 79 | + | Play(Switch), | |
| 80 | + | Slideshow(Switch), | |
| 81 | + | SlideshowDelay { | |
| 82 | + | secs: f32, | |
| 83 | + | relative: bool, | |
| 84 | + | }, | |
| 85 | + | Mode(ModeArg), | |
| 86 | + | Visual, | |
| 87 | + | Fullscreen(Switch), | |
| 88 | + | Bar(Switch), | |
| 89 | + | Info(Switch), | |
| 90 | + | Help, | |
| 91 | + | Palette, | |
| 92 | + | Search, | |
| 93 | + | /// Steps through `grid.sizes`. | |
| 94 | + | GridSize(i32), | |
| 95 | + | Sort(SortKey), | |
| 96 | + | Reverse, | |
| 97 | + | Filter(String), | |
| 98 | + | FilterClear, | |
| 36 | 99 | Remove, | |
| 100 | + | Reload, | |
| 101 | + | Refresh, | |
| 102 | + | Open(String), | |
| 103 | + | Run(String), | |
| 104 | + | ReloadConfig, | |
| 105 | + | Echo(String), | |
| 37 | 106 | Cancel, | |
| 38 | 107 | Quit, | |
| 108 | + | QuitNoOutput, | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// One row of the action reference: palette, help and `--dump-config`. | |
| 112 | + | pub struct Entry { | |
| 113 | + | pub name: &'static str, | |
| 114 | + | pub args: &'static str, | |
| 115 | + | pub desc: &'static str, | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | const fn e(name: &'static str, args: &'static str, desc: &'static str) -> Entry { | |
| 119 | + | Entry { name, args, desc } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | pub const CATALOG: &[Entry] = &[ | |
| 123 | + | e("next", "[n]", "Next image"), | |
| 124 | + | e("prev", "[n]", "Previous image"), | |
| 125 | + | e("first", "", "First image"), | |
| 126 | + | e("last", "", "Last image"), | |
| 127 | + | e("goto", "N", "Go to image N"), | |
| 128 | + | e("random", "", "Random image"), | |
| 129 | + | e("next-mark", "", "Next marked image"), | |
| 130 | + | e("prev-mark", "", "Previous marked image"), | |
| 131 | + | e("next-frame", "", "Next frame or page"), | |
| 132 | + | e("prev-frame", "", "Previous frame or page"), | |
| 133 | + | e("mark", "[toggle|on|off]", "Mark the current image"), | |
| 134 | + | e("mark-all", "", "Mark every image"), | |
| 135 | + | e("unmark-all", "", "Clear all marks"), | |
| 136 | + | e("invert-marks", "", "Invert marks"), | |
| 137 | + | e("zoom", "in [n]|out [n]|N%", "Zoom"), | |
| 138 | + | e("fit", "down|fit|fill|width|height|original", "Fit mode"), | |
| 139 | + | e("pan", "left|right|up|down", "Pan (held keys pan smoothly)"), | |
| 140 | + | e("pan-edge", "left|right|up|down", "Jump to an edge"), | |
| 141 | + | e("center", "", "Centre the image"), | |
| 142 | + | e("rotate", "cw|ccw|180", "Rotate the view"), | |
| 143 | + | e("flip", "h|v", "Flip the view"), | |
| 144 | + | e("filter", "toggle", "Toggle nearest/linear filtering"), | |
| 145 | + | e("alpha-bg", "cycle", "Cycle the transparency background"), | |
| 146 | + | e("gamma", "+|-|reset", "Adjust gamma"), | |
| 147 | + | e("brightness", "+|-|reset", "Adjust brightness"), | |
| 148 | + | e("contrast", "+|-|reset", "Adjust contrast"), | |
| 149 | + | e("play", "[toggle|on|off]", "Play or pause animation"), | |
| 150 | + | e("slideshow", "[toggle|on|off]", "Slideshow"), | |
| 151 | + | e("slideshow-delay", "±N|N", "Slideshow delay in seconds"), | |
| 152 | + | e("mode", "image|grid|toggle", "Switch between image and grid"), | |
| 153 | + | e("visual", "", "Visual range selection"), | |
| 154 | + | e("fullscreen", "[toggle|on|off]", "Fullscreen"), | |
| 155 | + | e("bar", "[toggle|on|off]", "Status bar"), | |
| 156 | + | e("info", "[toggle|on|off]", "Info panel"), | |
| 157 | + | e("help", "", "Key reference"), | |
| 158 | + | e("palette", "", "Command palette"), | |
| 159 | + | e("search", "", "Search filenames"), | |
| 160 | + | e("grid-size", "+|-", "Thumbnail size"), | |
| 161 | + | e("sort", "natural|name|mtime|size|random", "Sort the queue"), | |
| 162 | + | e("reverse", "", "Reverse the queue order"), | |
| 163 | + | e("filter", "<pattern>", "Show only matching files"), | |
| 164 | + | e("filter-clear", "", "Clear the filter"), | |
| 165 | + | e("remove", "", "Remove from the queue"), | |
| 166 | + | e("reload", "", "Reload the image from disk"), | |
| 167 | + | e("refresh", "", "Re-check targets on disk"), | |
| 168 | + | e("open", "<path>", "Open a file or directory"), | |
| 169 | + | e("run", "<command>", "Run a user command"), | |
| 170 | + | e("reload-config", "", "Reload the config"), | |
| 171 | + | e("echo", "<message>", "Show a message"), | |
| 172 | + | e("cancel", "", "Cancel pending keys and messages"), | |
| 173 | + | e("quit", "", "Quit"), | |
| 174 | + | e("quit-no-output", "", "Quit without printing marks"), | |
| 175 | + | ]; | |
| 176 | + | ||
| 177 | + | fn names() -> impl Iterator<Item = &'static str> { | |
| 178 | + | CATALOG.iter().map(|e| e.name) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 182 | + | pub struct ParseError(pub String); | |
| 183 | + | ||
| 184 | + | impl fmt::Display for ParseError { | |
| 185 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 186 | + | f.write_str(&self.0) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | impl std::error::Error for ParseError {} | |
| 191 | + | ||
| 192 | + | fn err<T>(msg: impl Into<String>) -> Result<T, ParseError> { | |
| 193 | + | Err(ParseError(msg.into())) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | /// Match one word of a fixed set, with a "did you mean" on failure. | |
| 197 | + | fn one_of<T: Copy>(name: &str, arg: &str, opts: &[(&str, T)]) -> Result<T, ParseError> { | |
| 198 | + | if let Some((_, v)) = opts.iter().find(|(k, _)| k.eq_ignore_ascii_case(arg)) { | |
| 199 | + | return Ok(*v); | |
| 200 | + | } | |
| 201 | + | let expected: Vec<&str> = opts.iter().map(|(k, _)| *k).collect(); | |
| 202 | + | let hint = suggest(arg, expected.iter().copied()) | |
| 203 | + | .map(|s| format!(", did you mean `{s}`?")) | |
| 204 | + | .unwrap_or_default(); | |
| 205 | + | if arg.is_empty() { | |
| 206 | + | err(format!("`{name}` needs one of: {}", expected.join(", "))) | |
| 207 | + | } else { | |
| 208 | + | err(format!("`{name}`: unknown argument `{arg}`{hint}")) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | fn no_args(name: &str, rest: &str) -> Result<(), ParseError> { | |
| 213 | + | if rest.is_empty() { | |
| 214 | + | Ok(()) | |
| 215 | + | } else { | |
| 216 | + | err(format!("`{name}` takes no arguments")) | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | fn count(name: &str, rest: &str) -> Result<usize, ParseError> { | |
| 221 | + | if rest.is_empty() { | |
| 222 | + | return Ok(1); | |
| 223 | + | } | |
| 224 | + | match rest.parse::<usize>() { | |
| 225 | + | Ok(n) if n > 0 => Ok(n), | |
| 226 | + | _ => err(format!("`{name}`: expected a positive count, got `{rest}`")), | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | fn switch(name: &str, rest: &str) -> Result<Switch, ParseError> { | |
| 231 | + | if rest.is_empty() { | |
| 232 | + | return Ok(Switch::Toggle); | |
| 233 | + | } | |
| 234 | + | one_of( | |
| 235 | + | name, | |
| 236 | + | rest, | |
| 237 | + | &[ | |
| 238 | + | ("toggle", Switch::Toggle), | |
| 239 | + | ("on", Switch::On), | |
| 240 | + | ("off", Switch::Off), | |
| 241 | + | ], | |
| 242 | + | ) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | fn dir(name: &str, rest: &str) -> Result<Dir, ParseError> { | |
| 246 | + | one_of( | |
| 247 | + | name, | |
| 248 | + | rest, | |
| 249 | + | &[ | |
| 250 | + | ("left", Dir::Left), | |
| 251 | + | ("right", Dir::Right), | |
| 252 | + | ("up", Dir::Up), | |
| 253 | + | ("down", Dir::Down), | |
| 254 | + | ], | |
| 255 | + | ) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | fn step(name: &str, rest: &str) -> Result<Step, ParseError> { | |
| 259 | + | one_of( | |
| 260 | + | name, | |
| 261 | + | rest, | |
| 262 | + | &[("+", Step::Up), ("-", Step::Down), ("reset", Step::Reset)], | |
| 263 | + | ) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | /// A free-form argument, optionally quoted. | |
| 267 | + | fn text(name: &str, rest: &str) -> Result<String, ParseError> { | |
| 268 | + | if rest.is_empty() { | |
| 269 | + | return err(format!("`{name}` needs an argument")); | |
| 270 | + | } | |
| 271 | + | for q in ['"', '\''] { | |
| 272 | + | if rest.len() >= 2 && rest.starts_with(q) && rest.ends_with(q) { | |
| 273 | + | return Ok(rest[1..rest.len() - 1].to_string()); | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | Ok(rest.to_string()) | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | /// Quote a free-form argument when it would not parse back as itself. | |
| 280 | + | fn quoted(s: &str, reserved: &[&str]) -> String { | |
| 281 | + | let plain = | |
| 282 | + | !s.is_empty() && s.trim() == s && !s.starts_with(['"', '\'']) && !reserved.contains(&s); | |
| 283 | + | if plain { | |
| 284 | + | s.into() | |
| 285 | + | } else if s.contains('"') { | |
| 286 | + | format!("'{s}'") | |
| 287 | + | } else { | |
| 288 | + | format!("\"{s}\"") | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | fn value_enum<T: ValueEnum>(name: &str, rest: &str) -> Result<T, ParseError> { | |
| 293 | + | if let Ok(v) = T::from_str(rest, true) { | |
| 294 | + | return Ok(v); | |
| 295 | + | } | |
| 296 | + | let all: Vec<String> = T::value_variants() | |
| 297 | + | .iter() | |
| 298 | + | .filter_map(|v| v.to_possible_value().map(|p| p.get_name().to_string())) | |
| 299 | + | .collect(); | |
| 300 | + | let hint = suggest(rest, all.iter().map(String::as_str)) | |
| 301 | + | .map(|s| format!(", did you mean `{s}`?")) | |
| 302 | + | .unwrap_or_default(); | |
| 303 | + | err(format!( | |
| 304 | + | "`{name}`: expected one of {}, got `{rest}`{hint}", | |
| 305 | + | all.join(", ") | |
| 306 | + | )) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | fn value_name<T: ValueEnum>(v: &T) -> String { | |
| 310 | + | v.to_possible_value() | |
| 311 | + | .map(|p| p.get_name().to_string()) | |
| 312 | + | .unwrap_or_default() | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | impl FromStr for Action { | |
| 316 | + | type Err = ParseError; | |
| 317 | + | ||
| 318 | + | fn from_str(s: &str) -> Result<Self, ParseError> { | |
| 319 | + | let s = s.trim(); | |
| 320 | + | let (name, rest) = s | |
| 321 | + | .split_once(char::is_whitespace) | |
| 322 | + | .map_or((s, ""), |(n, r)| (n, r.trim())); | |
| 323 | + | let a = match name { | |
| 324 | + | "next" => Action::Next(count(name, rest)?), | |
| 325 | + | "prev" => Action::Prev(count(name, rest)?), | |
| 326 | + | "goto" => match rest.parse::<usize>() { | |
| 327 | + | Ok(n) if n > 0 => Action::Goto(n), | |
| 328 | + | _ => return err(format!("`goto` needs a positive index, got `{rest}`")), | |
| 329 | + | }, | |
| 330 | + | "mark" => Action::Mark(switch(name, rest)?), | |
| 331 | + | "zoom" => match rest.split_once(' ').unwrap_or((rest, "")) { | |
| 332 | + | ("in", n) => Action::Zoom(Zoom::In(count(name, n.trim())? as u32)), | |
| 333 | + | ("out", n) => Action::Zoom(Zoom::Out(count(name, n.trim())? as u32)), | |
| 334 | + | _ => match rest.trim_end_matches('%').trim().parse::<f32>() { | |
| 335 | + | Ok(p) if p.is_finite() && p > 0.0 => Action::Zoom(Zoom::Percent(p)), | |
| 336 | + | _ => return err(format!("`zoom` needs in, out or N%, got `{rest}`")), | |
| 337 | + | }, | |
| 338 | + | }, | |
| 339 | + | "fit" => Action::Fit(value_enum(name, rest)?), | |
| 340 | + | "pan" => Action::Pan(dir(name, rest)?), | |
| 341 | + | "pan-edge" => Action::PanEdge(dir(name, rest)?), | |
| 342 | + | "rotate" => match rest { | |
| 343 | + | "cw" => Action::Rotate(1), | |
| 344 | + | "ccw" => Action::Rotate(-1), | |
| 345 | + | _ => match rest.parse::<i32>() { | |
| 346 | + | Ok(d) if d != 0 && d % 90 == 0 => Action::Rotate(d / 90), | |
| 347 | + | _ => return err(format!("`rotate` needs cw, ccw or 180, got `{rest}`")), | |
| 348 | + | }, | |
| 349 | + | }, | |
| 350 | + | "flip" => Action::Flip(one_of( | |
| 351 | + | name, | |
| 352 | + | rest, | |
| 353 | + | &[ | |
| 354 | + | ("h", Axis::Horizontal), | |
| 355 | + | ("v", Axis::Vertical), | |
| 356 | + | ("horizontal", Axis::Horizontal), | |
| 357 | + | ("vertical", Axis::Vertical), | |
| 358 | + | ], | |
| 359 | + | )?), | |
| 360 | + | "filter" => match rest { | |
| 361 | + | "toggle" => Action::FilterToggle, | |
| 362 | + | _ => Action::Filter(text(name, rest)?), | |
| 363 | + | }, | |
| 364 | + | "alpha-bg" => { | |
| 365 | + | one_of(name, rest, &[("cycle", ())])?; | |
| 366 | + | Action::AlphaBgCycle | |
| 367 | + | } | |
| 368 | + | "gamma" => Action::Adjust(Adjust::Gamma, step(name, rest)?), | |
| 369 | + | "brightness" => Action::Adjust(Adjust::Brightness, step(name, rest)?), | |
| 370 | + | "contrast" => Action::Adjust(Adjust::Contrast, step(name, rest)?), | |
| 371 | + | "play" => Action::Play(switch(name, rest)?), | |
| 372 | + | "slideshow" => Action::Slideshow(switch(name, rest)?), | |
| 373 | + | "slideshow-delay" => { | |
| 374 | + | let relative = rest.starts_with(['+', '-']); | |
| 375 | + | match rest.parse::<f32>() { | |
| 376 | + | Ok(secs) if secs.is_finite() && (relative || secs > 0.0) => { | |
| 377 | + | Action::SlideshowDelay { secs, relative } | |
| 378 | + | } | |
| 379 | + | _ => return err(format!("`slideshow-delay` needs ±N or N, got `{rest}`")), | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | "mode" => Action::Mode(one_of( | |
| 383 | + | name, | |
| 384 | + | rest, | |
| 385 | + | &[ | |
| 386 | + | ("image", ModeArg::Image), | |
| 387 | + | ("grid", ModeArg::Grid), | |
| 388 | + | ("toggle", ModeArg::Toggle), | |
| 389 | + | ], | |
| 390 | + | )?), | |
| 391 | + | "fullscreen" => Action::Fullscreen(switch(name, rest)?), | |
| 392 | + | "bar" => Action::Bar(switch(name, rest)?), | |
| 393 | + | "info" => Action::Info(switch(name, rest)?), | |
| 394 | + | "grid-size" => Action::GridSize(one_of(name, rest, &[("+", 1), ("-", -1)])?), | |
| 395 | + | "sort" => Action::Sort(value_enum(name, rest)?), | |
| 396 | + | "open" => Action::Open(text(name, rest)?), | |
| 397 | + | "run" => match rest { | |
| 398 | + | "" => return err("`run` needs a command name"), | |
| 399 | + | r if r.contains(char::is_whitespace) => { | |
| 400 | + | return err(format!("`run`: command names have no spaces, got `{r}`")); | |
| 401 | + | } | |
| 402 | + | r => Action::Run(r.to_string()), | |
| 403 | + | }, | |
| 404 | + | "echo" => Action::Echo(text(name, rest)?), | |
| 405 | + | _ => { | |
| 406 | + | let a = match name { | |
| 407 | + | "first" => Action::First, | |
| 408 | + | "last" => Action::Last, | |
| 409 | + | "random" => Action::Random, | |
| 410 | + | "next-mark" => Action::NextMark, | |
| 411 | + | "prev-mark" => Action::PrevMark, | |
| 412 | + | "next-frame" => Action::NextFrame, | |
| 413 | + | "prev-frame" => Action::PrevFrame, | |
| 414 | + | "mark-all" => Action::MarkAll, | |
| 415 | + | "unmark-all" => Action::UnmarkAll, | |
| 416 | + | "invert-marks" => Action::InvertMarks, | |
| 417 | + | "center" | "centre" => Action::Center, | |
| 418 | + | "visual" => Action::Visual, | |
| 419 | + | "help" => Action::Help, | |
| 420 | + | "palette" => Action::Palette, | |
| 421 | + | "search" => Action::Search, | |
| 422 | + | "reverse" => Action::Reverse, | |
| 423 | + | "filter-clear" => Action::FilterClear, | |
| 424 | + | "remove" => Action::Remove, | |
| 425 | + | "reload" => Action::Reload, | |
| 426 | + | "refresh" => Action::Refresh, | |
| 427 | + | "reload-config" => Action::ReloadConfig, | |
| 428 | + | "cancel" => Action::Cancel, | |
| 429 | + | "quit" => Action::Quit, | |
| 430 | + | "quit-no-output" => Action::QuitNoOutput, | |
| 431 | + | "" => return err("empty action"), | |
| 432 | + | _ => { | |
| 433 | + | let hint = suggest(name, names()) | |
| 434 | + | .map(|s| format!(", did you mean `{s}`?")) | |
| 435 | + | .unwrap_or_default(); | |
| 436 | + | return err(format!("unknown action `{name}`{hint}")); | |
| 437 | + | } | |
| 438 | + | }; | |
| 439 | + | no_args(name, rest)?; | |
| 440 | + | a | |
| 441 | + | } | |
| 442 | + | }; | |
| 443 | + | Ok(a) | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | fn switch_suffix(s: Switch) -> &'static str { | |
| 448 | + | match s { | |
| 449 | + | Switch::Toggle => "", | |
| 450 | + | Switch::On => " on", | |
| 451 | + | Switch::Off => " off", | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | fn dir_name(d: Dir) -> &'static str { | |
| 456 | + | match d { | |
| 457 | + | Dir::Left => "left", | |
| 458 | + | Dir::Right => "right", | |
| 459 | + | Dir::Up => "up", | |
| 460 | + | Dir::Down => "down", | |
| 461 | + | } | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | fn step_name(s: Step) -> &'static str { | |
| 465 | + | match s { | |
| 466 | + | Step::Up => "+", | |
| 467 | + | Step::Down => "-", | |
| 468 | + | Step::Reset => "reset", | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | impl fmt::Display for Action { | |
| 473 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 474 | + | let n = self.name(); | |
| 475 | + | match self { | |
| 476 | + | Action::Next(1) | Action::Prev(1) => f.write_str(n), | |
| 477 | + | Action::Next(c) | Action::Prev(c) => write!(f, "{n} {c}"), | |
| 478 | + | Action::Goto(i) => write!(f, "goto {i}"), | |
| 479 | + | Action::Mark(s) | |
| 480 | + | | Action::Play(s) | |
| 481 | + | | Action::Slideshow(s) | |
| 482 | + | | Action::Fullscreen(s) | |
| 483 | + | | Action::Bar(s) | |
| 484 | + | | Action::Info(s) => write!(f, "{n}{}", switch_suffix(*s)), | |
| 485 | + | Action::Zoom(Zoom::In(1)) => f.write_str("zoom in"), | |
| 486 | + | Action::Zoom(Zoom::Out(1)) => f.write_str("zoom out"), | |
| 487 | + | Action::Zoom(Zoom::In(c)) => write!(f, "zoom in {c}"), | |
| 488 | + | Action::Zoom(Zoom::Out(c)) => write!(f, "zoom out {c}"), | |
| 489 | + | Action::Zoom(Zoom::Percent(p)) => write!(f, "zoom {p}%"), | |
| 490 | + | Action::Fit(m) => write!(f, "fit {}", value_name(m)), | |
| 491 | + | Action::Pan(d) | Action::PanEdge(d) => write!(f, "{n} {}", dir_name(*d)), | |
| 492 | + | Action::Rotate(1) => f.write_str("rotate cw"), | |
| 493 | + | Action::Rotate(-1) => f.write_str("rotate ccw"), | |
| 494 | + | Action::Rotate(q) => write!(f, "rotate {}", q * 90), | |
| 495 | + | Action::Flip(Axis::Horizontal) => f.write_str("flip h"), | |
| 496 | + | Action::Flip(Axis::Vertical) => f.write_str("flip v"), | |
| 497 | + | Action::FilterToggle => f.write_str("filter toggle"), | |
| 498 | + | Action::AlphaBgCycle => f.write_str("alpha-bg cycle"), | |
| 499 | + | Action::Adjust(_, s) => write!(f, "{n} {}", step_name(*s)), | |
| 500 | + | Action::SlideshowDelay { secs, relative } => { | |
| 501 | + | if *relative { | |
| 502 | + | write!(f, "{n} {secs:+}") | |
| 503 | + | } else { | |
| 504 | + | write!(f, "{n} {secs}") | |
| 505 | + | } | |
| 506 | + | } | |
| 507 | + | Action::Mode(m) => write!( | |
| 508 | + | f, | |
| 509 | + | "mode {}", | |
| 510 | + | match m { | |
| 511 | + | ModeArg::Image => "image", | |
| 512 | + | ModeArg::Grid => "grid", | |
| 513 | + | ModeArg::Toggle => "toggle", | |
| 514 | + | } | |
| 515 | + | ), | |
| 516 | + | Action::GridSize(d) if *d > 0 => f.write_str("grid-size +"), | |
| 517 | + | Action::GridSize(_) => f.write_str("grid-size -"), | |
| 518 | + | Action::Sort(k) => write!(f, "sort {}", value_name(k)), | |
| 519 | + | Action::Filter(p) => write!(f, "filter {}", quoted(p, &["toggle"])), | |
| 520 | + | Action::Open(p) => write!(f, "open {}", quoted(p, &[])), | |
| 521 | + | Action::Echo(m) => write!(f, "echo {}", quoted(m, &[])), | |
| 522 | + | Action::Run(c) => write!(f, "run {c}"), | |
| 523 | + | _ => f.write_str(n), | |
| 524 | + | } | |
| 525 | + | } | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | impl Action { | |
| 529 | + | /// The grammar's head word. | |
| 530 | + | pub fn name(&self) -> &'static str { | |
| 531 | + | match self { | |
| 532 | + | Action::Next(_) => "next", | |
| 533 | + | Action::Prev(_) => "prev", | |
| 534 | + | Action::First => "first", | |
| 535 | + | Action::Last => "last", | |
| 536 | + | Action::Goto(_) => "goto", | |
| 537 | + | Action::Random => "random", | |
| 538 | + | Action::NextMark => "next-mark", | |
| 539 | + | Action::PrevMark => "prev-mark", | |
| 540 | + | Action::NextFrame => "next-frame", | |
| 541 | + | Action::PrevFrame => "prev-frame", | |
| 542 | + | Action::Mark(_) => "mark", | |
| 543 | + | Action::MarkAll => "mark-all", | |
| 544 | + | Action::UnmarkAll => "unmark-all", | |
| 545 | + | Action::InvertMarks => "invert-marks", | |
| 546 | + | Action::Zoom(_) => "zoom", | |
| 547 | + | Action::Fit(_) => "fit", | |
| 548 | + | Action::Pan(_) => "pan", | |
| 549 | + | Action::PanEdge(_) => "pan-edge", | |
| 550 | + | Action::Center => "center", | |
| 551 | + | Action::Rotate(_) => "rotate", | |
| 552 | + | Action::Flip(_) => "flip", | |
| 553 | + | Action::FilterToggle => "filter", | |
| 554 | + | Action::AlphaBgCycle => "alpha-bg", | |
| 555 | + | Action::Adjust(Adjust::Gamma, _) => "gamma", | |
| 556 | + | Action::Adjust(Adjust::Brightness, _) => "brightness", | |
| 557 | + | Action::Adjust(Adjust::Contrast, _) => "contrast", | |
| 558 | + | Action::Play(_) => "play", | |
| 559 | + | Action::Slideshow(_) => "slideshow", | |
| 560 | + | Action::SlideshowDelay { .. } => "slideshow-delay", | |
| 561 | + | Action::Mode(_) => "mode", | |
| 562 | + | Action::Visual => "visual", | |
| 563 | + | Action::Fullscreen(_) => "fullscreen", | |
| 564 | + | Action::Bar(_) => "bar", | |
| 565 | + | Action::Info(_) => "info", | |
| 566 | + | Action::Help => "help", | |
| 567 | + | Action::Palette => "palette", | |
| 568 | + | Action::Search => "search", | |
| 569 | + | Action::GridSize(_) => "grid-size", | |
| 570 | + | Action::Sort(_) => "sort", | |
| 571 | + | Action::Reverse => "reverse", | |
| 572 | + | Action::Filter(_) => "filter", | |
| 573 | + | Action::FilterClear => "filter-clear", | |
| 574 | + | Action::Remove => "remove", | |
| 575 | + | Action::Reload => "reload", | |
| 576 | + | Action::Refresh => "refresh", | |
| 577 | + | Action::Open(_) => "open", | |
| 578 | + | Action::Run(_) => "run", | |
| 579 | + | Action::ReloadConfig => "reload-config", | |
| 580 | + | Action::Echo(_) => "echo", | |
| 581 | + | Action::Cancel => "cancel", | |
| 582 | + | Action::Quit => "quit", | |
| 583 | + | Action::QuitNoOutput => "quit-no-output", | |
| 584 | + | } | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | /// A one-line description of this exact action, for which-key, help and the palette. | |
| 588 | + | #[allow(dead_code)] // which-key, help and the palette arrive in M6 | |
| 589 | + | pub fn description(&self) -> String { | |
| 590 | + | let toggle = |s: &Switch, what: &str| match s { | |
| 591 | + | Switch::Toggle => format!("Toggle {what}"), | |
| 592 | + | Switch::On => format!("Show {what}"), | |
| 593 | + | Switch::Off => format!("Hide {what}"), | |
| 594 | + | }; | |
| 595 | + | match self { | |
| 596 | + | Action::Next(1) => "Next image".into(), | |
| 597 | + | Action::Prev(1) => "Previous image".into(), | |
| 598 | + | Action::Next(n) => format!("Forward {n} images"), | |
| 599 | + | Action::Prev(n) => format!("Back {n} images"), | |
| 600 | + | Action::Goto(n) => format!("Go to image {n}"), | |
| 601 | + | Action::Mark(Switch::Toggle) => "Toggle mark".into(), | |
| 602 | + | Action::Mark(Switch::On) => "Mark".into(), | |
| 603 | + | Action::Mark(Switch::Off) => "Unmark".into(), | |
| 604 | + | Action::Zoom(Zoom::In(_)) => "Zoom in".into(), | |
| 605 | + | Action::Zoom(Zoom::Out(_)) => "Zoom out".into(), | |
| 606 | + | Action::Zoom(Zoom::Percent(p)) => format!("Zoom to {p}%"), | |
| 607 | + | Action::Fit(m) => match m { | |
| 608 | + | FitMode::Down => "Fit, never enlarge".into(), | |
| 609 | + | FitMode::Fit => "Fit to window".into(), | |
| 610 | + | FitMode::Fill => "Fill window".into(), | |
| 611 | + | FitMode::Width => "Fit width".into(), | |
| 612 | + | FitMode::Height => "Fit height".into(), | |
| 613 | + | FitMode::Original => "Original size".into(), | |
| 614 | + | }, | |
| 615 | + | Action::Pan(d) => format!("Pan {}", dir_name(*d)), | |
| 616 | + | Action::PanEdge(d) => format!("Jump to {} edge", dir_name(*d)), | |
| 617 | + | Action::Rotate(1) => "Rotate clockwise".into(), | |
| 618 | + | Action::Rotate(-1) => "Rotate counter-clockwise".into(), | |
| 619 | + | Action::Rotate(q) => format!("Rotate {}°", q * 90), | |
| 620 | + | Action::Flip(Axis::Horizontal) => "Flip horizontally".into(), | |
| 621 | + | Action::Flip(Axis::Vertical) => "Flip vertically".into(), | |
| 622 | + | Action::Adjust(a, s) => { | |
| 623 | + | let what = match a { | |
| 624 | + | Adjust::Gamma => "gamma", | |
| 625 | + | Adjust::Brightness => "brightness", | |
| 626 | + | Adjust::Contrast => "contrast", | |
| 627 | + | }; | |
| 628 | + | match s { | |
| 629 | + | Step::Up => format!("Increase {what}"), | |
| 630 | + | Step::Down => format!("Decrease {what}"), | |
| 631 | + | Step::Reset => format!("Reset {what}"), | |
| 632 | + | } | |
| 633 | + | } | |
| 634 | + | Action::Play(s) => match s { | |
| 635 | + | Switch::Toggle => "Play / pause".into(), | |
| 636 | + | Switch::On => "Play".into(), | |
| 637 | + | Switch::Off => "Pause".into(), | |
| 638 | + | }, | |
| 639 | + | Action::Slideshow(s) => toggle(s, "slideshow"), | |
| 640 | + | Action::SlideshowDelay { secs, relative } if *relative => { | |
| 641 | + | format!("Slideshow delay {secs:+}s") | |
| 642 | + | } | |
| 643 | + | Action::SlideshowDelay { secs, .. } => format!("Slideshow delay {secs}s"), | |
| 644 | + | Action::Mode(ModeArg::Image) => "Image mode".into(), | |
| 645 | + | Action::Mode(ModeArg::Grid) => "Grid mode".into(), | |
| 646 | + | Action::Mode(ModeArg::Toggle) => "Toggle image / grid".into(), | |
| 647 | + | Action::Fullscreen(s) => toggle(s, "fullscreen"), | |
| 648 | + | Action::Bar(s) => toggle(s, "status bar"), | |
| 649 | + | Action::Info(s) => toggle(s, "info panel"), | |
| 650 | + | Action::GridSize(d) if *d > 0 => "Larger thumbnails".into(), | |
| 651 | + | Action::GridSize(_) => "Smaller thumbnails".into(), | |
| 652 | + | Action::Sort(k) => format!("Sort by {}", value_name(k)), | |
| 653 | + | Action::Filter(p) => format!("Filter: {p}"), | |
| 654 | + | Action::Open(p) => format!("Open {p}"), | |
| 655 | + | Action::Run(c) => format!("Run {c}"), | |
| 656 | + | Action::Echo(m) => format!("Echo: {m}"), | |
| 657 | + | other => CATALOG | |
| 658 | + | .iter() | |
| 659 | + | .find(|e| e.name == other.name()) | |
| 660 | + | .map_or_else(|| other.to_string(), |e| e.desc.to_string()), | |
| 661 | + | } | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | /// Apply a count prefix: `5n` moves five images, `12G` goes to image 12, `3+` zooms three | |
| 665 | + | /// steps. Actions without a meaningful count ignore it. | |
| 666 | + | pub fn with_count(self, n: usize) -> Action { | |
| 667 | + | let n = n.max(1); | |
| 668 | + | let n32 = n.min(i32::MAX as usize) as i32; | |
| 669 | + | match self { | |
| 670 | + | Action::Next(k) => Action::Next(k.saturating_mul(n)), | |
| 671 | + | Action::Prev(k) => Action::Prev(k.saturating_mul(n)), | |
| 672 | + | Action::First | Action::Last => Action::Goto(n), | |
| 673 | + | Action::Zoom(Zoom::In(k)) => Action::Zoom(Zoom::In(k.saturating_mul(n32 as u32))), | |
| 674 | + | Action::Zoom(Zoom::Out(k)) => Action::Zoom(Zoom::Out(k.saturating_mul(n32 as u32))), | |
| 675 | + | Action::Rotate(q) => { | |
| 676 | + | // Keep at most one full turn so the animation stays sane. | |
| 677 | + | let total = q.saturating_mul(n32); | |
| 678 | + | Action::Rotate(total.signum() * ((total.abs() - 1) % 4 + 1)) | |
| 679 | + | } | |
| 680 | + | Action::GridSize(d) => Action::GridSize(d.saturating_mul(n32)), | |
| 681 | + | other => other, | |
| 682 | + | } | |
| 683 | + | } | |
| 684 | + | ||
| 685 | + | /// Whether holding the key should repeat the action. | |
| 686 | + | pub fn repeats(&self) -> bool { | |
| 687 | + | matches!( | |
| 688 | + | self, | |
| 689 | + | Action::Next(_) | |
| 690 | + | | Action::Prev(_) | |
| 691 | + | | Action::NextMark | |
| 692 | + | | Action::PrevMark | |
| 693 | + | | Action::NextFrame | |
| 694 | + | | Action::PrevFrame | |
| 695 | + | | Action::Zoom(Zoom::In(_) | Zoom::Out(_)) | |
| 696 | + | | Action::GridSize(_) | |
| 697 | + | | Action::Adjust(_, Step::Up | Step::Down) | |
| 698 | + | ) | |
| 699 | + | } | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | #[cfg(test)] | |
| 703 | + | mod tests { | |
| 704 | + | use super::*; | |
| 705 | + | ||
| 706 | + | fn p(s: &str) -> Action { | |
| 707 | + | s.parse().unwrap_or_else(|e| panic!("{s}: {e}")) | |
| 708 | + | } | |
| 709 | + | ||
| 710 | + | #[test] | |
| 711 | + | fn parses_the_grammar() { | |
| 712 | + | assert_eq!(p("next"), Action::Next(1)); | |
| 713 | + | assert_eq!(p(" next 5 "), Action::Next(5)); | |
| 714 | + | assert_eq!(p("goto 12"), Action::Goto(12)); | |
| 715 | + | assert_eq!(p("zoom in"), Action::Zoom(Zoom::In(1))); | |
| 716 | + | assert_eq!(p("zoom 150%"), Action::Zoom(Zoom::Percent(150.0))); | |
| 717 | + | assert_eq!(p("zoom 50"), Action::Zoom(Zoom::Percent(50.0))); | |
| 718 | + | assert_eq!(p("fit width"), Action::Fit(FitMode::Width)); | |
| 719 | + | assert_eq!(p("rotate 180"), Action::Rotate(2)); | |
| 720 | + | assert_eq!(p("rotate ccw"), Action::Rotate(-1)); | |
| 721 | + | assert_eq!(p("flip v"), Action::Flip(Axis::Vertical)); | |
| 722 | + | assert_eq!(p("filter toggle"), Action::FilterToggle); | |
| 723 | + | assert_eq!(p("filter *.png"), Action::Filter("*.png".into())); | |
| 724 | + | assert_eq!(p("filter \"toggle\""), Action::Filter("toggle".into())); | |
| 725 | + | assert_eq!(p("bar toggle"), Action::Bar(Switch::Toggle)); | |
| 726 | + | assert_eq!(p("bar off"), Action::Bar(Switch::Off)); | |
| 727 | + | assert_eq!(p("mark"), Action::Mark(Switch::Toggle)); | |
| 728 | + | assert_eq!(p("gamma +"), Action::Adjust(Adjust::Gamma, Step::Up)); | |
| 729 | + | assert_eq!( | |
| 730 | + | p("slideshow-delay -1.5"), | |
| 731 | + | Action::SlideshowDelay { | |
| 732 | + | secs: -1.5, | |
| 733 | + | relative: true | |
| 734 | + | } | |
| 735 | + | ); | |
| 736 | + | assert_eq!(p("sort mtime"), Action::Sort(SortKey::Mtime)); | |
| 737 | + | assert_eq!(p("run wallpaper"), Action::Run("wallpaper".into())); | |
| 738 | + | assert_eq!(p("echo hello world"), Action::Echo("hello world".into())); | |
| 739 | + | assert_eq!(p("quit-no-output"), Action::QuitNoOutput); | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | #[test] | |
| 743 | + | fn errors_are_helpful() { | |
| 744 | + | let e = |s: &str| s.parse::<Action>().unwrap_err().0; | |
| 745 | + | assert_eq!(e("nxt"), "unknown action `nxt`, did you mean `next`?"); | |
| 746 | + | assert!( | |
| 747 | + | e("fit wdth").contains("did you mean `width`"), | |
| 748 | + | "{}", | |
| 749 | + | e("fit wdth") | |
| 750 | + | ); | |
| 751 | + | assert!(e("quit now").contains("takes no arguments")); | |
| 752 | + | assert!(e("goto 0").contains("positive")); | |
| 753 | + | assert!(e("next -1").contains("positive")); | |
| 754 | + | assert!(e("rotate 45").contains("cw, ccw")); | |
| 755 | + | assert!(e("run two words").contains("no spaces")); | |
| 756 | + | assert!(e("").contains("empty")); | |
| 757 | + | } | |
| 758 | + | ||
| 759 | + | #[test] | |
| 760 | + | fn display_round_trips() { | |
| 761 | + | let all = [ | |
| 762 | + | "next", | |
| 763 | + | "next 5", | |
| 764 | + | "prev", | |
| 765 | + | "prev 3", | |
| 766 | + | "first", | |
| 767 | + | "last", | |
| 768 | + | "goto 7", | |
| 769 | + | "random", | |
| 770 | + | "next-mark", | |
| 771 | + | "prev-mark", | |
| 772 | + | "next-frame", | |
| 773 | + | "prev-frame", | |
| 774 | + | "mark", | |
| 775 | + | "mark on", | |
| 776 | + | "mark off", | |
| 777 | + | "mark-all", | |
| 778 | + | "unmark-all", | |
| 779 | + | "invert-marks", | |
| 780 | + | "zoom in", | |
| 781 | + | "zoom out 3", | |
| 782 | + | "zoom 100%", | |
| 783 | + | "zoom 12.5%", | |
| 784 | + | "fit down", | |
| 785 | + | "fit original", | |
| 786 | + | "pan left", | |
| 787 | + | "pan-edge down", | |
| 788 | + | "center", | |
| 789 | + | "rotate cw", | |
| 790 | + | "rotate ccw", | |
| 791 | + | "rotate 180", | |
| 792 | + | "flip h", | |
| 793 | + | "filter toggle", | |
| 794 | + | "alpha-bg cycle", | |
| 795 | + | "gamma +", | |
| 796 | + | "brightness -", | |
| 797 | + | "contrast reset", | |
| 798 | + | "play", | |
| 799 | + | "slideshow on", | |
| 800 | + | "slideshow-delay +1", | |
| 801 | + | "slideshow-delay 5", | |
| 802 | + | "mode toggle", | |
| 803 | + | "mode grid", | |
| 804 | + | "visual", | |
| 805 | + | "fullscreen", | |
| 806 | + | "bar", | |
| 807 | + | "info off", | |
| 808 | + | "help", | |
| 809 | + | "palette", | |
| 810 | + | "search", | |
| 811 | + | "grid-size +", | |
| 812 | + | "grid-size -", | |
| 813 | + | "sort natural", | |
| 814 | + | "reverse", | |
| 815 | + | "filter cat", | |
| 816 | + | "filter \"toggle\"", | |
| 817 | + | "filter-clear", | |
| 818 | + | "remove", | |
| 819 | + | "reload", | |
| 820 | + | "refresh", | |
| 821 | + | "open ~/Pictures/a b.png", | |
| 822 | + | "run copy", | |
| 823 | + | "reload-config", | |
| 824 | + | "echo hi there", | |
| 825 | + | "echo \" padded \"", | |
| 826 | + | "cancel", | |
| 827 | + | "quit", | |
| 828 | + | "quit-no-output", | |
| 829 | + | ]; | |
| 830 | + | for s in all { | |
| 831 | + | let a = p(s); | |
| 832 | + | assert_eq!(a.to_string(), s, "display of {a:?}"); | |
| 833 | + | assert_eq!(p(&a.to_string()), a); | |
| 834 | + | assert!(!a.description().is_empty()); | |
| 835 | + | } | |
| 836 | + | // Every catalog entry is reachable from the list above. | |
| 837 | + | for entry in CATALOG { | |
| 838 | + | assert!( | |
| 839 | + | all.iter().any(|s| p(s).name() == entry.name), | |
| 840 | + | "{} untested", | |
| 841 | + | entry.name | |
| 842 | + | ); | |
| 843 | + | } | |
| 844 | + | } | |
| 845 | + | ||
| 846 | + | #[test] | |
| 847 | + | fn counts() { | |
| 848 | + | assert_eq!(Action::Next(10).with_count(3), Action::Next(30)); | |
| 849 | + | assert_eq!(Action::First.with_count(12), Action::Goto(12)); | |
| 850 | + | assert_eq!(Action::Last.with_count(5), Action::Goto(5)); | |
| 851 | + | assert_eq!( | |
| 852 | + | Action::Zoom(Zoom::In(1)).with_count(3), | |
| 853 | + | Action::Zoom(Zoom::In(3)) | |
| 854 | + | ); | |
| 855 | + | assert_eq!(Action::GridSize(-1).with_count(2), Action::GridSize(-2)); | |
| 856 | + | assert_eq!(Action::Quit.with_count(4), Action::Quit); | |
| 857 | + | } | |
| 39 | 858 | } | |
| @@ -13,14 +13,18 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}; | |||
| 13 | 13 | use winit::keyboard::{ModifiersState, PhysicalKey}; | |
| 14 | 14 | use winit::window::{Fullscreen, Window, WindowId}; | |
| 15 | 15 | ||
| 16 | − | use crate::action::Action; | |
| 16 | + | use crate::action::{Action, Switch, Zoom}; | |
| 17 | 17 | use crate::cache::ImageCache; | |
| 18 | − | use crate::config::theme::Theme; | |
| 18 | + | use crate::config::watch::ConfigWatch; | |
| 19 | + | use crate::config::{Config, LoadError, Loaded}; | |
| 19 | 20 | use crate::decode::{self, DecodedImage}; | |
| 20 | − | use crate::input::{self, Input}; | |
| 21 | + | use crate::input::keymap::{KeyTables, Keymap}; | |
| 22 | + | use crate::input::keys::{Key, Scroll}; | |
| 23 | + | use crate::input::{self}; | |
| 21 | 24 | use crate::render::{Renderer, text}; | |
| 22 | − | use crate::state::view::Dir; | |
| 23 | − | use crate::state::{AppState, Effects, MsgKind}; | |
| 25 | + | use crate::scan; | |
| 26 | + | use crate::state::view::{Dir, FitMode}; | |
| 27 | + | use crate::state::{AppState, Effects, MsgKind, Settings}; | |
| 24 | 28 | use crate::ui; | |
| 25 | 29 | use crate::worker::{JobKey, Pool, Priority}; | |
| 26 | 30 | ||
| @@ -30,6 +34,13 @@ const UPLOAD_BUDGET: usize = 32 << 20; | |||
| 30 | 34 | const SHOW_TIMEOUT: Duration = Duration::from_millis(300); | |
| 31 | 35 | /// Wheel pixels per zoom step for touchpads. | |
| 32 | 36 | const PIXELS_PER_STEP: f32 = 60.0; | |
| 37 | + | /// An ambiguous key sequence (`g` bound, and `gg` too) resolves after this long, like vim's | |
| 38 | + | /// `timeoutlen`. | |
| 39 | + | const SEQUENCE_TIMEOUT: Duration = Duration::from_millis(1000); | |
| 40 | + | /// A press and release closer than this is a click, not a drag. | |
| 41 | + | const CLICK_SLOP: f32 = 4.0; | |
| 42 | + | /// Config errors stay on screen until fixed (or dismissed with `<Esc>`). | |
| 43 | + | const CONFIG_ERROR_TIME: Duration = Duration::from_secs(3600); | |
| 33 | 44 | ||
| 34 | 45 | pub enum UserEvent { | |
| 35 | 46 | Decoded { | |
| @@ -40,13 +51,36 @@ pub enum UserEvent { | |||
| 40 | 51 | Found(Vec<PathBuf>), | |
| 41 | 52 | /// Embedded plus system fonts, loaded off-thread after the first frame. | |
| 42 | 53 | Fonts(glyphon::fontdb::Database), | |
| 54 | + | /// The config was reloaded (file change, SIGUSR1 or `reload-config`). | |
| 55 | + | Config(Box<Result<Loaded, LoadError>>), | |
| 56 | + | /// A re-sort finished: the queue in its new order. | |
| 57 | + | Sorted(Vec<PathBuf>), | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Command-line settings that beat the config, across reloads too. | |
| 61 | + | #[derive(Clone, Debug, Default)] | |
| 62 | + | pub struct Overrides { | |
| 63 | + | pub fit: Option<FitMode>, | |
| 64 | + | pub zoom: Option<f32>, | |
| 65 | + | pub bar: Option<bool>, | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | impl Overrides { | |
| 69 | + | pub fn apply(&self, s: &mut Settings) { | |
| 70 | + | if let Some(fit) = self.fit { | |
| 71 | + | s.fit = fit; | |
| 72 | + | } | |
| 73 | + | if self.zoom.is_some() { | |
| 74 | + | s.zoom = self.zoom; | |
| 75 | + | } | |
| 76 | + | } | |
| 43 | 77 | } | |
| 44 | 78 | ||
| 45 | 79 | pub struct Options { | |
| 46 | 80 | pub fullscreen: bool, | |
| 47 | 81 | pub geometry: Option<(u32, u32)>, | |
| 48 | 82 | pub class: String, | |
| 49 | − | pub cache_mb: usize, | |
| 83 | + | pub overrides: Overrides, | |
| 50 | 84 | } | |
| 51 | 85 | ||
| 52 | 86 | struct Gfx { | |
| @@ -58,8 +92,17 @@ struct Gfx { | |||
| 58 | 92 | pub struct App { | |
| 59 | 93 | opts: Options, | |
| 60 | 94 | state: AppState, | |
| 61 | − | theme: Theme, | |
| 62 | − | input: Input, | |
| 95 | + | config: Config, | |
| 96 | + | keymap: Keymap, | |
| 97 | + | watch: ConfigWatch, | |
| 98 | + | /// A config error is on the status line. | |
| 99 | + | config_error: bool, | |
| 100 | + | /// `reload-config` ran, so the next reload is confirmed on screen. | |
| 101 | + | reload_requested: bool, | |
| 102 | + | /// When the pending key sequence became ambiguous. | |
| 103 | + | ambiguous_since: Option<Instant>, | |
| 104 | + | /// Actions a held key repeats. | |
| 105 | + | repeat: Option<(PhysicalKey, Vec<Action>)>, | |
| 63 | 106 | pool: Pool, | |
| 64 | 107 | proxy: EventLoopProxy<UserEvent>, | |
| 65 | 108 | cache: ImageCache, | |
| @@ -71,6 +114,10 @@ pub struct App { | |||
| 71 | 114 | modifiers: ModifiersState, | |
| 72 | 115 | cursor: [f32; 2], | |
| 73 | 116 | dragging: bool, | |
| 117 | + | /// Where the left button went down, while it is a click candidate. | |
| 118 | + | press: Option<[f32; 2]>, | |
| 119 | + | /// Wheel motion not yet turned into whole `<ScrollUp>`/`<ScrollDown>` presses. | |
| 120 | + | scroll: [f32; 2], | |
| 74 | 121 | held: HashMap<PhysicalKey, Dir>, | |
| 75 | 122 | started: Instant, | |
| 76 | 123 | shown_window: bool, | |
| @@ -78,13 +125,24 @@ pub struct App { | |||
| 78 | 125 | } | |
| 79 | 126 | ||
| 80 | 127 | impl App { | |
| 81 | − | pub fn new(opts: Options, state: AppState, proxy: EventLoopProxy<UserEvent>) -> Self { | |
| 82 | − | let cache = ImageCache::new(opts.cache_mb << 20); | |
| 128 | + | pub fn new( | |
| 129 | + | opts: Options, | |
| 130 | + | state: AppState, | |
| 131 | + | proxy: EventLoopProxy<UserEvent>, | |
| 132 | + | loaded: Loaded, | |
| 133 | + | watch: ConfigWatch, | |
| 134 | + | ) -> Self { | |
| 135 | + | let cache = ImageCache::new(loaded.config.image.cache_mb << 20); | |
| 83 | 136 | let mut app = Self { | |
| 84 | 137 | opts, | |
| 85 | 138 | state, | |
| 86 | − | theme: Theme::nothing_dark(), | |
| 87 | − | input: Input::default(), | |
| 139 | + | keymap: keymap(&loaded.keys), | |
| 140 | + | config: loaded.config, | |
| 141 | + | watch, | |
| 142 | + | config_error: false, | |
| 143 | + | reload_requested: false, | |
| 144 | + | ambiguous_since: None, | |
| 145 | + | repeat: None, | |
| 88 | 146 | pool: Pool::new(), | |
| 89 | 147 | proxy, | |
| 90 | 148 | cache, | |
| @@ -96,6 +154,8 @@ impl App { | |||
| 96 | 154 | modifiers: ModifiersState::empty(), | |
| 97 | 155 | cursor: [0.0, 0.0], | |
| 98 | 156 | dragging: false, | |
| 157 | + | press: None, | |
| 158 | + | scroll: [0.0, 0.0], | |
| 99 | 159 | held: HashMap::new(), | |
| 100 | 160 | started: Instant::now(), | |
| 101 | 161 | shown_window: false, | |
| @@ -152,8 +212,6 @@ impl App { | |||
| 152 | 212 | let now = Instant::now(); | |
| 153 | 213 | if navigated { | |
| 154 | 214 | self.pool.bump_epoch(); | |
| 155 | − | self.input.cancel(); | |
| 156 | − | self.state.pending.clear(); | |
| 157 | 215 | } | |
| 158 | 216 | let wanted = self.window_paths(); | |
| 159 | 217 | self.update_title(); | |
| @@ -217,14 +275,82 @@ impl App { | |||
| 217 | 275 | ||
| 218 | 276 | fn update_title(&self) { | |
| 219 | 277 | if let Some(g) = &self.gfx { | |
| 220 | − | let title = match self.state.queue.current() { | |
| 221 | − | Some(p) => format!("{} — remage", ui::file_name(p)), | |
| 278 | + | let q = &self.state.queue; | |
| 279 | + | let title = match q.current() { | |
| 280 | + | Some(p) => self | |
| 281 | + | .config | |
| 282 | + | .window | |
| 283 | + | .title | |
| 284 | + | .replace("{name}", &ui::file_name(p)) | |
| 285 | + | .replace("{index}", &(q.index() + 1).to_string()) | |
| 286 | + | .replace("{count}", &q.len().to_string()), | |
| 222 | 287 | None => "remage".into(), | |
| 223 | 288 | }; | |
| 224 | 289 | g.window.set_title(&title); | |
| 225 | 290 | } | |
| 226 | 291 | } | |
| 227 | 292 | ||
| 293 | + | /// Show a config error until the config is fixed. | |
| 294 | + | pub fn config_failed(&mut self, message: &str) { | |
| 295 | + | self.state.notify_for( | |
| 296 | + | format!("config error: {}", short_error(message)), | |
| 297 | + | MsgKind::Error, | |
| 298 | + | Instant::now(), | |
| 299 | + | CONFIG_ERROR_TIME, | |
| 300 | + | ); | |
| 301 | + | self.config_error = true; | |
| 302 | + | self.request_redraw(); | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /// Adopt a reloaded config. Runtime toggles (bar, filter, alpha background) only change | |
| 306 | + | /// when their config value did, so a reload doesn't undo what the user just toggled. | |
| 307 | + | fn apply_config(&mut self, loaded: Loaded) { | |
| 308 | + | let now = Instant::now(); | |
| 309 | + | for w in &loaded.warnings { | |
| 310 | + | log::warn!("{w}"); | |
| 311 | + | } | |
| 312 | + | let old = std::mem::replace(&mut self.config, loaded.config); | |
| 313 | + | let new = &self.config; | |
| 314 | + | let mut settings = Settings::from_config(new); | |
| 315 | + | self.opts.overrides.apply(&mut settings); | |
| 316 | + | let preload_changed = settings.preload != self.state.settings.preload; | |
| 317 | + | self.state.set_settings(settings); | |
| 318 | + | self.state.set_theme(loaded.theme, now); | |
| 319 | + | self.keymap = keymap(&loaded.keys); | |
| 320 | + | self.ambiguous_since = None; | |
| 321 | + | self.state.pending.clear(); | |
| 322 | + | self.cache.set_budget(new.image.cache_mb << 20); | |
| 323 | + | if old.image.filter != new.image.filter { | |
| 324 | + | self.state.nearest = new.image.filter.nearest(); | |
| 325 | + | } | |
| 326 | + | if old.image.alpha_background != new.image.alpha_background { | |
| 327 | + | self.state.alpha_bg = new.image.alpha_background; | |
| 328 | + | } | |
| 329 | + | if old.ui.statusbar != new.ui.statusbar && self.opts.overrides.bar.is_none() { | |
| 330 | + | let s = if new.ui.statusbar { | |
| 331 | + | Switch::On | |
| 332 | + | } else { | |
| 333 | + | Switch::Off | |
| 334 | + | }; | |
| 335 | + | self.state.apply(Action::Bar(s), now); | |
| 336 | + | } | |
| 337 | + | if let Some(g) = &mut self.gfx { | |
| 338 | + | if old.window.decorations != new.window.decorations { | |
| 339 | + | g.window.set_decorations(new.window.decorations); | |
| 340 | + | } | |
| 341 | + | g.renderer.set_font_families(font_families(new)); | |
| 342 | + | } | |
| 343 | + | self.update_title(); | |
| 344 | + | // Silent on routine changes (a new wallpaper palette); confirm fixes and manual reloads. | |
| 345 | + | if std::mem::take(&mut self.config_error) || std::mem::take(&mut self.reload_requested) { | |
| 346 | + | self.state.notify("config reloaded", MsgKind::Info, now); | |
| 347 | + | } | |
| 348 | + | if preload_changed { | |
| 349 | + | self.sync(false); | |
| 350 | + | } | |
| 351 | + | self.request_redraw(); | |
| 352 | + | } | |
| 353 | + | ||
| 228 | 354 | fn on_decoded(&mut self, path: PathBuf, result: Result<Arc<DecodedImage>, String>) { | |
| 229 | 355 | lock(&self.arriving).remove(&path); | |
| 230 | 356 | let now = Instant::now(); | |
| @@ -267,9 +393,20 @@ impl App { | |||
| 267 | 393 | } | |
| 268 | 394 | } | |
| 269 | 395 | ||
| 270 | − | fn apply(&mut self, action: Action, event_loop: &ActiveEventLoop) { | |
| 271 | − | let fx = self.state.apply(action, Instant::now()); | |
| 272 | − | self.effects(fx, event_loop); | |
| 396 | + | /// Run actions in order; `anchor` is the cursor for mouse-triggered ones. | |
| 397 | + | fn run(&mut self, actions: Vec<Action>, anchor: Option<[f32; 2]>, el: &ActiveEventLoop) { | |
| 398 | + | for action in actions { | |
| 399 | + | if action == Action::Cancel { | |
| 400 | + | self.keymap.cancel(); | |
| 401 | + | self.state.pending.clear(); | |
| 402 | + | self.config_error = false; | |
| 403 | + | } | |
| 404 | + | let fx = self.state.apply_at(action, anchor, Instant::now()); | |
| 405 | + | self.effects(fx, el); | |
| 406 | + | if fx.quit { | |
| 407 | + | return; | |
| 408 | + | } | |
| 409 | + | } | |
| 273 | 410 | } | |
| 274 | 411 | ||
| 275 | 412 | fn effects(&mut self, fx: Effects, event_loop: &ActiveEventLoop) { | |
| @@ -277,24 +414,58 @@ impl App { | |||
| 277 | 414 | event_loop.exit(); | |
| 278 | 415 | return; | |
| 279 | 416 | } | |
| 280 | − | if fx.fullscreen | |
| 417 | + | if let Some(s) = fx.fullscreen | |
| 281 | 418 | && let Some(g) = &self.gfx | |
| 282 | 419 | { | |
| 283 | 420 | let on = g.window.fullscreen().is_some(); | |
| 284 | − | g.window | |
| 285 | − | .set_fullscreen((!on).then_some(Fullscreen::Borderless(None))); | |
| 421 | + | let want = match s { | |
| 422 | + | Switch::Toggle => !on, | |
| 423 | + | Switch::On => true, | |
| 424 | + | Switch::Off => false, | |
| 425 | + | }; | |
| 426 | + | if want != on { | |
| 427 | + | g.window | |
| 428 | + | .set_fullscreen(want.then_some(Fullscreen::Borderless(None))); | |
| 429 | + | } | |
| 286 | 430 | } | |
| 287 | 431 | if fx.reload { | |
| 288 | 432 | self.reload(); | |
| 289 | 433 | } | |
| 434 | + | if fx.reload_config { | |
| 435 | + | self.reload_requested = true; | |
| 436 | + | self.watch.reload(); | |
| 437 | + | } | |
| 438 | + | if let Some(key) = fx.sort { | |
| 439 | + | let mut files = self.state.queue.files().to_vec(); | |
| 440 | + | let proxy = self.proxy.clone(); | |
| 441 | + | std::thread::spawn(move || { | |
| 442 | + | scan::sort_paths(&mut files, key, false); | |
| 443 | + | let _ = proxy.send_event(UserEvent::Sorted(files)); | |
| 444 | + | }); | |
| 445 | + | } | |
| 290 | 446 | if fx.navigated { | |
| 291 | 447 | self.sync(true); | |
| 448 | + | } else if fx.reordered { | |
| 449 | + | self.sync(false); | |
| 292 | 450 | } | |
| 293 | 451 | if fx.redraw || fx.layout { | |
| 294 | 452 | self.request_redraw(); | |
| 295 | 453 | } | |
| 296 | 454 | } | |
| 297 | 455 | ||
| 456 | + | /// Mirror the keymap's pending keys on the status line and arm the ambiguity timeout. | |
| 457 | + | fn update_pending(&mut self, now: Instant) { | |
| 458 | + | let pending = self.keymap.pending_display(); | |
| 459 | + | if pending != self.state.pending { | |
| 460 | + | self.state.pending = pending; | |
| 461 | + | self.request_redraw(); | |
| 462 | + | } | |
| 463 | + | self.ambiguous_since = self | |
| 464 | + | .keymap | |
| 465 | + | .is_ambiguous(self.state.mode) | |
| 466 | + | .then(|| self.ambiguous_since.unwrap_or(now)); | |
| 467 | + | } | |
| 468 | + | ||
| 298 | 469 | fn on_key(&mut self, event: winit::event::KeyEvent, event_loop: &ActiveEventLoop) { | |
| 299 | 470 | let now = Instant::now(); | |
| 300 | 471 | if event.state == ElementState::Released { | |
| @@ -302,27 +473,100 @@ impl App { | |||
| 302 | 473 | self.state.hold_pan(dir, false, now); | |
| 303 | 474 | self.request_redraw(); | |
| 304 | 475 | } | |
| 476 | + | if self | |
| 477 | + | .repeat | |
| 478 | + | .as_ref() | |
| 479 | + | .is_some_and(|(k, _)| *k == event.physical_key) | |
| 480 | + | { | |
| 481 | + | self.repeat = None; | |
| 482 | + | } | |
| 305 | 483 | return; | |
| 306 | 484 | } | |
| 307 | − | let action = self.input.key(&event.logical_key, self.modifiers); | |
| 308 | − | let pending = self.input.pending(); | |
| 309 | − | if pending != self.state.pending { | |
| 310 | − | self.state.pending = pending; | |
| 311 | − | self.request_redraw(); | |
| 485 | + | if event.repeat { | |
| 486 | + | // Repeats bypass the trie: only a completed, repeatable binding repeats. | |
| 487 | + | if let Some((k, actions)) = &self.repeat | |
| 488 | + | && *k == event.physical_key | |
| 489 | + | { | |
| 490 | + | let actions = actions.clone(); | |
| 491 | + | self.run(actions, None, event_loop); | |
| 492 | + | } | |
| 493 | + | return; | |
| 312 | 494 | } | |
| 313 | − | let Some(action) = action else { return }; | |
| 314 | − | if event.repeat && !input::repeats(&action) { | |
| 495 | + | let Some(key) = input::key(&event.logical_key, self.modifiers) else { | |
| 315 | 496 | return; | |
| 497 | + | }; | |
| 498 | + | let runs = self.keymap.feed(self.state.mode, key); | |
| 499 | + | self.update_pending(now); | |
| 500 | + | self.repeat = None; | |
| 501 | + | for run in runs { | |
| 502 | + | let actions = run.counted(); | |
| 503 | + | if let [Action::Pan(dir)] = actions[..] { | |
| 504 | + | if self.state.shown.is_some() { | |
| 505 | + | self.held.insert(event.physical_key, dir); | |
| 506 | + | self.state.hold_pan(dir, true, now); | |
| 507 | + | self.request_redraw(); | |
| 508 | + | } | |
| 509 | + | continue; | |
| 510 | + | } | |
| 511 | + | if !actions.is_empty() && actions.iter().all(Action::repeats) { | |
| 512 | + | self.repeat = Some((event.physical_key, actions.clone())); | |
| 513 | + | } | |
| 514 | + | self.run(actions, None, event_loop); | |
| 316 | 515 | } | |
| 317 | − | if let Action::Pan(dir) = action { | |
| 318 | − | if self.state.shown.is_some() { | |
| 319 | − | self.held.insert(event.physical_key, dir); | |
| 320 | − | self.state.hold_pan(dir, true, now); | |
| 321 | − | self.request_redraw(); | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | /// A mouse button or wheel notch: bound actions run at the cursor, bypassing sequences. | |
| 519 | + | fn on_mouse_key(&mut self, key: Key, event_loop: &ActiveEventLoop) { | |
| 520 | + | self.keymap.cancel(); | |
| 521 | + | self.update_pending(Instant::now()); | |
| 522 | + | if let Some(actions) = self.keymap.lookup(self.state.mode, &[key]) { | |
| 523 | + | let actions = actions.to_vec(); | |
| 524 | + | self.run(actions, Some(self.cursor), event_loop); | |
| 525 | + | } | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | fn on_wheel(&mut self, delta: [f32; 2], event_loop: &ActiveEventLoop) { | |
| 529 | + | let now = Instant::now(); | |
| 530 | + | let [dx, dy] = delta; | |
| 531 | + | if dy != 0.0 { | |
| 532 | + | let dir = if dy > 0.0 { Scroll::Up } else { Scroll::Down }; | |
| 533 | + | let key = input::scroll(dir, self.modifiers); | |
| 534 | + | // Wheel zoom stays continuous for touchpads rather than stepping per notch. | |
| 535 | + | match self.keymap.lookup(self.state.mode, &[key]) { | |
| 536 | + | Some([Action::Zoom(Zoom::In(n))]) => { | |
| 537 | + | let steps = dy.abs() * *n as f32; | |
| 538 | + | self.state.zoom_at(steps, self.cursor, now); | |
| 539 | + | self.request_redraw(); | |
| 540 | + | return; | |
| 541 | + | } | |
| 542 | + | Some([Action::Zoom(Zoom::Out(n))]) => { | |
| 543 | + | let steps = -dy.abs() * *n as f32; | |
| 544 | + | self.state.zoom_at(steps, self.cursor, now); | |
| 545 | + | self.request_redraw(); | |
| 546 | + | return; | |
| 547 | + | } | |
| 548 | + | _ => {} | |
| 549 | + | } | |
| 550 | + | } | |
| 551 | + | for (axis, d, pos, neg) in [ | |
| 552 | + | (0, dx, Scroll::Left, Scroll::Right), | |
| 553 | + | (1, dy, Scroll::Up, Scroll::Down), | |
| 554 | + | ] { | |
| 555 | + | if d == 0.0 { | |
| 556 | + | continue; | |
| 557 | + | } | |
| 558 | + | // Reversing direction drops the leftover fraction. | |
| 559 | + | if self.scroll[axis] * d < 0.0 { | |
| 560 | + | self.scroll[axis] = 0.0; | |
| 561 | + | } | |
| 562 | + | self.scroll[axis] += d; | |
| 563 | + | while self.scroll[axis].abs() >= 1.0 { | |
| 564 | + | let up = self.scroll[axis] > 0.0; | |
| 565 | + | self.scroll[axis] -= self.scroll[axis].signum(); | |
| 566 | + | let key = input::scroll(if up { pos } else { neg }, self.modifiers); | |
| 567 | + | self.on_mouse_key(key, event_loop); | |
| 322 | 568 | } | |
| 323 | − | return; | |
| 324 | 569 | } | |
| 325 | − | self.apply(action, event_loop); | |
| 326 | 570 | } | |
| 327 | 571 | ||
| 328 | 572 | fn redraw(&mut self) { | |
| @@ -339,9 +583,8 @@ impl App { | |||
| 339 | 583 | if !self.shown_window { | |
| 340 | 584 | g.window.set_visible(true); | |
| 341 | 585 | } | |
| 342 | − | let scene = ui::scene(&self.state, &self.theme, now, &mut |t| { | |
| 343 | − | g.renderer.measure(t) | |
| 344 | − | }); | |
| 586 | + | let theme = self.state.theme_at(now); | |
| 587 | + | let scene = ui::scene(&self.state, &theme, now, &mut |t| g.renderer.measure(t)); | |
| 345 | 588 | let result = g.renderer.render(&scene); | |
| 346 | 589 | if let Some(path) = std::env::var_os("REMAGE_SNAPSHOT") | |
| 347 | 590 | && !self.state.is_animating(now) | |
| @@ -384,6 +627,37 @@ impl App { | |||
| 384 | 627 | } | |
| 385 | 628 | } | |
| 386 | 629 | ||
| 630 | + | /// A config error trimmed for the status line: `config.toml:2:1: unknown field `fitt`; did you | |
| 631 | + | /// mean `fit`?` without the directory or the list of every valid field. | |
| 632 | + | fn short_error(message: &str) -> String { | |
| 633 | + | let mut s = match message.split_once(": ") { | |
| 634 | + | Some((loc, rest)) if loc.contains('/') => { | |
| 635 | + | format!("{}: {rest}", loc.rsplit('/').next().unwrap_or(loc)) | |
| 636 | + | } | |
| 637 | + | _ => message.to_string(), | |
| 638 | + | }; | |
| 639 | + | if let Some(start) = s.find(", expected one of") { | |
| 640 | + | let end = s[start..] | |
| 641 | + | .find("; did you mean") | |
| 642 | + | .map_or(s.len(), |i| start + i); | |
| 643 | + | s.replace_range(start..end, ""); | |
| 644 | + | } | |
| 645 | + | s | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | fn keymap(layers: &[KeyTables]) -> Keymap { | |
| 649 | + | let refs: Vec<&KeyTables> = layers.iter().collect(); | |
| 650 | + | Keymap::new(&refs) | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | fn font_families(c: &Config) -> [String; 3] { | |
| 654 | + | [ | |
| 655 | + | c.ui.font_ui.clone(), | |
| 656 | + | c.ui.font_mono.clone(), | |
| 657 | + | c.ui.font_display.clone(), | |
| 658 | + | ] | |
| 659 | + | } | |
| 660 | + | ||
| 387 | 661 | fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> { | |
| 388 | 662 | m.lock().unwrap_or_else(|e| e.into_inner()) | |
| 389 | 663 | } | |
| @@ -420,7 +694,7 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 420 | 694 | let mut attrs = Window::default_attributes() | |
| 421 | 695 | .with_title("remage") | |
| 422 | 696 | .with_inner_size(LogicalSize::new(w, h)) | |
| 423 | − | .with_decorations(false) | |
| 697 | + | .with_decorations(self.config.window.decorations) | |
| 424 | 698 | .with_visible(false); | |
| 425 | 699 | if self.opts.fullscreen { | |
| 426 | 700 | attrs = attrs.with_fullscreen(Some(Fullscreen::Borderless(None))); | |
| @@ -442,7 +716,10 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 442 | 716 | } | |
| 443 | 717 | }; | |
| 444 | 718 | let renderer = match Renderer::new(window.clone(), event_loop.owned_display_handle()) { | |
| 445 | − | Ok(r) => r, | |
| 719 | + | Ok(mut r) => { | |
| 720 | + | r.set_font_families(font_families(&self.config)); | |
| 721 | + | r | |
| 722 | + | } | |
| 446 | 723 | Err(e) => { | |
| 447 | 724 | self.error = Some(e.context("initialise GPU")); | |
| 448 | 725 | event_loop.exit(); | |
| @@ -477,11 +754,24 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 477 | 754 | g.window.request_redraw(); | |
| 478 | 755 | } | |
| 479 | 756 | } | |
| 757 | + | UserEvent::Config(result) => match *result { | |
| 758 | + | Ok(loaded) => { | |
| 759 | + | log::info!("config reloaded"); | |
| 760 | + | self.apply_config(loaded); | |
| 761 | + | } | |
| 762 | + | Err(e) => { | |
| 763 | + | log::error!("{e}"); | |
| 764 | + | self.config_failed(&e.message); | |
| 765 | + | } | |
| 766 | + | }, | |
| 767 | + | UserEvent::Sorted(files) => { | |
| 768 | + | self.state.queue.reorder(files); | |
| 769 | + | self.sync(false); | |
| 770 | + | } | |
| 480 | 771 | } | |
| 481 | 772 | } | |
| 482 | 773 | ||
| 483 | 774 | fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { | |
| 484 | − | let now = Instant::now(); | |
| 485 | 775 | match event { | |
| 486 | 776 | WindowEvent::CloseRequested => event_loop.exit(), | |
| 487 | 777 | WindowEvent::Resized(size) => { | |
| @@ -521,22 +811,35 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 521 | 811 | } | |
| 522 | 812 | self.cursor = p; | |
| 523 | 813 | } | |
| 524 | − | WindowEvent::MouseInput { | |
| 525 | − | state, | |
| 526 | − | button: MouseButton::Left, | |
| 527 | − | .. | |
| 528 | − | } => { | |
| 529 | − | self.dragging = state == ElementState::Pressed; | |
| 814 | + | WindowEvent::MouseInput { state, button, .. } => { | |
| 815 | + | let pressed = state == ElementState::Pressed; | |
| 816 | + | if button == MouseButton::Left { | |
| 817 | + | // Drag pans; a press and release in place is a `<LeftMouse>` click. | |
| 818 | + | self.dragging = pressed; | |
| 819 | + | if pressed { | |
| 820 | + | self.press = Some(self.cursor); | |
| 821 | + | return; | |
| 822 | + | } | |
| 823 | + | let Some(p) = self.press.take() else { return }; | |
| 824 | + | let moved = (self.cursor[0] - p[0]).hypot(self.cursor[1] - p[1]); | |
| 825 | + | if moved > CLICK_SLOP * self.state.scale_factor() { | |
| 826 | + | return; | |
| 827 | + | } | |
| 828 | + | } else if !pressed { | |
| 829 | + | return; | |
| 830 | + | } | |
| 831 | + | if let Some(key) = input::button(button, self.modifiers) { | |
| 832 | + | self.on_mouse_key(key, event_loop); | |
| 833 | + | } | |
| 530 | 834 | } | |
| 531 | 835 | WindowEvent::MouseWheel { delta, .. } => { | |
| 532 | − | let steps = match delta { | |
| 533 | − | MouseScrollDelta::LineDelta(_, y) => y, | |
| 534 | − | MouseScrollDelta::PixelDelta(p) => p.y as f32 / PIXELS_PER_STEP, | |
| 836 | + | let d = match delta { | |
| 837 | + | MouseScrollDelta::LineDelta(x, y) => [x, y], | |
| 838 | + | MouseScrollDelta::PixelDelta(p) => { | |
| 839 | + | [p.x as f32 / PIXELS_PER_STEP, p.y as f32 / PIXELS_PER_STEP] | |
| 840 | + | } | |
| 535 | 841 | }; | |
| 536 | − | if steps != 0.0 { | |
| 537 | − | self.state.zoom_at(steps, self.cursor, now); | |
| 538 | − | self.request_redraw(); | |
| 539 | − | } | |
| 842 | + | self.on_wheel(d, event_loop); | |
| 540 | 843 | } | |
| 541 | 844 | _ => {} | |
| 542 | 845 | } | |
| @@ -551,7 +854,20 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 551 | 854 | fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { | |
| 552 | 855 | let now = Instant::now(); | |
| 553 | 856 | let busy = self.pump_uploads(); | |
| 857 | + | if let Some(t) = self.ambiguous_since | |
| 858 | + | && now >= t + SEQUENCE_TIMEOUT | |
| 859 | + | { | |
| 860 | + | let runs = self.keymap.timeout(self.state.mode); | |
| 861 | + | self.update_pending(now); | |
| 862 | + | for run in runs { | |
| 863 | + | self.run(run.counted(), None, event_loop); | |
| 864 | + | } | |
| 865 | + | } | |
| 554 | 866 | let (redraw, mut next) = self.state.tick(now); | |
| 867 | + | if let Some(t) = self.ambiguous_since { | |
| 868 | + | let at = t + SEQUENCE_TIMEOUT; | |
| 869 | + | next = Some(next.map_or(at, |n| n.min(at))); | |
| 870 | + | } | |
| 555 | 871 | if redraw { | |
| 556 | 872 | self.request_redraw(); | |
| 557 | 873 | } | |
| @@ -566,3 +882,23 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 566 | 882 | }); | |
| 567 | 883 | } | |
| 568 | 884 | } | |
| 885 | + | ||
| 886 | + | #[cfg(test)] | |
| 887 | + | mod tests { | |
| 888 | + | use super::*; | |
| 889 | + | ||
| 890 | + | #[test] | |
| 891 | + | fn config_errors_are_shortened_for_the_bar() { | |
| 892 | + | assert_eq!( | |
| 893 | + | short_error( | |
| 894 | + | "/home/u/.config/remage/config.toml:2:1: unknown field `fitt`, expected one of \ | |
| 895 | + | `fit`, `zoom_step`; did you mean `fit`?" | |
| 896 | + | ), | |
| 897 | + | "config.toml:2:1: unknown field `fitt`; did you mean `fit`?" | |
| 898 | + | ); | |
| 899 | + | assert_eq!( | |
| 900 | + | short_error("a.toml:1:1: unknown field `x`, expected `y`"), | |
| 901 | + | "a.toml:1:1: unknown field `x`, expected `y`" | |
| 902 | + | ); | |
| 903 | + | } | |
| 904 | + | } | |
| @@ -28,6 +28,11 @@ impl ImageCache { | |||
| 28 | 28 | } | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | + | /// Takes effect at the next insert. | |
| 32 | + | pub fn set_budget(&mut self, budget_bytes: usize) { | |
| 33 | + | self.budget = budget_bytes; | |
| 34 | + | } | |
| 35 | + | ||
| 31 | 36 | pub fn get(&mut self, path: &Path) -> Option<Arc<DecodedImage>> { | |
| 32 | 37 | self.clock += 1; | |
| 33 | 38 | let slot = self.map.get_mut(path)?; | |
| @@ -41,13 +41,13 @@ pub struct Cli { | |||
| 41 | 41 | #[arg(short = 'g', long, value_name = "WxH", value_parser = parse_geometry)] | |
| 42 | 42 | pub geometry: Option<(u32, u32)>, | |
| 43 | 43 | ||
| 44 | − | /// Wayland app_id / X11 class | |
| 45 | − | #[arg(long, value_name = "NAME", default_value = "remage")] | |
| 46 | − | pub class: String, | |
| 44 | + | /// Wayland app_id / X11 class [default: remage] | |
| 45 | + | #[arg(long, value_name = "NAME")] | |
| 46 | + | pub class: Option<String>, | |
| 47 | 47 | ||
| 48 | − | /// Sort order for directory contents | |
| 49 | − | #[arg(long, value_enum, value_name = "KEY", default_value = "natural")] | |
| 50 | − | pub sort: SortKey, | |
| 48 | + | /// Sort order for directory contents [default: natural] | |
| 49 | + | #[arg(long, value_enum, value_name = "KEY")] | |
| 50 | + | pub sort: Option<SortKey>, | |
| 51 | 51 | ||
| 52 | 52 | /// Reverse the sort order | |
| 53 | 53 | #[arg(long)] | |
| @@ -56,6 +56,18 @@ pub struct Cli { | |||
| 56 | 56 | /// Open only the named file, not the rest of its directory | |
| 57 | 57 | #[arg(long)] | |
| 58 | 58 | pub no_siblings: bool, | |
| 59 | + | ||
| 60 | + | /// Config file [default: $XDG_CONFIG_HOME/remage/config.toml] | |
| 61 | + | #[arg(short = 'c', long, value_name = "PATH")] | |
| 62 | + | pub config: Option<PathBuf>, | |
| 63 | + | ||
| 64 | + | /// Print the full commented default config and exit | |
| 65 | + | #[arg(long)] | |
| 66 | + | pub dump_config: bool, | |
| 67 | + | ||
| 68 | + | /// Validate the config and exit non-zero on errors | |
| 69 | + | #[arg(long)] | |
| 70 | + | pub check_config: bool, | |
| 59 | 71 | } | |
| 60 | 72 | ||
| 61 | 73 | fn parse_geometry(s: &str) -> Result<(u32, u32), String> { | |
| @@ -0,0 +1,175 @@ | |||
| 1 | + | # remage configuration — the complete default. | |
| 2 | + | # | |
| 3 | + | # Copy only what you want to change into ~/.config/remage/config.toml: your file is merged | |
| 4 | + | # over these defaults, so a partial file is fine. `remage --check-config` validates it. | |
| 5 | + | # Changes apply live, and SIGUSR1 or the `reload-config` action reload by hand. | |
| 6 | + | ||
| 7 | + | # Files merged before this one, in order; this file wins. `~` and `$VAR` expand, relative | |
| 8 | + | # paths resolve against this file's directory, and a missing include is only a warning. | |
| 9 | + | # include = ["~/.cache/matugen/remage.toml"] | |
| 10 | + | include = [] | |
| 11 | + | ||
| 12 | + | [general] | |
| 13 | + | open_siblings = true # a single file argument opens its whole directory | |
| 14 | + | recursive = false # descend into directory arguments | |
| 15 | + | sort = "natural" # natural | name | mtime | size | random | |
| 16 | + | reverse = false | |
| 17 | + | start_mode = "image" # image | grid | |
| 18 | + | wrap = true # next on the last image goes to the first | |
| 19 | + | watch_dir = true # add and drop files that appear or vanish on disk | |
| 20 | + | ||
| 21 | + | [window] | |
| 22 | + | decorations = false | |
| 23 | + | title = "{name} — remage" # {name} {index} {count} | |
| 24 | + | class = "remage" # Wayland app_id / X11 class | |
| 25 | + | fullscreen = false | |
| 26 | + | ||
| 27 | + | [image] | |
| 28 | + | fit = "down" # down (fit, never enlarge) | fit | fill | width | height | original | |
| 29 | + | zoom_step = 1.25 | |
| 30 | + | filter = "auto" # auto (nearest above 300%) | linear | nearest | |
| 31 | + | alpha_background = "dots" # dots | checker | solid | |
| 32 | + | auto_orient = true # apply EXIF orientation | |
| 33 | + | color_management = true # convert ICC / CICP colour to sRGB | |
| 34 | + | preload = 2 # images decoded and uploaded on each side | |
| 35 | + | cache_mb = 512 # decoded-image cache budget | |
| 36 | + | slideshow_delay = 5.0 # seconds | |
| 37 | + | ||
| 38 | + | [grid] | |
| 39 | + | sizes = [96, 128, 160, 224, 320] | |
| 40 | + | size = 160 | |
| 41 | + | gap = 16 | |
| 42 | + | thumbnail_cache = true # read and write the shared freedesktop thumbnail cache | |
| 43 | + | names = false # filenames under thumbnails | |
| 44 | + | ||
| 45 | + | [ui] | |
| 46 | + | statusbar = true | |
| 47 | + | which_key_delay_ms = 300 | |
| 48 | + | scale = 1.0 # UI size multiplier on top of the display scale | |
| 49 | + | statusbar_left = "{name}" | |
| 50 | + | statusbar_right = "{format} {width}×{height} {zoom}% {index}/{count}" | |
| 51 | + | info_command = "" # script whose output fills a status segment | |
| 52 | + | font_ui = "Space Grotesk" | |
| 53 | + | font_mono = "Space Mono" | |
| 54 | + | font_display = "Doto" | |
| 55 | + | ||
| 56 | + | [animation] | |
| 57 | + | enabled = true | |
| 58 | + | duration_ms = 180 | |
| 59 | + | transition = "fade" # fade | none | |
| 60 | + | ||
| 61 | + | [theme] | |
| 62 | + | preset = "nothing-dark" # nothing-dark | nothing-light | |
| 63 | + | # Read colours from pywal: special.background / special.foreground / colors.color1. | |
| 64 | + | # pywal = "~/.cache/wal/colors.json" | |
| 65 | + | # | |
| 66 | + | # background, foreground and accent are enough; every other token is derived from them. | |
| 67 | + | # Any token can be set explicitly as "#RRGGBB" or "#RRGGBBAA": | |
| 68 | + | # background = "#000000" | |
| 69 | + | # foreground = "#FFFFFF" | |
| 70 | + | # accent = "#D71921" | |
| 71 | + | # surface surface_raised border border_visible text_disabled text_secondary | |
| 72 | + | # text_primary text_display success warning image_background | |
| 73 | + | ||
| 74 | + | # Key bindings. Values are an action, an array of actions run in order, or "none" to remove | |
| 75 | + | # a default. Mode tables override [keys.global]; visual mode also inherits [keys.grid]. | |
| 76 | + | # Notation is vim's: n G <C-x> <S-Tab> <Space>w <CR> <BS> <Esc> <F5> <lt> <Bar>, plus | |
| 77 | + | # <LeftMouse> <RightMouse> <MiddleMouse> <ScrollUp> <ScrollDown> with modifiers (<C-ScrollUp>). | |
| 78 | + | # A count typed first applies to the action: 5n, 12G, 3+. | |
| 79 | + | ||
| 80 | + | [keys.global] | |
| 81 | + | "q" = "quit" | |
| 82 | + | "<CR>" = "mode toggle" | |
| 83 | + | "n" = "next" | |
| 84 | + | "p" = "prev" | |
| 85 | + | "<BS>" = "prev" | |
| 86 | + | "]" = "next 10" | |
| 87 | + | "[" = "prev 10" | |
| 88 | + | "gg" = "first" | |
| 89 | + | "G" = "last" | |
| 90 | + | "<Home>" = "first" | |
| 91 | + | "<End>" = "last" | |
| 92 | + | "m" = "mark" | |
| 93 | + | "M" = "invert-marks" | |
| 94 | + | "u" = "unmark-all" | |
| 95 | + | "N" = "next-mark" | |
| 96 | + | "P" = "prev-mark" | |
| 97 | + | "f" = "fullscreen" | |
| 98 | + | "b" = "bar" | |
| 99 | + | "i" = "info" | |
| 100 | + | "r" = "reload" | |
| 101 | + | "D" = "remove" | |
| 102 | + | "s" = "slideshow" | |
| 103 | + | ":" = "palette" | |
| 104 | + | "/" = "search" | |
| 105 | + | "?" = "help" | |
| 106 | + | "<Esc>" = "cancel" | |
| 107 | + | ||
| 108 | + | [keys.image] | |
| 109 | + | "h" = "pan left" | |
| 110 | + | "j" = "pan down" | |
| 111 | + | "k" = "pan up" | |
| 112 | + | "l" = "pan right" | |
| 113 | + | "<Left>" = "pan left" | |
| 114 | + | "<Down>" = "pan down" | |
| 115 | + | "<Up>" = "pan up" | |
| 116 | + | "<Right>" = "pan right" | |
| 117 | + | "H" = "pan-edge left" | |
| 118 | + | "J" = "pan-edge down" | |
| 119 | + | "K" = "pan-edge up" | |
| 120 | + | "L" = "pan-edge right" | |
| 121 | + | "z" = "center" | |
| 122 | + | "+" = "zoom in" | |
| 123 | + | "-" = "zoom out" | |
| 124 | + | "=" = "zoom 100%" | |
| 125 | + | "w" = "fit fit" | |
| 126 | + | "W" = "fit fill" | |
| 127 | + | "e" = "fit width" | |
| 128 | + | "E" = "fit height" | |
| 129 | + | "<lt>" = "rotate ccw" | |
| 130 | + | ">" = "rotate cw" | |
| 131 | + | "|" = "flip h" | |
| 132 | + | "_" = "flip v" | |
| 133 | + | "a" = "filter toggle" | |
| 134 | + | "A" = "alpha-bg cycle" | |
| 135 | + | "{" = "gamma -" | |
| 136 | + | "}" = "gamma +" | |
| 137 | + | "<C-Space>" = "play" | |
| 138 | + | "<C-n>" = "next-frame" | |
| 139 | + | "<C-p>" = "prev-frame" | |
| 140 | + | "<ScrollUp>" = "zoom in" | |
| 141 | + | "<ScrollDown>" = "zoom out" | |
| 142 | + | ||
| 143 | + | [keys.grid] | |
| 144 | + | "+" = "grid-size +" | |
| 145 | + | "-" = "grid-size -" | |
| 146 | + | "v" = "visual" | |
| 147 | + | ||
| 148 | + | [keys.visual] | |
| 149 | + | "m" = "mark on" | |
| 150 | + | "<Esc>" = "mode grid" | |
| 151 | + | ||
| 152 | + | # User commands: bind them as "run <name>"; unbound ones still show in the : palette. | |
| 153 | + | # | |
| 154 | + | # [commands.wallpaper] | |
| 155 | + | # desc = "Set as wallpaper" | |
| 156 | + | # run = ["setwall", "-p", "{file}"] # array: exec directly, {files} expands to N args | |
| 157 | + | # | |
| 158 | + | # [commands.copy] | |
| 159 | + | # desc = "Copy image" | |
| 160 | + | # run = "wl-copy --type image/png < {file}" # string: sh -c, placeholders shell-quoted | |
| 161 | + | # | |
| 162 | + | # [commands.delete] | |
| 163 | + | # desc = "Delete files" | |
| 164 | + | # run = ["rm", "--", "{files}"] | |
| 165 | + | # confirm = "Delete {count} file(s)?" | |
| 166 | + | # | |
| 167 | + | # Fields: targets = auto | current | marked | all, stdin = none | files | files0, | |
| 168 | + | # output = status | actions | ignore, after = actions run on success (default "refresh"). | |
| 169 | + | # Placeholders: {file} {files} {dir} {name} {stem} {ext} {index} {count} {width} {height}. | |
| 170 | + | [commands] | |
| 171 | + | ||
| 172 | + | # External decoders for anything else; they write PNG, PPM or TIFF to stdout. | |
| 173 | + | # [[decoders]] | |
| 174 | + | # match = ["*.psd"] | |
| 175 | + | # run = ["magick", "{file}[0]", "png:-"] | |
| @@ -1,3 +1,1048 @@ | |||
| 1 | − | //! Configuration. Until the TOML schema lands in M2, defaults come from code and the CLI. | |
| 1 | + | //! Configuration: schema, compiled-in defaults, includes, validation and live reload. | |
| 2 | + | //! | |
| 3 | + | //! The compiled-in default is `default.toml` itself (`--dump-config` prints it), so every option | |
| 4 | + | //! has a default by construction. Layers merge in order — default, includes, the user file — | |
| 5 | + | //! with tables merged key by key. Each file is first deserialised on its own, which is what | |
| 6 | + | //! gives errors their `file:line:col`. | |
| 2 | 7 | ||
| 3 | 8 | pub mod theme; | |
| 9 | + | pub mod watch; | |
| 10 | + | ||
| 11 | + | use std::collections::BTreeMap; | |
| 12 | + | use std::fmt; | |
| 13 | + | use std::path::{Path, PathBuf}; | |
| 14 | + | ||
| 15 | + | use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor}; | |
| 16 | + | use serde::{Deserialize, Serialize}; | |
| 17 | + | ||
| 18 | + | use crate::action::{Action, CATALOG}; | |
| 19 | + | use crate::input::keymap::{Bindings, KeyTables}; | |
| 20 | + | use crate::input::keys::KeySeq; | |
| 21 | + | use crate::state::AlphaBg; | |
| 22 | + | use crate::state::queue::SortKey; | |
| 23 | + | use crate::state::view::FitMode; | |
| 24 | + | use theme::{Theme, ThemeConfig}; | |
| 25 | + | ||
| 26 | + | pub const DEFAULT: &str = include_str!("default.toml"); | |
| 27 | + | ||
| 28 | + | /// Includes nest at most this deep. | |
| 29 | + | const MAX_INCLUDE_DEPTH: usize = 5; | |
| 30 | + | ||
| 31 | + | #[derive(Clone, Debug, Default, PartialEq, Deserialize)] | |
| 32 | + | #[serde(default, deny_unknown_fields)] | |
| 33 | + | pub struct Config { | |
| 34 | + | pub include: Vec<String>, | |
| 35 | + | pub general: General, | |
| 36 | + | pub window: WindowConfig, | |
| 37 | + | pub image: ImageConfig, | |
| 38 | + | pub grid: GridConfig, | |
| 39 | + | pub ui: UiConfig, | |
| 40 | + | pub animation: AnimationConfig, | |
| 41 | + | pub theme: ThemeConfig, | |
| 42 | + | pub keys: Keys, | |
| 43 | + | pub commands: BTreeMap<String, Command>, | |
| 44 | + | pub decoders: Vec<Decoder>, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] | |
| 48 | + | #[serde(rename_all = "lowercase")] | |
| 49 | + | pub enum StartMode { | |
| 50 | + | #[default] | |
| 51 | + | Image, | |
| 52 | + | Grid, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | #[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] | |
| 56 | + | #[serde(default, deny_unknown_fields)] | |
| 57 | + | pub struct General { | |
| 58 | + | pub open_siblings: bool, | |
| 59 | + | pub recursive: bool, | |
| 60 | + | pub sort: SortKey, | |
| 61 | + | pub reverse: bool, | |
| 62 | + | pub start_mode: StartMode, | |
| 63 | + | pub wrap: bool, | |
| 64 | + | pub watch_dir: bool, | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | #[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] | |
| 68 | + | #[serde(default, deny_unknown_fields)] | |
| 69 | + | pub struct WindowConfig { | |
| 70 | + | pub decorations: bool, | |
| 71 | + | pub title: String, | |
| 72 | + | pub class: String, | |
| 73 | + | pub fullscreen: bool, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] | |
| 77 | + | #[serde(rename_all = "lowercase")] | |
| 78 | + | pub enum FilterMode { | |
| 79 | + | #[default] | |
| 80 | + | Auto, | |
| 81 | + | Linear, | |
| 82 | + | Nearest, | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | impl FilterMode { | |
| 86 | + | /// `None` = automatic, otherwise whether to force nearest. | |
| 87 | + | pub fn nearest(self) -> Option<bool> { | |
| 88 | + | match self { | |
| 89 | + | FilterMode::Auto => None, | |
| 90 | + | FilterMode::Linear => Some(false), | |
| 91 | + | FilterMode::Nearest => Some(true), | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | #[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] | |
| 97 | + | #[serde(default, deny_unknown_fields)] | |
| 98 | + | pub struct ImageConfig { | |
| 99 | + | pub fit: FitMode, | |
| 100 | + | pub zoom_step: f32, | |
| 101 | + | pub filter: FilterMode, | |
| 102 | + | pub alpha_background: AlphaBg, | |
| 103 | + | pub auto_orient: bool, | |
| 104 | + | pub color_management: bool, | |
| 105 | + | pub preload: usize, | |
| 106 | + | pub cache_mb: usize, | |
| 107 | + | pub slideshow_delay: f32, | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | #[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] | |
| 111 | + | #[serde(default, deny_unknown_fields)] | |
| 112 | + | pub struct GridConfig { | |
| 113 | + | pub sizes: Vec<u32>, | |
| 114 | + | pub size: u32, | |
| 115 | + | pub gap: u32, | |
| 116 | + | pub thumbnail_cache: bool, | |
| 117 | + | pub names: bool, | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | #[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] | |
| 121 | + | #[serde(default, deny_unknown_fields)] | |
| 122 | + | pub struct UiConfig { | |
| 123 | + | pub statusbar: bool, | |
| 124 | + | pub which_key_delay_ms: u64, | |
| 125 | + | pub scale: f32, | |
| 126 | + | pub statusbar_left: String, | |
| 127 | + | pub statusbar_right: String, | |
| 128 | + | pub info_command: String, | |
| 129 | + | pub font_ui: String, | |
| 130 | + | pub font_mono: String, | |
| 131 | + | pub font_display: String, | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] | |
| 135 | + | #[serde(rename_all = "lowercase")] | |
| 136 | + | pub enum Transition { | |
| 137 | + | #[default] | |
| 138 | + | Fade, | |
| 139 | + | None, | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | #[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] | |
| 143 | + | #[serde(default, deny_unknown_fields)] | |
| 144 | + | pub struct AnimationConfig { | |
| 145 | + | pub enabled: bool, | |
| 146 | + | pub duration_ms: u64, | |
| 147 | + | pub transition: Transition, | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | #[derive(Clone, Debug, Default, PartialEq, Deserialize)] | |
| 151 | + | #[serde(default, deny_unknown_fields)] | |
| 152 | + | pub struct Keys { | |
| 153 | + | pub global: BindingTable, | |
| 154 | + | pub image: BindingTable, | |
| 155 | + | pub grid: BindingTable, | |
| 156 | + | pub visual: BindingTable, | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | impl Keys { | |
| 160 | + | fn tables(&self) -> KeyTables { | |
| 161 | + | KeyTables { | |
| 162 | + | global: self.global.bindings.clone(), | |
| 163 | + | image: self.image.bindings.clone(), | |
| 164 | + | grid: self.grid.bindings.clone(), | |
| 165 | + | visual: self.visual.bindings.clone(), | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | fn first_error(&self) -> Option<&(std::ops::Range<usize>, String)> { | |
| 170 | + | [&self.global, &self.image, &self.grid, &self.visual] | |
| 171 | + | .into_iter() | |
| 172 | + | .flat_map(|t| t.errors.first()) | |
| 173 | + | .min_by_key(|(span, _)| span.start) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | /// A `[keys.<mode>]` table in file order. | |
| 178 | + | #[derive(Clone, Debug, Default, PartialEq)] | |
| 179 | + | pub struct BindingTable { | |
| 180 | + | pub bindings: Bindings, | |
| 181 | + | /// Bad key notation and duplicates, with the key's byte span. | |
| 182 | + | pub errors: Vec<(std::ops::Range<usize>, String)>, | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | impl<'de> Deserialize<'de> for BindingTable { | |
| 186 | + | fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> { | |
| 187 | + | struct V; | |
| 188 | + | impl<'de> Visitor<'de> for V { | |
| 189 | + | type Value = BindingTable; | |
| 190 | + | fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { | |
| 191 | + | f.write_str("a table of key = action") | |
| 192 | + | } | |
| 193 | + | fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<BindingTable, A::Error> { | |
| 194 | + | let mut out = BindingTable::default(); | |
| 195 | + | while let Some(k) = map.next_key::<toml::Spanned<String>>()? { | |
| 196 | + | let actions = map.next_value::<Binding>()?.0; | |
| 197 | + | // serde errors can't point at a key, so key errors are kept with their | |
| 198 | + | // span and reported by `Layer::parse`. | |
| 199 | + | let seq: KeySeq = match k.get_ref().parse() { | |
| 200 | + | Ok(seq) => seq, | |
| 201 | + | Err(e) => { | |
| 202 | + | out.errors.push((k.span(), e)); | |
| 203 | + | continue; | |
| 204 | + | } | |
| 205 | + | }; | |
| 206 | + | if let Some((prev, _)) = out.bindings.iter().find(|(s, _)| *s == seq) { | |
| 207 | + | let msg = format!( | |
| 208 | + | "`{}` is bound twice in this table (also as `{prev}`)", | |
| 209 | + | k.get_ref() | |
| 210 | + | ); | |
| 211 | + | out.errors.push((k.span(), msg)); | |
| 212 | + | continue; | |
| 213 | + | } | |
| 214 | + | out.bindings.push((seq, actions)); | |
| 215 | + | } | |
| 216 | + | Ok(out) | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | d.deserialize_map(V) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | /// One binding value: an action, an array of actions, or `"none"`. | |
| 224 | + | #[derive(Clone, Debug, PartialEq)] | |
| 225 | + | pub struct Binding(pub Option<Vec<Action>>); | |
| 226 | + | ||
| 227 | + | impl<'de> Deserialize<'de> for Binding { | |
| 228 | + | fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> { | |
| 229 | + | let list = ActionList::deserialize(d)?; | |
| 230 | + | Ok(Binding(list.0)) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// An action or an array of them; `"none"` (or `[]`) is `None`. | |
| 235 | + | #[derive(Clone, Debug, PartialEq)] | |
| 236 | + | pub struct ActionList(pub Option<Vec<Action>>); | |
| 237 | + | ||
| 238 | + | impl<'de> Deserialize<'de> for ActionList { | |
| 239 | + | fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> { | |
| 240 | + | struct V; | |
| 241 | + | fn parse<E: de::Error>(s: &str) -> Result<Action, E> { | |
| 242 | + | s.parse().map_err(|e| E::custom(e)) | |
| 243 | + | } | |
| 244 | + | impl<'de> Visitor<'de> for V { | |
| 245 | + | type Value = ActionList; | |
| 246 | + | fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { | |
| 247 | + | f.write_str("an action, an array of actions, or \"none\"") | |
| 248 | + | } | |
| 249 | + | fn visit_str<E: de::Error>(self, s: &str) -> Result<ActionList, E> { | |
| 250 | + | if s.trim() == "none" { | |
| 251 | + | return Ok(ActionList(None)); | |
| 252 | + | } | |
| 253 | + | Ok(ActionList(Some(vec![parse(s)?]))) | |
| 254 | + | } | |
| 255 | + | fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<ActionList, A::Error> { | |
| 256 | + | let mut out = Vec::new(); | |
| 257 | + | while let Some(s) = seq.next_element::<String>()? { | |
| 258 | + | out.push(parse(&s)?); | |
| 259 | + | } | |
| 260 | + | Ok(ActionList((!out.is_empty()).then_some(out))) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | d.deserialize_any(V) | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | impl Default for ActionList { | |
| 268 | + | fn default() -> Self { | |
| 269 | + | ActionList(Some(vec![Action::Refresh])) | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | /// A command line: a string runs through `sh -c`, an array is exec'd directly. | |
| 274 | + | #[derive(Clone, Debug, PartialEq, Deserialize)] | |
| 275 | + | #[serde(untagged)] | |
| 276 | + | pub enum CommandLine { | |
| 277 | + | Shell(String), | |
| 278 | + | Exec(Vec<String>), | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | impl Default for CommandLine { | |
| 282 | + | fn default() -> Self { | |
| 283 | + | CommandLine::Exec(Vec::new()) | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] | |
| 288 | + | #[serde(rename_all = "lowercase")] | |
| 289 | + | pub enum Targets { | |
| 290 | + | #[default] | |
| 291 | + | Auto, | |
| 292 | + | Current, | |
| 293 | + | Marked, | |
| 294 | + | All, | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] | |
| 298 | + | #[serde(rename_all = "lowercase")] | |
| 299 | + | pub enum Stdin { | |
| 300 | + | #[default] | |
| 301 | + | None, | |
| 302 | + | Files, | |
| 303 | + | Files0, | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] | |
| 307 | + | #[serde(rename_all = "lowercase")] | |
| 308 | + | pub enum Output { | |
| 309 | + | #[default] | |
| 310 | + | Status, | |
| 311 | + | Actions, | |
| 312 | + | Ignore, | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | /// A `[commands.<name>]` entry. Execution arrives with M4. | |
| 316 | + | #[derive(Clone, Debug, Default, PartialEq, Deserialize)] | |
| 317 | + | #[serde(default, deny_unknown_fields)] | |
| 318 | + | pub struct Command { | |
| 319 | + | pub desc: String, | |
| 320 | + | pub run: CommandLine, | |
| 321 | + | pub targets: Targets, | |
| 322 | + | pub stdin: Stdin, | |
| 323 | + | pub confirm: Option<String>, | |
| 324 | + | pub output: Output, | |
| 325 | + | pub after: ActionList, | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | /// A `[[decoders]]` entry. Used from M5. | |
| 329 | + | #[derive(Clone, Debug, Default, PartialEq, Deserialize)] | |
| 330 | + | #[serde(default, deny_unknown_fields)] | |
| 331 | + | pub struct Decoder { | |
| 332 | + | #[serde(rename = "match")] | |
| 333 | + | pub patterns: Vec<String>, | |
| 334 | + | pub run: Vec<String>, | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | /// A successfully loaded configuration. | |
| 338 | + | #[derive(Clone, Debug)] | |
| 339 | + | pub struct Loaded { | |
| 340 | + | pub config: Config, | |
| 341 | + | /// Key tables per layer, lowest priority first. | |
| 342 | + | pub keys: Vec<KeyTables>, | |
| 343 | + | pub theme: Theme, | |
| 344 | + | pub warnings: Vec<String>, | |
| 345 | + | /// Files whose changes should trigger a reload. | |
| 346 | + | pub watch: Vec<PathBuf>, | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | #[derive(Clone, Debug)] | |
| 350 | + | pub struct LoadError { | |
| 351 | + | pub message: String, | |
| 352 | + | pub watch: Vec<PathBuf>, | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | impl fmt::Display for LoadError { | |
| 356 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 357 | + | f.write_str(&self.message) | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | /// `$XDG_CONFIG_HOME/remage/config.toml`, falling back to `~/.config/remage/`. | |
| 362 | + | pub fn default_path() -> Option<PathBuf> { | |
| 363 | + | dirs::config_dir().map(|d| d.join("remage").join("config.toml")) | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | /// The closest candidate within a small edit distance, for "did you mean" hints. | |
| 367 | + | pub fn suggest<'a>(word: &str, candidates: impl IntoIterator<Item = &'a str>) -> Option<&'a str> { | |
| 368 | + | let word = word.to_lowercase(); | |
| 369 | + | candidates | |
| 370 | + | .into_iter() | |
| 371 | + | .map(|c| (levenshtein(&word, &c.to_lowercase()), c)) | |
| 372 | + | .filter(|(d, _)| *d > 0 && (*d <= 2 || *d * 3 <= word.chars().count())) | |
| 373 | + | .min_by_key(|(d, _)| *d) | |
| 374 | + | .map(|(_, c)| c) | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | fn levenshtein(a: &str, b: &str) -> usize { | |
| 378 | + | let b: Vec<char> = b.chars().collect(); | |
| 379 | + | let mut row: Vec<usize> = (0..=b.len()).collect(); | |
| 380 | + | for (i, ca) in a.chars().enumerate() { | |
| 381 | + | let mut prev = row[0]; | |
| 382 | + | row[0] = i + 1; | |
| 383 | + | for (j, cb) in b.iter().enumerate() { | |
| 384 | + | let cur = row[j + 1]; | |
| 385 | + | row[j + 1] = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(cur + 1); | |
| 386 | + | prev = cur; | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | row[b.len()] | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | /// Expand `~` and `$VAR` / `${VAR}`; relative paths resolve against `base`. | |
| 393 | + | pub fn expand_path(s: &str, base: &Path) -> PathBuf { | |
| 394 | + | let mut out = String::new(); | |
| 395 | + | let mut rest = s; | |
| 396 | + | if let Some(r) = rest.strip_prefix('~') | |
| 397 | + | && (r.is_empty() || r.starts_with('/')) | |
| 398 | + | && let Some(home) = dirs::home_dir() | |
| 399 | + | { | |
| 400 | + | out.push_str(&home.to_string_lossy()); | |
| 401 | + | rest = r; | |
| 402 | + | } | |
| 403 | + | while let Some(i) = rest.find('$') { | |
| 404 | + | out.push_str(&rest[..i]); | |
| 405 | + | let after = &rest[i + 1..]; | |
| 406 | + | let (name, used) = if let Some(b) = after.strip_prefix('{') { | |
| 407 | + | match b.find('}') { | |
| 408 | + | Some(end) => (&b[..end], end + 2), | |
| 409 | + | None => ("", 0), | |
| 410 | + | } | |
| 411 | + | } else { | |
| 412 | + | let end = after | |
| 413 | + | .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) | |
| 414 | + | .unwrap_or(after.len()); | |
| 415 | + | (&after[..end], end) | |
| 416 | + | }; | |
| 417 | + | if name.is_empty() { | |
| 418 | + | out.push('$'); | |
| 419 | + | rest = after; | |
| 420 | + | } else { | |
| 421 | + | out.push_str(&std::env::var(name).unwrap_or_default()); | |
| 422 | + | rest = &after[used..]; | |
| 423 | + | } | |
| 424 | + | } | |
| 425 | + | out.push_str(rest); | |
| 426 | + | let p = PathBuf::from(out); | |
| 427 | + | if p.is_relative() { base.join(p) } else { p } | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | fn line_col(src: &str, offset: usize) -> (usize, usize) { | |
| 431 | + | let offset = offset.min(src.len()); | |
| 432 | + | let before = &src[..offset]; | |
| 433 | + | let line = before.matches('\n').count() + 1; | |
| 434 | + | let col = before | |
| 435 | + | .rfind('\n') | |
| 436 | + | .map_or(before, |i| &before[i + 1..]) | |
| 437 | + | .chars() | |
| 438 | + | .count() | |
| 439 | + | + 1; | |
| 440 | + | (line, col) | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | /// Add a "did you mean" to serde's unknown field / variant messages. | |
| 444 | + | fn with_hint(msg: &str) -> String { | |
| 445 | + | let Some(rest) = msg | |
| 446 | + | .strip_prefix("unknown field `") | |
| 447 | + | .or_else(|| msg.strip_prefix("unknown variant `")) | |
| 448 | + | else { | |
| 449 | + | return msg.to_string(); | |
| 450 | + | }; | |
| 451 | + | let Some((word, expected)) = rest.split_once('`') else { | |
| 452 | + | return msg.to_string(); | |
| 453 | + | }; | |
| 454 | + | let options: Vec<&str> = expected.split('`').skip(1).step_by(2).collect(); | |
| 455 | + | match suggest(word, options) { | |
| 456 | + | Some(s) => format!("{msg}; did you mean `{s}`?"), | |
| 457 | + | None => msg.to_string(), | |
| 458 | + | } | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | fn located(file: &Path, src: &str, span: Option<std::ops::Range<usize>>, msg: &str) -> String { | |
| 462 | + | let msg = with_hint(msg.trim_end()); | |
| 463 | + | match span { | |
| 464 | + | Some(s) => { | |
| 465 | + | let (l, c) = line_col(src, s.start); | |
| 466 | + | format!("{}:{l}:{c}: {msg}", file.display()) | |
| 467 | + | } | |
| 468 | + | None => format!("{}: {msg}", file.display()), | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | /// One parsed config file. | |
| 473 | + | struct Layer { | |
| 474 | + | file: PathBuf, | |
| 475 | + | src: String, | |
| 476 | + | table: toml::Table, | |
| 477 | + | keys: KeyTables, | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | impl Layer { | |
| 481 | + | fn parse(file: PathBuf, src: String) -> Result<(Layer, Config), String> { | |
| 482 | + | let typed: Config = | |
| 483 | + | toml::from_str(&src).map_err(|e| located(&file, &src, e.span(), e.message()))?; | |
| 484 | + | if let Some((span, msg)) = typed.keys.first_error() { | |
| 485 | + | return Err(located(&file, &src, Some(span.clone()), msg)); | |
| 486 | + | } | |
| 487 | + | let table: toml::Table = | |
| 488 | + | toml::from_str(&src).map_err(|e| located(&file, &src, e.span(), e.message()))?; | |
| 489 | + | let keys = typed.keys.tables(); | |
| 490 | + | Ok(( | |
| 491 | + | Layer { | |
| 492 | + | file, | |
| 493 | + | src, | |
| 494 | + | table, | |
| 495 | + | keys, | |
| 496 | + | }, | |
| 497 | + | typed, | |
| 498 | + | )) | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | /// `file:line:col` of a dotted path in this file, if it sets it. | |
| 502 | + | fn locate(&self, path: &[&str], msg: &str) -> Option<String> { | |
| 503 | + | let span = span_of(&self.src, path, |_| true)?; | |
| 504 | + | Some(located(&self.file, &self.src, Some(span), msg)) | |
| 505 | + | } | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | type DeTable<'i> = toml::de::DeTable<'i>; | |
| 509 | + | type DeValue<'i> = toml::de::DeValue<'i>; | |
| 510 | + | ||
| 511 | + | fn entry<'a, 'i>(t: &'a DeTable<'i>, key: &str) -> Option<&'a toml::Spanned<DeValue<'i>>> { | |
| 512 | + | t.iter().find(|(k, _)| k.get_ref() == key).map(|(_, v)| v) | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | /// The span of the value at a dotted path; with `pick`, of the first entry in the table at | |
| 516 | + | /// `path` whose value it accepts. | |
| 517 | + | fn span_of( | |
| 518 | + | src: &str, | |
| 519 | + | path: &[&str], | |
| 520 | + | pick: impl Fn(&DeValue) -> bool, | |
| 521 | + | ) -> Option<std::ops::Range<usize>> { | |
| 522 | + | let doc = DeTable::parse(src).ok()?; | |
| 523 | + | let (last, init) = path.split_last()?; | |
| 524 | + | let mut table = doc.get_ref(); | |
| 525 | + | for p in init { | |
| 526 | + | table = entry(table, p)?.get_ref().as_table()?; | |
| 527 | + | } | |
| 528 | + | let v = entry(table, last)?; | |
| 529 | + | match v.get_ref().as_table() { | |
| 530 | + | Some(t) if path.len() > 1 && path[0] == "keys" => t | |
| 531 | + | .iter() | |
| 532 | + | .find(|(_, v)| pick(v.get_ref())) | |
| 533 | + | .map(|(_, v)| v.span()), | |
| 534 | + | _ => Some(v.span()), | |
| 535 | + | } | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | struct Loader { | |
| 539 | + | layers: Vec<Layer>, | |
| 540 | + | warnings: Vec<String>, | |
| 541 | + | watch: Vec<PathBuf>, | |
| 542 | + | stack: Vec<PathBuf>, | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | impl Loader { | |
| 546 | + | fn watch(&mut self, path: &Path) { | |
| 547 | + | for p in [Some(path.to_path_buf()), std::fs::canonicalize(path).ok()] | |
| 548 | + | .into_iter() | |
| 549 | + | .flatten() | |
| 550 | + | { | |
| 551 | + | if !self.watch.contains(&p) { | |
| 552 | + | self.watch.push(p); | |
| 553 | + | } | |
| 554 | + | } | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | fn file(&mut self, path: &Path) -> Result<(), String> { | |
| 558 | + | self.watch(path); | |
| 559 | + | let src = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; | |
| 560 | + | let (layer, typed) = Layer::parse(path.to_path_buf(), src)?; | |
| 561 | + | let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); | |
| 562 | + | self.stack.push(canon); | |
| 563 | + | let base = path.parent().unwrap_or(Path::new("/")).to_path_buf(); | |
| 564 | + | for inc in &typed.include { | |
| 565 | + | let p = expand_path(inc, &base); | |
| 566 | + | let canon = std::fs::canonicalize(&p).unwrap_or_else(|_| p.clone()); | |
| 567 | + | if self.stack.contains(&canon) { | |
| 568 | + | let msg = format!("include cycle through {}", p.display()); | |
| 569 | + | return Err(layer.locate(&["include"], &msg).unwrap_or(msg)); | |
| 570 | + | } | |
| 571 | + | if self.stack.len() > MAX_INCLUDE_DEPTH { | |
| 572 | + | let msg = format!("includes nest deeper than {MAX_INCLUDE_DEPTH}"); | |
| 573 | + | return Err(layer.locate(&["include"], &msg).unwrap_or(msg)); | |
| 574 | + | } | |
| 575 | + | if p.is_file() { | |
| 576 | + | self.file(&p)?; | |
| 577 | + | } else { | |
| 578 | + | // Watched anyway: matugen or pywal may create it later. | |
| 579 | + | self.watch(&p); | |
| 580 | + | self.warnings.push(format!( | |
| 581 | + | "{}: include not found: {}", | |
| 582 | + | path.display(), | |
| 583 | + | p.display() | |
| 584 | + | )); | |
| 585 | + | } | |
| 586 | + | } | |
| 587 | + | self.stack.pop(); | |
| 588 | + | self.layers.push(layer); | |
| 589 | + | Ok(()) | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | /// The last layer that sets `path`, formatted as an error there. | |
| 593 | + | fn error_at(&self, path: &[&str], msg: &str) -> String { | |
| 594 | + | self.layers | |
| 595 | + | .iter() | |
| 596 | + | .rev() | |
| 597 | + | .find_map(|l| l.locate(path, msg)) | |
| 598 | + | .unwrap_or_else(|| format!("{}: {msg}", path.join("."))) | |
| 599 | + | } | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | /// Merge `over` into `base`, table by table. Commands replace whole, not field by field. | |
| 603 | + | fn merge(base: &mut toml::Table, over: toml::Table, depth: usize, key: &str) { | |
| 604 | + | for (k, v) in over { | |
| 605 | + | match (base.get_mut(&k), v) { | |
| 606 | + | (Some(toml::Value::Table(b)), toml::Value::Table(o)) | |
| 607 | + | if !(depth == 1 && key == "commands") => | |
| 608 | + | { | |
| 609 | + | merge(b, o, depth + 1, &k); | |
| 610 | + | } | |
| 611 | + | (_, v) => { | |
| 612 | + | base.insert(k, v); | |
| 613 | + | } | |
| 614 | + | } | |
| 615 | + | } | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | fn default_layer() -> (Layer, Config) { | |
| 619 | + | match Layer::parse(PathBuf::from("<default>"), DEFAULT.to_string()) { | |
| 620 | + | Ok(l) => l, | |
| 621 | + | // The default is tested; this only guards against a broken build. | |
| 622 | + | Err(e) => panic!("built-in default config: {e}"), | |
| 623 | + | } | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | /// The compiled-in defaults alone. | |
| 627 | + | pub fn defaults() -> Loaded { | |
| 628 | + | let (layer, config) = default_layer(); | |
| 629 | + | Loaded { | |
| 630 | + | theme: theme::resolve(&config.theme, None), | |
| 631 | + | keys: vec![layer.keys], | |
| 632 | + | config, | |
| 633 | + | warnings: Vec::new(), | |
| 634 | + | watch: Vec::new(), | |
| 635 | + | } | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | /// Load the config at `path` (or the default location) over the defaults. An explicit path | |
| 639 | + | /// must exist; a missing default file just means defaults. | |
| 640 | + | pub fn load(path: Option<&Path>) -> Result<Loaded, LoadError> { | |
| 641 | + | let explicit = path.is_some(); | |
| 642 | + | let path = path.map(Path::to_path_buf).or_else(default_path); | |
| 643 | + | let mut loader = Loader { | |
| 644 | + | layers: vec![default_layer().0], | |
| 645 | + | warnings: Vec::new(), | |
| 646 | + | watch: Vec::new(), | |
| 647 | + | stack: Vec::new(), | |
| 648 | + | }; | |
| 649 | + | let result = (|| { | |
| 650 | + | if let Some(path) = &path { | |
| 651 | + | if path.is_file() || explicit { | |
| 652 | + | loader.file(path)?; | |
| 653 | + | } else { | |
| 654 | + | loader.watch(path); | |
| 655 | + | } | |
| 656 | + | } | |
| 657 | + | finish(&mut loader) | |
| 658 | + | })(); | |
| 659 | + | result.map_err(|message| LoadError { | |
| 660 | + | message, | |
| 661 | + | watch: loader.watch.clone(), | |
| 662 | + | }) | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | fn finish(loader: &mut Loader) -> Result<Loaded, String> { | |
| 666 | + | let mut table = toml::Table::new(); | |
| 667 | + | for layer in &loader.layers { | |
| 668 | + | let mut t = layer.table.clone(); | |
| 669 | + | t.remove("keys"); | |
| 670 | + | t.remove("include"); | |
| 671 | + | merge(&mut table, t, 0, ""); | |
| 672 | + | } | |
| 673 | + | let config: Config = toml::Value::Table(table) | |
| 674 | + | .try_into() | |
| 675 | + | .map_err(|e: toml::de::Error| format!("merged config: {}", e.message()))?; | |
| 676 | + | validate(&config, loader)?; | |
| 677 | + | ||
| 678 | + | let pywal = match &config.theme.pywal { | |
| 679 | + | Some(p) => { | |
| 680 | + | let base = loader.layers.last().and_then(|l| l.file.parent()); | |
| 681 | + | let path = expand_path(p, base.unwrap_or(Path::new("/"))); | |
| 682 | + | loader.watch(&path); | |
| 683 | + | match theme::read_pywal(&path) { | |
| 684 | + | Ok(colors) => Some(colors), | |
| 685 | + | Err(e) => { | |
| 686 | + | loader.warnings.push(format!("pywal: {e}")); | |
| 687 | + | None | |
| 688 | + | } | |
| 689 | + | } | |
| 690 | + | } | |
| 691 | + | None => None, | |
| 692 | + | }; | |
| 693 | + | Ok(Loaded { | |
| 694 | + | theme: theme::resolve(&config.theme, pywal), | |
| 695 | + | keys: loader.layers.iter().map(|l| l.keys.clone()).collect(), | |
| 696 | + | config, | |
| 697 | + | warnings: loader.warnings.clone(), | |
| 698 | + | watch: loader.watch.clone(), | |
| 699 | + | }) | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | /// Range checks and cross-references that the types alone can't express. | |
| 703 | + | fn validate(c: &Config, loader: &Loader) -> Result<(), String> { | |
| 704 | + | let check = |ok: bool, path: &[&str], msg: &str| -> Result<(), String> { | |
| 705 | + | if ok { | |
| 706 | + | Ok(()) | |
| 707 | + | } else { | |
| 708 | + | Err(loader.error_at(path, msg)) | |
| 709 | + | } | |
| 710 | + | }; | |
| 711 | + | let i = &c.image; | |
| 712 | + | check( | |
| 713 | + | i.zoom_step > 1.0 && i.zoom_step <= 10.0, | |
| 714 | + | &["image", "zoom_step"], | |
| 715 | + | "zoom_step must be above 1.0 and at most 10", | |
| 716 | + | )?; | |
| 717 | + | check( | |
| 718 | + | i.preload <= 16, | |
| 719 | + | &["image", "preload"], | |
| 720 | + | "preload must be at most 16", | |
| 721 | + | )?; | |
| 722 | + | check( | |
| 723 | + | i.cache_mb >= 16, | |
| 724 | + | &["image", "cache_mb"], | |
| 725 | + | "cache_mb must be at least 16", | |
| 726 | + | )?; | |
| 727 | + | check( | |
| 728 | + | i.slideshow_delay.is_finite() && i.slideshow_delay > 0.0, | |
| 729 | + | &["image", "slideshow_delay"], | |
| 730 | + | "slideshow_delay must be positive", | |
| 731 | + | )?; | |
| 732 | + | let g = &c.grid; | |
| 733 | + | check( | |
| 734 | + | !g.sizes.is_empty() | |
| 735 | + | && g.sizes.windows(2).all(|w| w[0] < w[1]) | |
| 736 | + | && g.sizes.iter().all(|s| (16..=1024).contains(s)), | |
| 737 | + | &["grid", "sizes"], | |
| 738 | + | "sizes must be ascending, each between 16 and 1024", | |
| 739 | + | )?; | |
| 740 | + | check( | |
| 741 | + | (16..=1024).contains(&g.size), | |
| 742 | + | &["grid", "size"], | |
| 743 | + | "size must be between 16 and 1024", | |
| 744 | + | )?; | |
| 745 | + | check(g.gap <= 256, &["grid", "gap"], "gap must be at most 256")?; | |
| 746 | + | check( | |
| 747 | + | c.ui.scale.is_finite() && (0.25..=4.0).contains(&c.ui.scale), | |
| 748 | + | &["ui", "scale"], | |
| 749 | + | "scale must be between 0.25 and 4", | |
| 750 | + | )?; | |
| 751 | + | check( | |
| 752 | + | c.animation.duration_ms <= 2000, | |
| 753 | + | &["animation", "duration_ms"], | |
| 754 | + | "duration_ms must be at most 2000", | |
| 755 | + | )?; | |
| 756 | + | check( | |
| 757 | + | !c.window.class.is_empty(), | |
| 758 | + | &["window", "class"], | |
| 759 | + | "class must not be empty", | |
| 760 | + | )?; | |
| 761 | + | for (name, cmd) in &c.commands { | |
| 762 | + | let empty = match &cmd.run { | |
| 763 | + | CommandLine::Shell(s) => s.trim().is_empty(), | |
| 764 | + | CommandLine::Exec(v) => v.is_empty(), | |
| 765 | + | }; | |
| 766 | + | check( | |
| 767 | + | !empty, | |
| 768 | + | &["commands", name], | |
| 769 | + | &format!("command `{name}` needs a `run`"), | |
| 770 | + | )?; | |
| 771 | + | } | |
| 772 | + | // `run <name>` must name a command. | |
| 773 | + | for layer in &loader.layers { | |
| 774 | + | for (mode, table) in [ | |
| 775 | + | ("global", &layer.keys.global), | |
| 776 | + | ("image", &layer.keys.image), | |
| 777 | + | ("grid", &layer.keys.grid), | |
| 778 | + | ("visual", &layer.keys.visual), | |
| 779 | + | ] { | |
| 780 | + | for (_, actions) in table { | |
| 781 | + | for a in actions.iter().flatten() { | |
| 782 | + | if let Action::Run(name) = a | |
| 783 | + | && !c.commands.contains_key(name) | |
| 784 | + | { | |
| 785 | + | let hint = suggest(name, c.commands.keys().map(String::as_str)) | |
| 786 | + | .map(|s| format!(", did you mean `{s}`?")) | |
| 787 | + | .unwrap_or_default(); | |
| 788 | + | let msg = format!("no command named `{name}`{hint}"); | |
| 789 | + | let loc = span_of(&layer.src, &["keys", mode], |v| mentions(v, name)) | |
| 790 | + | .map(|span| located(&layer.file, &layer.src, Some(span), &msg)); | |
| 791 | + | return Err(loc.unwrap_or(msg)); | |
| 792 | + | } | |
| 793 | + | } | |
| 794 | + | } | |
| 795 | + | } | |
| 796 | + | } | |
| 797 | + | Ok(()) | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | /// Whether a binding value contains `run <name>`. | |
| 801 | + | fn mentions(v: &DeValue, name: &str) -> bool { | |
| 802 | + | let is_run = |s: &str| matches!(s.parse(), Ok(Action::Run(n)) if n == name); | |
| 803 | + | match v { | |
| 804 | + | DeValue::String(s) => is_run(s), | |
| 805 | + | DeValue::Array(a) => a.iter().any(|e| e.get_ref().as_str().is_some_and(is_run)), | |
| 806 | + | _ => false, | |
| 807 | + | } | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | /// The full commented default, plus a reference of every action. | |
| 811 | + | pub fn dump() -> String { | |
| 812 | + | let mut out = DEFAULT.to_string(); | |
| 813 | + | out.push_str("\n# Actions, for key bindings, the : palette, IPC and command output:\n#\n"); | |
| 814 | + | for e in CATALOG { | |
| 815 | + | let usage = if e.args.is_empty() { | |
| 816 | + | e.name.to_string() | |
| 817 | + | } else { | |
| 818 | + | format!("{} {}", e.name, e.args) | |
| 819 | + | }; | |
| 820 | + | out.push_str(&format!("# {usage:<46} {}\n", e.desc)); | |
| 821 | + | } | |
| 822 | + | out | |
| 823 | + | } | |
| 824 | + | ||
| 825 | + | #[cfg(test)] | |
| 826 | + | mod tests { | |
| 827 | + | use super::*; | |
| 828 | + | use crate::input::keymap::Keymap; | |
| 829 | + | use crate::state::Mode; | |
| 830 | + | ||
| 831 | + | fn tmp(name: &str) -> PathBuf { | |
| 832 | + | let dir = std::env::temp_dir().join(format!("remage-test-{}-{name}", std::process::id())); | |
| 833 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 834 | + | std::fs::create_dir_all(&dir).unwrap(); | |
| 835 | + | dir | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | fn write(dir: &Path, name: &str, src: &str) -> PathBuf { | |
| 839 | + | let p = dir.join(name); | |
| 840 | + | std::fs::write(&p, src).unwrap(); | |
| 841 | + | p | |
| 842 | + | } | |
| 843 | + | ||
| 844 | + | fn err(src: &str) -> String { | |
| 845 | + | static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); | |
| 846 | + | let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); | |
| 847 | + | let dir = tmp(&format!("err{n}")); | |
| 848 | + | let p = write(&dir, "config.toml", src); | |
| 849 | + | load(Some(&p)) | |
| 850 | + | .unwrap_err() | |
| 851 | + | .message | |
| 852 | + | .replace(&dir.display().to_string(), "") | |
| 853 | + | } | |
| 854 | + | ||
| 855 | + | #[test] | |
| 856 | + | fn default_parses_and_is_complete() { | |
| 857 | + | let d = defaults(); | |
| 858 | + | assert_eq!(d.config.image.fit, FitMode::Down); | |
| 859 | + | assert_eq!(d.config.grid.sizes, [96, 128, 160, 224, 320]); | |
| 860 | + | assert!(d.config.commands.is_empty()); | |
| 861 | + | // Every field of the plain sections is spelled out in default.toml, so none of the | |
| 862 | + | // placeholder serde defaults can leak through. | |
| 863 | + | let doc: toml::Table = toml::from_str(DEFAULT).unwrap(); | |
| 864 | + | let sections: [(&str, toml::Value); 6] = [ | |
| 865 | + | ("general", toml::Value::try_from(&d.config.general).unwrap()), | |
| 866 | + | ("window", toml::Value::try_from(&d.config.window).unwrap()), | |
| 867 | + | ("image", toml::Value::try_from(&d.config.image).unwrap()), | |
| 868 | + | ("grid", toml::Value::try_from(&d.config.grid).unwrap()), | |
| 869 | + | ("ui", toml::Value::try_from(&d.config.ui).unwrap()), | |
| 870 | + | ( | |
| 871 | + | "animation", | |
| 872 | + | toml::Value::try_from(&d.config.animation).unwrap(), | |
| 873 | + | ), | |
| 874 | + | ]; | |
| 875 | + | for (name, v) in sections { | |
| 876 | + | let written = doc[name].as_table().unwrap(); | |
| 877 | + | for key in v.as_table().unwrap().keys() { | |
| 878 | + | assert!(written.contains_key(key), "default.toml lacks {name}.{key}"); | |
| 879 | + | } | |
| 880 | + | } | |
| 881 | + | assert!(doc["theme"].as_table().unwrap().contains_key("preset")); | |
| 882 | + | } | |
| 883 | + | ||
| 884 | + | #[test] | |
| 885 | + | fn default_validates() { | |
| 886 | + | let mut loader = Loader { | |
| 887 | + | layers: vec![default_layer().0], | |
| 888 | + | warnings: Vec::new(), | |
| 889 | + | watch: Vec::new(), | |
| 890 | + | stack: Vec::new(), | |
| 891 | + | }; | |
| 892 | + | finish(&mut loader).unwrap(); | |
| 893 | + | } | |
| 894 | + | ||
| 895 | + | #[test] | |
| 896 | + | fn partial_file_merges_over_defaults() { | |
| 897 | + | let dir = tmp("partial"); | |
| 898 | + | let p = write(&dir, "config.toml", "[image]\nfit = \"width\"\n"); | |
| 899 | + | let l = load(Some(&p)).unwrap(); | |
| 900 | + | assert_eq!(l.config.image.fit, FitMode::Width); | |
| 901 | + | assert_eq!(l.config.image.zoom_step, 1.25); | |
| 902 | + | assert_eq!(l.config.window.class, "remage"); | |
| 903 | + | assert!(l.watch.contains(&p)); | |
| 904 | + | } | |
| 905 | + | ||
| 906 | + | #[test] | |
| 907 | + | fn errors_carry_file_line_col_and_hints() { | |
| 908 | + | assert_eq!( | |
| 909 | + | err("[image]\nfitt = \"down\"\n"), | |
| 910 | + | "/config.toml:2:1: unknown field `fitt`, expected one of `fit`, `zoom_step`, \ | |
| 911 | + | `filter`, `alpha_background`, `auto_orient`, `color_management`, `preload`, \ | |
| 912 | + | `cache_mb`, `slideshow_delay`; did you mean `fit`?" | |
| 913 | + | ); | |
| 914 | + | let e = err("[image]\nfit = \"wdth\"\n"); | |
| 915 | + | assert!( | |
| 916 | + | e.starts_with("/config.toml:2:7: unknown variant `wdth`"), | |
| 917 | + | "{e}" | |
| 918 | + | ); | |
| 919 | + | assert!(e.ends_with("did you mean `width`?"), "{e}"); | |
| 920 | + | let e = err("[keys.global]\nx = \"nxt\"\n"); | |
| 921 | + | assert!( | |
| 922 | + | e.starts_with("/config.toml:2:5: unknown action `nxt`"), | |
| 923 | + | "{e}" | |
| 924 | + | ); | |
| 925 | + | let e = err("[keys.global]\n\"<C-Bogus>\" = \"next\"\n"); | |
| 926 | + | assert!(e.contains("/config.toml:2:"), "{e}"); | |
| 927 | + | assert!(e.contains("unknown key `<C-Bogus>`"), "{e}"); | |
| 928 | + | let e = err("[image]\nzoom_step = 0.5\n"); | |
| 929 | + | assert!(e.starts_with("/config.toml:2:13: zoom_step"), "{e}"); | |
| 930 | + | let e = err("[keys.image]\n\"<Space>w\" = \"run wallpaper\"\n"); | |
| 931 | + | assert!( | |
| 932 | + | e.starts_with("/config.toml:2:14: no command named `wallpaper`"), | |
| 933 | + | "{e}" | |
| 934 | + | ); | |
| 935 | + | let e = err("[theme]\naccent = \"#12345\"\n"); | |
| 936 | + | assert!(e.starts_with("/config.toml:2:10:"), "{e}"); | |
| 937 | + | let e = err("[keys.global]\n\"G\" = \"last\"\n\"<S-g>\" = \"first\"\n"); | |
| 938 | + | assert!(e.contains("bound twice"), "{e}"); | |
| 939 | + | let e = err("[image\n"); | |
| 940 | + | assert!(e.starts_with("/config.toml:1:"), "{e}"); | |
| 941 | + | } | |
| 942 | + | ||
| 943 | + | #[test] | |
| 944 | + | fn includes_load_in_order_and_the_includer_wins() { | |
| 945 | + | let dir = tmp("include"); | |
| 946 | + | write( | |
| 947 | + | &dir, | |
| 948 | + | "a.toml", | |
| 949 | + | "[theme]\nbackground = \"#101010\"\naccent = \"#00ff00\"\n[image]\nfit = \"fill\"\n", | |
| 950 | + | ); | |
| 951 | + | write(&dir, "b.toml", "[theme]\naccent = \"#0000ff\"\n"); | |
| 952 | + | let p = write( | |
| 953 | + | &dir, | |
| 954 | + | "config.toml", | |
| 955 | + | "include = [\"a.toml\", \"b.toml\", \"missing.toml\"]\n[image]\nfit = \"width\"\n\ | |
| 956 | + | [commands.wallpaper]\nrun = [\"true\"]\n\ | |
| 957 | + | [keys.global]\n\"<Space>w\" = \"run wallpaper\"\n", | |
| 958 | + | ); | |
| 959 | + | let l = load(Some(&p)).unwrap(); | |
| 960 | + | assert_eq!(l.config.image.fit, FitMode::Width); | |
| 961 | + | assert_eq!(l.theme.background, theme::Rgba::rgb(0x101010)); | |
| 962 | + | assert_eq!(l.theme.accent, theme::Rgba::rgb(0x0000ff)); | |
| 963 | + | assert_eq!(l.warnings.len(), 1); | |
| 964 | + | assert!(l.warnings[0].contains("missing.toml")); | |
| 965 | + | assert!(l.watch.contains(&dir.join("missing.toml"))); | |
| 966 | + | assert!(l.watch.contains(&dir.join("a.toml"))); | |
| 967 | + | let refs: Vec<&KeyTables> = l.keys.iter().collect(); | |
| 968 | + | let km = Keymap::new(&refs); | |
| 969 | + | let w: KeySeq = "<Space>w".parse().unwrap(); | |
| 970 | + | assert_eq!( | |
| 971 | + | km.lookup(Mode::Image, &w.0), | |
| 972 | + | Some(&[Action::Run("wallpaper".into())][..]) | |
| 973 | + | ); | |
| 974 | + | } | |
| 975 | + | ||
| 976 | + | #[test] | |
| 977 | + | fn include_cycles_are_errors() { | |
| 978 | + | let dir = tmp("cycle"); | |
| 979 | + | write(&dir, "a.toml", "include = [\"config.toml\"]\n"); | |
| 980 | + | let p = write(&dir, "config.toml", "include = [\"a.toml\"]\n"); | |
| 981 | + | assert!(load(Some(&p)).unwrap_err().message.contains("cycle")); | |
| 982 | + | } | |
| 983 | + | ||
| 984 | + | #[test] | |
| 985 | + | fn commands_replace_whole() { | |
| 986 | + | let dir = tmp("commands"); | |
| 987 | + | write( | |
| 988 | + | &dir, | |
| 989 | + | "a.toml", | |
| 990 | + | "[commands.rm]\nrun = [\"rm\", \"{files}\"]\nconfirm = \"Sure?\"\n", | |
| 991 | + | ); | |
| 992 | + | let p = write( | |
| 993 | + | &dir, | |
| 994 | + | "config.toml", | |
| 995 | + | "include = [\"a.toml\"]\n[commands.rm]\nrun = \"trash {files}\"\n", | |
| 996 | + | ); | |
| 997 | + | let l = load(Some(&p)).unwrap(); | |
| 998 | + | let rm = &l.config.commands["rm"]; | |
| 999 | + | assert_eq!(rm.confirm, None); | |
| 1000 | + | assert_eq!(rm.run, CommandLine::Shell("trash {files}".into())); | |
| 1001 | + | assert_eq!(rm.after, ActionList(Some(vec![Action::Refresh]))); | |
| 1002 | + | } | |
| 1003 | + | ||
| 1004 | + | #[test] | |
| 1005 | + | fn missing_explicit_file_is_an_error() { | |
| 1006 | + | let dir = tmp("missing"); | |
| 1007 | + | assert!(load(Some(&dir.join("nope.toml"))).is_err()); | |
| 1008 | + | } | |
| 1009 | + | ||
| 1010 | + | #[test] | |
| 1011 | + | fn path_expansion() { | |
| 1012 | + | let base = Path::new("/etc/remage"); | |
| 1013 | + | // SAFETY: tests in this module don't read REMAGE_TEST_DIR concurrently. | |
| 1014 | + | unsafe { std::env::set_var("REMAGE_TEST_DIR", "/opt/x") }; | |
| 1015 | + | assert_eq!( | |
| 1016 | + | expand_path("a.toml", base), | |
| 1017 | + | PathBuf::from("/etc/remage/a.toml") | |
| 1018 | + | ); | |
| 1019 | + | assert_eq!( | |
| 1020 | + | expand_path("$REMAGE_TEST_DIR/a", base), | |
| 1021 | + | PathBuf::from("/opt/x/a") | |
| 1022 | + | ); | |
| 1023 | + | assert_eq!( | |
| 1024 | + | expand_path("${REMAGE_TEST_DIR}b", base), | |
| 1025 | + | PathBuf::from("/opt/xb") | |
| 1026 | + | ); | |
| 1027 | + | let home = dirs::home_dir().unwrap(); | |
| 1028 | + | assert_eq!(expand_path("~/c", base), home.join("c")); | |
| 1029 | + | assert_eq!(expand_path("/abs", base), PathBuf::from("/abs")); | |
| 1030 | + | } | |
| 1031 | + | ||
| 1032 | + | #[test] | |
| 1033 | + | fn suggestions() { | |
| 1034 | + | assert_eq!(suggest("nxt", ["next", "prev"]), Some("next")); | |
| 1035 | + | assert_eq!(suggest("zzzzzz", ["next", "prev"]), None); | |
| 1036 | + | assert_eq!(suggest("next", ["next"]), None); | |
| 1037 | + | } | |
| 1038 | + | ||
| 1039 | + | #[test] | |
| 1040 | + | fn dump_lists_every_action() { | |
| 1041 | + | let d = dump(); | |
| 1042 | + | for e in CATALOG { | |
| 1043 | + | assert!(d.contains(&format!("# {}", e.name)), "{}", e.name); | |
| 1044 | + | } | |
| 1045 | + | // The dump is itself a valid config. | |
| 1046 | + | toml::from_str::<Config>(&d).unwrap(); | |
| 1047 | + | } | |
| 1048 | + | } | |
| @@ -1,4 +1,8 @@ | |||
| 1 | − | //! Colour tokens, presets and palette derivation. | |
| 1 | + | //! Colour tokens, presets, palette derivation and pywal import. | |
| 2 | + | ||
| 3 | + | use std::path::Path; | |
| 4 | + | ||
| 5 | + | use serde::{Deserialize, Deserializer, Serialize, Serializer}; | |
| 2 | 6 | ||
| 3 | 7 | /// An sRGB colour with straight alpha. | |
| 4 | 8 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| @@ -36,24 +40,131 @@ impl Rgba { | |||
| 36 | 40 | self.0[3] as f32 / 255.0, | |
| 37 | 41 | ] | |
| 38 | 42 | } | |
| 43 | + | ||
| 44 | + | /// `#RGB`, `#RRGGBB` or `#RRGGBBAA`; the `#` is optional. | |
| 45 | + | pub fn parse(s: &str) -> Result<Rgba, String> { | |
| 46 | + | let hex = s.trim().strip_prefix('#').unwrap_or(s.trim()); | |
| 47 | + | let bad = || format!("expected a colour like \"#D71921\", got \"{s}\""); | |
| 48 | + | if !hex.chars().all(|c| c.is_ascii_hexdigit()) { | |
| 49 | + | return Err(bad()); | |
| 50 | + | } | |
| 51 | + | let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|_| bad()); | |
| 52 | + | match hex.len() { | |
| 53 | + | 3 => { | |
| 54 | + | let n = |i: usize| u8::from_str_radix(&hex[i..=i], 16).map(|v| v * 17); | |
| 55 | + | Ok(Rgba([ | |
| 56 | + | n(0).map_err(|_| bad())?, | |
| 57 | + | n(1).map_err(|_| bad())?, | |
| 58 | + | n(2).map_err(|_| bad())?, | |
| 59 | + | 255, | |
| 60 | + | ])) | |
| 61 | + | } | |
| 62 | + | 6 => Ok(Rgba([byte(0)?, byte(2)?, byte(4)?, 255])), | |
| 63 | + | 8 => Ok(Rgba([byte(0)?, byte(2)?, byte(4)?, byte(6)?])), | |
| 64 | + | _ => Err(bad()), | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | impl std::fmt::Display for Rgba { | |
| 70 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 71 | + | let [r, g, b, a] = self.0; | |
| 72 | + | if a == 255 { | |
| 73 | + | write!(f, "#{r:02X}{g:02X}{b:02X}") | |
| 74 | + | } else { | |
| 75 | + | write!(f, "#{r:02X}{g:02X}{b:02X}{a:02X}") | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | impl<'de> Deserialize<'de> for Rgba { | |
| 81 | + | fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> { | |
| 82 | + | let s = String::deserialize(d)?; | |
| 83 | + | Rgba::parse(&s).map_err(serde::de::Error::custom) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | impl Serialize for Rgba { | |
| 88 | + | fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> { | |
| 89 | + | s.serialize_str(&self.to_string()) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// Generates the token struct, the optional overrides in `[theme]`, and per-token helpers. | |
| 94 | + | macro_rules! tokens { | |
| 95 | + | ($($name:ident),* $(,)?) => { | |
| 96 | + | #[derive(Clone, Debug, PartialEq)] | |
| 97 | + | pub struct Theme { | |
| 98 | + | $(pub $name: Rgba,)* | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | #[derive(Clone, Debug, Default, PartialEq, Deserialize)] | |
| 102 | + | #[serde(default, deny_unknown_fields)] | |
| 103 | + | pub struct ThemeConfig { | |
| 104 | + | pub preset: Preset, | |
| 105 | + | /// pywal's `colors.json`. | |
| 106 | + | pub pywal: Option<String>, | |
| 107 | + | $(pub $name: Option<Rgba>,)* | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | impl Theme { | |
| 111 | + | /// Every token mixed from `self` (t = 0) to `other` (t = 1). | |
| 112 | + | pub fn mix(&self, other: &Theme, t: f32) -> Theme { | |
| 113 | + | Theme { | |
| 114 | + | $($name: other.$name.mix_over(self.$name, t),)* | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | fn apply(&mut self, cfg: &ThemeConfig) { | |
| 119 | + | $(if let Some(c) = cfg.$name { | |
| 120 | + | self.$name = c; | |
| 121 | + | })* | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | }; | |
| 39 | 125 | } | |
| 40 | 126 | ||
| 41 | − | #[derive(Clone, Debug, PartialEq)] | |
| 42 | − | pub struct Theme { | |
| 43 | − | pub background: Rgba, | |
| 44 | − | pub foreground: Rgba, | |
| 45 | − | pub surface: Rgba, | |
| 46 | − | pub surface_raised: Rgba, | |
| 47 | − | pub border: Rgba, | |
| 48 | − | pub border_visible: Rgba, | |
| 49 | − | pub text_disabled: Rgba, | |
| 50 | − | pub text_secondary: Rgba, | |
| 51 | − | pub text_primary: Rgba, | |
| 52 | − | pub text_display: Rgba, | |
| 53 | − | pub accent: Rgba, | |
| 54 | − | pub success: Rgba, | |
| 55 | − | pub warning: Rgba, | |
| 56 | − | pub image_background: Rgba, | |
| 127 | + | tokens!( | |
| 128 | + | background, | |
| 129 | + | foreground, | |
| 130 | + | surface, | |
| 131 | + | surface_raised, | |
| 132 | + | border, | |
| 133 | + | border_visible, | |
| 134 | + | text_disabled, | |
| 135 | + | text_secondary, | |
| 136 | + | text_primary, | |
| 137 | + | text_display, | |
| 138 | + | accent, | |
| 139 | + | success, | |
| 140 | + | warning, | |
| 141 | + | image_background, | |
| 142 | + | ); | |
| 143 | + | ||
| 144 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] | |
| 145 | + | #[serde(rename_all = "kebab-case")] | |
| 146 | + | pub enum Preset { | |
| 147 | + | #[default] | |
| 148 | + | NothingDark, | |
| 149 | + | NothingLight, | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | impl Preset { | |
| 153 | + | /// Background, foreground, accent. | |
| 154 | + | fn colors(self) -> [Rgba; 3] { | |
| 155 | + | match self { | |
| 156 | + | Preset::NothingDark => [ | |
| 157 | + | Rgba::rgb(0x000000), | |
| 158 | + | Rgba::rgb(0xFFFFFF), | |
| 159 | + | Rgba::rgb(0xD71921), | |
| 160 | + | ], | |
| 161 | + | Preset::NothingLight => [ | |
| 162 | + | Rgba::rgb(0xF5F5F5), | |
| 163 | + | Rgba::rgb(0x000000), | |
| 164 | + | Rgba::rgb(0xD71921), | |
| 165 | + | ], | |
| 166 | + | } | |
| 167 | + | } | |
| 57 | 168 | } | |
| 58 | 169 | ||
| 59 | 170 | impl Theme { | |
| @@ -80,14 +191,41 @@ impl Theme { | |||
| 80 | 191 | } | |
| 81 | 192 | ||
| 82 | 193 | pub fn nothing_dark() -> Self { | |
| 83 | − | Self::derive( | |
| 84 | − | Rgba::rgb(0x000000), | |
| 85 | − | Rgba::rgb(0xFFFFFF), | |
| 86 | − | Rgba::rgb(0xD71921), | |
| 87 | − | ) | |
| 194 | + | let [bg, fg, accent] = Preset::NothingDark.colors(); | |
| 195 | + | Self::derive(bg, fg, accent) | |
| 88 | 196 | } | |
| 89 | 197 | } | |
| 90 | 198 | ||
| 199 | + | /// Preset, then pywal, then explicit base colours; derive the rest, then explicit tokens win. | |
| 200 | + | /// `image_background` follows the final background unless set. | |
| 201 | + | pub fn resolve(cfg: &ThemeConfig, pywal: Option<[Rgba; 3]>) -> Theme { | |
| 202 | + | let [mut bg, mut fg, mut accent] = pywal.unwrap_or_else(|| cfg.preset.colors()); | |
| 203 | + | bg = cfg.background.unwrap_or(bg); | |
| 204 | + | fg = cfg.foreground.unwrap_or(fg); | |
| 205 | + | accent = cfg.accent.unwrap_or(accent); | |
| 206 | + | let mut theme = Theme::derive(bg, fg, accent); | |
| 207 | + | theme.apply(cfg); | |
| 208 | + | theme | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | /// Background, foreground and accent from pywal's `colors.json`. | |
| 212 | + | pub fn read_pywal(path: &Path) -> Result<[Rgba; 3], String> { | |
| 213 | + | let src = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; | |
| 214 | + | let json: serde_json::Value = | |
| 215 | + | serde_json::from_str(&src).map_err(|e| format!("{}: {e}", path.display()))?; | |
| 216 | + | let get = |a: &str, b: &str| -> Result<Rgba, String> { | |
| 217 | + | let s = json[a][b] | |
| 218 | + | .as_str() | |
| 219 | + | .ok_or_else(|| format!("{}: missing {a}.{b}", path.display()))?; | |
| 220 | + | Rgba::parse(s).map_err(|e| format!("{}: {a}.{b}: {e}", path.display())) | |
| 221 | + | }; | |
| 222 | + | Ok([ | |
| 223 | + | get("special", "background")?, | |
| 224 | + | get("special", "foreground")?, | |
| 225 | + | get("colors", "color1")?, | |
| 226 | + | ]) | |
| 227 | + | } | |
| 228 | + | ||
| 91 | 229 | #[cfg(test)] | |
| 92 | 230 | mod tests { | |
| 93 | 231 | use super::*; | |
| @@ -112,4 +250,81 @@ mod tests { | |||
| 112 | 250 | let mid = Rgba::rgb(0x808080).linear()[0]; | |
| 113 | 251 | assert!((mid - 0.2158).abs() < 1e-3); | |
| 114 | 252 | } | |
| 253 | + | ||
| 254 | + | #[test] | |
| 255 | + | fn hex_parsing() { | |
| 256 | + | assert_eq!(Rgba::parse("#D71921"), Ok(Rgba::rgb(0xD71921))); | |
| 257 | + | assert_eq!(Rgba::parse("d71921"), Ok(Rgba::rgb(0xD71921))); | |
| 258 | + | assert_eq!(Rgba::parse("#fff"), Ok(Rgba::rgb(0xFFFFFF))); | |
| 259 | + | assert_eq!(Rgba::parse("#00000080"), Ok(Rgba([0, 0, 0, 0x80]))); | |
| 260 | + | assert!(Rgba::parse("#12345").is_err()); | |
| 261 | + | assert!(Rgba::parse("#gggggg").is_err()); | |
| 262 | + | assert!(Rgba::parse("#ÿÿÿ").is_err()); | |
| 263 | + | assert_eq!(Rgba::rgb(0xD71921).to_string(), "#D71921"); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | #[test] | |
| 267 | + | fn resolution_order() { | |
| 268 | + | let mut cfg = ThemeConfig::default(); | |
| 269 | + | assert_eq!(resolve(&cfg, None), Theme::nothing_dark()); | |
| 270 | + | cfg.preset = Preset::NothingLight; | |
| 271 | + | let light = resolve(&cfg, None); | |
| 272 | + | assert_eq!(light.background, Rgba::rgb(0xF5F5F5)); | |
| 273 | + | assert_eq!(light.image_background, Rgba::rgb(0xF5F5F5)); | |
| 274 | + | assert_eq!(light.text_display, Rgba::rgb(0x000000)); | |
| 275 | + | // pywal replaces the preset; explicit colours win over pywal. | |
| 276 | + | let wal = [ | |
| 277 | + | Rgba::rgb(0x102030), | |
| 278 | + | Rgba::rgb(0xF0E0D0), | |
| 279 | + | Rgba::rgb(0x00FF00), | |
| 280 | + | ]; | |
| 281 | + | cfg.accent = Some(Rgba::rgb(0x0000FF)); | |
| 282 | + | cfg.border = Some(Rgba::rgb(0x123456)); | |
| 283 | + | let t = resolve(&cfg, Some(wal)); | |
| 284 | + | assert_eq!(t.background, Rgba::rgb(0x102030)); | |
| 285 | + | assert_eq!(t.accent, Rgba::rgb(0x0000FF)); | |
| 286 | + | assert_eq!(t.border, Rgba::rgb(0x123456)); | |
| 287 | + | assert_eq!( | |
| 288 | + | t.surface, | |
| 289 | + | Rgba::rgb(0xF0E0D0).mix_over(Rgba::rgb(0x102030), 0.067) | |
| 290 | + | ); | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | #[test] | |
| 294 | + | fn mixing_themes() { | |
| 295 | + | let a = Theme::nothing_dark(); | |
| 296 | + | let b = resolve( | |
| 297 | + | &ThemeConfig { | |
| 298 | + | preset: Preset::NothingLight, | |
| 299 | + | ..Default::default() | |
| 300 | + | }, | |
| 301 | + | None, | |
| 302 | + | ); | |
| 303 | + | assert_eq!(a.mix(&b, 0.0), a); | |
| 304 | + | assert_eq!(a.mix(&b, 1.0), b); | |
| 305 | + | assert_eq!(a.mix(&b, 0.5).background, Rgba([123, 123, 123, 255])); | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | #[test] | |
| 309 | + | fn pywal_import() { | |
| 310 | + | let dir = std::env::temp_dir().join(format!("remage-wal-{}", std::process::id())); | |
| 311 | + | std::fs::create_dir_all(&dir).unwrap(); | |
| 312 | + | let p = dir.join("colors.json"); | |
| 313 | + | std::fs::write( | |
| 314 | + | &p, | |
| 315 | + | r##"{"special":{"background":"#0b0c10","foreground":"#c5c6c7","cursor":"#c5c6c7"}, | |
| 316 | + | "colors":{"color0":"#0b0c10","color1":"#66fcf1"}}"##, | |
| 317 | + | ) | |
| 318 | + | .unwrap(); | |
| 319 | + | assert_eq!( | |
| 320 | + | read_pywal(&p), | |
| 321 | + | Ok([ | |
| 322 | + | Rgba::rgb(0x0B0C10), | |
| 323 | + | Rgba::rgb(0xC5C6C7), | |
| 324 | + | Rgba::rgb(0x66FCF1) | |
| 325 | + | ]) | |
| 326 | + | ); | |
| 327 | + | std::fs::write(&p, "{}").unwrap(); | |
| 328 | + | assert!(read_pywal(&p).unwrap_err().contains("special.background")); | |
| 329 | + | } | |
| 115 | 330 | } | |
| @@ -0,0 +1,143 @@ | |||
| 1 | + | //! Live reload: a background thread that reloads the config when a watched file changes, on | |
| 2 | + | //! SIGUSR1, or on request, and hands the result to the event loop. | |
| 3 | + | ||
| 4 | + | use std::collections::HashSet; | |
| 5 | + | use std::path::{Path, PathBuf}; | |
| 6 | + | use std::time::Duration; | |
| 7 | + | ||
| 8 | + | use crossbeam_channel::{Receiver, RecvTimeoutError, Sender}; | |
| 9 | + | use notify::event::{AccessKind, AccessMode}; | |
| 10 | + | use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; | |
| 11 | + | ||
| 12 | + | use super::{LoadError, Loaded, load}; | |
| 13 | + | ||
| 14 | + | /// Quiet time before reloading, so editors' multi-step saves reload once. | |
| 15 | + | const DEBOUNCE: Duration = Duration::from_millis(100); | |
| 16 | + | ||
| 17 | + | enum Msg { | |
| 18 | + | Fs(notify::Event), | |
| 19 | + | Reload, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /// Handle to the reload thread. Dropping it stops nothing; the thread lives with the process. | |
| 23 | + | #[derive(Clone)] | |
| 24 | + | pub struct ConfigWatch { | |
| 25 | + | tx: Sender<Msg>, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | impl ConfigWatch { | |
| 29 | + | /// Reload now (the `reload-config` action). | |
| 30 | + | pub fn reload(&self) { | |
| 31 | + | let _ = self.tx.send(Msg::Reload); | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// Start watching `watch` (from the initial load); `deliver` receives every reload result. | |
| 36 | + | pub fn spawn( | |
| 37 | + | path: Option<PathBuf>, | |
| 38 | + | watch: Vec<PathBuf>, | |
| 39 | + | deliver: impl Fn(Result<Loaded, LoadError>) + Send + 'static, | |
| 40 | + | ) -> ConfigWatch { | |
| 41 | + | let (tx, rx) = crossbeam_channel::unbounded(); | |
| 42 | + | let fs_tx = tx.clone(); | |
| 43 | + | let sig_tx = tx.clone(); | |
| 44 | + | std::thread::Builder::new() | |
| 45 | + | .name("remage-sigusr1".into()) | |
| 46 | + | .spawn( | |
| 47 | + | move || match signal_hook::iterator::Signals::new([signal_hook::consts::SIGUSR1]) { | |
| 48 | + | Ok(mut signals) => { | |
| 49 | + | for _ in signals.forever() { | |
| 50 | + | if sig_tx.send(Msg::Reload).is_err() { | |
| 51 | + | break; | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | Err(e) => log::warn!("SIGUSR1 handler: {e}"), | |
| 56 | + | }, | |
| 57 | + | ) | |
| 58 | + | .ok(); | |
| 59 | + | std::thread::Builder::new() | |
| 60 | + | .name("remage-config".into()) | |
| 61 | + | .spawn(move || { | |
| 62 | + | let watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| { | |
| 63 | + | if let Ok(ev) = res { | |
| 64 | + | let _ = fs_tx.send(Msg::Fs(ev)); | |
| 65 | + | } | |
| 66 | + | }); | |
| 67 | + | let mut watcher = match watcher { | |
| 68 | + | Ok(w) => Some(w), | |
| 69 | + | Err(e) => { | |
| 70 | + | log::warn!("config watcher: {e}"); | |
| 71 | + | None | |
| 72 | + | } | |
| 73 | + | }; | |
| 74 | + | run(path.as_deref(), watch, &rx, watcher.as_mut(), deliver); | |
| 75 | + | }) | |
| 76 | + | .ok(); | |
| 77 | + | ConfigWatch { tx } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | fn run( | |
| 81 | + | path: Option<&Path>, | |
| 82 | + | mut files: Vec<PathBuf>, | |
| 83 | + | rx: &Receiver<Msg>, | |
| 84 | + | mut watcher: Option<&mut RecommendedWatcher>, | |
| 85 | + | deliver: impl Fn(Result<Loaded, LoadError>), | |
| 86 | + | ) { | |
| 87 | + | let mut dirs = HashSet::new(); | |
| 88 | + | if let Some(w) = watcher.as_deref_mut() { | |
| 89 | + | watch_dirs(w, &mut dirs, &files); | |
| 90 | + | } | |
| 91 | + | while let Ok(msg) = rx.recv() { | |
| 92 | + | if let Msg::Fs(ev) = &msg | |
| 93 | + | && !relevant(ev, &files) | |
| 94 | + | { | |
| 95 | + | continue; | |
| 96 | + | } | |
| 97 | + | loop { | |
| 98 | + | match rx.recv_timeout(DEBOUNCE) { | |
| 99 | + | Ok(_) => continue, | |
| 100 | + | Err(RecvTimeoutError::Timeout) => break, | |
| 101 | + | Err(RecvTimeoutError::Disconnected) => return, | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | let result = load(path); | |
| 105 | + | files = match &result { | |
| 106 | + | Ok(l) => l.watch.clone(), | |
| 107 | + | Err(e) => e.watch.clone(), | |
| 108 | + | }; | |
| 109 | + | if let Some(w) = watcher.as_deref_mut() { | |
| 110 | + | watch_dirs(w, &mut dirs, &files); | |
| 111 | + | } | |
| 112 | + | deliver(result); | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// Watch the parent directory of every file: editors replace files by renaming, which a | |
| 117 | + | /// watch on the file itself would lose. | |
| 118 | + | fn watch_dirs(w: &mut RecommendedWatcher, dirs: &mut HashSet<PathBuf>, files: &[PathBuf]) { | |
| 119 | + | let wanted: HashSet<PathBuf> = files | |
| 120 | + | .iter() | |
| 121 | + | .filter_map(|f| f.parent()) | |
| 122 | + | .filter(|d| d.is_dir()) | |
| 123 | + | .map(Path::to_path_buf) | |
| 124 | + | .collect(); | |
| 125 | + | for d in dirs.difference(&wanted) { | |
| 126 | + | let _ = w.unwatch(d); | |
| 127 | + | } | |
| 128 | + | for d in wanted.difference(dirs) { | |
| 129 | + | if let Err(e) = w.watch(d, RecursiveMode::NonRecursive) { | |
| 130 | + | log::warn!("watch {}: {e}", d.display()); | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | *dirs = wanted; | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | fn relevant(ev: ¬ify::Event, files: &[PathBuf]) -> bool { | |
| 137 | + | let changes = match ev.kind { | |
| 138 | + | EventKind::Access(AccessKind::Close(AccessMode::Write)) => true, | |
| 139 | + | EventKind::Access(_) => false, | |
| 140 | + | _ => true, | |
| 141 | + | }; | |
| 142 | + | changes && ev.paths.iter().any(|p| files.iter().any(|f| f == p)) | |
| 143 | + | } | |
| @@ -1 +1,436 @@ | |||
| 1 | 1 | //! Per-mode key trie: pending sequences, counts and which-key candidates. | |
| 2 | + | ||
| 3 | + | use std::collections::BTreeMap; | |
| 4 | + | ||
| 5 | + | use super::keys::{Key, KeySeq}; | |
| 6 | + | use crate::action::Action; | |
| 7 | + | use crate::state::Mode; | |
| 8 | + | ||
| 9 | + | /// Bindings from one config layer, in file order. `None` is `"none"`: remove the binding. | |
| 10 | + | pub type Bindings = Vec<(KeySeq, Option<Vec<Action>>)>; | |
| 11 | + | ||
| 12 | + | /// One layer's `[keys.*]` tables. | |
| 13 | + | #[derive(Clone, Debug, Default, PartialEq)] | |
| 14 | + | pub struct KeyTables { | |
| 15 | + | pub global: Bindings, | |
| 16 | + | pub image: Bindings, | |
| 17 | + | pub grid: Bindings, | |
| 18 | + | pub visual: Bindings, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /// A completed binding: the actions to run and the count typed before it. | |
| 22 | + | #[derive(Clone, Debug, PartialEq)] | |
| 23 | + | pub struct Run { | |
| 24 | + | pub actions: Vec<Action>, | |
| 25 | + | pub count: Option<usize>, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | impl Run { | |
| 29 | + | /// The actions with the count applied. | |
| 30 | + | pub fn counted(self) -> Vec<Action> { | |
| 31 | + | match self.count { | |
| 32 | + | Some(n) => self.actions.into_iter().map(|a| a.with_count(n)).collect(), | |
| 33 | + | None => self.actions, | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// A next key that would continue the pending sequence. | |
| 39 | + | #[allow(dead_code)] // which-key arrives in M6 | |
| 40 | + | #[derive(Clone, Debug, PartialEq)] | |
| 41 | + | pub struct Candidate { | |
| 42 | + | pub key: Key, | |
| 43 | + | pub actions: Option<Vec<Action>>, | |
| 44 | + | /// Longer sequences continue through this key. | |
| 45 | + | pub prefix: bool, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | #[derive(Clone, Debug, Default)] | |
| 49 | + | struct Node { | |
| 50 | + | actions: Option<Vec<Action>>, | |
| 51 | + | children: BTreeMap<Key, Node>, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | impl Node { | |
| 55 | + | fn walk(&self, keys: &[Key]) -> Option<&Node> { | |
| 56 | + | keys.iter().try_fold(self, |n, k| n.children.get(k)) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | fn insert(&mut self, keys: &[Key], actions: Vec<Action>) { | |
| 60 | + | let node = keys | |
| 61 | + | .iter() | |
| 62 | + | .fold(self, |n, k| n.children.entry(*k).or_default()); | |
| 63 | + | node.actions = Some(actions); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | fn collect(&self, prefix: &mut Vec<Key>, out: &mut Vec<(KeySeq, Vec<Action>)>) { | |
| 67 | + | if let Some(a) = &self.actions { | |
| 68 | + | out.push((KeySeq(prefix.clone()), a.clone())); | |
| 69 | + | } | |
| 70 | + | for (k, child) in &self.children { | |
| 71 | + | prefix.push(*k); | |
| 72 | + | child.collect(prefix, out); | |
| 73 | + | prefix.pop(); | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Largest count accepted; more digits are ignored. | |
| 79 | + | const MAX_COUNT: usize = 9_999_999; | |
| 80 | + | ||
| 81 | + | #[derive(Clone, Debug, Default)] | |
| 82 | + | pub struct Keymap { | |
| 83 | + | /// Image, grid, visual. | |
| 84 | + | tries: [Node; 3], | |
| 85 | + | pending: Vec<Key>, | |
| 86 | + | count: Option<usize>, | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | fn idx(mode: Mode) -> usize { | |
| 90 | + | match mode { | |
| 91 | + | Mode::Image => 0, | |
| 92 | + | Mode::Grid => 1, | |
| 93 | + | Mode::Visual => 2, | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | impl Keymap { | |
| 98 | + | /// Build from config layers, lowest priority first. Within each mode the levels stack | |
| 99 | + | /// global < mode (< visual for visual mode, which sits on grid); later layers win inside a | |
| 100 | + | /// level, and `"none"` removes whatever a lower level bound. | |
| 101 | + | pub fn new(layers: &[&KeyTables]) -> Self { | |
| 102 | + | type Level = fn(&KeyTables) -> &Bindings; | |
| 103 | + | let global: Level = |t| &t.global; | |
| 104 | + | let image: Level = |t| &t.image; | |
| 105 | + | let grid: Level = |t| &t.grid; | |
| 106 | + | let visual: Level = |t| &t.visual; | |
| 107 | + | let chains: [&[Level]; 3] = [&[global, image], &[global, grid], &[global, grid, visual]]; | |
| 108 | + | let mut km = Keymap::default(); | |
| 109 | + | for (trie, chain) in km.tries.iter_mut().zip(chains) { | |
| 110 | + | let mut flat: BTreeMap<&KeySeq, &Vec<Action>> = BTreeMap::new(); | |
| 111 | + | for level in chain { | |
| 112 | + | let mut merged: BTreeMap<&KeySeq, Option<&Vec<Action>>> = BTreeMap::new(); | |
| 113 | + | for layer in layers { | |
| 114 | + | for (k, v) in level(layer) { | |
| 115 | + | merged.insert(k, v.as_ref()); | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | for (k, v) in merged { | |
| 119 | + | match v { | |
| 120 | + | Some(a) => flat.insert(k, a), | |
| 121 | + | None => flat.remove(k), | |
| 122 | + | }; | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | for (k, a) in flat { | |
| 126 | + | trie.insert(&k.0, a.clone()); | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | km | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | fn root(&self, mode: Mode) -> &Node { | |
| 133 | + | &self.tries[idx(mode)] | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /// Feed one key press. Returns the bindings it completed (usually none or one; an | |
| 137 | + | /// ambiguous prefix followed by a non-continuing key yields the prefix's binding first). | |
| 138 | + | pub fn feed(&mut self, mode: Mode, key: Key) -> Vec<Run> { | |
| 139 | + | if self.pending.is_empty() | |
| 140 | + | && let Some(d) = key.digit() | |
| 141 | + | && (d != 0 || self.count.is_some()) | |
| 142 | + | { | |
| 143 | + | let c = self.count.unwrap_or(0).saturating_mul(10) + d as usize; | |
| 144 | + | self.count = Some(c.min(MAX_COUNT)); | |
| 145 | + | return Vec::new(); | |
| 146 | + | } | |
| 147 | + | self.pending.push(key); | |
| 148 | + | match self.root(mode).walk(&self.pending) { | |
| 149 | + | Some(node) if node.children.is_empty() => { | |
| 150 | + | let actions = node.actions.clone().unwrap_or_default(); | |
| 151 | + | self.pending.clear(); | |
| 152 | + | vec![Run { | |
| 153 | + | actions, | |
| 154 | + | count: self.count.take(), | |
| 155 | + | }] | |
| 156 | + | } | |
| 157 | + | Some(_) => Vec::new(), | |
| 158 | + | None => { | |
| 159 | + | self.pending.pop(); | |
| 160 | + | let prefix = self | |
| 161 | + | .root(mode) | |
| 162 | + | .walk(&self.pending) | |
| 163 | + | .and_then(|n| n.actions.clone()) | |
| 164 | + | .filter(|_| !self.pending.is_empty()); | |
| 165 | + | self.pending.clear(); | |
| 166 | + | match prefix { | |
| 167 | + | // `g` is bound and so is `gg`: `gx` runs `g`, then `x` on its own. | |
| 168 | + | Some(actions) => { | |
| 169 | + | let mut out = vec![Run { | |
| 170 | + | actions, | |
| 171 | + | count: self.count.take(), | |
| 172 | + | }]; | |
| 173 | + | out.extend(self.feed(mode, key)); | |
| 174 | + | out | |
| 175 | + | } | |
| 176 | + | None => { | |
| 177 | + | self.count = None; | |
| 178 | + | Vec::new() | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | /// The pending sequence is itself bound and also a prefix of longer ones. | |
| 186 | + | pub fn is_ambiguous(&self, mode: Mode) -> bool { | |
| 187 | + | !self.pending.is_empty() | |
| 188 | + | && self | |
| 189 | + | .root(mode) | |
| 190 | + | .walk(&self.pending) | |
| 191 | + | .is_some_and(|n| n.actions.is_some() && !n.children.is_empty()) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /// The user stopped typing: an ambiguous sequence runs its own binding. | |
| 195 | + | pub fn timeout(&mut self, mode: Mode) -> Vec<Run> { | |
| 196 | + | if !self.is_ambiguous(mode) { | |
| 197 | + | return Vec::new(); | |
| 198 | + | } | |
| 199 | + | let actions = self | |
| 200 | + | .root(mode) | |
| 201 | + | .walk(&self.pending) | |
| 202 | + | .and_then(|n| n.actions.clone()) | |
| 203 | + | .unwrap_or_default(); | |
| 204 | + | self.pending.clear(); | |
| 205 | + | vec![Run { | |
| 206 | + | actions, | |
| 207 | + | count: self.count.take(), | |
| 208 | + | }] | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | /// A key sequence (not a count) is waiting for more keys. | |
| 212 | + | #[allow(dead_code)] // which-key arrives in M6 | |
| 213 | + | pub fn is_pending(&self) -> bool { | |
| 214 | + | !self.pending.is_empty() | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | pub fn cancel(&mut self) { | |
| 218 | + | self.pending.clear(); | |
| 219 | + | self.count = None; | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | /// The count and keys typed so far, for the status bar. | |
| 223 | + | pub fn pending_display(&self) -> String { | |
| 224 | + | let mut s = self.count.map(|c| c.to_string()).unwrap_or_default(); | |
| 225 | + | if !self.pending.is_empty() { | |
| 226 | + | s.push_str(&KeySeq(self.pending.clone()).to_string()); | |
| 227 | + | } | |
| 228 | + | s | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | /// The binding of a complete sequence (mouse buttons and the wheel bypass the trie). | |
| 232 | + | pub fn lookup(&self, mode: Mode, keys: &[Key]) -> Option<&[Action]> { | |
| 233 | + | self.root(mode).walk(keys)?.actions.as_deref() | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | /// The keys that continue the pending sequence, for which-key. | |
| 237 | + | #[allow(dead_code)] // which-key arrives in M6 | |
| 238 | + | pub fn candidates(&self, mode: Mode) -> Vec<Candidate> { | |
| 239 | + | let Some(node) = self.root(mode).walk(&self.pending) else { | |
| 240 | + | return Vec::new(); | |
| 241 | + | }; | |
| 242 | + | node.children | |
| 243 | + | .iter() | |
| 244 | + | .map(|(k, n)| Candidate { | |
| 245 | + | key: *k, | |
| 246 | + | actions: n.actions.clone(), | |
| 247 | + | prefix: !n.children.is_empty(), | |
| 248 | + | }) | |
| 249 | + | .collect() | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | /// Every binding in a mode, in key order. | |
| 253 | + | pub fn bindings(&self, mode: Mode) -> Vec<(KeySeq, Vec<Action>)> { | |
| 254 | + | let mut out = Vec::new(); | |
| 255 | + | self.root(mode).collect(&mut Vec::new(), &mut out); | |
| 256 | + | out | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | /// The sequences bound to exactly `action` in a mode, shortest first. | |
| 260 | + | #[allow(dead_code)] // the palette and help arrive in M6 | |
| 261 | + | pub fn keys_for(&self, mode: Mode, action: &Action) -> Vec<KeySeq> { | |
| 262 | + | let mut keys: Vec<KeySeq> = self | |
| 263 | + | .bindings(mode) | |
| 264 | + | .into_iter() | |
| 265 | + | .filter(|(_, a)| a.len() == 1 && a[0] == *action) | |
| 266 | + | .map(|(k, _)| k) | |
| 267 | + | .collect(); | |
| 268 | + | keys.sort_by_key(|k| k.0.len()); | |
| 269 | + | keys | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | #[cfg(test)] | |
| 274 | + | mod tests { | |
| 275 | + | use super::*; | |
| 276 | + | use crate::action::Zoom; | |
| 277 | + | ||
| 278 | + | fn bind(pairs: &[(&str, &str)]) -> Bindings { | |
| 279 | + | pairs | |
| 280 | + | .iter() | |
| 281 | + | .map(|(k, a)| { | |
| 282 | + | let actions = (*a != "none").then(|| vec![a.parse().unwrap()]); | |
| 283 | + | (k.parse().unwrap(), actions) | |
| 284 | + | }) | |
| 285 | + | .collect() | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | fn keys(s: &str) -> Vec<Key> { | |
| 289 | + | s.parse::<KeySeq>().unwrap().0 | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | /// Feed a whole string and collect the counted actions. | |
| 293 | + | fn type_(km: &mut Keymap, mode: Mode, s: &str) -> Vec<Action> { | |
| 294 | + | keys(s) | |
| 295 | + | .into_iter() | |
| 296 | + | .flat_map(|k| km.feed(mode, k)) | |
| 297 | + | .flat_map(Run::counted) | |
| 298 | + | .collect() | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | fn defaults() -> KeyTables { | |
| 302 | + | KeyTables { | |
| 303 | + | global: bind(&[ | |
| 304 | + | ("n", "next"), | |
| 305 | + | ("gg", "first"), | |
| 306 | + | ("G", "last"), | |
| 307 | + | ("q", "quit"), | |
| 308 | + | ("0", "first"), | |
| 309 | + | ("<Space>w", "run wallpaper"), | |
| 310 | + | ("<Space>c", "run copy"), | |
| 311 | + | ]), | |
| 312 | + | image: bind(&[("+", "zoom in"), ("h", "pan left")]), | |
| 313 | + | grid: bind(&[("h", "prev"), ("v", "visual")]), | |
| 314 | + | visual: bind(&[("m", "mark-all")]), | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | #[test] | |
| 319 | + | fn sequences_and_counts() { | |
| 320 | + | let t = defaults(); | |
| 321 | + | let mut km = Keymap::new(&[&t]); | |
| 322 | + | let m = Mode::Image; | |
| 323 | + | assert_eq!(type_(&mut km, m, "n"), [Action::Next(1)]); | |
| 324 | + | assert_eq!(type_(&mut km, m, "g"), []); | |
| 325 | + | assert_eq!(km.pending_display(), "g"); | |
| 326 | + | assert_eq!(type_(&mut km, m, "g"), [Action::First]); | |
| 327 | + | assert_eq!(type_(&mut km, m, "12G"), [Action::Goto(12)]); | |
| 328 | + | assert_eq!(type_(&mut km, m, "5gg"), [Action::Goto(5)]); | |
| 329 | + | assert_eq!(type_(&mut km, m, "3n"), [Action::Next(3)]); | |
| 330 | + | assert_eq!(type_(&mut km, m, "3+"), [Action::Zoom(Zoom::In(3))]); | |
| 331 | + | // `0` is a key unless a count is pending. | |
| 332 | + | assert_eq!(type_(&mut km, m, "0"), [Action::First]); | |
| 333 | + | assert_eq!(type_(&mut km, m, "10n"), [Action::Next(10)]); | |
| 334 | + | assert_eq!(km.pending_display(), ""); | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | #[test] | |
| 338 | + | fn broken_sequences_are_dropped() { | |
| 339 | + | let t = defaults(); | |
| 340 | + | let mut km = Keymap::new(&[&t]); | |
| 341 | + | let m = Mode::Image; | |
| 342 | + | assert_eq!(type_(&mut km, m, "5gx"), []); | |
| 343 | + | assert!(!km.is_pending()); | |
| 344 | + | assert_eq!(km.pending_display(), ""); | |
| 345 | + | assert_eq!(type_(&mut km, m, "<Space>"), []); | |
| 346 | + | assert_eq!(type_(&mut km, m, "<Esc>"), []); | |
| 347 | + | assert_eq!(type_(&mut km, m, "n"), [Action::Next(1)]); | |
| 348 | + | assert_eq!(type_(&mut km, m, "Z"), []); | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | #[test] | |
| 352 | + | fn modes_override_global() { | |
| 353 | + | let t = defaults(); | |
| 354 | + | let mut km = Keymap::new(&[&t]); | |
| 355 | + | assert_eq!( | |
| 356 | + | type_(&mut km, Mode::Image, "h"), | |
| 357 | + | [Action::Pan(crate::state::view::Dir::Left)] | |
| 358 | + | ); | |
| 359 | + | assert_eq!(type_(&mut km, Mode::Grid, "h"), [Action::Prev(1)]); | |
| 360 | + | // Visual sits on grid. | |
| 361 | + | assert_eq!(type_(&mut km, Mode::Visual, "h"), [Action::Prev(1)]); | |
| 362 | + | assert_eq!(type_(&mut km, Mode::Visual, "m"), [Action::MarkAll]); | |
| 363 | + | assert_eq!(type_(&mut km, Mode::Grid, "m"), []); | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | #[test] | |
| 367 | + | fn later_layers_win_and_none_removes() { | |
| 368 | + | let base = defaults(); | |
| 369 | + | let user = KeyTables { | |
| 370 | + | global: bind(&[("n", "prev"), ("q", "none")]), | |
| 371 | + | image: bind(&[("+", "none"), ("G", "none")]), | |
| 372 | + | ..Default::default() | |
| 373 | + | }; | |
| 374 | + | let mut km = Keymap::new(&[&base, &user]); | |
| 375 | + | assert_eq!(type_(&mut km, Mode::Image, "n"), [Action::Prev(1)]); | |
| 376 | + | assert_eq!(type_(&mut km, Mode::Image, "q"), []); | |
| 377 | + | assert_eq!(type_(&mut km, Mode::Image, "+"), []); | |
| 378 | + | // A mode's "none" masks the global binding in that mode only. | |
| 379 | + | assert_eq!(type_(&mut km, Mode::Image, "G"), []); | |
| 380 | + | assert_eq!(type_(&mut km, Mode::Grid, "G"), [Action::Last]); | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | #[test] | |
| 384 | + | fn ambiguous_prefix_waits_then_resolves() { | |
| 385 | + | let t = KeyTables { | |
| 386 | + | global: bind(&[("g", "first"), ("gg", "last"), ("n", "next")]), | |
| 387 | + | ..Default::default() | |
| 388 | + | }; | |
| 389 | + | let mut km = Keymap::new(&[&t]); | |
| 390 | + | let m = Mode::Image; | |
| 391 | + | assert_eq!(type_(&mut km, m, "g"), []); | |
| 392 | + | assert!(km.is_ambiguous(m)); | |
| 393 | + | assert_eq!(type_(&mut km, m, "g"), [Action::Last]); | |
| 394 | + | // A non-continuing key runs the prefix, then itself. | |
| 395 | + | assert_eq!(type_(&mut km, m, "gn"), [Action::First, Action::Next(1)]); | |
| 396 | + | type_(&mut km, m, "4g"); | |
| 397 | + | let fired: Vec<Action> = km.timeout(m).into_iter().flat_map(Run::counted).collect(); | |
| 398 | + | assert_eq!(fired, [Action::Goto(4)]); | |
| 399 | + | assert!(!km.is_pending()); | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | #[test] | |
| 403 | + | fn which_key_candidates() { | |
| 404 | + | let t = defaults(); | |
| 405 | + | let mut km = Keymap::new(&[&t]); | |
| 406 | + | type_(&mut km, Mode::Image, "<Space>"); | |
| 407 | + | let c = km.candidates(Mode::Image); | |
| 408 | + | assert_eq!(c.len(), 2); | |
| 409 | + | assert_eq!(c[0].key, Key::plain('c')); | |
| 410 | + | assert_eq!(c[0].actions, Some(vec![Action::Run("copy".into())])); | |
| 411 | + | assert!(!c[0].prefix); | |
| 412 | + | km.cancel(); | |
| 413 | + | let root = km.candidates(Mode::Image); | |
| 414 | + | assert!(root.iter().any(|c| c.key == Key::plain('g') && c.prefix)); | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | #[test] | |
| 418 | + | fn lookup_and_reverse_lookup() { | |
| 419 | + | let t = defaults(); | |
| 420 | + | let km = Keymap::new(&[&t]); | |
| 421 | + | assert_eq!( | |
| 422 | + | km.lookup(Mode::Image, &keys("n")), | |
| 423 | + | Some(&[Action::Next(1)][..]) | |
| 424 | + | ); | |
| 425 | + | assert_eq!(km.lookup(Mode::Image, &keys("g")), None); | |
| 426 | + | assert_eq!( | |
| 427 | + | km.keys_for(Mode::Image, &Action::First), | |
| 428 | + | vec!["0".parse().unwrap(), "gg".parse().unwrap()] | |
| 429 | + | ); | |
| 430 | + | assert!( | |
| 431 | + | km.bindings(Mode::Grid) | |
| 432 | + | .iter() | |
| 433 | + | .any(|(k, _)| k.to_string() == "v") | |
| 434 | + | ); | |
| 435 | + | } | |
| 436 | + | } | |
| @@ -1 +1,437 @@ | |||
| 1 | − | //! Vim key-notation parser: `<C-x>`, `<S-Tab>`, `gg`, `<Space>w`, mouse buttons and wheel. | |
| 1 | + | //! Vim key notation: `n`, `G`, `<C-x>`, `<S-Tab>`, `<Space>w`, `gg`, mouse buttons and wheel. | |
| 2 | + | //! | |
| 3 | + | //! Keys are normalised so that the config and the keyboard agree: a character already carries | |
| 4 | + | //! its shift state (`G`, `?`), so shift is dropped from character keys and `<S-g>` means `G`. | |
| 5 | + | ||
| 6 | + | use std::fmt; | |
| 7 | + | use std::str::FromStr; | |
| 8 | + | ||
| 9 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| 10 | + | pub struct Mods { | |
| 11 | + | pub ctrl: bool, | |
| 12 | + | pub alt: bool, | |
| 13 | + | pub shift: bool, | |
| 14 | + | /// The logo key, `<D-…>` as in vim. | |
| 15 | + | pub sup: bool, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | impl Mods { | |
| 19 | + | pub const NONE: Mods = Mods { | |
| 20 | + | ctrl: false, | |
| 21 | + | alt: false, | |
| 22 | + | shift: false, | |
| 23 | + | sup: false, | |
| 24 | + | }; | |
| 25 | + | ||
| 26 | + | pub fn is_empty(self) -> bool { | |
| 27 | + | self == Mods::NONE | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| 32 | + | pub enum Named { | |
| 33 | + | Space, | |
| 34 | + | Enter, | |
| 35 | + | Esc, | |
| 36 | + | Tab, | |
| 37 | + | Backspace, | |
| 38 | + | Delete, | |
| 39 | + | Insert, | |
| 40 | + | Home, | |
| 41 | + | End, | |
| 42 | + | PageUp, | |
| 43 | + | PageDown, | |
| 44 | + | Up, | |
| 45 | + | Down, | |
| 46 | + | Left, | |
| 47 | + | Right, | |
| 48 | + | F(u8), | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| 52 | + | pub enum Button { | |
| 53 | + | Left, | |
| 54 | + | Right, | |
| 55 | + | Middle, | |
| 56 | + | Back, | |
| 57 | + | Forward, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| 61 | + | pub enum Scroll { | |
| 62 | + | Up, | |
| 63 | + | Down, | |
| 64 | + | Left, | |
| 65 | + | Right, | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| 69 | + | pub enum Code { | |
| 70 | + | Char(char), | |
| 71 | + | Named(Named), | |
| 72 | + | Mouse(Button), | |
| 73 | + | Scroll(Scroll), | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// One key press (or click, or wheel notch) with its modifiers. | |
| 77 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| 78 | + | pub struct Key { | |
| 79 | + | pub mods: Mods, | |
| 80 | + | pub code: Code, | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | impl Key { | |
| 84 | + | pub fn new(mods: Mods, code: Code) -> Self { | |
| 85 | + | Self { mods, code }.normalized() | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | pub fn plain(c: char) -> Self { | |
| 89 | + | Self::new(Mods::NONE, Code::Char(c)) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /// Characters carry their own shift state; a space is `<Space>`. | |
| 93 | + | fn normalized(mut self) -> Self { | |
| 94 | + | if let Code::Char(c) = self.code { | |
| 95 | + | if c == ' ' { | |
| 96 | + | self.code = Code::Named(Named::Space); | |
| 97 | + | } else if self.mods.shift { | |
| 98 | + | self.code = Code::Char(c.to_uppercase().next().unwrap_or(c)); | |
| 99 | + | self.mods.shift = false; | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | self | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// A digit typed without modifiers, which may start or extend a count. | |
| 106 | + | pub fn digit(self) -> Option<u32> { | |
| 107 | + | match self.code { | |
| 108 | + | Code::Char(c) if self.mods.is_empty() => c.to_digit(10), | |
| 109 | + | _ => None, | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | const NAMES: &[(&str, Code)] = &[ | |
| 115 | + | ("Space", Code::Named(Named::Space)), | |
| 116 | + | ("CR", Code::Named(Named::Enter)), | |
| 117 | + | ("Enter", Code::Named(Named::Enter)), | |
| 118 | + | ("Return", Code::Named(Named::Enter)), | |
| 119 | + | ("Esc", Code::Named(Named::Esc)), | |
| 120 | + | ("Escape", Code::Named(Named::Esc)), | |
| 121 | + | ("Tab", Code::Named(Named::Tab)), | |
| 122 | + | ("BS", Code::Named(Named::Backspace)), | |
| 123 | + | ("Backspace", Code::Named(Named::Backspace)), | |
| 124 | + | ("Del", Code::Named(Named::Delete)), | |
| 125 | + | ("Delete", Code::Named(Named::Delete)), | |
| 126 | + | ("Insert", Code::Named(Named::Insert)), | |
| 127 | + | ("Home", Code::Named(Named::Home)), | |
| 128 | + | ("End", Code::Named(Named::End)), | |
| 129 | + | ("PageUp", Code::Named(Named::PageUp)), | |
| 130 | + | ("PageDown", Code::Named(Named::PageDown)), | |
| 131 | + | ("Up", Code::Named(Named::Up)), | |
| 132 | + | ("Down", Code::Named(Named::Down)), | |
| 133 | + | ("Left", Code::Named(Named::Left)), | |
| 134 | + | ("Right", Code::Named(Named::Right)), | |
| 135 | + | ("lt", Code::Char('<')), | |
| 136 | + | ("gt", Code::Char('>')), | |
| 137 | + | ("Bar", Code::Char('|')), | |
| 138 | + | ("Bslash", Code::Char('\\')), | |
| 139 | + | ("LeftMouse", Code::Mouse(Button::Left)), | |
| 140 | + | ("RightMouse", Code::Mouse(Button::Right)), | |
| 141 | + | ("MiddleMouse", Code::Mouse(Button::Middle)), | |
| 142 | + | ("X1Mouse", Code::Mouse(Button::Back)), | |
| 143 | + | ("X2Mouse", Code::Mouse(Button::Forward)), | |
| 144 | + | ("ScrollUp", Code::Scroll(Scroll::Up)), | |
| 145 | + | ("ScrollDown", Code::Scroll(Scroll::Down)), | |
| 146 | + | ("ScrollLeft", Code::Scroll(Scroll::Left)), | |
| 147 | + | ("ScrollRight", Code::Scroll(Scroll::Right)), | |
| 148 | + | ("ScrollWheelUp", Code::Scroll(Scroll::Up)), | |
| 149 | + | ("ScrollWheelDown", Code::Scroll(Scroll::Down)), | |
| 150 | + | ("ScrollWheelLeft", Code::Scroll(Scroll::Left)), | |
| 151 | + | ("ScrollWheelRight", Code::Scroll(Scroll::Right)), | |
| 152 | + | ]; | |
| 153 | + | ||
| 154 | + | fn lookup(name: &str) -> Option<Code> { | |
| 155 | + | if let Some(n) = name.strip_prefix(['F', 'f']) | |
| 156 | + | && let Ok(n) = n.parse::<u8>() | |
| 157 | + | && (1..=35).contains(&n) | |
| 158 | + | { | |
| 159 | + | return Some(Code::Named(Named::F(n))); | |
| 160 | + | } | |
| 161 | + | NAMES | |
| 162 | + | .iter() | |
| 163 | + | .find(|(n, _)| n.eq_ignore_ascii_case(name)) | |
| 164 | + | .map(|(_, c)| *c) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | fn code_name(code: Code) -> Option<String> { | |
| 168 | + | let s = match code { | |
| 169 | + | Code::Char('<') => "lt", | |
| 170 | + | Code::Char(_) => return None, | |
| 171 | + | Code::Named(Named::F(n)) => return Some(format!("F{n}")), | |
| 172 | + | Code::Named(Named::Space) => "Space", | |
| 173 | + | Code::Named(Named::Enter) => "CR", | |
| 174 | + | Code::Named(Named::Esc) => "Esc", | |
| 175 | + | Code::Named(Named::Tab) => "Tab", | |
| 176 | + | Code::Named(Named::Backspace) => "BS", | |
| 177 | + | Code::Named(Named::Delete) => "Del", | |
| 178 | + | Code::Named(Named::Insert) => "Insert", | |
| 179 | + | Code::Named(Named::Home) => "Home", | |
| 180 | + | Code::Named(Named::End) => "End", | |
| 181 | + | Code::Named(Named::PageUp) => "PageUp", | |
| 182 | + | Code::Named(Named::PageDown) => "PageDown", | |
| 183 | + | Code::Named(Named::Up) => "Up", | |
| 184 | + | Code::Named(Named::Down) => "Down", | |
| 185 | + | Code::Named(Named::Left) => "Left", | |
| 186 | + | Code::Named(Named::Right) => "Right", | |
| 187 | + | Code::Mouse(Button::Left) => "LeftMouse", | |
| 188 | + | Code::Mouse(Button::Right) => "RightMouse", | |
| 189 | + | Code::Mouse(Button::Middle) => "MiddleMouse", | |
| 190 | + | Code::Mouse(Button::Back) => "X1Mouse", | |
| 191 | + | Code::Mouse(Button::Forward) => "X2Mouse", | |
| 192 | + | Code::Scroll(Scroll::Up) => "ScrollUp", | |
| 193 | + | Code::Scroll(Scroll::Down) => "ScrollDown", | |
| 194 | + | Code::Scroll(Scroll::Left) => "ScrollLeft", | |
| 195 | + | Code::Scroll(Scroll::Right) => "ScrollRight", | |
| 196 | + | }; | |
| 197 | + | Some(s.into()) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | impl fmt::Display for Key { | |
| 201 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 202 | + | let m = self.mods; | |
| 203 | + | let name = code_name(self.code); | |
| 204 | + | if m.is_empty() | |
| 205 | + | && let Code::Char(c) = self.code | |
| 206 | + | && name.is_none() | |
| 207 | + | { | |
| 208 | + | return write!(f, "{c}"); | |
| 209 | + | } | |
| 210 | + | f.write_str("<")?; | |
| 211 | + | for (on, p) in [ | |
| 212 | + | (m.ctrl, "C-"), | |
| 213 | + | (m.alt, "A-"), | |
| 214 | + | (m.shift, "S-"), | |
| 215 | + | (m.sup, "D-"), | |
| 216 | + | ] { | |
| 217 | + | if on { | |
| 218 | + | f.write_str(p)?; | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | match (name, self.code) { | |
| 222 | + | (Some(n), _) => f.write_str(&n)?, | |
| 223 | + | (None, Code::Char(c)) => write!(f, "{c}")?, | |
| 224 | + | (None, _) => unreachable!("every non-char code has a name"), | |
| 225 | + | } | |
| 226 | + | f.write_str(">") | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | /// Parse a `<…>` token at the start of `s`. Returns the key and the bytes consumed, or `None` | |
| 231 | + | /// when `s` doesn't start with a well-formed token (a lone `<` is then a literal). | |
| 232 | + | fn parse_bracket(s: &str) -> Result<Option<(Key, usize)>, String> { | |
| 233 | + | let Some(inner) = s.strip_prefix('<') else { | |
| 234 | + | return Ok(None); | |
| 235 | + | }; | |
| 236 | + | let mut mods = Mods::NONE; | |
| 237 | + | let mut r = inner; | |
| 238 | + | loop { | |
| 239 | + | let mut it = r.chars(); | |
| 240 | + | let (Some(m), Some('-'), Some(_)) = (it.next(), it.next(), it.next()) else { | |
| 241 | + | break; | |
| 242 | + | }; | |
| 243 | + | match m.to_ascii_uppercase() { | |
| 244 | + | 'C' => mods.ctrl = true, | |
| 245 | + | 'A' | 'M' => mods.alt = true, | |
| 246 | + | 'S' => mods.shift = true, | |
| 247 | + | 'D' | 'T' => mods.sup = true, | |
| 248 | + | _ => break, | |
| 249 | + | } | |
| 250 | + | r = &r[2..]; | |
| 251 | + | } | |
| 252 | + | let mut it = r.chars(); | |
| 253 | + | let Some(first) = it.next() else { | |
| 254 | + | return Ok(None); | |
| 255 | + | }; | |
| 256 | + | // A single character: `<C-x>`, `<C-->`. | |
| 257 | + | if it.next() == Some('>') { | |
| 258 | + | if mods.shift && !first.is_alphabetic() { | |
| 259 | + | return Err(format!( | |
| 260 | + | "`<S-{first}>`: shift only combines with letters and named keys; \ | |
| 261 | + | write the shifted character instead" | |
| 262 | + | )); | |
| 263 | + | } | |
| 264 | + | let used = 1 + (inner.len() - r.len()) + first.len_utf8() + 1; | |
| 265 | + | return Ok(Some((Key::new(mods, Code::Char(first)), used))); | |
| 266 | + | } | |
| 267 | + | let Some(end) = r.find('>') else { | |
| 268 | + | return Ok(None); | |
| 269 | + | }; | |
| 270 | + | let name = &r[..end]; | |
| 271 | + | if !name.chars().all(|c| c.is_ascii_alphanumeric()) { | |
| 272 | + | return Ok(None); | |
| 273 | + | } | |
| 274 | + | let used = 1 + (inner.len() - r.len()) + end + 1; | |
| 275 | + | match lookup(name) { | |
| 276 | + | Some(code) => { | |
| 277 | + | if mods.shift && matches!(code, Code::Char(c) if !c.is_alphabetic()) { | |
| 278 | + | return Err(format!( | |
| 279 | + | "`<S-{name}>`: shift only combines with letters and named keys" | |
| 280 | + | )); | |
| 281 | + | } | |
| 282 | + | Ok(Some((Key::new(mods, code), used))) | |
| 283 | + | } | |
| 284 | + | None if mods.is_empty() && !inner.starts_with(char::is_alphabetic) => Ok(None), | |
| 285 | + | None => Err(format!("unknown key `<{}>`", &inner[..used - 2])), | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | /// A key sequence such as `gg` or `<Space>w`. | |
| 290 | + | #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| 291 | + | pub struct KeySeq(pub Vec<Key>); | |
| 292 | + | ||
| 293 | + | impl FromStr for KeySeq { | |
| 294 | + | type Err = String; | |
| 295 | + | ||
| 296 | + | fn from_str(s: &str) -> Result<Self, String> { | |
| 297 | + | let mut keys = Vec::new(); | |
| 298 | + | let mut rest = s; | |
| 299 | + | while let Some(c) = rest.chars().next() { | |
| 300 | + | if c == '<' | |
| 301 | + | && let Some((key, used)) = parse_bracket(rest)? | |
| 302 | + | { | |
| 303 | + | keys.push(key); | |
| 304 | + | rest = &rest[used..]; | |
| 305 | + | continue; | |
| 306 | + | } | |
| 307 | + | if c.is_control() { | |
| 308 | + | return Err(format!("control character in key `{}`", s.escape_debug())); | |
| 309 | + | } | |
| 310 | + | keys.push(Key::plain(c)); | |
| 311 | + | rest = &rest[c.len_utf8()..]; | |
| 312 | + | } | |
| 313 | + | if keys.is_empty() { | |
| 314 | + | return Err("empty key".into()); | |
| 315 | + | } | |
| 316 | + | Ok(KeySeq(keys)) | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | impl fmt::Display for KeySeq { | |
| 321 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 322 | + | self.0.iter().try_for_each(|k| write!(f, "{k}")) | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | #[cfg(test)] | |
| 327 | + | mod tests { | |
| 328 | + | use super::*; | |
| 329 | + | ||
| 330 | + | fn seq(s: &str) -> Vec<Key> { | |
| 331 | + | s.parse::<KeySeq>().unwrap().0 | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | fn ctrl() -> Mods { | |
| 335 | + | Mods { | |
| 336 | + | ctrl: true, | |
| 337 | + | ..Mods::NONE | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | #[test] | |
| 342 | + | fn plain_characters_and_sequences() { | |
| 343 | + | assert_eq!(seq("n"), [Key::plain('n')]); | |
| 344 | + | assert_eq!(seq("gg"), [Key::plain('g'), Key::plain('g')]); | |
| 345 | + | assert_eq!(seq("G"), [Key::plain('G')]); | |
| 346 | + | assert_eq!(seq("<"), [Key::plain('<')]); | |
| 347 | + | assert_eq!(seq("<>"), [Key::plain('<'), Key::plain('>')]); | |
| 348 | + | assert_eq!(seq("|"), [Key::plain('|')]); | |
| 349 | + | assert_eq!(seq(" "), [Key::new(Mods::NONE, Code::Named(Named::Space))]); | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | #[test] | |
| 353 | + | fn bracket_notation() { | |
| 354 | + | assert_eq!(seq("<C-x>"), [Key::new(ctrl(), Code::Char('x'))]); | |
| 355 | + | assert_eq!(seq("<c-X>"), [Key::new(ctrl(), Code::Char('X'))]); | |
| 356 | + | assert_eq!(seq("<C-->"), [Key::new(ctrl(), Code::Char('-'))]); | |
| 357 | + | let shift = Mods { | |
| 358 | + | shift: true, | |
| 359 | + | ..Mods::NONE | |
| 360 | + | }; | |
| 361 | + | assert_eq!(seq("<S-Tab>"), [Key::new(shift, Code::Named(Named::Tab))]); | |
| 362 | + | assert_eq!(seq("<S-g>"), [Key::plain('G')]); | |
| 363 | + | assert_eq!(seq("<cr>"), seq("<Enter>")); | |
| 364 | + | assert_eq!(seq("<lt>"), [Key::plain('<')]); | |
| 365 | + | assert_eq!( | |
| 366 | + | seq("<F5>"), | |
| 367 | + | [Key::new(Mods::NONE, Code::Named(Named::F(5)))] | |
| 368 | + | ); | |
| 369 | + | assert_eq!( | |
| 370 | + | seq("<Space>w"), | |
| 371 | + | [ | |
| 372 | + | Key::new(Mods::NONE, Code::Named(Named::Space)), | |
| 373 | + | Key::plain('w') | |
| 374 | + | ] | |
| 375 | + | ); | |
| 376 | + | assert_eq!( | |
| 377 | + | seq("<C-ScrollUp>"), | |
| 378 | + | [Key::new(ctrl(), Code::Scroll(Scroll::Up))] | |
| 379 | + | ); | |
| 380 | + | assert_eq!( | |
| 381 | + | seq("<LeftMouse>"), | |
| 382 | + | [Key::new(Mods::NONE, Code::Mouse(Button::Left))] | |
| 383 | + | ); | |
| 384 | + | assert_eq!( | |
| 385 | + | seq("<C-S-A-Up>"), | |
| 386 | + | [Key::new( | |
| 387 | + | Mods { | |
| 388 | + | ctrl: true, | |
| 389 | + | alt: true, | |
| 390 | + | shift: true, | |
| 391 | + | sup: false | |
| 392 | + | }, | |
| 393 | + | Code::Named(Named::Up) | |
| 394 | + | )] | |
| 395 | + | ); | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | #[test] | |
| 399 | + | fn errors() { | |
| 400 | + | assert!("".parse::<KeySeq>().is_err()); | |
| 401 | + | assert!("<C-Bogus>".parse::<KeySeq>().is_err()); | |
| 402 | + | assert!("<Bogus>".parse::<KeySeq>().is_err()); | |
| 403 | + | assert!("<S-1>".parse::<KeySeq>().is_err()); | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | #[test] | |
| 407 | + | fn display_round_trips() { | |
| 408 | + | for s in [ | |
| 409 | + | "n", | |
| 410 | + | "gg", | |
| 411 | + | "<C-x>", | |
| 412 | + | "<S-Tab>", | |
| 413 | + | "<Space>w", | |
| 414 | + | "<lt>", | |
| 415 | + | "<C-Space>", | |
| 416 | + | "<C-ScrollUp>", | |
| 417 | + | "<F12>", | |
| 418 | + | "<CR>", | |
| 419 | + | "|", | |
| 420 | + | "<C-->", | |
| 421 | + | "<A-D-x>", | |
| 422 | + | ] { | |
| 423 | + | let k: KeySeq = s.parse().unwrap(); | |
| 424 | + | assert_eq!(k.to_string(), s); | |
| 425 | + | assert_eq!(k.to_string().parse::<KeySeq>().unwrap(), k); | |
| 426 | + | } | |
| 427 | + | assert_eq!("<S-a>".parse::<KeySeq>().unwrap().to_string(), "A"); | |
| 428 | + | assert_eq!("<escape>".parse::<KeySeq>().unwrap().to_string(), "<Esc>"); | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | #[test] | |
| 432 | + | fn digits() { | |
| 433 | + | assert_eq!(Key::plain('5').digit(), Some(5)); | |
| 434 | + | assert_eq!(Key::new(ctrl(), Code::Char('5')).digit(), None); | |
| 435 | + | assert_eq!(Key::plain('x').digit(), None); | |
| 436 | + | } | |
| 437 | + | } | |
| @@ -1,173 +1,120 @@ | |||
| 1 | − | //! Keyboard and mouse input: vim key notation and per-mode keymaps. | |
| 2 | − | //! | |
| 3 | − | //! Until the configurable keymap lands (M2), [`Input`] holds the default bindings in code. | |
| 1 | + | //! Keyboard and mouse input: vim key notation, per-mode keymaps, and the bridge from winit. | |
| 4 | 2 | ||
| 5 | 3 | pub mod keymap; | |
| 6 | 4 | pub mod keys; | |
| 7 | 5 | ||
| 8 | − | use winit::keyboard::{Key, ModifiersState, NamedKey}; | |
| 6 | + | use winit::event::MouseButton; | |
| 7 | + | use winit::keyboard::{Key as WinitKey, ModifiersState, NamedKey}; | |
| 9 | 8 | ||
| 10 | − | use crate::action::{Action, Zoom}; | |
| 11 | − | use crate::state::view::{Axis, Dir, FitMode}; | |
| 9 | + | use keys::{Button, Code, Key, Mods, Named, Scroll}; | |
| 12 | 10 | ||
| 13 | − | #[derive(Debug, Default)] | |
| 14 | − | pub struct Input { | |
| 15 | − | /// A `g` is waiting for its second key. | |
| 16 | − | pending_g: bool, | |
| 17 | − | /// A count prefix (`5n`, `12G`). | |
| 18 | − | count: Option<usize>, | |
| 11 | + | pub fn mods(m: ModifiersState) -> Mods { | |
| 12 | + | Mods { | |
| 13 | + | ctrl: m.control_key(), | |
| 14 | + | alt: m.alt_key(), | |
| 15 | + | shift: m.shift_key(), | |
| 16 | + | sup: m.super_key(), | |
| 17 | + | } | |
| 19 | 18 | } | |
| 20 | 19 | ||
| 21 | − | impl Input { | |
| 22 | − | /// The incomplete sequence typed so far, for the status bar. | |
| 23 | − | pub fn pending(&self) -> String { | |
| 24 | − | let mut s = self.count.map(|c| c.to_string()).unwrap_or_default(); | |
| 25 | − | if self.pending_g { | |
| 26 | − | s.push('g'); | |
| 20 | + | /// A key press in vim terms. Modifier keys on their own, dead keys and unnamed keys give `None`. | |
| 21 | + | pub fn key(key: &WinitKey, m: ModifiersState) -> Option<Key> { | |
| 22 | + | let code = match key { | |
| 23 | + | WinitKey::Character(s) => { | |
| 24 | + | let mut chars = s.chars(); | |
| 25 | + | match (chars.next(), chars.next()) { | |
| 26 | + | (Some(c), None) => Code::Char(c), | |
| 27 | + | _ => return None, | |
| 28 | + | } | |
| 27 | 29 | } | |
| 28 | − | s | |
| 29 | − | } | |
| 30 | + | WinitKey::Named(n) => Code::Named(match n { | |
| 31 | + | NamedKey::Space => Named::Space, | |
| 32 | + | NamedKey::Enter => Named::Enter, | |
| 33 | + | NamedKey::Escape => Named::Esc, | |
| 34 | + | NamedKey::Tab => Named::Tab, | |
| 35 | + | NamedKey::Backspace => Named::Backspace, | |
| 36 | + | NamedKey::Delete => Named::Delete, | |
| 37 | + | NamedKey::Insert => Named::Insert, | |
| 38 | + | NamedKey::Home => Named::Home, | |
| 39 | + | NamedKey::End => Named::End, | |
| 40 | + | NamedKey::PageUp => Named::PageUp, | |
| 41 | + | NamedKey::PageDown => Named::PageDown, | |
| 42 | + | NamedKey::ArrowUp => Named::Up, | |
| 43 | + | NamedKey::ArrowDown => Named::Down, | |
| 44 | + | NamedKey::ArrowLeft => Named::Left, | |
| 45 | + | NamedKey::ArrowRight => Named::Right, | |
| 46 | + | other => { | |
| 47 | + | return function_key(*other) | |
| 48 | + | .map(Named::F) | |
| 49 | + | .map(|f| Key::new(mods(m), Code::Named(f))); | |
| 50 | + | } | |
| 51 | + | }), | |
| 52 | + | _ => return None, | |
| 53 | + | }; | |
| 54 | + | Some(Key::new(mods(m), code)) | |
| 55 | + | } | |
| 30 | 56 | ||
| 31 | − | pub fn cancel(&mut self) { | |
| 32 | − | self.pending_g = false; | |
| 33 | − | self.count = None; | |
| 34 | − | } | |
| 57 | + | fn function_key(n: NamedKey) -> Option<u8> { | |
| 58 | + | use NamedKey::*; | |
| 59 | + | const F: [NamedKey; 35] = [ | |
| 60 | + | F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, F13, F14, F15, F16, F17, F18, F19, F20, | |
| 61 | + | F21, F22, F23, F24, F25, F26, F27, F28, F29, F30, F31, F32, F33, F34, F35, | |
| 62 | + | ]; | |
| 63 | + | F.iter().position(|f| *f == n).map(|i| i as u8 + 1) | |
| 64 | + | } | |
| 35 | 65 | ||
| 36 | − | /// Map a key press to an action using the default bindings. | |
| 37 | − | pub fn key(&mut self, key: &Key, mods: ModifiersState) -> Option<Action> { | |
| 38 | − | if mods.control_key() || mods.alt_key() || mods.super_key() { | |
| 39 | − | return None; | |
| 40 | − | } | |
| 41 | − | if let Key::Character(s) = key { | |
| 42 | − | // `0` is a motion unless a count is already pending. | |
| 43 | − | if let Some(d) = s.chars().next().and_then(|c| c.to_digit(10)) | |
| 44 | − | && s.len() == 1 | |
| 45 | − | && !self.pending_g | |
| 46 | − | && (d != 0 || self.count.is_some()) | |
| 47 | − | { | |
| 48 | − | self.count = Some( | |
| 49 | − | self.count | |
| 50 | − | .unwrap_or(0) | |
| 51 | − | .saturating_mul(10) | |
| 52 | − | .saturating_add(d as usize) | |
| 53 | − | .min(9_999_999), | |
| 54 | − | ); | |
| 55 | − | return None; | |
| 56 | − | } | |
| 57 | − | } | |
| 58 | − | let was_g = std::mem::take(&mut self.pending_g); | |
| 59 | − | if !was_g && matches!(key, Key::Character(s) if s == "g") { | |
| 60 | − | self.pending_g = true; | |
| 61 | − | return None; | |
| 62 | − | } | |
| 63 | − | let count = self.count.take(); | |
| 64 | − | let n = count.unwrap_or(1); | |
| 65 | − | let action = match key { | |
| 66 | − | Key::Character(s) => match s.as_str() { | |
| 67 | − | "g" => count.map_or(Action::First, Action::Goto), | |
| 68 | − | "q" => Action::Quit, | |
| 69 | − | "n" => Action::Next(n), | |
| 70 | − | "p" => Action::Prev(n), | |
| 71 | − | "]" => Action::Next(10 * n), | |
| 72 | − | "[" => Action::Prev(10 * n), | |
| 73 | − | "G" => count.map_or(Action::Last, Action::Goto), | |
| 74 | − | "h" => Action::Pan(Dir::Left), | |
| 75 | − | "j" => Action::Pan(Dir::Down), | |
| 76 | − | "k" => Action::Pan(Dir::Up), | |
| 77 | − | "l" => Action::Pan(Dir::Right), | |
| 78 | − | "H" => Action::PanEdge(Dir::Left), | |
| 79 | − | "J" => Action::PanEdge(Dir::Down), | |
| 80 | − | "K" => Action::PanEdge(Dir::Up), | |
| 81 | − | "L" => Action::PanEdge(Dir::Right), | |
| 82 | − | "+" => Action::Zoom(Zoom::In), | |
| 83 | − | "-" => Action::Zoom(Zoom::Out), | |
| 84 | − | "=" => Action::Zoom(Zoom::Percent(100.0)), | |
| 85 | − | "w" => Action::Fit(FitMode::Fit), | |
| 86 | − | "W" => Action::Fit(FitMode::Fill), | |
| 87 | − | "e" => Action::Fit(FitMode::Width), | |
| 88 | − | "E" => Action::Fit(FitMode::Height), | |
| 89 | − | "z" => Action::Center, | |
| 90 | − | "<" => Action::Rotate(-1), | |
| 91 | − | ">" => Action::Rotate(1), | |
| 92 | − | "|" => Action::Flip(Axis::Horizontal), | |
| 93 | − | "_" => Action::Flip(Axis::Vertical), | |
| 94 | − | "a" => Action::FilterToggle, | |
| 95 | − | "A" => Action::AlphaBgCycle, | |
| 96 | − | "f" => Action::Fullscreen, | |
| 97 | − | "b" => Action::BarToggle, | |
| 98 | − | "r" => Action::Reload, | |
| 99 | − | "D" => Action::Remove, | |
| 100 | − | _ => return None, | |
| 101 | − | }, | |
| 102 | − | Key::Named(named) => match named { | |
| 103 | − | NamedKey::Backspace => Action::Prev(n), | |
| 104 | − | NamedKey::Escape => Action::Cancel, | |
| 105 | − | NamedKey::ArrowLeft => Action::Pan(Dir::Left), | |
| 106 | − | NamedKey::ArrowDown => Action::Pan(Dir::Down), | |
| 107 | − | NamedKey::ArrowUp => Action::Pan(Dir::Up), | |
| 108 | − | NamedKey::ArrowRight => Action::Pan(Dir::Right), | |
| 109 | − | NamedKey::Home => Action::First, | |
| 110 | − | NamedKey::End => Action::Last, | |
| 111 | − | _ => return None, | |
| 112 | − | }, | |
| 113 | − | _ => return None, | |
| 114 | − | }; | |
| 115 | − | Some(action) | |
| 116 | − | } | |
| 66 | + | pub fn button(b: MouseButton, m: ModifiersState) -> Option<Key> { | |
| 67 | + | let b = match b { | |
| 68 | + | MouseButton::Left => Button::Left, | |
| 69 | + | MouseButton::Right => Button::Right, | |
| 70 | + | MouseButton::Middle => Button::Middle, | |
| 71 | + | MouseButton::Back => Button::Back, | |
| 72 | + | MouseButton::Forward => Button::Forward, | |
| 73 | + | MouseButton::Other(_) => return None, | |
| 74 | + | }; | |
| 75 | + | Some(Key::new(mods(m), Code::Mouse(b))) | |
| 117 | 76 | } | |
| 118 | 77 | ||
| 119 | − | /// Whether holding the key should repeat the action. | |
| 120 | − | pub fn repeats(action: &Action) -> bool { | |
| 121 | − | matches!( | |
| 122 | − | action, | |
| 123 | − | Action::Next(_) | Action::Prev(_) | Action::Zoom(Zoom::In | Zoom::Out) | |
| 124 | − | ) | |
| 78 | + | pub fn scroll(dir: Scroll, m: ModifiersState) -> Key { | |
| 79 | + | Key::new(mods(m), Code::Scroll(dir)) | |
| 125 | 80 | } | |
| 126 | 81 | ||
| 127 | 82 | #[cfg(test)] | |
| 128 | 83 | mod tests { | |
| 129 | 84 | use super::*; | |
| 85 | + | use keys::KeySeq; | |
| 130 | 86 | ||
| 131 | − | fn ch(s: &str) -> Key { | |
| 132 | − | Key::Character(s.into()) | |
| 87 | + | fn k(s: &str) -> Key { | |
| 88 | + | s.parse::<KeySeq>().unwrap().0[0] | |
| 133 | 89 | } | |
| 134 | 90 | ||
| 135 | 91 | #[test] | |
| 136 | − | fn gg_is_a_sequence() { | |
| 137 | − | let mut input = Input::default(); | |
| 92 | + | fn winit_keys_match_notation() { | |
| 138 | 93 | let none = ModifiersState::empty(); | |
| 139 | − | assert_eq!(input.key(&ch("g"), none), None); | |
| 140 | − | assert_eq!(input.pending(), "g"); | |
| 141 | − | assert_eq!(input.key(&ch("g"), none), Some(Action::First)); | |
| 142 | − | assert_eq!(input.key(&ch("g"), none), None); | |
| 143 | − | assert_eq!(input.key(&ch("n"), none), Some(Action::Next(1))); | |
| 144 | − | assert_eq!(input.pending(), ""); | |
| 145 | − | } | |
| 146 | − | ||
| 147 | − | #[test] | |
| 148 | − | fn counts() { | |
| 149 | − | let mut input = Input::default(); | |
| 150 | − | let none = ModifiersState::empty(); | |
| 151 | − | for k in ["1", "2"] { | |
| 152 | − | assert_eq!(input.key(&ch(k), none), None); | |
| 153 | − | } | |
| 154 | − | assert_eq!(input.pending(), "12"); | |
| 155 | − | assert_eq!(input.key(&ch("G"), none), Some(Action::Goto(12))); | |
| 156 | − | assert_eq!(input.key(&ch("3"), none), None); | |
| 157 | − | assert_eq!(input.key(&ch("n"), none), Some(Action::Next(3))); | |
| 158 | − | assert_eq!(input.key(&ch("G"), none), Some(Action::Last)); | |
| 159 | − | // `0` alone is not a count. | |
| 160 | − | assert_eq!(input.key(&ch("0"), none), None); | |
| 161 | − | assert_eq!(input.pending(), ""); | |
| 162 | − | input.key(&ch("5"), none); | |
| 163 | − | input.key(&ch("g"), none); | |
| 164 | − | assert_eq!(input.pending(), "5g"); | |
| 165 | − | assert_eq!(input.key(&ch("g"), none), Some(Action::Goto(5))); | |
| 166 | − | } | |
| 167 | − | ||
| 168 | − | #[test] | |
| 169 | − | fn modifiers_block_plain_bindings() { | |
| 170 | − | let mut input = Input::default(); | |
| 171 | − | assert_eq!(input.key(&ch("n"), ModifiersState::CONTROL), None); | |
| 94 | + | let ch = |s: &str| WinitKey::Character(s.into()); | |
| 95 | + | assert_eq!(key(&ch("n"), none), Some(k("n"))); | |
| 96 | + | // Shift is already in the character. | |
| 97 | + | assert_eq!(key(&ch("G"), ModifiersState::SHIFT), Some(k("G"))); | |
| 98 | + | assert_eq!(key(&ch("?"), ModifiersState::SHIFT), Some(k("?"))); | |
| 99 | + | assert_eq!(key(&ch("x"), ModifiersState::CONTROL), Some(k("<C-x>"))); | |
| 100 | + | assert_eq!(key(&ch(" "), none), Some(k("<Space>"))); | |
| 101 | + | assert_eq!( | |
| 102 | + | key(&WinitKey::Named(NamedKey::Space), ModifiersState::CONTROL), | |
| 103 | + | Some(k("<C-Space>")) | |
| 104 | + | ); | |
| 105 | + | assert_eq!( | |
| 106 | + | key(&WinitKey::Named(NamedKey::Tab), ModifiersState::SHIFT), | |
| 107 | + | Some(k("<S-Tab>")) | |
| 108 | + | ); | |
| 109 | + | assert_eq!(key(&WinitKey::Named(NamedKey::F5), none), Some(k("<F5>"))); | |
| 110 | + | assert_eq!( | |
| 111 | + | key(&WinitKey::Named(NamedKey::Shift), ModifiersState::SHIFT), | |
| 112 | + | None | |
| 113 | + | ); | |
| 114 | + | assert_eq!(button(MouseButton::Right, none), Some(k("<RightMouse>"))); | |
| 115 | + | assert_eq!( | |
| 116 | + | scroll(Scroll::Up, ModifiersState::CONTROL), | |
| 117 | + | k("<C-ScrollUp>") | |
| 118 | + | ); | |
| 172 | 119 | } | |
| 173 | 120 | } | |
| @@ -17,12 +17,13 @@ mod thumbs; | |||
| 17 | 17 | mod ui; | |
| 18 | 18 | mod worker; | |
| 19 | 19 | ||
| 20 | + | use std::path::Path; | |
| 20 | 21 | use std::time::Instant; | |
| 21 | 22 | ||
| 22 | 23 | use clap::Parser; | |
| 23 | 24 | use winit::event_loop::EventLoop; | |
| 24 | 25 | ||
| 25 | − | use app::{App, Options, UserEvent}; | |
| 26 | + | use app::{App, Options, Overrides, UserEvent}; | |
| 26 | 27 | use state::queue::Queue; | |
| 27 | 28 | use state::{AppState, MsgKind, Settings}; | |
| 28 | 29 | ||
| @@ -30,15 +31,37 @@ fn main() -> anyhow::Result<()> { | |||
| 30 | 31 | let started = Instant::now(); | |
| 31 | 32 | env_logger::init(); | |
| 32 | 33 | let cli = cli::Cli::parse(); | |
| 34 | + | if cli.dump_config { | |
| 35 | + | print!("{}", config::dump()); | |
| 36 | + | return Ok(()); | |
| 37 | + | } | |
| 38 | + | if cli.check_config { | |
| 39 | + | check_config(cli.config.as_deref()); | |
| 40 | + | } | |
| 33 | 41 | log::info!("remage {}", env!("CARGO_PKG_VERSION")); | |
| 34 | 42 | ||
| 43 | + | let (loaded, config_error) = match config::load(cli.config.as_deref()) { | |
| 44 | + | Ok(l) => (l, None), | |
| 45 | + | Err(e) => { | |
| 46 | + | log::error!("{e}"); | |
| 47 | + | let mut d = config::defaults(); | |
| 48 | + | d.watch = e.watch; | |
| 49 | + | (d, Some(e.message)) | |
| 50 | + | } | |
| 51 | + | }; | |
| 52 | + | for w in &loaded.warnings { | |
| 53 | + | log::warn!("{w}"); | |
| 54 | + | } | |
| 55 | + | log::debug!("config loaded after {:.1?}", started.elapsed()); | |
| 56 | + | let cfg = &loaded.config; | |
| 57 | + | ||
| 35 | 58 | let scan = scan::scan( | |
| 36 | 59 | &cli.paths, | |
| 37 | 60 | &scan::ScanOptions { | |
| 38 | − | recursive: cli.recursive, | |
| 39 | − | siblings: !cli.no_siblings, | |
| 40 | − | sort: cli.sort, | |
| 41 | − | reverse: cli.reverse, | |
| 61 | + | recursive: cli.recursive || cfg.general.recursive, | |
| 62 | + | siblings: !cli.no_siblings && cfg.general.open_siblings, | |
| 63 | + | sort: cli.sort.unwrap_or(cfg.general.sort), | |
| 64 | + | reverse: cli.reverse || cfg.general.reverse, | |
| 42 | 65 | }, | |
| 43 | 66 | ); | |
| 44 | 67 | log::debug!( | |
| @@ -48,14 +71,20 @@ fn main() -> anyhow::Result<()> { | |||
| 48 | 71 | started.elapsed() | |
| 49 | 72 | ); | |
| 50 | 73 | ||
| 51 | − | let settings = Settings { | |
| 52 | − | fit: cli.fit.unwrap_or(Settings::default().fit), | |
| 74 | + | let overrides = Overrides { | |
| 75 | + | fit: cli.fit, | |
| 53 | 76 | zoom: cli.zoom.map(|z| z / 100.0), | |
| 54 | − | ..Settings::default() | |
| 77 | + | bar: cli.no_bar.then_some(false), | |
| 55 | 78 | }; | |
| 79 | + | let mut settings = Settings::from_config(cfg); | |
| 80 | + | overrides.apply(&mut settings); | |
| 56 | 81 | let start_index = cli.start.unwrap_or(1).saturating_sub(1); | |
| 57 | 82 | let queue = Queue::new(scan.files, scan.start.as_deref(), start_index); | |
| 58 | − | let mut state = AppState::new(queue, settings, !cli.no_bar, Instant::now()); | |
| 83 | + | let bar = overrides.bar.unwrap_or(cfg.ui.statusbar); | |
| 84 | + | let mut state = AppState::new(queue, settings, bar, Instant::now()); | |
| 85 | + | state.theme = loaded.theme.clone(); | |
| 86 | + | state.nearest = cfg.image.filter.nearest(); | |
| 87 | + | state.alpha_bg = cfg.image.alpha_background; | |
| 59 | 88 | if let Some(err) = scan.errors.first() { | |
| 60 | 89 | state.notify(format!("error: {err}"), MsgKind::Error, Instant::now()); | |
| 61 | 90 | } | |
| @@ -72,17 +101,47 @@ fn main() -> anyhow::Result<()> { | |||
| 72 | 101 | } | |
| 73 | 102 | }); | |
| 74 | 103 | } | |
| 104 | + | let watch = { | |
| 105 | + | let proxy = proxy.clone(); | |
| 106 | + | config::watch::spawn(cli.config.clone(), loaded.watch.clone(), move |r| { | |
| 107 | + | let _ = proxy.send_event(UserEvent::Config(Box::new(r))); | |
| 108 | + | }) | |
| 109 | + | }; | |
| 75 | 110 | ||
| 76 | 111 | let opts = Options { | |
| 77 | − | fullscreen: cli.fullscreen, | |
| 112 | + | fullscreen: cli.fullscreen || cfg.window.fullscreen, | |
| 78 | 113 | geometry: cli.geometry, | |
| 79 | − | class: cli.class, | |
| 80 | − | cache_mb: 512, | |
| 114 | + | class: cli.class.unwrap_or_else(|| cfg.window.class.clone()), | |
| 115 | + | overrides, | |
| 81 | 116 | }; | |
| 82 | − | let mut app = App::new(opts, state, proxy); | |
| 117 | + | let mut app = App::new(opts, state, proxy, loaded, watch); | |
| 118 | + | if let Some(e) = config_error { | |
| 119 | + | app.config_failed(&e); | |
| 120 | + | } | |
| 83 | 121 | event_loop.run_app(&mut app)?; | |
| 84 | 122 | match app.error.take() { | |
| 85 | 123 | Some(e) => Err(e), | |
| 86 | 124 | None => Ok(()), | |
| 87 | 125 | } | |
| 88 | 126 | } | |
| 127 | + | ||
| 128 | + | /// `--check-config`: report and exit, non-zero on errors. | |
| 129 | + | fn check_config(path: Option<&Path>) -> ! { | |
| 130 | + | match config::load(path) { | |
| 131 | + | Ok(l) => { | |
| 132 | + | for w in &l.warnings { | |
| 133 | + | eprintln!("warning: {w}"); | |
| 134 | + | } | |
| 135 | + | match path.map(Path::to_path_buf).or_else(config::default_path) { | |
| 136 | + | Some(p) if p.is_file() => println!("{}: ok", p.display()), | |
| 137 | + | Some(p) => println!("{} not found: using the defaults", p.display()), | |
| 138 | + | None => println!("no config directory: using the defaults"), | |
| 139 | + | } | |
| 140 | + | std::process::exit(0) | |
| 141 | + | } | |
| 142 | + | Err(e) => { | |
| 143 | + | eprintln!("{e}"); | |
| 144 | + | std::process::exit(1) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | } | |
| @@ -153,6 +153,10 @@ impl Renderer { | |||
| 153 | 153 | self.text.measure(item) | |
| 154 | 154 | } | |
| 155 | 155 | ||
| 156 | + | pub fn set_font_families(&mut self, families: [String; 3]) { | |
| 157 | + | self.text.set_families(families); | |
| 158 | + | } | |
| 159 | + | ||
| 156 | 160 | pub fn set_fonts(&mut self, db: glyphon::fontdb::Database) { | |
| 157 | 161 | self.text.set_fonts(&self.device, &self.queue, db); | |
| 158 | 162 | } | |
| @@ -31,15 +31,18 @@ pub enum FontRole { | |||
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | impl FontRole { | |
| 34 | − | fn family(self) -> &'static str { | |
| 34 | + | fn index(self) -> usize { | |
| 35 | 35 | match self { | |
| 36 | − | FontRole::Ui => "Space Grotesk", | |
| 37 | − | FontRole::Mono => "Space Mono", | |
| 38 | − | FontRole::Display => "Doto", | |
| 36 | + | FontRole::Ui => 0, | |
| 37 | + | FontRole::Mono => 1, | |
| 38 | + | FontRole::Display => 2, | |
| 39 | 39 | } | |
| 40 | 40 | } | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | + | /// The embedded families: UI, mono, display. | |
| 44 | + | pub const DEFAULT_FAMILIES: [&str; 3] = ["Space Grotesk", "Space Mono", "Doto"]; | |
| 45 | + | ||
| 43 | 46 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 44 | 47 | pub enum Align { | |
| 45 | 48 | Left, | |
| @@ -102,6 +105,8 @@ pub struct TextSystem { | |||
| 102 | 105 | viewport: Viewport, | |
| 103 | 106 | format: wgpu::TextureFormat, | |
| 104 | 107 | shaped: HashMap<Key, Shaped>, | |
| 108 | + | /// Family names per [`FontRole`], from `ui.font_*`. | |
| 109 | + | families: [String; 3], | |
| 105 | 110 | } | |
| 106 | 111 | ||
| 107 | 112 | /// Locale for shaping, from the usual environment variables. | |
| @@ -149,6 +154,14 @@ impl TextSystem { | |||
| 149 | 154 | viewport, | |
| 150 | 155 | format, | |
| 151 | 156 | shaped: HashMap::new(), | |
| 157 | + | families: DEFAULT_FAMILIES.map(String::from), | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | pub fn set_families(&mut self, families: [String; 3]) { | |
| 162 | + | if families != self.families { | |
| 163 | + | self.families = families; | |
| 164 | + | self.shaped.clear(); | |
| 152 | 165 | } | |
| 153 | 166 | } | |
| 154 | 167 | ||
| @@ -175,8 +188,9 @@ impl TextSystem { | |||
| 175 | 188 | let line_height = (item.size * 1.25).ceil(); | |
| 176 | 189 | let mut buffer = Buffer::new(&mut self.fonts, Metrics::new(item.size, line_height)); | |
| 177 | 190 | buffer.set_size(None, None); | |
| 191 | + | let family = self.families[item.role.index()].clone(); | |
| 178 | 192 | let base = Attrs::new() | |
| 179 | − | .family(Family::Name(item.role.family())) | |
| 193 | + | .family(Family::Name(&family)) | |
| 180 | 194 | .weight(Weight(item.weight)) | |
| 181 | 195 | .letter_spacing(item.tracking); | |
| 182 | 196 | let spans = item.spans.iter().map(|(s, c)| { | |
| @@ -58,6 +58,11 @@ pub fn scan(args: &[PathBuf], opts: &ScanOptions) -> Scan { | |||
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | fn sort(files: &mut [PathBuf], opts: &ScanOptions) { | |
| 61 | + | sort_paths(files, opts.sort, opts.reverse); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// Sort with the filesystem metadata the stat-based keys need. Can be slow: not on the UI thread. | |
| 65 | + | pub fn sort_paths(files: &mut [PathBuf], key: SortKey, reverse: bool) { | |
| 61 | 66 | let meta = |p: &Path| { | |
| 62 | 67 | std::fs::metadata(p).map_or((0, 0), |m| { | |
| 63 | 68 | let mtime = m | |
| @@ -71,7 +76,7 @@ fn sort(files: &mut [PathBuf], opts: &ScanOptions) { | |||
| 71 | 76 | let seed = SystemTime::now() | |
| 72 | 77 | .duration_since(UNIX_EPOCH) | |
| 73 | 78 | .map_or(1, |d| d.as_nanos() as u64); | |
| 74 | − | sort_files(files, opts.sort, opts.reverse, meta, seed); | |
| 79 | + | sort_files(files, key, reverse, meta, seed); | |
| 75 | 80 | } | |
| 76 | 81 | ||
| 77 | 82 | /// Collect visible files under `dir`. Symlinked directories are not followed. | |
| @@ -7,9 +7,11 @@ pub mod view; | |||
| 7 | 7 | use std::path::{Path, PathBuf}; | |
| 8 | 8 | use std::time::{Duration, Instant}; | |
| 9 | 9 | ||
| 10 | − | use crate::action::{Action, Zoom}; | |
| 10 | + | use crate::action::{Action, ModeArg, Switch, Zoom}; | |
| 11 | 11 | use crate::anim::Tween; | |
| 12 | − | use queue::Queue; | |
| 12 | + | use crate::config::theme::Theme; | |
| 13 | + | use crate::config::{Config, Transition}; | |
| 14 | + | use queue::{Queue, SortKey}; | |
| 13 | 15 | use view::{Affine, Dir, FitMode, View}; | |
| 14 | 16 | ||
| 15 | 17 | /// `[LOADING]` appears only once a load has taken this long, so it never flickers. | |
| @@ -20,8 +22,22 @@ const MIN_PAN_HOLD: Duration = Duration::from_millis(120); | |||
| 20 | 22 | const PAN_SPEED: f32 = 1.25; | |
| 21 | 23 | /// Logical height of the status bar. | |
| 22 | 24 | pub const BAR_HEIGHT: f32 = 32.0; | |
| 25 | + | /// Image switch crossfade with `transition = "fade"`. | |
| 26 | + | const CROSSFADE: Duration = Duration::from_millis(100); | |
| 27 | + | /// Theme changes (a new wallpaper palette) crossfade this long. | |
| 28 | + | const THEME_FADE: Duration = Duration::from_millis(250); | |
| 29 | + | ||
| 30 | + | /// Which keymap applies, and (from M3) what is on screen. | |
| 31 | + | #[allow(dead_code)] // grid and visual arrive in M3 | |
| 32 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 33 | + | pub enum Mode { | |
| 34 | + | #[default] | |
| 35 | + | Image, | |
| 36 | + | Grid, | |
| 37 | + | Visual, | |
| 38 | + | } | |
| 23 | 39 | ||
| 24 | − | #[derive(Clone, Debug)] | |
| 40 | + | #[derive(Clone, Debug, PartialEq)] | |
| 25 | 41 | pub struct Settings { | |
| 26 | 42 | pub fit: FitMode, | |
| 27 | 43 | pub zoom: Option<f32>, | |
| @@ -30,6 +46,10 @@ pub struct Settings { | |||
| 30 | 46 | pub preload: usize, | |
| 31 | 47 | pub anim: Duration, | |
| 32 | 48 | pub crossfade: Duration, | |
| 49 | + | pub theme_fade: Duration, | |
| 50 | + | /// UI size multiplier on top of the display scale. | |
| 51 | + | pub ui_scale: f32, | |
| 52 | + | pub which_key_delay: Duration, | |
| 33 | 53 | } | |
| 34 | 54 | ||
| 35 | 55 | impl Default for Settings { | |
| @@ -41,13 +61,40 @@ impl Default for Settings { | |||
| 41 | 61 | wrap: true, | |
| 42 | 62 | preload: 2, | |
| 43 | 63 | anim: Duration::from_millis(180), | |
| 44 | − | crossfade: Duration::from_millis(100), | |
| 64 | + | crossfade: CROSSFADE, | |
| 65 | + | theme_fade: THEME_FADE, | |
| 66 | + | ui_scale: 1.0, | |
| 67 | + | which_key_delay: Duration::from_millis(300), | |
| 45 | 68 | } | |
| 46 | 69 | } | |
| 47 | 70 | } | |
| 48 | 71 | ||
| 49 | − | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 72 | + | impl Settings { | |
| 73 | + | pub fn from_config(c: &Config) -> Self { | |
| 74 | + | let on = c.animation.enabled; | |
| 75 | + | let anim = |d: Duration| if on { d } else { Duration::ZERO }; | |
| 76 | + | Self { | |
| 77 | + | fit: c.image.fit, | |
| 78 | + | zoom: None, | |
| 79 | + | zoom_step: c.image.zoom_step, | |
| 80 | + | wrap: c.general.wrap, | |
| 81 | + | preload: c.image.preload, | |
| 82 | + | anim: anim(Duration::from_millis(c.animation.duration_ms)), | |
| 83 | + | crossfade: match c.animation.transition { | |
| 84 | + | Transition::Fade => anim(CROSSFADE), | |
| 85 | + | Transition::None => Duration::ZERO, | |
| 86 | + | }, | |
| 87 | + | theme_fade: anim(THEME_FADE), | |
| 88 | + | ui_scale: c.ui.scale, | |
| 89 | + | which_key_delay: Duration::from_millis(c.ui.which_key_delay_ms), | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] | |
| 95 | + | #[serde(rename_all = "lowercase")] | |
| 50 | 96 | pub enum AlphaBg { | |
| 97 | + | #[default] | |
| 51 | 98 | Dots, | |
| 52 | 99 | Checker, | |
| 53 | 100 | Solid, | |
| @@ -106,10 +153,15 @@ pub struct Effects { | |||
| 106 | 153 | pub redraw: bool, | |
| 107 | 154 | /// The queue's current file changed: load it and refresh the preload window. | |
| 108 | 155 | pub navigated: bool, | |
| 156 | + | /// The queue order changed around the current file: refresh the preload window. | |
| 157 | + | pub reordered: bool, | |
| 109 | 158 | /// The status bar appeared or vanished, so the image area changed. | |
| 110 | 159 | pub layout: bool, | |
| 111 | − | pub fullscreen: bool, | |
| 160 | + | pub fullscreen: Option<Switch>, | |
| 112 | 161 | pub reload: bool, | |
| 162 | + | pub reload_config: bool, | |
| 163 | + | /// Re-sort the queue (stat-based keys need the filesystem, so the loop does it). | |
| 164 | + | pub sort: Option<SortKey>, | |
| 113 | 165 | pub quit: bool, | |
| 114 | 166 | } | |
| 115 | 167 | ||
| @@ -118,6 +170,10 @@ pub struct AppState { | |||
| 118 | 170 | pub queue: Queue, | |
| 119 | 171 | pub view: View, | |
| 120 | 172 | pub settings: Settings, | |
| 173 | + | pub mode: Mode, | |
| 174 | + | pub theme: Theme, | |
| 175 | + | /// The previous theme fading out after a reload. | |
| 176 | + | theme_fade: Option<(Theme, Tween<f32>)>, | |
| 121 | 177 | pub bar: bool, | |
| 122 | 178 | /// `None` = automatic (nearest above 300%), otherwise forced nearest/linear. | |
| 123 | 179 | pub nearest: Option<bool>, | |
| @@ -141,6 +197,9 @@ impl AppState { | |||
| 141 | 197 | queue, | |
| 142 | 198 | view: View::new(now), | |
| 143 | 199 | settings, | |
| 200 | + | mode: Mode::Image, | |
| 201 | + | theme: Theme::nothing_dark(), | |
| 202 | + | theme_fade: None, | |
| 144 | 203 | bar, | |
| 145 | 204 | nearest: None, | |
| 146 | 205 | alpha_bg: AlphaBg::Dots, | |
| @@ -172,10 +231,15 @@ impl AppState { | |||
| 172 | 231 | self.scale_factor | |
| 173 | 232 | } | |
| 174 | 233 | ||
| 234 | + | /// Display scale times `ui.scale`: the factor for every UI size. | |
| 235 | + | pub fn ui_scale(&self) -> f32 { | |
| 236 | + | self.scale_factor * self.settings.ui_scale | |
| 237 | + | } | |
| 238 | + | ||
| 175 | 239 | /// Physical height of the status bar, 0 when hidden. | |
| 176 | 240 | pub fn bar_height(&self) -> f32 { | |
| 177 | 241 | if self.bar { | |
| 178 | − | (BAR_HEIGHT * self.scale_factor).round() | |
| 242 | + | (BAR_HEIGHT * self.ui_scale()).round() | |
| 179 | 243 | } else { | |
| 180 | 244 | 0.0 | |
| 181 | 245 | } | |
| @@ -190,25 +254,77 @@ impl AppState { | |||
| 190 | 254 | self.settings.anim | |
| 191 | 255 | } | |
| 192 | 256 | ||
| 257 | + | /// Apply settings from a (re)loaded config. The view keeps its zoom until the next image. | |
| 258 | + | pub fn set_settings(&mut self, settings: Settings) { | |
| 259 | + | self.settings = settings; | |
| 260 | + | self.update_viewport(); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | /// Switch to a new theme, crossfading from whatever is on screen now. | |
| 264 | + | pub fn set_theme(&mut self, theme: Theme, now: Instant) { | |
| 265 | + | if theme == self.theme { | |
| 266 | + | return; | |
| 267 | + | } | |
| 268 | + | let shown = self.theme_at(now); | |
| 269 | + | self.theme = theme; | |
| 270 | + | if self.settings.theme_fade.is_zero() { | |
| 271 | + | self.theme_fade = None; | |
| 272 | + | return; | |
| 273 | + | } | |
| 274 | + | let mut t = Tween::new(0.0, now); | |
| 275 | + | t.retarget(1.0, now, self.settings.theme_fade); | |
| 276 | + | self.theme_fade = Some((shown, t)); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | /// The theme for the frame at `now`. | |
| 280 | + | pub fn theme_at(&self, now: Instant) -> Theme { | |
| 281 | + | match &self.theme_fade { | |
| 282 | + | Some((from, t)) if t.is_active(now) => from.mix(&self.theme, t.value(now)), | |
| 283 | + | _ => self.theme.clone(), | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 193 | 287 | pub fn apply(&mut self, action: Action, now: Instant) -> Effects { | |
| 288 | + | self.apply_at(action, None, now) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | /// Apply an action; `anchor` (viewport pixels) is where mouse-driven zoom centres. | |
| 292 | + | pub fn apply_at(&mut self, action: Action, anchor: Option<[f32; 2]>, now: Instant) -> Effects { | |
| 194 | 293 | let mut fx = Effects { | |
| 195 | 294 | redraw: true, | |
| 196 | 295 | ..Effects::default() | |
| 197 | 296 | }; | |
| 198 | 297 | let dur = self.dur(); | |
| 199 | 298 | let has_image = self.shown.is_some(); | |
| 299 | + | let switch = |s: Switch, cur: bool| match s { | |
| 300 | + | Switch::Toggle => !cur, | |
| 301 | + | Switch::On => true, | |
| 302 | + | Switch::Off => false, | |
| 303 | + | }; | |
| 200 | 304 | match action { | |
| 201 | 305 | Action::Next(n) => fx.navigated = self.queue.step(n as isize, self.settings.wrap), | |
| 202 | 306 | Action::Prev(n) => fx.navigated = self.queue.step(-(n as isize), self.settings.wrap), | |
| 203 | 307 | Action::First => fx.navigated = self.queue.goto(0), | |
| 204 | 308 | Action::Last => fx.navigated = self.queue.goto(usize::MAX), | |
| 205 | 309 | Action::Goto(n) => fx.navigated = self.queue.goto(n.saturating_sub(1)), | |
| 310 | + | Action::Random => { | |
| 311 | + | let len = self.queue.len(); | |
| 312 | + | if len > 1 { | |
| 313 | + | let r = std::hash::BuildHasher::hash_one( | |
| 314 | + | &std::collections::hash_map::RandomState::new(), | |
| 315 | + | now, | |
| 316 | + | ) as usize; | |
| 317 | + | // Any image but the current one. | |
| 318 | + | let i = (self.queue.index() + 1 + r % (len - 1)) % len; | |
| 319 | + | fx.navigated = self.queue.goto(i); | |
| 320 | + | } | |
| 321 | + | } | |
| 206 | 322 | Action::Zoom(z) if has_image => { | |
| 207 | 323 | let step = self.settings.zoom_step; | |
| 208 | 324 | match z { | |
| 209 | − | Zoom::In => self.view.zoom_by(step, None, now, dur), | |
| 210 | − | Zoom::Out => self.view.zoom_by(1.0 / step, None, now, dur), | |
| 211 | − | Zoom::Percent(p) => self.view.zoom_to(p / 100.0, None, now, dur), | |
| 325 | + | Zoom::In(n) => self.view.zoom_by(step.powi(n as i32), anchor, now, dur), | |
| 326 | + | Zoom::Out(n) => self.view.zoom_by(step.powi(-(n as i32)), anchor, now, dur), | |
| 327 | + | Zoom::Percent(p) => self.view.zoom_to(p / 100.0, anchor, now, dur), | |
| 212 | 328 | } | |
| 213 | 329 | } | |
| 214 | 330 | Action::Fit(fit) if has_image => self.view.set_fit(fit, now, dur), | |
| @@ -227,11 +343,20 @@ impl AppState { | |||
| 227 | 343 | ); | |
| 228 | 344 | } | |
| 229 | 345 | Action::AlphaBgCycle => self.alpha_bg = self.alpha_bg.next(), | |
| 230 | − | Action::Fullscreen => fx.fullscreen = true, | |
| 231 | − | Action::BarToggle => { | |
| 232 | − | self.bar = !self.bar; | |
| 233 | − | self.update_viewport(); | |
| 234 | − | fx.layout = true; | |
| 346 | + | Action::Fullscreen(s) => fx.fullscreen = Some(s), | |
| 347 | + | Action::Bar(s) => { | |
| 348 | + | let bar = switch(s, self.bar); | |
| 349 | + | if bar != self.bar { | |
| 350 | + | self.bar = bar; | |
| 351 | + | self.update_viewport(); | |
| 352 | + | fx.layout = true; | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | Action::Mode(ModeArg::Image) if self.mode == Mode::Image => fx.redraw = false, | |
| 356 | + | Action::Sort(key) => fx.sort = Some(key), | |
| 357 | + | Action::Reverse => { | |
| 358 | + | self.queue.reverse(); | |
| 359 | + | fx.reordered = true; | |
| 235 | 360 | } | |
| 236 | 361 | Action::Reload => fx.reload = true, | |
| 237 | 362 | Action::Remove => { | |
| @@ -240,8 +365,10 @@ impl AppState { | |||
| 240 | 365 | fx.navigated = true; | |
| 241 | 366 | } | |
| 242 | 367 | } | |
| 368 | + | Action::ReloadConfig => fx.reload_config = true, | |
| 369 | + | Action::Echo(msg) => self.notify(msg, MsgKind::Info, now), | |
| 243 | 370 | Action::Cancel => self.message = None, | |
| 244 | − | Action::Quit => fx.quit = true, | |
| 371 | + | Action::Quit | Action::QuitNoOutput => fx.quit = true, | |
| 245 | 372 | Action::Zoom(_) | |
| 246 | 373 | | Action::Fit(_) | |
| 247 | 374 | | Action::Pan(_) | |
| @@ -249,6 +376,37 @@ impl AppState { | |||
| 249 | 376 | | Action::Center | |
| 250 | 377 | | Action::Rotate(_) | |
| 251 | 378 | | Action::Flip(_) => fx.redraw = false, | |
| 379 | + | // Parsed and bindable now; they arrive with later milestones. | |
| 380 | + | other @ (Action::NextMark | |
| 381 | + | | Action::PrevMark | |
| 382 | + | | Action::NextFrame | |
| 383 | + | | Action::PrevFrame | |
| 384 | + | | Action::Mark(_) | |
| 385 | + | | Action::MarkAll | |
| 386 | + | | Action::UnmarkAll | |
| 387 | + | | Action::InvertMarks | |
| 388 | + | | Action::Adjust(..) | |
| 389 | + | | Action::Play(_) | |
| 390 | + | | Action::Slideshow(_) | |
| 391 | + | | Action::SlideshowDelay { .. } | |
| 392 | + | | Action::Mode(_) | |
| 393 | + | | Action::Visual | |
| 394 | + | | Action::Info(_) | |
| 395 | + | | Action::Help | |
| 396 | + | | Action::Palette | |
| 397 | + | | Action::Search | |
| 398 | + | | Action::GridSize(_) | |
| 399 | + | | Action::Filter(_) | |
| 400 | + | | Action::FilterClear | |
| 401 | + | | Action::Refresh | |
| 402 | + | | Action::Open(_) | |
| 403 | + | | Action::Run(_)) => { | |
| 404 | + | self.notify( | |
| 405 | + | format!("{} not available yet", other.name()), | |
| 406 | + | MsgKind::Info, | |
| 407 | + | now, | |
| 408 | + | ); | |
| 409 | + | } | |
| 252 | 410 | } | |
| 253 | 411 | fx | |
| 254 | 412 | } | |
| @@ -341,7 +499,7 @@ impl AppState { | |||
| 341 | 499 | self.loading_since = None; | |
| 342 | 500 | } | |
| 343 | 501 | ||
| 344 | − | /// Zoom by `steps` zoom steps around a viewport point (mouse wheel). | |
| 502 | + | /// Zoom by `steps` zoom steps (fractional for touchpads) around a viewport point. | |
| 345 | 503 | pub fn zoom_at(&mut self, steps: f32, anchor: [f32; 2], now: Instant) { | |
| 346 | 504 | if self.shown.is_some() { | |
| 347 | 505 | let factor = self.settings.zoom_step.powf(steps); | |
| @@ -352,10 +510,20 @@ impl AppState { | |||
| 352 | 510 | ||
| 353 | 511 | pub fn notify(&mut self, text: impl Into<String>, kind: MsgKind, now: Instant) { | |
| 354 | 512 | let secs = if kind == MsgKind::Error { 5 } else { 2 }; | |
| 513 | + | self.notify_for(text, kind, now, Duration::from_secs(secs)); | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | pub fn notify_for( | |
| 517 | + | &mut self, | |
| 518 | + | text: impl Into<String>, | |
| 519 | + | kind: MsgKind, | |
| 520 | + | now: Instant, | |
| 521 | + | d: Duration, | |
| 522 | + | ) { | |
| 355 | 523 | self.message = Some(Message { | |
| 356 | 524 | text: text.into(), | |
| 357 | 525 | kind, | |
| 358 | − | until: now + Duration::from_secs(secs), | |
| 526 | + | until: now + d, | |
| 359 | 527 | }); | |
| 360 | 528 | } | |
| 361 | 529 | ||
| @@ -372,6 +540,10 @@ impl AppState { | |||
| 372 | 540 | pub fn is_animating(&self, now: Instant) -> bool { | |
| 373 | 541 | self.view.is_animating(now) | |
| 374 | 542 | || self.fade.as_ref().is_some_and(|f| f.alpha.is_active(now)) | |
| 543 | + | || self | |
| 544 | + | .theme_fade | |
| 545 | + | .as_ref() | |
| 546 | + | .is_some_and(|(_, t)| t.is_active(now)) | |
| 375 | 547 | || !self.holds.is_empty() | |
| 376 | 548 | } | |
| 377 | 549 | ||
| @@ -398,6 +570,14 @@ impl AppState { | |||
| 398 | 570 | self.fade = None; | |
| 399 | 571 | redraw = true; | |
| 400 | 572 | } | |
| 573 | + | if self | |
| 574 | + | .theme_fade | |
| 575 | + | .as_ref() | |
| 576 | + | .is_some_and(|(_, t)| !t.is_active(now)) | |
| 577 | + | { | |
| 578 | + | self.theme_fade = None; | |
| 579 | + | redraw = true; | |
| 580 | + | } | |
| 401 | 581 | (redraw, next) | |
| 402 | 582 | } | |
| 403 | 583 | } | |
| @@ -428,7 +608,14 @@ mod tests { | |||
| 428 | 608 | fn bar_shrinks_the_image_area() { | |
| 429 | 609 | let (mut s, now) = state(1); | |
| 430 | 610 | assert_eq!(s.view.viewport(), [800.0, 600.0]); | |
| 431 | − | assert!(s.apply(Action::BarToggle, now).layout); | |
| 611 | + | assert!(!s.apply(Action::Bar(Switch::On), now).layout); | |
| 612 | + | assert!(s.apply(Action::Bar(Switch::Toggle), now).layout); | |
| 613 | + | assert_eq!(s.view.viewport(), [800.0, 632.0]); | |
| 614 | + | assert!(!s.apply(Action::Bar(Switch::Off), now).layout); | |
| 615 | + | s.settings.ui_scale = 2.0; | |
| 616 | + | s.apply(Action::Bar(Switch::On), now); | |
| 617 | + | assert_eq!(s.view.viewport(), [800.0, 568.0]); | |
| 618 | + | s.apply(Action::Bar(Switch::Off), now); | |
| 432 | 619 | assert_eq!(s.view.viewport(), [800.0, 632.0]); | |
| 433 | 620 | } | |
| 434 | 621 | ||
| @@ -445,6 +632,44 @@ mod tests { | |||
| 445 | 632 | assert!(s.fade.is_none()); | |
| 446 | 633 | } | |
| 447 | 634 | ||
| 635 | + | #[test] | |
| 636 | + | fn theme_changes_crossfade() { | |
| 637 | + | use crate::config::theme::{Rgba, Theme}; | |
| 638 | + | let (mut s, now) = state(1); | |
| 639 | + | let light = Theme::derive( | |
| 640 | + | Rgba::rgb(0xFFFFFF), | |
| 641 | + | Rgba::rgb(0x000000), | |
| 642 | + | Rgba::rgb(0xD71921), | |
| 643 | + | ); | |
| 644 | + | s.set_theme(light.clone(), now); | |
| 645 | + | assert!(s.is_animating(now)); | |
| 646 | + | let mid = s.theme_at(now + THEME_FADE / 2).background; | |
| 647 | + | assert!(mid.0[0] > 0 && mid.0[0] < 255, "{mid:?}"); | |
| 648 | + | let later = now + THEME_FADE * 2; | |
| 649 | + | assert_eq!(s.theme_at(later), light); | |
| 650 | + | assert!(s.tick(later).0); | |
| 651 | + | assert!(!s.is_animating(later)); | |
| 652 | + | // With animation off the switch is instant. | |
| 653 | + | s.settings.theme_fade = Duration::ZERO; | |
| 654 | + | s.set_theme(Theme::nothing_dark(), later); | |
| 655 | + | assert_eq!(s.theme_at(later), Theme::nothing_dark()); | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | #[test] | |
| 659 | + | fn unfinished_actions_say_so() { | |
| 660 | + | let (mut s, now) = state(2); | |
| 661 | + | let fx = s.apply(Action::Palette, now); | |
| 662 | + | assert!(!fx.navigated); | |
| 663 | + | assert_eq!( | |
| 664 | + | s.message.as_ref().map(|m| m.text.as_str()), | |
| 665 | + | Some("palette not available yet") | |
| 666 | + | ); | |
| 667 | + | s.apply(Action::Echo("hi".into()), now); | |
| 668 | + | assert_eq!(s.message.as_ref().map(|m| m.text.as_str()), Some("hi")); | |
| 669 | + | assert!(s.apply(Action::Random, now).navigated); | |
| 670 | + | assert_eq!(s.queue.index(), 1); | |
| 671 | + | } | |
| 672 | + | ||
| 448 | 673 | #[test] | |
| 449 | 674 | fn tapped_pan_moves_a_minimum_distance() { | |
| 450 | 675 | let (mut s, now) = state(1); | |
| @@ -1,10 +1,23 @@ | |||
| 1 | 1 | //! The file list: current index, ordering and navigation. | |
| 2 | 2 | ||
| 3 | 3 | use std::cmp::Ordering; | |
| 4 | + | use std::collections::HashSet; | |
| 4 | 5 | use std::path::{Path, PathBuf}; | |
| 5 | 6 | ||
| 6 | − | #[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)] | |
| 7 | + | #[derive( | |
| 8 | + | Clone, | |
| 9 | + | Copy, | |
| 10 | + | Debug, | |
| 11 | + | Default, | |
| 12 | + | PartialEq, | |
| 13 | + | Eq, | |
| 14 | + | clap::ValueEnum, | |
| 15 | + | serde::Deserialize, | |
| 16 | + | serde::Serialize, | |
| 17 | + | )] | |
| 18 | + | #[serde(rename_all = "lowercase")] | |
| 7 | 19 | pub enum SortKey { | |
| 20 | + | #[default] | |
| 8 | 21 | Natural, | |
| 9 | 22 | Name, | |
| 10 | 23 | Mtime, | |
| @@ -126,6 +139,38 @@ impl Queue { | |||
| 126 | 139 | out | |
| 127 | 140 | } | |
| 128 | 141 | ||
| 142 | + | pub fn files(&self) -> &[PathBuf] { | |
| 143 | + | &self.files | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// Reverse the order, keeping the current file selected. | |
| 147 | + | pub fn reverse(&mut self) { | |
| 148 | + | self.files.reverse(); | |
| 149 | + | self.index = self.clamp(self.files.len().saturating_sub(1) - self.index); | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /// Adopt a new order of the same files (a finished re-sort), keeping the current file. | |
| 153 | + | /// Files removed meanwhile stay removed; files added meanwhile go at the end. | |
| 154 | + | pub fn reorder(&mut self, order: Vec<PathBuf>) { | |
| 155 | + | let current = self.current().map(Path::to_path_buf); | |
| 156 | + | let have: HashSet<&PathBuf> = self.files.iter().collect(); | |
| 157 | + | let mut files: Vec<PathBuf> = order.into_iter().filter(|f| have.contains(f)).collect(); | |
| 158 | + | let placed: HashSet<&PathBuf> = files.iter().collect(); | |
| 159 | + | let missing: Vec<PathBuf> = self | |
| 160 | + | .files | |
| 161 | + | .iter() | |
| 162 | + | .filter(|f| !placed.contains(f)) | |
| 163 | + | .cloned() | |
| 164 | + | .collect(); | |
| 165 | + | files.extend(missing); | |
| 166 | + | self.files = files; | |
| 167 | + | if let Some(cur) = current | |
| 168 | + | && let Some(i) = self.files.iter().position(|f| *f == cur) | |
| 169 | + | { | |
| 170 | + | self.index = i; | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 129 | 174 | fn clamp(&self, i: usize) -> usize { | |
| 130 | 175 | i.min(self.files.len().saturating_sub(1)) | |
| 131 | 176 | } | |
| @@ -288,6 +333,24 @@ mod tests { | |||
| 288 | 333 | assert_eq!(q.neighbours(2, true), vec![1]); | |
| 289 | 334 | } | |
| 290 | 335 | ||
| 336 | + | #[test] | |
| 337 | + | fn reverse_and_reorder_keep_current() { | |
| 338 | + | let mut q = Queue::new(paths(&["a", "b", "c", "d"]), Some(Path::new("b")), 0); | |
| 339 | + | q.reverse(); | |
| 340 | + | assert_eq!(q.files(), paths(&["d", "c", "b", "a"])); | |
| 341 | + | assert_eq!(q.current(), Some(Path::new("b"))); | |
| 342 | + | q.remove(Path::new("d")); | |
| 343 | + | q.reorder(paths(&["a", "b", "d", "c"])); | |
| 344 | + | assert_eq!(q.files(), paths(&["a", "b", "c"])); | |
| 345 | + | assert_eq!(q.current(), Some(Path::new("b"))); | |
| 346 | + | q.reorder(paths(&["c"])); | |
| 347 | + | assert_eq!(q.files(), paths(&["c", "a", "b"])); | |
| 348 | + | assert_eq!(q.current(), Some(Path::new("b"))); | |
| 349 | + | let mut empty = Queue::default(); | |
| 350 | + | empty.reverse(); | |
| 351 | + | assert!(empty.is_empty()); | |
| 352 | + | } | |
| 353 | + | ||
| 291 | 354 | #[test] | |
| 292 | 355 | fn random_sort_is_a_permutation() { | |
| 293 | 356 | let mut v = paths(&["a", "b", "c", "d", "e"]); | |
| @@ -14,9 +14,21 @@ pub const MAX_SCALE: f32 = 64.0; | |||
| 14 | 14 | /// Magnification above which `filter = auto` switches to nearest. | |
| 15 | 15 | pub const NEAREST_ABOVE: f32 = 3.0; | |
| 16 | 16 | ||
| 17 | − | #[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)] | |
| 17 | + | #[derive( | |
| 18 | + | Clone, | |
| 19 | + | Copy, | |
| 20 | + | Debug, | |
| 21 | + | Default, | |
| 22 | + | PartialEq, | |
| 23 | + | Eq, | |
| 24 | + | clap::ValueEnum, | |
| 25 | + | serde::Deserialize, | |
| 26 | + | serde::Serialize, | |
| 27 | + | )] | |
| 28 | + | #[serde(rename_all = "lowercase")] | |
| 18 | 29 | pub enum FitMode { | |
| 19 | 30 | /// Fit, but never enlarge. | |
| 31 | + | #[default] | |
| 20 | 32 | Down, | |
| 21 | 33 | Fit, | |
| 22 | 34 | Fill, | |
| @@ -46,7 +46,7 @@ pub fn scene<'a>( | |||
| 46 | 46 | now: Instant, | |
| 47 | 47 | measure: &mut dyn FnMut(&TextItem) -> f32, | |
| 48 | 48 | ) -> Scene<'a> { | |
| 49 | − | let sf = state.scale_factor(); | |
| 49 | + | let sf = state.ui_scale(); | |
| 50 | 50 | let [w, h] = state.window(); | |
| 51 | 51 | let bar_h = state.bar_height(); | |
| 52 | 52 | let mut scene = Scene { | |
| @@ -138,7 +138,7 @@ fn status_bar( | |||
| 138 | 138 | bar_h: f32, | |
| 139 | 139 | measure: &mut dyn FnMut(&TextItem) -> f32, | |
| 140 | 140 | ) { | |
| 141 | − | let sf = state.scale_factor(); | |
| 141 | + | let sf = state.ui_scale(); | |
| 142 | 142 | let pad = 16.0 * sf; | |
| 143 | 143 | let y = h - bar_h * 0.5; | |
| 144 | 144 | scene.prims.push(Prim::rect( | |
| @@ -185,7 +185,7 @@ fn status_bar( | |||
| 185 | 185 | info.push(format!("{:0digits$}/{count}", state.queue.index() + 1)); | |
| 186 | 186 | } | |
| 187 | 187 | if !info.is_empty() { | |
| 188 | − | spans.push((info.join(" "), theme.text_secondary)); | |
| 188 | + | spans.push((info.join(" ").to_uppercase(), theme.text_secondary)); | |
| 189 | 189 | } | |
| 190 | 190 | // Two spaces between segments, attached to the preceding span so colours stay put. | |
| 191 | 191 | let n = spans.len(); | |
| @@ -195,10 +195,8 @@ fn status_bar( | |||
| 195 | 195 | let mut right_w = 0.0; | |
| 196 | 196 | if !spans.is_empty() { | |
| 197 | 197 | let mut item = label("", theme.text_secondary, sf, w - pad, y, Align::Right); | |
| 198 | − | item.spans = spans | |
| 199 | − | .into_iter() | |
| 200 | − | .map(|(s, c)| (s.to_uppercase(), c)) | |
| 201 | − | .collect(); | |
| 198 | + | // Already in caps, except pending keys: `g` and `G` are different keys. | |
| 199 | + | item.spans = spans; | |
| 202 | 200 | right_w = measure(&item); | |
| 203 | 201 | scene.texts.push(item); | |
| 204 | 202 | } | |