Krolyxon
Commit 92928dd

Branches

Tags

Refs →

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.
Author
krolyxon
Date
2026-11-04 20:32 +0530
Parent
f2bd31a
Commit
92928ddec989b376ad2471df4e65afeeec05e3bd

+4268 −289

22 files changed

  1. M CLAUDE.md +1 −1
  2. A contrib/matugen/remage.toml +16 −0
  3. A contrib/pywal/remage.toml +15 −0
  4. M src/action.rs +827 −8
  5. M src/app.rs +391 −55
  6. M src/cache.rs +5 −0
  7. M src/cli.rs +18 −6
  8. A src/config/default.toml +175 −0
  9. M src/config/mod.rs +1046 −1
  10. M src/config/theme.rs +237 −22
  11. A src/config/watch.rs +143 −0
  12. M src/input/keymap.rs +435 −0
  13. M src/input/keys.rs +437 −1
  14. M src/input/mod.rs +95 −148
  15. M src/main.rs +72 −13
  16. M src/render/mod.rs +4 −0
  17. M src/render/text.rs +19 −5
  18. M src/scan.rs +6 −1
  19. M src/state/mod.rs +244 −19
  20. M src/state/queue.rs +64 −1
  21. M src/state/view.rs +13 −1
  22. M src/ui/mod.rs +5 −7
M CLAUDE.md View
@@ -430,7 +430,7 @@ Each milestone should end with a usable binary.
430430 - winit + wgpu window, decoding through the `image` crate.
431431 - Fit/zoom/pan/rotate/flip with animation, and `n`/`p`.
432432 - 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".
434434 - Action grammar, and a keymap trie with sequences, counts and mouse bindings.
435435 - TOML defaults, include and live reload, plus `--dump-config` and `--check-config`.
436436 - Theme tokens, presets and derivation; matugen + pywal support.
A contrib/matugen/remage.toml View
@@ -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}}"
A contrib/pywal/remage.toml View
@@ -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}"
M src/action.rs View
@@ -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.
33
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;
411 use crate::state::view::{Axis, Dir, FitMode};
512
613 #[derive(Clone, Copy, Debug, PartialEq)]
714 pub enum Zoom {
8− In,
9− Out,
15+ /// Zoom steps.
16+ In(u32),
17+ Out(u32),
1018 /// Absolute zoom, 100 = one image pixel per screen pixel.
1119 Percent(f32),
1220 }
1321
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)]
1551 pub enum Action {
1652 Next(usize),
1753 Prev(usize),
@@ -19,6 +55,15 @@ pub enum Action {
1955 Last,
2056 /// 1-based, like the status bar.
2157 Goto(usize),
58+ Random,
59+ NextMark,
60+ PrevMark,
61+ NextFrame,
62+ PrevFrame,
63+ Mark(Switch),
64+ MarkAll,
65+ UnmarkAll,
66+ InvertMarks,
2267 Zoom(Zoom),
2368 Fit(FitMode),
2469 /// From a key this pans for as long as the key is held; otherwise one step.
@@ -30,10 +75,784 @@ pub enum Action {
3075 Flip(Axis),
3176 FilterToggle,
3277 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,
3699 Remove,
100+ Reload,
101+ Refresh,
102+ Open(String),
103+ Run(String),
104+ ReloadConfig,
105+ Echo(String),
37106 Cancel,
38107 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+ }
39858 }
M src/app.rs View
@@ -13,14 +13,18 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy};
1313 use winit::keyboard::{ModifiersState, PhysicalKey};
1414 use winit::window::{Fullscreen, Window, WindowId};
1515
16−use crate::action::Action;
16+use crate::action::{Action, Switch, Zoom};
1717 use crate::cache::ImageCache;
18−use crate::config::theme::Theme;
18+use crate::config::watch::ConfigWatch;
19+use crate::config::{Config, LoadError, Loaded};
1920 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};
2124 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};
2428 use crate::ui;
2529 use crate::worker::{JobKey, Pool, Priority};
2630
@@ -30,6 +34,13 @@ const UPLOAD_BUDGET: usize = 32 << 20;
3034 const SHOW_TIMEOUT: Duration = Duration::from_millis(300);
3135 /// Wheel pixels per zoom step for touchpads.
3236 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);
3344
3445 pub enum UserEvent {
3546 Decoded {
@@ -40,13 +51,36 @@ pub enum UserEvent {
4051 Found(Vec<PathBuf>),
4152 /// Embedded plus system fonts, loaded off-thread after the first frame.
4253 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+ }
4377 }
4478
4579 pub struct Options {
4680 pub fullscreen: bool,
4781 pub geometry: Option<(u32, u32)>,
4882 pub class: String,
49− pub cache_mb: usize,
83+ pub overrides: Overrides,
5084 }
5185
5286 struct Gfx {
@@ -58,8 +92,17 @@ struct Gfx {
5892 pub struct App {
5993 opts: Options,
6094 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>)>,
63106 pool: Pool,
64107 proxy: EventLoopProxy<UserEvent>,
65108 cache: ImageCache,
@@ -71,6 +114,10 @@ pub struct App {
71114 modifiers: ModifiersState,
72115 cursor: [f32; 2],
73116 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],
74121 held: HashMap<PhysicalKey, Dir>,
75122 started: Instant,
76123 shown_window: bool,
@@ -78,13 +125,24 @@ pub struct App {
78125 }
79126
80127 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);
83136 let mut app = Self {
84137 opts,
85138 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,
88146 pool: Pool::new(),
89147 proxy,
90148 cache,
@@ -96,6 +154,8 @@ impl App {
96154 modifiers: ModifiersState::empty(),
97155 cursor: [0.0, 0.0],
98156 dragging: false,
157+ press: None,
158+ scroll: [0.0, 0.0],
99159 held: HashMap::new(),
100160 started: Instant::now(),
101161 shown_window: false,
@@ -152,8 +212,6 @@ impl App {
152212 let now = Instant::now();
153213 if navigated {
154214 self.pool.bump_epoch();
155− self.input.cancel();
156− self.state.pending.clear();
157215 }
158216 let wanted = self.window_paths();
159217 self.update_title();
@@ -217,14 +275,82 @@ impl App {
217275
218276 fn update_title(&self) {
219277 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()),
222287 None => "remage".into(),
223288 };
224289 g.window.set_title(&title);
225290 }
226291 }
227292
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+
228354 fn on_decoded(&mut self, path: PathBuf, result: Result<Arc<DecodedImage>, String>) {
229355 lock(&self.arriving).remove(&path);
230356 let now = Instant::now();
@@ -267,9 +393,20 @@ impl App {
267393 }
268394 }
269395
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+ }
273410 }
274411
275412 fn effects(&mut self, fx: Effects, event_loop: &ActiveEventLoop) {
@@ -277,24 +414,58 @@ impl App {
277414 event_loop.exit();
278415 return;
279416 }
280− if fx.fullscreen
417+ if let Some(s) = fx.fullscreen
281418 && let Some(g) = &self.gfx
282419 {
283420 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+ }
286430 }
287431 if fx.reload {
288432 self.reload();
289433 }
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+ }
290446 if fx.navigated {
291447 self.sync(true);
448+ } else if fx.reordered {
449+ self.sync(false);
292450 }
293451 if fx.redraw || fx.layout {
294452 self.request_redraw();
295453 }
296454 }
297455
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+
298469 fn on_key(&mut self, event: winit::event::KeyEvent, event_loop: &ActiveEventLoop) {
299470 let now = Instant::now();
300471 if event.state == ElementState::Released {
@@ -302,27 +473,100 @@ impl App {
302473 self.state.hold_pan(dir, false, now);
303474 self.request_redraw();
304475 }
476+ if self
477+ .repeat
478+ .as_ref()
479+ .is_some_and(|(k, _)| *k == event.physical_key)
480+ {
481+ self.repeat = None;
482+ }
305483 return;
306484 }
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;
312494 }
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 {
315496 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);
316515 }
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);
322568 }
323− return;
324569 }
325− self.apply(action, event_loop);
326570 }
327571
328572 fn redraw(&mut self) {
@@ -339,9 +583,8 @@ impl App {
339583 if !self.shown_window {
340584 g.window.set_visible(true);
341585 }
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));
345588 let result = g.renderer.render(&scene);
346589 if let Some(path) = std::env::var_os("REMAGE_SNAPSHOT")
347590 && !self.state.is_animating(now)
@@ -384,6 +627,37 @@ impl App {
384627 }
385628 }
386629
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+
387661 fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
388662 m.lock().unwrap_or_else(|e| e.into_inner())
389663 }
@@ -420,7 +694,7 @@ impl ApplicationHandler<UserEvent> for App {
420694 let mut attrs = Window::default_attributes()
421695 .with_title("remage")
422696 .with_inner_size(LogicalSize::new(w, h))
423− .with_decorations(false)
697+ .with_decorations(self.config.window.decorations)
424698 .with_visible(false);
425699 if self.opts.fullscreen {
426700 attrs = attrs.with_fullscreen(Some(Fullscreen::Borderless(None)));
@@ -442,7 +716,10 @@ impl ApplicationHandler<UserEvent> for App {
442716 }
443717 };
444718 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+ }
446723 Err(e) => {
447724 self.error = Some(e.context("initialise GPU"));
448725 event_loop.exit();
@@ -477,11 +754,24 @@ impl ApplicationHandler<UserEvent> for App {
477754 g.window.request_redraw();
478755 }
479756 }
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+ }
480771 }
481772 }
482773
483774 fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
484− let now = Instant::now();
485775 match event {
486776 WindowEvent::CloseRequested => event_loop.exit(),
487777 WindowEvent::Resized(size) => {
@@ -521,22 +811,35 @@ impl ApplicationHandler<UserEvent> for App {
521811 }
522812 self.cursor = p;
523813 }
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+ }
530834 }
531835 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+ }
535841 };
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);
540843 }
541844 _ => {}
542845 }
@@ -551,7 +854,20 @@ impl ApplicationHandler<UserEvent> for App {
551854 fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
552855 let now = Instant::now();
553856 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+ }
554866 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+ }
555871 if redraw {
556872 self.request_redraw();
557873 }
@@ -566,3 +882,23 @@ impl ApplicationHandler<UserEvent> for App {
566882 });
567883 }
568884 }
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+}
M src/cache.rs View
@@ -28,6 +28,11 @@ impl ImageCache {
2828 }
2929 }
3030
31+ /// Takes effect at the next insert.
32+ pub fn set_budget(&mut self, budget_bytes: usize) {
33+ self.budget = budget_bytes;
34+ }
35+
3136 pub fn get(&mut self, path: &Path) -> Option<Arc<DecodedImage>> {
3237 self.clock += 1;
3338 let slot = self.map.get_mut(path)?;
M src/cli.rs View
@@ -41,13 +41,13 @@ pub struct Cli {
4141 #[arg(short = 'g', long, value_name = "WxH", value_parser = parse_geometry)]
4242 pub geometry: Option<(u32, u32)>,
4343
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>,
4747
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>,
5151
5252 /// Reverse the sort order
5353 #[arg(long)]
@@ -56,6 +56,18 @@ pub struct Cli {
5656 /// Open only the named file, not the rest of its directory
5757 #[arg(long)]
5858 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,
5971 }
6072
6173 fn parse_geometry(s: &str) -> Result<(u32, u32), String> {
A src/config/default.toml View
@@ -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:-"]
M src/config/mod.rs View
@@ -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`.
27
38 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+}
M src/config/theme.rs View
@@ -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};
26
37 /// An sRGB colour with straight alpha.
48 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -36,24 +40,131 @@ impl Rgba {
3640 self.0[3] as f32 / 255.0,
3741 ]
3842 }
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+ };
39125 }
40126
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+ }
57168 }
58169
59170 impl Theme {
@@ -80,14 +191,41 @@ impl Theme {
80191 }
81192
82193 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)
88196 }
89197 }
90198
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+
91229 #[cfg(test)]
92230 mod tests {
93231 use super::*;
@@ -112,4 +250,81 @@ mod tests {
112250 let mid = Rgba::rgb(0x808080).linear()[0];
113251 assert!((mid - 0.2158).abs() < 1e-3);
114252 }
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+ }
115330 }
A src/config/watch.rs View
@@ -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: &notify::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+}
M src/input/keymap.rs View
@@ -1 +1,436 @@
11 //! 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+}
M src/input/keys.rs View
@@ -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+}
M src/input/mod.rs View
@@ -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.
42
53 pub mod keymap;
64 pub mod keys;
75
8−use winit::keyboard::{Key, ModifiersState, NamedKey};
6+use winit::event::MouseButton;
7+use winit::keyboard::{Key as WinitKey, ModifiersState, NamedKey};
98
10−use crate::action::{Action, Zoom};
11−use crate::state::view::{Axis, Dir, FitMode};
9+use keys::{Button, Code, Key, Mods, Named, Scroll};
1210
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+ }
1918 }
2019
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+ }
2729 }
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+}
3056
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+}
3565
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)))
11776 }
11877
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))
12580 }
12681
12782 #[cfg(test)]
12883 mod tests {
12984 use super::*;
85+ use keys::KeySeq;
13086
131− fn ch(s: &str) -> Key {
132− Key::Character(s.into())
87+ fn k(s: &str) -> Key {
88+ s.parse::<KeySeq>().unwrap().0[0]
13389 }
13490
13591 #[test]
136− fn gg_is_a_sequence() {
137− let mut input = Input::default();
92+ fn winit_keys_match_notation() {
13893 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+ );
172119 }
173120 }
M src/main.rs View
@@ -17,12 +17,13 @@ mod thumbs;
1717 mod ui;
1818 mod worker;
1919
20+use std::path::Path;
2021 use std::time::Instant;
2122
2223 use clap::Parser;
2324 use winit::event_loop::EventLoop;
2425
25−use app::{App, Options, UserEvent};
26+use app::{App, Options, Overrides, UserEvent};
2627 use state::queue::Queue;
2728 use state::{AppState, MsgKind, Settings};
2829
@@ -30,15 +31,37 @@ fn main() -> anyhow::Result<()> {
3031 let started = Instant::now();
3132 env_logger::init();
3233 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+ }
3341 log::info!("remage {}", env!("CARGO_PKG_VERSION"));
3442
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+
3558 let scan = scan::scan(
3659 &cli.paths,
3760 &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,
4265 },
4366 );
4467 log::debug!(
@@ -48,14 +71,20 @@ fn main() -> anyhow::Result<()> {
4871 started.elapsed()
4972 );
5073
51− let settings = Settings {
52− fit: cli.fit.unwrap_or(Settings::default().fit),
74+ let overrides = Overrides {
75+ fit: cli.fit,
5376 zoom: cli.zoom.map(|z| z / 100.0),
54− ..Settings::default()
77+ bar: cli.no_bar.then_some(false),
5578 };
79+ let mut settings = Settings::from_config(cfg);
80+ overrides.apply(&mut settings);
5681 let start_index = cli.start.unwrap_or(1).saturating_sub(1);
5782 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;
5988 if let Some(err) = scan.errors.first() {
6089 state.notify(format!("error: {err}"), MsgKind::Error, Instant::now());
6190 }
@@ -72,17 +101,47 @@ fn main() -> anyhow::Result<()> {
72101 }
73102 });
74103 }
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+ };
75110
76111 let opts = Options {
77− fullscreen: cli.fullscreen,
112+ fullscreen: cli.fullscreen || cfg.window.fullscreen,
78113 geometry: cli.geometry,
79− class: cli.class,
80− cache_mb: 512,
114+ class: cli.class.unwrap_or_else(|| cfg.window.class.clone()),
115+ overrides,
81116 };
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+ }
83121 event_loop.run_app(&mut app)?;
84122 match app.error.take() {
85123 Some(e) => Err(e),
86124 None => Ok(()),
87125 }
88126 }
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+}
M src/render/mod.rs View
@@ -153,6 +153,10 @@ impl Renderer {
153153 self.text.measure(item)
154154 }
155155
156+ pub fn set_font_families(&mut self, families: [String; 3]) {
157+ self.text.set_families(families);
158+ }
159+
156160 pub fn set_fonts(&mut self, db: glyphon::fontdb::Database) {
157161 self.text.set_fonts(&self.device, &self.queue, db);
158162 }
M src/render/text.rs View
@@ -31,15 +31,18 @@ pub enum FontRole {
3131 }
3232
3333 impl FontRole {
34− fn family(self) -> &'static str {
34+ fn index(self) -> usize {
3535 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,
3939 }
4040 }
4141 }
4242
43+/// The embedded families: UI, mono, display.
44+pub const DEFAULT_FAMILIES: [&str; 3] = ["Space Grotesk", "Space Mono", "Doto"];
45+
4346 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
4447 pub enum Align {
4548 Left,
@@ -102,6 +105,8 @@ pub struct TextSystem {
102105 viewport: Viewport,
103106 format: wgpu::TextureFormat,
104107 shaped: HashMap<Key, Shaped>,
108+ /// Family names per [`FontRole`], from `ui.font_*`.
109+ families: [String; 3],
105110 }
106111
107112 /// Locale for shaping, from the usual environment variables.
@@ -149,6 +154,14 @@ impl TextSystem {
149154 viewport,
150155 format,
151156 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();
152165 }
153166 }
154167
@@ -175,8 +188,9 @@ impl TextSystem {
175188 let line_height = (item.size * 1.25).ceil();
176189 let mut buffer = Buffer::new(&mut self.fonts, Metrics::new(item.size, line_height));
177190 buffer.set_size(None, None);
191+ let family = self.families[item.role.index()].clone();
178192 let base = Attrs::new()
179− .family(Family::Name(item.role.family()))
193+ .family(Family::Name(&family))
180194 .weight(Weight(item.weight))
181195 .letter_spacing(item.tracking);
182196 let spans = item.spans.iter().map(|(s, c)| {
M src/scan.rs View
@@ -58,6 +58,11 @@ pub fn scan(args: &[PathBuf], opts: &ScanOptions) -> Scan {
5858 }
5959
6060 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) {
6166 let meta = |p: &Path| {
6267 std::fs::metadata(p).map_or((0, 0), |m| {
6368 let mtime = m
@@ -71,7 +76,7 @@ fn sort(files: &mut [PathBuf], opts: &ScanOptions) {
7176 let seed = SystemTime::now()
7277 .duration_since(UNIX_EPOCH)
7378 .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);
7580 }
7681
7782 /// Collect visible files under `dir`. Symlinked directories are not followed.
M src/state/mod.rs View
@@ -7,9 +7,11 @@ pub mod view;
77 use std::path::{Path, PathBuf};
88 use std::time::{Duration, Instant};
99
10−use crate::action::{Action, Zoom};
10+use crate::action::{Action, ModeArg, Switch, Zoom};
1111 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};
1315 use view::{Affine, Dir, FitMode, View};
1416
1517 /// `[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);
2022 const PAN_SPEED: f32 = 1.25;
2123 /// Logical height of the status bar.
2224 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+}
2339
24−#[derive(Clone, Debug)]
40+#[derive(Clone, Debug, PartialEq)]
2541 pub struct Settings {
2642 pub fit: FitMode,
2743 pub zoom: Option<f32>,
@@ -30,6 +46,10 @@ pub struct Settings {
3046 pub preload: usize,
3147 pub anim: Duration,
3248 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,
3353 }
3454
3555 impl Default for Settings {
@@ -41,13 +61,40 @@ impl Default for Settings {
4161 wrap: true,
4262 preload: 2,
4363 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),
4568 }
4669 }
4770 }
4871
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")]
5096 pub enum AlphaBg {
97+ #[default]
5198 Dots,
5299 Checker,
53100 Solid,
@@ -106,10 +153,15 @@ pub struct Effects {
106153 pub redraw: bool,
107154 /// The queue's current file changed: load it and refresh the preload window.
108155 pub navigated: bool,
156+ /// The queue order changed around the current file: refresh the preload window.
157+ pub reordered: bool,
109158 /// The status bar appeared or vanished, so the image area changed.
110159 pub layout: bool,
111− pub fullscreen: bool,
160+ pub fullscreen: Option<Switch>,
112161 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>,
113165 pub quit: bool,
114166 }
115167
@@ -118,6 +170,10 @@ pub struct AppState {
118170 pub queue: Queue,
119171 pub view: View,
120172 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>)>,
121177 pub bar: bool,
122178 /// `None` = automatic (nearest above 300%), otherwise forced nearest/linear.
123179 pub nearest: Option<bool>,
@@ -141,6 +197,9 @@ impl AppState {
141197 queue,
142198 view: View::new(now),
143199 settings,
200+ mode: Mode::Image,
201+ theme: Theme::nothing_dark(),
202+ theme_fade: None,
144203 bar,
145204 nearest: None,
146205 alpha_bg: AlphaBg::Dots,
@@ -172,10 +231,15 @@ impl AppState {
172231 self.scale_factor
173232 }
174233
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+
175239 /// Physical height of the status bar, 0 when hidden.
176240 pub fn bar_height(&self) -> f32 {
177241 if self.bar {
178− (BAR_HEIGHT * self.scale_factor).round()
242+ (BAR_HEIGHT * self.ui_scale()).round()
179243 } else {
180244 0.0
181245 }
@@ -190,25 +254,77 @@ impl AppState {
190254 self.settings.anim
191255 }
192256
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+
193287 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 {
194293 let mut fx = Effects {
195294 redraw: true,
196295 ..Effects::default()
197296 };
198297 let dur = self.dur();
199298 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+ };
200304 match action {
201305 Action::Next(n) => fx.navigated = self.queue.step(n as isize, self.settings.wrap),
202306 Action::Prev(n) => fx.navigated = self.queue.step(-(n as isize), self.settings.wrap),
203307 Action::First => fx.navigated = self.queue.goto(0),
204308 Action::Last => fx.navigated = self.queue.goto(usize::MAX),
205309 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+ }
206322 Action::Zoom(z) if has_image => {
207323 let step = self.settings.zoom_step;
208324 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),
212328 }
213329 }
214330 Action::Fit(fit) if has_image => self.view.set_fit(fit, now, dur),
@@ -227,11 +343,20 @@ impl AppState {
227343 );
228344 }
229345 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;
235360 }
236361 Action::Reload => fx.reload = true,
237362 Action::Remove => {
@@ -240,8 +365,10 @@ impl AppState {
240365 fx.navigated = true;
241366 }
242367 }
368+ Action::ReloadConfig => fx.reload_config = true,
369+ Action::Echo(msg) => self.notify(msg, MsgKind::Info, now),
243370 Action::Cancel => self.message = None,
244− Action::Quit => fx.quit = true,
371+ Action::Quit | Action::QuitNoOutput => fx.quit = true,
245372 Action::Zoom(_)
246373 | Action::Fit(_)
247374 | Action::Pan(_)
@@ -249,6 +376,37 @@ impl AppState {
249376 | Action::Center
250377 | Action::Rotate(_)
251378 | 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+ }
252410 }
253411 fx
254412 }
@@ -341,7 +499,7 @@ impl AppState {
341499 self.loading_since = None;
342500 }
343501
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.
345503 pub fn zoom_at(&mut self, steps: f32, anchor: [f32; 2], now: Instant) {
346504 if self.shown.is_some() {
347505 let factor = self.settings.zoom_step.powf(steps);
@@ -352,10 +510,20 @@ impl AppState {
352510
353511 pub fn notify(&mut self, text: impl Into<String>, kind: MsgKind, now: Instant) {
354512 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+ ) {
355523 self.message = Some(Message {
356524 text: text.into(),
357525 kind,
358− until: now + Duration::from_secs(secs),
526+ until: now + d,
359527 });
360528 }
361529
@@ -372,6 +540,10 @@ impl AppState {
372540 pub fn is_animating(&self, now: Instant) -> bool {
373541 self.view.is_animating(now)
374542 || 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))
375547 || !self.holds.is_empty()
376548 }
377549
@@ -398,6 +570,14 @@ impl AppState {
398570 self.fade = None;
399571 redraw = true;
400572 }
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+ }
401581 (redraw, next)
402582 }
403583 }
@@ -428,7 +608,14 @@ mod tests {
428608 fn bar_shrinks_the_image_area() {
429609 let (mut s, now) = state(1);
430610 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);
432619 assert_eq!(s.view.viewport(), [800.0, 632.0]);
433620 }
434621
@@ -445,6 +632,44 @@ mod tests {
445632 assert!(s.fade.is_none());
446633 }
447634
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+
448673 #[test]
449674 fn tapped_pan_moves_a_minimum_distance() {
450675 let (mut s, now) = state(1);
M src/state/queue.rs View
@@ -1,10 +1,23 @@
11 //! The file list: current index, ordering and navigation.
22
33 use std::cmp::Ordering;
4+use std::collections::HashSet;
45 use std::path::{Path, PathBuf};
56
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")]
719 pub enum SortKey {
20+ #[default]
821 Natural,
922 Name,
1023 Mtime,
@@ -126,6 +139,38 @@ impl Queue {
126139 out
127140 }
128141
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+
129174 fn clamp(&self, i: usize) -> usize {
130175 i.min(self.files.len().saturating_sub(1))
131176 }
@@ -288,6 +333,24 @@ mod tests {
288333 assert_eq!(q.neighbours(2, true), vec![1]);
289334 }
290335
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+
291354 #[test]
292355 fn random_sort_is_a_permutation() {
293356 let mut v = paths(&["a", "b", "c", "d", "e"]);
M src/state/view.rs View
@@ -14,9 +14,21 @@ pub const MAX_SCALE: f32 = 64.0;
1414 /// Magnification above which `filter = auto` switches to nearest.
1515 pub const NEAREST_ABOVE: f32 = 3.0;
1616
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")]
1829 pub enum FitMode {
1930 /// Fit, but never enlarge.
31+ #[default]
2032 Down,
2133 Fit,
2234 Fill,
M src/ui/mod.rs View
@@ -46,7 +46,7 @@ pub fn scene<'a>(
4646 now: Instant,
4747 measure: &mut dyn FnMut(&TextItem) -> f32,
4848 ) -> Scene<'a> {
49− let sf = state.scale_factor();
49+ let sf = state.ui_scale();
5050 let [w, h] = state.window();
5151 let bar_h = state.bar_height();
5252 let mut scene = Scene {
@@ -138,7 +138,7 @@ fn status_bar(
138138 bar_h: f32,
139139 measure: &mut dyn FnMut(&TextItem) -> f32,
140140 ) {
141− let sf = state.scale_factor();
141+ let sf = state.ui_scale();
142142 let pad = 16.0 * sf;
143143 let y = h - bar_h * 0.5;
144144 scene.prims.push(Prim::rect(
@@ -185,7 +185,7 @@ fn status_bar(
185185 info.push(format!("{:0digits$}/{count}", state.queue.index() + 1));
186186 }
187187 if !info.is_empty() {
188− spans.push((info.join(" "), theme.text_secondary));
188+ spans.push((info.join(" ").to_uppercase(), theme.text_secondary));
189189 }
190190 // Two spaces between segments, attached to the preceding span so colours stay put.
191191 let n = spans.len();
@@ -195,10 +195,8 @@ fn status_bar(
195195 let mut right_w = 0.0;
196196 if !spans.is_empty() {
197197 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;
202200 right_w = measure(&item);
203201 scene.texts.push(item);
204202 }