Krolyxon
Commit a44bdf7

Branches

Tags

Refs →

remagea44bdf7

Add polish: palette, search, which-key, help, info panel

- `:` palette fuzzy-matches user commands and every action, with bound
  keys; `/` search filters the grid live or lists matches in image mode
- Which-key after a pending prefix, help built from the live keymap,
  and an info panel with file and EXIF metadata
- Slideshow (-S=SECS), gamma/brightness/contrast in the image shader,
  `filter`/`filter-clear`, and `open <path>`
- watch_dir follows files appearing, changing and vanishing on disk
- Title and status-bar templates with {placeholders}; info_command
  fills {info} like nsxiv's image-info
- Overlays draw in their own layer above the bar and grid text
Author
krolyxon
Date
2026-11-04 23:53 +0530
Parent
e3295f9
Commit
a44bdf77f7cfaccc5ca504157e801a690cc0cb00

+2915 −143

21 files changed

  1. M src/action.rs +66 −2
  2. M src/app.rs +597 −12
  3. M src/cli.rs +22 −0
  4. M src/config/default.toml +9 −3
  5. A src/decode/meta.rs +170 −0
  6. M src/decode/mod.rs +1 −0
  7. M src/input/keymap.rs +5 −4
  8. M src/main.rs +14 −0
  9. M src/render/image.rs +8 −1
  10. M src/render/mod.rs +12 −4
  11. M src/render/prims.rs +18 −13
  12. M src/render/shaders/image.wgsl +10 −1
  13. M src/render/text.rs +39 −5
  14. M src/scan.rs +4 −0
  15. A src/state/fuzzy.rs +77 −0
  16. M src/state/mod.rs +531 −27
  17. A src/state/overlay.rs +237 −0
  18. M src/state/queue.rs +291 −27
  19. M src/ui/mod.rs +546 −44
  20. A src/ui/template.rs +90 −0
  21. A src/watch.rs +168 −0
M src/action.rs View
@@ -647,8 +647,65 @@ impl Action {
647647 }
648648 }
649649
650+ /// The help section this action is listed under.
651+ pub fn group(&self) -> &'static str {
652+ match self {
653+ Action::Next(_)
654+ | Action::Prev(_)
655+ | Action::First
656+ | Action::Last
657+ | Action::Goto(_)
658+ | Action::Random
659+ | Action::NextMark
660+ | Action::PrevMark => "Navigation",
661+ Action::Mark(_) | Action::MarkAll | Action::UnmarkAll | Action::InvertMarks => "Marks",
662+ Action::Move(..)
663+ | Action::Page(_)
664+ | Action::RowStart
665+ | Action::RowEnd
666+ | Action::Scroll(..)
667+ | Action::GridSize(_)
668+ | Action::Visual => "Grid",
669+ Action::Zoom(_)
670+ | Action::Fit(_)
671+ | Action::Pan(_)
672+ | Action::PanEdge(_)
673+ | Action::Center
674+ | Action::Rotate(_)
675+ | Action::Flip(_)
676+ | Action::FilterToggle
677+ | Action::AlphaBgCycle
678+ | Action::Adjust(..) => "View",
679+ Action::Play(_)
680+ | Action::NextFrame
681+ | Action::PrevFrame
682+ | Action::Slideshow(_)
683+ | Action::SlideshowDelay { .. } => "Playback",
684+ Action::Mode(_)
685+ | Action::Fullscreen(_)
686+ | Action::Bar(_)
687+ | Action::Info(_)
688+ | Action::Help
689+ | Action::Palette
690+ | Action::Search => "Interface",
691+ Action::Sort(_)
692+ | Action::Reverse
693+ | Action::Filter(_)
694+ | Action::FilterClear
695+ | Action::Remove
696+ | Action::Reload
697+ | Action::Refresh
698+ | Action::Open(_) => "Queue",
699+ Action::Run(_)
700+ | Action::ReloadConfig
701+ | Action::Echo(_)
702+ | Action::Cancel
703+ | Action::Quit
704+ | Action::QuitNoOutput => "App",
705+ }
706+ }
707+
650708 /// A one-line description of this exact action, for which-key, help and the palette.
651− #[allow(dead_code)] // which-key, help and the palette arrive in M6
652709 pub fn description(&self) -> String {
653710 let toggle = |s: &Switch, what: &str| match s {
654711 Switch::Toggle => format!("Toggle {what}"),
@@ -676,7 +733,14 @@ impl Action {
676733 FitMode::Original => "Original size".into(),
677734 },
678735 Action::Pan(d) => format!("Pan {}", dir_name(*d)),
679− Action::PanEdge(d) => format!("Jump to {} edge", dir_name(*d)),
736+ Action::PanEdge(d) => format!(
737+ "Jump to {} edge",
738+ match d {
739+ Dir::Up => "top",
740+ Dir::Down => "bottom",
741+ other => dir_name(*other),
742+ }
743+ ),
680744 Action::Move(d, _) => format!("Move {}", dir_name(*d)),
681745 Action::Scroll(d, _) => format!("Scroll {}", dir_name(*d)),
682746 Action::Page(p) => match p {
M src/app.rs View
@@ -14,22 +14,25 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy};
1414 use winit::keyboard::{ModifiersState, PhysicalKey};
1515 use winit::window::{Fullscreen, Window, WindowId};
1616
17−use crate::action::{Action, ModeArg, Switch, Zoom};
17+use crate::action::{Action, CATALOG, ModeArg, Switch, Zoom};
1818 use crate::cache::ImageCache;
1919 use crate::config::watch::ConfigWatch;
2020 use crate::config::{Config, LoadError, Loaded, Output, Targets, suggest};
2121 use crate::decode::{self, DecodedImage};
2222 use crate::exec;
2323 use crate::input::keymap::{KeyTables, Keymap};
24−use crate::input::keys::{Key, Scroll};
24+use crate::input::keys::{Code, Key, KeySeq, Named, Scroll};
2525 use crate::input::{self};
2626 use crate::ipc::{self, Query, Request, Topic};
2727 use crate::render::{Renderer, text};
2828 use crate::scan;
29+use crate::state::overlay::{Edit, Help, HelpRow, Info, InputKind, PaletteItem, WhichRow};
30+use crate::state::queue::Queue;
2931 use crate::state::view::{Dir, FitMode};
3032 use crate::state::{AppState, Effects, Mode, MsgKind, Settings};
3133 use crate::thumbs::{self, CacheOptions, Thumb, ThumbError};
3234 use crate::ui;
35+use crate::watch::{self, DirWatch};
3336 use crate::worker::{JobKey, Pool, Priority};
3437
3538 /// GPU upload budget per event-loop turn, so preloads never cause a hitch.
@@ -86,6 +89,14 @@ pub enum UserEvent {
8689 Refreshed(Vec<(PathBuf, Option<SystemTime>)>),
8790 /// A request on the IPC socket.
8891 Ipc(ipc::Incoming),
92+ /// Metadata for the info panel.
93+ Info(Info),
94+ /// The last line `ui.info_command` printed for a file.
95+ InfoLine { path: PathBuf, line: String },
96+ /// Files appeared, changed or vanished in a watched directory.
97+ Dir(watch::Changes),
98+ /// `open <path>` finished scanning.
99+ Opened { arg: PathBuf, scan: scan::Scan },
89100 }
90101
91102 /// Command-line settings that beat the config, across reloads too.
@@ -94,10 +105,14 @@ pub struct Overrides {
94105 pub fit: Option<FitMode>,
95106 pub zoom: Option<f32>,
96107 pub bar: Option<bool>,
108+ pub slideshow_delay: Option<Duration>,
97109 }
98110
99111 impl Overrides {
100112 pub fn apply(&self, s: &mut Settings) {
113+ if let Some(d) = self.slideshow_delay {
114+ s.slideshow_delay = d;
115+ }
101116 if let Some(fit) = self.fit {
102117 s.fit = fit;
103118 }
@@ -116,6 +131,8 @@ pub struct Options {
116131 pub private: bool,
117132 /// The IPC socket, passed to commands as `$REMAGE_SOCKET`.
118133 pub socket: Option<PathBuf>,
134+ /// Directories the queue came from, for `watch_dir`.
135+ pub dirs: Vec<(PathBuf, bool)>,
119136 }
120137
121138 /// Where a chain of actions came from, which decides what some of them act on.
@@ -224,6 +241,14 @@ pub struct App {
224241 refine_failed: HashSet<PathBuf>,
225242 /// `quit-no-output` ran: `-o` prints nothing.
226243 no_output: bool,
244+ /// When the pending key sequence started waiting, for which-key.
245+ which_key_since: Option<Instant>,
246+ /// Watches the queue's directories while `watch_dir` is on.
247+ dir_watch: Option<DirWatch>,
248+ /// The file whose metadata is being read for the info panel.
249+ info_loading: Option<PathBuf>,
250+ /// The file `ui.info_command` last ran for.
251+ info_line_for: Option<PathBuf>,
227252 pub error: Option<anyhow::Error>,
228253 }
229254
@@ -275,8 +300,13 @@ impl App {
275300 refining: None,
276301 refine_failed: HashSet::new(),
277302 no_output: false,
303+ which_key_since: None,
304+ dir_watch: None,
305+ info_loading: None,
306+ info_line_for: None,
278307 error: None,
279308 };
309+ app.set_dir_watch();
280310 // Decode the first image while the window and GPU come up.
281311 app.sync(true);
282312 app
@@ -427,6 +457,7 @@ impl App {
427457 }
428458 let wanted = self.window_paths();
429459 self.update_title();
460+ self.request_info();
430461 let Some(cur) = wanted.first().cloned() else {
431462 self.state.clear_shown();
432463 self.uploads.clear();
@@ -618,26 +649,365 @@ impl App {
618649 self.uploads.retain(|(p, _)| p != path);
619650 self.state.show(path, img.source_size, img.format, now);
620651 self.state.set_frames(img.delays(), img.animated, now);
652+ self.request_info_line();
621653 self.request_redraw();
622654 }
623655
624656 fn update_title(&self) {
625657 if let Some(g) = &self.gfx {
626− let q = &self.state.queue;
627− let title = match q.current() {
628− Some(p) => self
629− .config
630− .window
631− .title
632− .replace("{name}", &ui::file_name(p))
633− .replace("{index}", &(q.index() + 1).to_string())
634− .replace("{count}", &q.len().to_string()),
658+ let title = match self.state.queue.current() {
659+ Some(_) => {
660+ ui::template::expand(&self.config.window.title, &|n| ui::var(&self.state, n))
661+ }
635662 None => "remage".into(),
636663 };
637664 g.window.set_title(&title);
638665 }
639666 }
640667
668+ /// Start, retarget or stop the directory watch to match `watch_dir` and the queue.
669+ fn set_dir_watch(&mut self) {
670+ let dirs = if self.config.general.watch_dir {
671+ self.opts.dirs.clone()
672+ } else {
673+ Vec::new()
674+ };
675+ match &self.dir_watch {
676+ Some(w) => w.set(dirs),
677+ None if !dirs.is_empty() => {
678+ let proxy = self.proxy.clone();
679+ self.dir_watch = Some(watch::spawn(dirs, move |c| {
680+ let _ = proxy.send_event(UserEvent::Dir(c));
681+ }));
682+ }
683+ None => {}
684+ }
685+ }
686+
687+ /// Files changed on disk: new images join the queue, changed ones reload, gone ones leave.
688+ fn on_dir(&mut self, c: watch::Changes) {
689+ let known: HashSet<&PathBuf> = self.state.queue.all_files().iter().collect();
690+ let (old, new): (Vec<_>, Vec<_>) =
691+ c.present.into_iter().partition(|(p, _)| known.contains(p));
692+ let mut found: Vec<(PathBuf, Option<SystemTime>)> =
693+ old.into_iter().map(|(p, t)| (p, Some(t))).collect();
694+ found.extend(c.gone.into_iter().map(|p| (p, None)));
695+ if !found.is_empty() {
696+ self.on_refreshed(found);
697+ }
698+ if !new.is_empty() {
699+ log::debug!("{} new files on disk", new.len());
700+ let was_empty = self.state.queue.is_empty();
701+ self.state
702+ .queue
703+ .insert_sorted(new.into_iter().map(|(p, _)| p));
704+ self.sync(was_empty);
705+ }
706+ }
707+
708+ /// `open <path>`: a file already queued is just selected; anything else is scanned
709+ /// (with its siblings) off-thread and replaces the queue.
710+ fn open(&mut self, arg: &str) {
711+ let cwd = std::env::current_dir().unwrap_or_default();
712+ let path = crate::config::expand_path(arg, &cwd);
713+ if let Some(i) = self.state.queue.files().iter().position(|f| *f == path) {
714+ let fx = self.state.apply(Action::Goto(i + 1), Instant::now());
715+ if fx.navigated {
716+ self.sync(true);
717+ }
718+ return;
719+ }
720+ let g = &self.config.general;
721+ let opts = scan::ScanOptions {
722+ recursive: g.recursive,
723+ siblings: g.open_siblings,
724+ sort: g.sort,
725+ reverse: g.reverse,
726+ };
727+ let proxy = self.proxy.clone();
728+ std::thread::spawn(move || {
729+ let scan = scan::scan(std::slice::from_ref(&path), &opts);
730+ let _ = proxy.send_event(UserEvent::Opened { arg: path, scan });
731+ });
732+ }
733+
734+ fn on_opened(&mut self, arg: PathBuf, scan: scan::Scan) {
735+ let now = Instant::now();
736+ if scan.files.is_empty() && scan.unknown.is_empty() {
737+ let why = scan
738+ .errors
739+ .first()
740+ .cloned()
741+ .unwrap_or_else(|| format!("{}: no images", arg.display()));
742+ self.state
743+ .notify(format!("error: {why}"), MsgKind::Error, now);
744+ self.request_redraw();
745+ return;
746+ }
747+ log::debug!("opened {}: {} files", arg.display(), scan.files.len());
748+ if !scan.unknown.is_empty() {
749+ let proxy = self.proxy.clone();
750+ let unknown = scan.unknown;
751+ std::thread::spawn(move || {
752+ let found: Vec<_> = unknown.into_iter().filter(|p| decode::sniff(p)).collect();
753+ if !found.is_empty() {
754+ let _ = proxy.send_event(UserEvent::Found(found));
755+ }
756+ });
757+ }
758+ self.state.queue = Queue::new(scan.files, scan.start.as_deref(), 0);
759+ self.state.skipped = 0;
760+ self.state.input = None;
761+ self.opts.dirs = scan.dirs;
762+ self.set_dir_watch();
763+ if self.state.in_grid() {
764+ let l = self.state.grid_layout();
765+ self.state
766+ .grid
767+ .jump_to(l.follow(self.state.queue.index(), 0.0));
768+ }
769+ self.sync(true);
770+ }
771+
772+ /// Read the current file's metadata for the info panel, off-thread.
773+ fn request_info(&mut self) {
774+ if !self.state.info_open {
775+ return;
776+ }
777+ let Some(cur) = self.state.queue.current().map(Path::to_path_buf) else {
778+ return;
779+ };
780+ if self.state.info.as_ref().is_some_and(|i| i.path == cur)
781+ || self.info_loading.as_ref() == Some(&cur)
782+ {
783+ return;
784+ }
785+ self.info_loading = Some(cur.clone());
786+ let proxy = self.proxy.clone();
787+ std::thread::spawn(move || {
788+ let rows = decode::meta::read(&cur);
789+ let _ = proxy.send_event(UserEvent::Info(Info { path: cur, rows }));
790+ });
791+ }
792+
793+ /// Run `ui.info_command` for the image on screen (nsxiv's image-info: file, width,
794+ /// height); its last line fills `{info}`.
795+ fn request_info_line(&mut self) {
796+ let cmd = self.config.ui.info_command.trim();
797+ let Some(shown) = &self.state.shown else {
798+ return;
799+ };
800+ if cmd.is_empty() || self.info_line_for.as_ref() == Some(&shown.path) {
801+ return;
802+ }
803+ let path = shown.path.clone();
804+ self.info_line_for = Some(path.clone());
805+ let mut c = std::process::Command::new("sh");
806+ c.arg("-c")
807+ .arg(format!("{cmd} \"$@\""))
808+ .arg("remage")
809+ .arg(&path)
810+ .arg(shown.size[0].to_string())
811+ .arg(shown.size[1].to_string())
812+ .stdin(std::process::Stdio::null())
813+ .stderr(std::process::Stdio::null());
814+ if let Some(sock) = &self.opts.socket {
815+ c.env("REMAGE_SOCKET", sock);
816+ }
817+ let proxy = self.proxy.clone();
818+ std::thread::spawn(move || {
819+ let line = match c.output() {
820+ Ok(o) => String::from_utf8_lossy(&o.stdout)
821+ .lines()
822+ .rev()
823+ .find(|l| !l.trim().is_empty())
824+ .unwrap_or("")
825+ .trim()
826+ .to_string(),
827+ Err(e) => {
828+ log::warn!("info_command: {e}");
829+ String::new()
830+ }
831+ };
832+ let _ = proxy.send_event(UserEvent::InfoLine { path, line });
833+ });
834+ }
835+
836+ /// What a binding does, in a few words: a command's own `desc` for `run`.
837+ fn describe(&self, actions: &[Action]) -> String {
838+ let one = |a: &Action| match a {
839+ Action::Run(name) => self
840+ .config
841+ .commands
842+ .get(name)
843+ .map(|c| c.desc.clone())
844+ .filter(|d| !d.is_empty())
845+ .unwrap_or_else(|| a.description()),
846+ _ => a.description(),
847+ };
848+ actions.iter().map(one).collect::<Vec<_>>().join(" · ")
849+ }
850+
851+ /// The keys bound to exactly `action` in the current mode, keyboard first.
852+ fn keys_for(&self, action: &Action) -> String {
853+ let mut keys = self.keymap.keys_for(self.state.mode, action);
854+ keys.sort_by_key(|k| {
855+ k.0.iter()
856+ .any(|k| matches!(k.code, Code::Mouse(_) | Code::Scroll(_)))
857+ });
858+ keys.iter()
859+ .take(2)
860+ .map(|k| ui::pretty_keys(&k.to_string()))
861+ .collect::<Vec<_>>()
862+ .join(" ")
863+ }
864+
865+ /// Palette rows: user commands, then every action (each argument choice spelled out,
866+ /// placeholders like `goto N` left to complete).
867+ fn palette_items(&self) -> Vec<PaletteItem> {
868+ let mut seen = HashSet::new();
869+ let mut items = Vec::new();
870+ let mut push = |items: &mut Vec<PaletteItem>,
871+ action: String,
872+ desc: String,
873+ keys: String,
874+ template: bool| {
875+ if seen.insert(action.clone()) {
876+ items.push(PaletteItem {
877+ action,
878+ desc,
879+ keys,
880+ template,
881+ });
882+ }
883+ };
884+ for (name, cmd) in &self.config.commands {
885+ let a = Action::Run(name.clone());
886+ let desc = if cmd.desc.is_empty() {
887+ format!("Run {name}")
888+ } else {
889+ cmd.desc.clone()
890+ };
891+ push(&mut items, a.to_string(), desc, self.keys_for(&a), false);
892+ }
893+ for entry in CATALOG {
894+ if entry.name == "run" {
895+ continue;
896+ }
897+ let args = entry.args.trim_start_matches('[').trim_end_matches(']');
898+ // Optional arguments (`[n]`, `[toggle|on|off]`) also offer the bare name.
899+ let mut options: Vec<&str> = vec![""];
900+ if !entry.args.is_empty() {
901+ if !entry.args.starts_with('[') {
902+ options.clear();
903+ }
904+ options.extend(
905+ args.split('|')
906+ .map(|o| o.trim_end_matches(" [n]").trim_end_matches(']'))
907+ .filter(|o| *o != "n"),
908+ );
909+ }
910+ for opt in options {
911+ let text = format!("{} {opt}", entry.name);
912+ let required = !entry.args.is_empty() && !entry.args.starts_with('[');
913+ let placeholder = opt.contains(['<', 'N']);
914+ match text.trim().parse::<Action>() {
915+ Ok(a) if !placeholder => {
916+ let desc = a.description();
917+ push(&mut items, a.to_string(), desc, self.keys_for(&a), false);
918+ }
919+ _ if required || placeholder => {
920+ push(
921+ &mut items,
922+ text.trim().to_string(),
923+ entry.desc.to_string(),
924+ String::new(),
925+ true,
926+ );
927+ }
928+ _ => {}
929+ }
930+ }
931+ }
932+ items
933+ }
934+
935+ /// Help rows: the live keymap of the current mode, grouped, keys for one action merged.
936+ fn help_rows(&self) -> Vec<HelpRow> {
937+ const GROUPS: [&str; 8] = [
938+ "Navigation",
939+ "Marks",
940+ "Grid",
941+ "View",
942+ "Playback",
943+ "Interface",
944+ "Queue",
945+ "App",
946+ ];
947+ let rank = |a: &Action| {
948+ CATALOG
949+ .iter()
950+ .position(|e| e.name == a.name())
951+ .unwrap_or(usize::MAX)
952+ };
953+ // (group, catalog rank, action, keys, description)
954+ let mut entries: Vec<(usize, usize, String, String, String)> = Vec::new();
955+ for (keys, actions) in self.keymap.bindings(self.state.mode) {
956+ let Some(first) = actions.first() else {
957+ continue;
958+ };
959+ let group = GROUPS.iter().position(|g| *g == first.group()).unwrap_or(0);
960+ let desc = self.describe(&actions);
961+ let k = ui::pretty_keys(&keys.to_string());
962+ match entries.iter_mut().find(|e| e.0 == group && e.4 == desc) {
963+ Some(e) => {
964+ e.3.push_str(" ");
965+ e.3.push_str(&k);
966+ }
967+ None => entries.push((group, rank(first), first.to_string(), k, desc)),
968+ }
969+ }
970+ // Within a group: catalog order, then by action (`next` before `next 10`).
971+ entries.sort_by(|a, b| (a.0, a.1, &a.2).cmp(&(b.0, b.1, &b.2)));
972+ let mut rows = Vec::new();
973+ let mut last = None;
974+ for (group, _, _, keys, desc) in entries {
975+ if last != Some(group) {
976+ rows.push(HelpRow::Header(GROUPS[group].to_string()));
977+ last = Some(group);
978+ }
979+ rows.push(HelpRow::Binding { keys, desc });
980+ }
981+ rows
982+ }
983+
984+ /// Which-key rows for the pending sequence.
985+ fn which_key_rows(&self) -> Vec<WhichRow> {
986+ let mode = self.state.mode;
987+ let pending = self.keymap.pending_keys().to_vec();
988+ let all = self.keymap.bindings(mode);
989+ self.keymap
990+ .candidates(mode)
991+ .into_iter()
992+ .map(|c| {
993+ let desc = match &c.actions {
994+ Some(a) => self.describe(a),
995+ None => {
996+ let mut prefix = pending.clone();
997+ prefix.push(c.key);
998+ let n = all.iter().filter(|(k, _)| k.0.starts_with(&prefix)).count();
999+ format!("+{n} more")
1000+ }
1001+ };
1002+ WhichRow {
1003+ key: ui::pretty_keys(&KeySeq(vec![c.key]).to_string()),
1004+ desc,
1005+ prefix: c.prefix && c.actions.is_none(),
1006+ }
1007+ })
1008+ .collect()
1009+ }
1010+
6411011 /// Show a config error until the config is fixed.
6421012 pub fn config_failed(&mut self, message: &str) {
6431013 self.state.notify_for(
@@ -666,6 +1036,7 @@ impl App {
6661036 if old.grid.size != new.grid.size {
6671037 self.state.grid.size = new.grid.size;
6681038 }
1039+ let watch_changed = old.general.watch_dir != new.general.watch_dir;
6691040 if old.grid.thumbnail_cache != new.grid.thumbnail_cache {
6701041 self.thumb_cache = CacheOptions::new(new.grid.thumbnail_cache, self.opts.private);
6711042 }
@@ -695,6 +1066,11 @@ impl App {
6951066 }
6961067 g.renderer.set_font_families(font_families(new));
6971068 }
1069+ if watch_changed {
1070+ self.set_dir_watch();
1071+ }
1072+ self.info_line_for = None;
1073+ self.request_info_line();
6981074 self.update_title();
6991075 // Silent on routine changes (a new wallpaper palette); confirm fixes and manual reloads.
7001076 if std::mem::take(&mut self.config_error) || std::mem::take(&mut self.reload_requested) {
@@ -935,7 +1311,7 @@ impl App {
9351311 fn refresh(&mut self, targets: Option<Arc<[PathBuf]>>) {
9361312 let mut files: Vec<PathBuf> = match targets {
9371313 Some(t) => t.to_vec(),
938− None => self.state.queue.files().to_vec(),
1314+ None => self.state.queue.all_files().to_vec(),
9391315 };
9401316 if let Some(cur) = self.state.queue.current()
9411317 && !files.iter().any(|f| f == cur)
@@ -1059,6 +1435,8 @@ impl App {
10591435 "bar": s.bar,
10601436 "fullscreen": fullscreen,
10611437 "skipped": s.skipped,
1438+ "filter": s.queue.filter(),
1439+ "slideshow": s.slideshow.is_some(),
10621440 "running": s.running.iter().map(|(_, n)| n.as_str()).collect::<Vec<_>>(),
10631441 "pid": std::process::id(),
10641442 })
@@ -1146,6 +1524,23 @@ impl App {
11461524 if fx.reload {
11471525 self.reload();
11481526 }
1527+ if fx.palette {
1528+ let items = self.palette_items();
1529+ self.keymap.cancel();
1530+ self.state.open_palette(items);
1531+ }
1532+ if fx.help {
1533+ let rows = self.help_rows();
1534+ self.state.help = Some(Help { rows, scroll: 0 });
1535+ }
1536+ if fx.info {
1537+ self.request_info();
1538+ }
1539+ if fx.open
1540+ && let Some(p) = self.state.open.take()
1541+ {
1542+ self.open(&p);
1543+ }
11491544 if fx.reload_config {
11501545 self.reload_requested = true;
11511546 self.watch.reload();
@@ -1182,6 +1577,15 @@ impl App {
11821577 if pending != self.state.pending {
11831578 self.state.pending = pending;
11841579 self.request_redraw();
1580+ if !self.keymap.is_pending() {
1581+ self.which_key_since = None;
1582+ self.state.which_key = None;
1583+ } else if self.state.which_key.is_some() {
1584+ // Already showing: follow the sequence at once.
1585+ self.state.which_key = Some(self.which_key_rows());
1586+ } else {
1587+ self.which_key_since = Some(now);
1588+ }
11851589 }
11861590 self.ambiguous_since = self
11871591 .keymap
@@ -1214,6 +1618,18 @@ impl App {
12141618 }
12151619 return;
12161620 }
1621+ if self.state.input.is_some() {
1622+ self.on_input_key(&event, event_loop);
1623+ return;
1624+ }
1625+ if self.state.help.is_some()
1626+ && !event.repeat
1627+ && let Some(key) = input::key(&event.logical_key, self.modifiers)
1628+ && !self.keymap.is_pending()
1629+ && self.help_key(key)
1630+ {
1631+ return;
1632+ }
12171633 if event.repeat {
12181634 // Repeats bypass the trie: only a completed, repeatable binding repeats.
12191635 if let Some((k, actions)) = &self.repeat
@@ -1249,6 +1665,128 @@ impl App {
12491665 }
12501666 }
12511667
1668+ /// A key while the palette or search line is open: it edits the line.
1669+ fn on_input_key(&mut self, event: &winit::event::KeyEvent, el: &ActiveEventLoop) {
1670+ let now = Instant::now();
1671+ let Some(key) = input::key(&event.logical_key, self.modifiers) else {
1672+ return;
1673+ };
1674+ let ctrl = |c: char| {
1675+ Key::new(
1676+ input::keys::Mods {
1677+ ctrl: true,
1678+ ..Default::default()
1679+ },
1680+ Code::Char(c),
1681+ )
1682+ };
1683+ let named = |n: Named| Key::new(Default::default(), Code::Named(n));
1684+ let shift_tab = Key::new(
1685+ input::keys::Mods {
1686+ shift: true,
1687+ ..Default::default()
1688+ },
1689+ Code::Named(Named::Tab),
1690+ );
1691+ let palette = self
1692+ .state
1693+ .input
1694+ .as_ref()
1695+ .is_some_and(|i| i.kind == InputKind::Palette);
1696+ let empty = self.state.input.as_ref().is_some_and(|i| i.text.is_empty());
1697+ let edit = if key == named(Named::Esc) || key == ctrl('c') || key == ctrl('[') {
1698+ let fx = self.state.input_cancel(now);
1699+ self.effects(fx, &Ctx::default(), el);
1700+ return;
1701+ } else if key == named(Named::Enter) {
1702+ if event.repeat {
1703+ return;
1704+ }
1705+ let (fx, run) = self.state.input_submit(now);
1706+ self.effects(fx, &Ctx::default(), el);
1707+ if let Some(text) = run {
1708+ match text.parse::<Action>() {
1709+ Ok(a) => {
1710+ let _ = self.run(vec![a], &Ctx::default(), el);
1711+ }
1712+ Err(e) => self
1713+ .state
1714+ .notify(format!("error: {e}"), MsgKind::Error, now),
1715+ }
1716+ }
1717+ self.request_redraw();
1718+ return;
1719+ } else if key == named(Named::Backspace) && empty {
1720+ let fx = self.state.input_cancel(now);
1721+ self.effects(fx, &Ctx::default(), el);
1722+ return;
1723+ } else if key == named(Named::Backspace) || key == ctrl('h') {
1724+ Edit::Backspace
1725+ } else if key == ctrl('w') {
1726+ Edit::DeleteWord
1727+ } else if key == ctrl('u') {
1728+ Edit::Clear
1729+ } else if key == named(Named::Tab) && palette {
1730+ Edit::Complete
1731+ } else if key == named(Named::Up) || key == ctrl('p') || key == shift_tab {
1732+ Edit::Up
1733+ } else if key == named(Named::Down) || key == ctrl('n') || key == named(Named::Tab) {
1734+ Edit::Down
1735+ } else if key == named(Named::Left) {
1736+ Edit::Left
1737+ } else if key == named(Named::Right) {
1738+ Edit::Right
1739+ } else if key == named(Named::Space) && key.mods.is_empty() {
1740+ Edit::Insert(" ".into())
1741+ } else {
1742+ let m = self.modifiers;
1743+ match &event.text {
1744+ Some(t) if !m.control_key() && !m.alt_key() && !m.super_key() => {
1745+ Edit::Insert(t.to_string())
1746+ }
1747+ _ => return,
1748+ }
1749+ };
1750+ let fx = self.state.input_edit(edit, now);
1751+ self.effects(fx, &Ctx::default(), el);
1752+ }
1753+
1754+ /// Scrolling and closing keys while help is open. Returns whether the key was used.
1755+ fn help_key(&mut self, key: Key) -> bool {
1756+ let page = self.state.help_page();
1757+ let Some(help) = &mut self.state.help else {
1758+ return false;
1759+ };
1760+ let ctrl = |c: char| {
1761+ Key::new(
1762+ input::keys::Mods {
1763+ ctrl: true,
1764+ ..Default::default()
1765+ },
1766+ Code::Char(c),
1767+ )
1768+ };
1769+ let named = |n: Named| Key::new(Default::default(), Code::Named(n));
1770+ let half = (page / 2).max(1) as isize;
1771+ let delta = match key {
1772+ k if k == Key::plain('j') || k == named(Named::Down) => 1,
1773+ k if k == Key::plain('k') || k == named(Named::Up) => -1,
1774+ k if k == ctrl('d') || k == ctrl('f') || k == named(Named::PageDown) => half,
1775+ k if k == ctrl('u') || k == ctrl('b') || k == named(Named::PageUp) => -half,
1776+ k if k == Key::plain('g') || k == named(Named::Home) => isize::MIN / 2,
1777+ k if k == Key::plain('G') || k == named(Named::End) => isize::MAX / 2,
1778+ k if k == Key::plain('q') || k == Key::plain('?') || k == named(Named::Esc) => {
1779+ self.state.help = None;
1780+ self.request_redraw();
1781+ return true;
1782+ }
1783+ _ => return false,
1784+ };
1785+ help.scroll_by(delta, page);
1786+ self.request_redraw();
1787+ true
1788+ }
1789+
12521790 /// A mouse button or wheel notch: bound actions run at the cursor, bypassing sequences.
12531791 fn on_mouse_key(&mut self, key: Key, event_loop: &ActiveEventLoop) {
12541792 self.keymap.cancel();
@@ -1267,6 +1805,17 @@ impl App {
12671805 fn on_wheel(&mut self, delta: [f32; 2], pixels: bool, event_loop: &ActiveEventLoop) {
12681806 let now = Instant::now();
12691807 let [dx, dy] = delta;
1808+ if self.state.help.is_some() {
1809+ let page = self.state.help_page();
1810+ self.scroll[1] += dy;
1811+ let rows = self.scroll[1].trunc();
1812+ self.scroll[1] -= rows;
1813+ if let Some(h) = &mut self.state.help {
1814+ h.scroll_by(-(rows as isize) * 3, page);
1815+ }
1816+ self.request_redraw();
1817+ return;
1818+ }
12701819 if dy != 0.0 {
12711820 let dir = if dy > 0.0 { Scroll::Up } else { Scroll::Down };
12721821 let key = input::scroll(dir, self.modifiers);
@@ -1578,6 +2127,21 @@ impl ApplicationHandler<UserEvent> for App {
15782127 UserEvent::Command { id, event } => self.on_command(id, event, event_loop),
15792128 UserEvent::Refreshed(found) => self.on_refreshed(found),
15802129 UserEvent::Ipc(inc) => self.on_ipc(inc, event_loop),
2130+ UserEvent::Info(info) => {
2131+ if self.info_loading.as_ref() == Some(&info.path) {
2132+ self.info_loading = None;
2133+ }
2134+ if self.state.queue.current() == Some(info.path.as_path()) {
2135+ self.state.info = Some(info);
2136+ self.request_redraw();
2137+ }
2138+ }
2139+ UserEvent::InfoLine { path, line } => {
2140+ self.state.info_line = Some((path, line));
2141+ self.request_redraw();
2142+ }
2143+ UserEvent::Dir(changes) => self.on_dir(changes),
2144+ UserEvent::Opened { arg, scan } => self.on_opened(arg, scan),
15812145 }
15822146 }
15832147
@@ -1692,8 +2256,29 @@ impl ApplicationHandler<UserEvent> for App {
16922256 self.decode(&cur, Priority::Preload);
16932257 }
16942258 }
2259+ if let Some(t) = self.which_key_since
2260+ && now >= t + self.state.settings.which_key_delay
2261+ {
2262+ self.which_key_since = None;
2263+ if self.keymap.is_pending() && self.state.input.is_none() {
2264+ self.state.which_key = Some(self.which_key_rows());
2265+ self.request_redraw();
2266+ }
2267+ }
2268+ if self.state.slideshow_due(now) {
2269+ let fx = self.state.apply(Action::Next(1), now);
2270+ self.state.slideshow_advanced(fx.navigated);
2271+ if !fx.navigated {
2272+ self.state.notify("slideshow done", MsgKind::Info, now);
2273+ }
2274+ self.effects(fx, &Ctx::default(), event_loop);
2275+ }
16952276 self.publish();
16962277 let (redraw, mut next) = self.state.tick(now);
2278+ if let Some(t) = self.which_key_since {
2279+ let at = t + self.state.settings.which_key_delay;
2280+ next = Some(next.map_or(at, |n| n.min(at)));
2281+ }
16972282 if let Some(t) = self.grid_rest {
16982283 let at = t + GRID_REST;
16992284 next = Some(next.map_or(at, |n| n.min(at)));
M src/cli.rs View
@@ -57,6 +57,16 @@ pub struct Cli {
5757 #[arg(short = 'b', long)]
5858 pub no_bar: bool,
5959
60+ /// Start a slideshow, optionally with this delay: -S=3 (the = keeps paths apart)
61+ #[arg(
62+ short = 'S',
63+ long,
64+ value_name = "SECS",
65+ num_args = 0..=1,
66+ require_equals = true
67+ )]
68+ pub slideshow: Option<Option<f32>>,
69+
6070 /// How images fit the window
6171 #[arg(short = 's', long, value_enum, value_name = "MODE")]
6272 pub fit: Option<FitMode>,
@@ -159,6 +169,18 @@ mod tests {
159169 assert!(c.stdin && c.print_marked && c.null && c.command.is_none());
160170 }
161171
172+ #[test]
173+ fn slideshow_seconds_need_an_equals() {
174+ let c = Cli::try_parse_from(["remage", "-S", "pics"]).unwrap();
175+ assert_eq!(c.slideshow, Some(None));
176+ assert_eq!(c.paths, [PathBuf::from("pics")]);
177+ let c = Cli::try_parse_from(["remage", "-S=2.5", "pics"]).unwrap();
178+ assert_eq!(c.slideshow, Some(Some(2.5)));
179+ let c = Cli::try_parse_from(["remage", "--slideshow=3"]).unwrap();
180+ assert_eq!(c.slideshow, Some(Some(3.0)));
181+ assert_eq!(Cli::try_parse_from(["remage"]).unwrap().slideshow, None);
182+ }
183+
162184 #[test]
163185 fn geometry() {
164186 assert_eq!(parse_geometry("800x600"), Ok((800, 600)));
M src/config/default.toml View
@@ -20,7 +20,7 @@ watch_dir = true # add and drop files that appear or vanish on disk
2020
2121 [window]
2222 decorations = false
23−title = "{name} — remage" # {name} {index} {count}
23+title = "{name} — remage" # placeholders as in ui.statusbar_left
2424 class = "remage" # Wayland app_id / X11 class
2525 fullscreen = false
2626
@@ -44,11 +44,17 @@ names = false # filenames under thumbnails
4444
4545 [ui]
4646 statusbar = true
47−which_key_delay_ms = 300
47+which_key_delay_ms = 300 # pending keys wait this long before which-key shows
4848 scale = 1.0 # UI size multiplier on top of the display scale
49+# Status bar templates. Placeholders: {name} {path} {dir} {stem} {ext} {format} {width}
50+# {height} {zoom} {index} {count} {marked} {frame} {mode} {filter} {info}. Two or more spaces
51+# separate segments; a segment whose placeholder is empty right now (no image in the grid,
52+# say) drops out.
4953 statusbar_left = "{name}"
5054 statusbar_right = "{format} {width}×{height} {zoom}% {index}/{count}"
51−info_command = "" # script whose output fills a status segment
55+# Run for each image shown, like nsxiv's image-info: `<command> FILE WIDTH HEIGHT` through
56+# sh. Its last stdout line fills {info}, or replaces the left side if no template has {info}.
57+info_command = ""
5258 font_ui = "Space Grotesk"
5359 font_mono = "Space Mono"
5460 font_display = "Doto"
A src/decode/meta.rs View
@@ -0,0 +1,170 @@
1+//! File and EXIF metadata for the info panel.
2+
3+use std::path::Path;
4+use std::time::SystemTime;
5+
6+use exif::{In, Tag};
7+
8+/// Label/value rows describing `path` (the UI adds the decoded format and size). Never fails: missing data just means fewer rows.
9+pub fn read(path: &Path) -> Vec<(String, String)> {
10+ let mut rows: Vec<(String, String)> = Vec::new();
11+ let mut push = |l: &str, v: String| {
12+ if !v.trim().is_empty() {
13+ rows.push((l.to_string(), v));
14+ }
15+ };
16+ push("File", crate::state::file_name(path));
17+ if let Some(d) = path.parent() {
18+ push("Folder", d.display().to_string());
19+ }
20+ if let Ok(m) = std::fs::metadata(path) {
21+ push("Size", human_size(m.len()));
22+ if let Ok(t) = m.modified() {
23+ push("Modified", datetime(t));
24+ }
25+ }
26+ if let Some(exif) = std::fs::File::open(path)
27+ .ok()
28+ .map(std::io::BufReader::new)
29+ .and_then(|mut r| exif::Reader::new().read_from_container(&mut r).ok())
30+ {
31+ let field = |t: Tag| {
32+ exif.get_field(t, In::PRIMARY)
33+ .map(|f| f.display_value().with_unit(&exif).to_string())
34+ .map(|s| s.trim_matches('"').trim().to_string())
35+ };
36+ let make = field(Tag::Make).unwrap_or_default();
37+ let model = field(Tag::Model).unwrap_or_default();
38+ // Most models already start with the make ("Canon EOS R6").
39+ let camera = if model.to_lowercase().starts_with(&make.to_lowercase()) {
40+ model
41+ } else {
42+ format!("{make} {model}")
43+ };
44+ push("Camera", camera);
45+ push("Lens", field(Tag::LensModel).unwrap_or_default());
46+ push(
47+ "Taken",
48+ field(Tag::DateTimeOriginal)
49+ .or_else(|| field(Tag::DateTime))
50+ .unwrap_or_default(),
51+ );
52+ let exposure = [
53+ field(Tag::ExposureTime),
54+ field(Tag::FNumber),
55+ field(Tag::PhotographicSensitivity).map(|i| format!("ISO {i}")),
56+ field(Tag::FocalLength),
57+ ];
58+ push(
59+ "Exposure",
60+ exposure
61+ .into_iter()
62+ .flatten()
63+ .collect::<Vec<_>>()
64+ .join(" "),
65+ );
66+ push("Flash", field(Tag::Flash).unwrap_or_default());
67+ push("Software", field(Tag::Software).unwrap_or_default());
68+ push("Artist", field(Tag::Artist).unwrap_or_default());
69+ push("Copyright", field(Tag::Copyright).unwrap_or_default());
70+ if let (Some(lat), Some(lon)) = (
71+ gps(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef),
72+ gps(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef),
73+ ) {
74+ push("GPS", format!("{lat:.5}, {lon:.5}"));
75+ }
76+ }
77+ rows
78+}
79+
80+/// A GPS coordinate in signed decimal degrees.
81+fn gps(exif: &exif::Exif, tag: Tag, reference: Tag) -> Option<f64> {
82+ let f = exif.get_field(tag, In::PRIMARY)?;
83+ let exif::Value::Rational(v) = &f.value else {
84+ return None;
85+ };
86+ let deg = v.first()?.to_f64()
87+ + v.get(1).map_or(0.0, |m| m.to_f64() / 60.0)
88+ + v.get(2).map_or(0.0, |s| s.to_f64() / 3600.0);
89+ let neg = exif
90+ .get_field(reference, In::PRIMARY)
91+ .map(|r| r.display_value().to_string())
92+ .is_some_and(|r| r.contains('S') || r.contains('W'));
93+ Some(if neg { -deg } else { deg })
94+}
95+
96+pub fn human_size(bytes: u64) -> String {
97+ const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
98+ let mut v = bytes as f64;
99+ let mut u = 0;
100+ while v >= 1024.0 && u < UNITS.len() - 1 {
101+ v /= 1024.0;
102+ u += 1;
103+ }
104+ if u == 0 {
105+ format!("{bytes} B")
106+ } else {
107+ format!("{v:.1} {}", UNITS[u])
108+ }
109+}
110+
111+/// `YYYY-MM-DD HH:MM` in UTC (no time zone database needed).
112+fn datetime(t: SystemTime) -> String {
113+ let secs = t
114+ .duration_since(SystemTime::UNIX_EPOCH)
115+ .map_or(0, |d| d.as_secs() as i64);
116+ let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
117+ let (y, m, d) = civil(days);
118+ format!(
119+ "{y:04}-{m:02}-{d:02} {:02}:{:02} UTC",
120+ rem / 3600,
121+ rem % 3600 / 60
122+ )
123+}
124+
125+/// Days since 1970-01-01 to a calendar date (Howard Hinnant's algorithm).
126+fn civil(z: i64) -> (i64, u32, u32) {
127+ let z = z + 719_468;
128+ let era = z.div_euclid(146_097);
129+ let doe = z.rem_euclid(146_097);
130+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
131+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
132+ let mp = (5 * doy + 2) / 153;
133+ let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
134+ let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
135+ let y = yoe + era * 400 + i64::from(m <= 2);
136+ (y, m, d)
137+}
138+
139+#[cfg(test)]
140+mod tests {
141+ use super::*;
142+
143+ #[test]
144+ fn sizes_and_dates() {
145+ assert_eq!(human_size(512), "512 B");
146+ assert_eq!(human_size(1536), "1.5 KB");
147+ assert_eq!(human_size(5 << 20), "5.0 MB");
148+ assert_eq!(civil(0), (1970, 1, 1));
149+ assert_eq!(civil(19_723), (2024, 1, 1));
150+ assert_eq!(civil(11_016), (2000, 2, 29));
151+ let t = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
152+ assert_eq!(datetime(t), "2023-11-14 22:13 UTC");
153+ }
154+
155+ #[test]
156+ fn reads_fixture_metadata() {
157+ let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
158+ let Some(f) = std::fs::read_dir(&dir).ok().and_then(|mut d| {
159+ d.find_map(|e| {
160+ let p = e.ok()?.path();
161+ (p.extension()? == "png").then_some(p)
162+ })
163+ }) else {
164+ return;
165+ };
166+ let rows = read(&f);
167+ assert_eq!(rows[0].0, "File");
168+ assert!(rows.iter().any(|(l, _)| l == "Size"), "{rows:?}");
169+ }
170+}
M src/decode/mod.rs View
@@ -11,6 +11,7 @@ mod heif;
1111 mod image_rs;
1212 #[cfg(feature = "jxl")]
1313 mod jxl;
14+pub mod meta;
1415 #[cfg(feature = "raw")]
1516 mod raw;
1617 #[cfg(feature = "svg")]
M src/input/keymap.rs View
@@ -36,7 +36,6 @@ impl Run {
3636 }
3737
3838 /// A next key that would continue the pending sequence.
39−#[allow(dead_code)] // which-key arrives in M6
4039 #[derive(Clone, Debug, PartialEq)]
4140 pub struct Candidate {
4241 pub key: Key,
@@ -209,11 +208,15 @@ impl Keymap {
209208 }
210209
211210 /// A key sequence (not a count) is waiting for more keys.
212− #[allow(dead_code)] // which-key arrives in M6
213211 pub fn is_pending(&self) -> bool {
214212 !self.pending.is_empty()
215213 }
216214
215+ /// The keys of the pending sequence.
216+ pub fn pending_keys(&self) -> &[Key] {
217+ &self.pending
218+ }
219+
217220 pub fn cancel(&mut self) {
218221 self.pending.clear();
219222 self.count = None;
@@ -234,7 +237,6 @@ impl Keymap {
234237 }
235238
236239 /// The keys that continue the pending sequence, for which-key.
237− #[allow(dead_code)] // which-key arrives in M6
238240 pub fn candidates(&self, mode: Mode) -> Vec<Candidate> {
239241 let Some(node) = self.root(mode).walk(&self.pending) else {
240242 return Vec::new();
@@ -257,7 +259,6 @@ impl Keymap {
257259 }
258260
259261 /// The sequences bound to exactly `action` in a mode, shortest first.
260− #[allow(dead_code)] // the palette and help arrive in M6
261262 pub fn keys_for(&self, mode: Mode, action: &Action) -> Vec<KeySeq> {
262263 let mut keys: Vec<KeySeq> = self
263264 .bindings(mode)
M src/main.rs View
@@ -15,6 +15,7 @@ mod scan;
1515 mod state;
1616 mod thumbs;
1717 mod ui;
18+mod watch;
1819 mod worker;
1920
2021 use std::ffi::OsString;
@@ -103,6 +104,11 @@ fn main() -> anyhow::Result<()> {
103104 fit: cli.fit,
104105 zoom: cli.zoom.map(|z| z / 100.0),
105106 bar: cli.no_bar.then_some(false),
107+ slideshow_delay: cli
108+ .slideshow
109+ .flatten()
110+ .filter(|s| s.is_finite() && *s > 0.0)
111+ .map(std::time::Duration::from_secs_f32),
106112 };
107113 let mut settings = Settings::from_config(cfg);
108114 overrides.apply(&mut settings);
@@ -116,6 +122,13 @@ fn main() -> anyhow::Result<()> {
116122 state.theme = loaded.theme.clone();
117123 state.nearest = cfg.image.filter.nearest();
118124 state.alpha_bg = cfg.image.alpha_background;
125+ if cli.slideshow.is_some() {
126+ state.apply(
127+ action::Action::Slideshow(action::Switch::On),
128+ Instant::now(),
129+ );
130+ state.message = None;
131+ }
119132 if let Some(err) = scan.errors.first() {
120133 state.notify(format!("error: {err}"), MsgKind::Error, Instant::now());
121134 }
@@ -163,6 +176,7 @@ fn main() -> anyhow::Result<()> {
163176 overrides,
164177 private: cli.private,
165178 socket: server.as_ref().map(|s| s.path().to_path_buf()),
179+ dirs: scan.dirs,
166180 };
167181 let mut app = App::new(opts, state, proxy, loaded, watch);
168182 if let Some(e) = config_error {
M src/render/image.rs View
@@ -18,11 +18,15 @@ struct Uniforms {
1818 bg: [f32; 4],
1919 dot: [f32; 4],
2020 params: [f32; 4],
21+ /// Gamma, brightness, contrast, and 1 when any differs from neutral.
22+ adjust: [f32; 4],
2123 }
2224
23−/// How the area behind transparent pixels is painted.
25+/// How images are painted: the area behind transparent pixels, and display adjustments.
2426 #[derive(Clone, Copy, Debug)]
2527 pub struct AlphaStyle {
28+ /// Gamma, brightness, contrast.
29+ pub adjust: [f32; 3],
2630 /// 0 solid, 1 dots, 2 checker.
2731 pub mode: u32,
2832 pub bg: [f32; 4],
@@ -435,6 +439,8 @@ impl ImagePipeline {
435439 alpha: f32,
436440 style: &AlphaStyle,
437441 ) {
442+ let adjust = style.adjust;
443+ let on = adjust != [1.0, 0.0, 1.0];
438444 let [iw, ih] = [gpu.size[0] as f32, gpu.size[1] as f32];
439445 let m = affine.m;
440446 for tile in &gpu.tiles {
@@ -451,6 +457,7 @@ impl ImagePipeline {
451457 bg: style.bg,
452458 dot: style.dot,
453459 params: [alpha, style.mode as f32, style.cell, style.radius],
460+ adjust: [adjust[0], adjust[1], adjust[2], on as u32 as f32],
454461 };
455462 queue.write_buffer(&tile.uniforms, 0, bytemuck::bytes_of(&u));
456463 }
M src/render/mod.rs View
@@ -41,6 +41,9 @@ pub struct Scene<'a> {
4141 pub under: Vec<Prim>,
4242 pub prims: Vec<Prim>,
4343 pub texts: Vec<TextItem>,
44+ /// Panels (palette, help, info, which-key) and their text, above everything else.
45+ pub overlay_prims: Vec<Prim>,
46+ pub overlay_texts: Vec<TextItem>,
4447 }
4548
4649 pub struct Renderer {
@@ -305,12 +308,15 @@ impl Renderer {
305308 self.prims.prepare(
306309 &self.device,
307310 &self.queue,
308− &scene.under,
309− &scene.prims,
311+ [&scene.under, &scene.prims, &scene.overlay_prims],
310312 viewport,
311313 );
312− self.text
313− .prepare(&self.device, &self.queue, &scene.texts, size)?;
314+ self.text.prepare(
315+ &self.device,
316+ &self.queue,
317+ [&scene.texts, &scene.overlay_texts],
318+ size,
319+ )?;
314320
315321 let mut encoder = self
316322 .device
@@ -351,6 +357,8 @@ impl Renderer {
351357 }
352358 self.prims.draw_over(&mut pass);
353359 self.text.render(&mut pass)?;
360+ self.prims.draw_overlay(&mut pass);
361+ self.text.render_overlay(&mut pass)?;
354362 }
355363 Ok(encoder.finish())
356364 }
M src/render/prims.rs View
@@ -60,9 +60,8 @@ pub struct PrimPipeline {
6060 bind_group: wgpu::BindGroup,
6161 instances: wgpu::Buffer,
6262 capacity: usize,
63− /// Instances of the under layer, then the over layer.
64− split: u32,
65− count: u32,
63+ /// Instances of the under, over and overlay layers, in that order: their end offsets.
64+ ends: [u32; 3],
6665 }
6766
6867 const ATTRS: [wgpu::VertexAttribute; 4] =
@@ -147,21 +146,20 @@ impl PrimPipeline {
147146 bind_group,
148147 instances: instance_buffer(device, capacity),
149148 capacity,
150− split: 0,
151− count: 0,
149+ ends: [0; 3],
152150 }
153151 }
154152
155− /// Upload both layers: `under` draws before images and thumbnails, `over` after.
153+ /// Upload the layers: `under` draws before images and thumbnails, `over` after them,
154+ /// `overlay` after the base layer's text.
156155 pub fn prepare(
157156 &mut self,
158157 device: &wgpu::Device,
159158 queue: &wgpu::Queue,
160− under: &[Prim],
161− over: &[Prim],
159+ layers: [&[Prim]; 3],
162160 viewport: [f32; 2],
163161 ) {
164− let prims: Vec<Prim> = under.iter().chain(over).copied().collect();
162+ let prims: Vec<Prim> = layers.iter().flat_map(|l| l.iter()).copied().collect();
165163 queue.write_buffer(
166164 &self.globals,
167165 0,
@@ -174,8 +172,11 @@ impl PrimPipeline {
174172 if !prims.is_empty() {
175173 queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(&prims));
176174 }
177− self.split = under.len() as u32;
178− self.count = prims.len() as u32;
175+ let mut end = 0;
176+ for (e, l) in self.ends.iter_mut().zip(layers) {
177+ end += l.len() as u32;
178+ *e = end;
179+ }
179180 }
180181
181182 fn draw_range(&self, pass: &mut wgpu::RenderPass<'_>, range: std::ops::Range<u32>) {
@@ -189,10 +190,14 @@ impl PrimPipeline {
189190 }
190191
191192 pub fn draw_under(&self, pass: &mut wgpu::RenderPass<'_>) {
192− self.draw_range(pass, 0..self.split);
193+ self.draw_range(pass, 0..self.ends[0]);
193194 }
194195
195196 pub fn draw_over(&self, pass: &mut wgpu::RenderPass<'_>) {
196− self.draw_range(pass, self.split..self.count);
197+ self.draw_range(pass, self.ends[0]..self.ends[1]);
198+ }
199+
200+ pub fn draw_overlay(&self, pass: &mut wgpu::RenderPass<'_>) {
201+ self.draw_range(pass, self.ends[1]..self.ends[2]);
197202 }
198203 }
M src/render/shaders/image.wgsl View
@@ -9,6 +9,8 @@ struct Uniforms {
99 dot_color: vec4<f32>,
1010 // x: opacity, y: alpha-bg mode (0 solid, 1 dots, 2 checker), z: cell px, w: dot radius px
1111 params: vec4<f32>,
12+ // x: gamma, y: brightness, z: contrast, w: 1 when any is not neutral
13+ adjust: vec4<f32>,
1214 };
1315
1416 @group(0) @binding(0) var<uniform> u: Uniforms;
@@ -36,7 +38,14 @@ fn vs(@builtin(vertex_index) i: u32) -> VOut {
3638
3739 @fragment
3840 fn fs(in: VOut) -> @location(0) vec4<f32> {
39− let c = textureSample(tex, samp, in.uv);
41+ var c = textureSample(tex, samp, in.uv);
42+ if u.adjust.w > 0.5 {
43+ // Adjust in display (sRGB-ish) space, as viewers do, then back to linear.
44+ var v = pow(max(c.rgb, vec3(0.0)), vec3(1.0 / 2.2));
45+ v = (v - 0.5) * u.adjust.z + 0.5 + u.adjust.y;
46+ v = pow(clamp(v, vec3(0.0), vec3(1.0)), vec3(1.0 / u.adjust.x));
47+ c = vec4(pow(v, vec3(2.2)), c.a);
48+ }
4049 var bg = u.bg.rgb;
4150 let mode = u32(u.params.y);
4251 let cell = u.params.z;
M src/render/text.rs View
@@ -104,6 +104,8 @@ pub struct TextSystem {
104104 cache: Cache,
105105 atlas: TextAtlas,
106106 renderer: TextRenderer,
107+ /// Text above overlay panels, sharing the atlas.
108+ overlay: TextRenderer,
107109 viewport: Viewport,
108110 format: wgpu::TextureFormat,
109111 shaped: HashMap<Key, Shaped>,
@@ -146,6 +148,8 @@ impl TextSystem {
146148 let mut atlas = TextAtlas::new(device, queue, &cache, format);
147149 let renderer =
148150 TextRenderer::new(&mut atlas, device, wgpu::MultisampleState::default(), None);
151+ let overlay =
152+ TextRenderer::new(&mut atlas, device, wgpu::MultisampleState::default(), None);
149153 let viewport = Viewport::new(device, &cache);
150154 Self {
151155 fonts,
@@ -153,6 +157,7 @@ impl TextSystem {
153157 cache,
154158 atlas,
155159 renderer,
160+ overlay,
156161 viewport,
157162 format,
158163 shaped: HashMap::new(),
@@ -178,6 +183,12 @@ impl TextSystem {
178183 wgpu::MultisampleState::default(),
179184 None,
180185 );
186+ self.overlay = TextRenderer::new(
187+ &mut self.atlas,
188+ device,
189+ wgpu::MultisampleState::default(),
190+ None,
191+ );
181192 self.shaped.clear();
182193 }
183194
@@ -234,15 +245,15 @@ impl TextSystem {
234245 self.shaped.get(&key).map_or(0.0, |s| s.width)
235246 }
236247
237− /// Shape and upload this frame's text.
248+ /// Shape and upload this frame's text: the base layer and the overlay layer.
238249 pub fn prepare(
239250 &mut self,
240251 device: &wgpu::Device,
241252 queue: &wgpu::Queue,
242− items: &[TextItem],
253+ layers: [&[TextItem]; 2],
243254 size: [u32; 2],
244255 ) -> anyhow::Result<()> {
245− let keys: Vec<Key> = items.iter().map(|i| self.shape(i)).collect();
256+ let keys: [Vec<Key>; 2] = layers.map(|l| l.iter().map(|i| self.shape(i)).collect());
246257 // Keep what this frame (including any `measure` calls) used, and reset for the next.
247258 self.shaped.retain(|_, s| std::mem::take(&mut s.used));
248259 self.viewport.update(
@@ -252,8 +263,21 @@ impl TextSystem {
252263 height: size[1],
253264 },
254265 );
266+ for (layer, (items, keys)) in layers.iter().zip(&keys).enumerate() {
267+ self.prepare_layer(device, queue, layer, items, keys)?;
268+ }
269+ Ok(())
270+ }
255271
256− let areas = items.iter().zip(&keys).filter_map(|(item, key)| {
272+ fn prepare_layer(
273+ &mut self,
274+ device: &wgpu::Device,
275+ queue: &wgpu::Queue,
276+ layer: usize,
277+ items: &[TextItem],
278+ keys: &[Key],
279+ ) -> anyhow::Result<()> {
280+ let areas = items.iter().zip(keys).filter_map(|(item, key)| {
257281 let s = self.shaped.get(key)?;
258282 let width = s.width;
259283 let left = match item.align {
@@ -290,7 +314,12 @@ impl TextSystem {
290314 custom_glyphs: &[],
291315 })
292316 });
293− self.renderer.prepare(
317+ let renderer = if layer == 0 {
318+ &mut self.renderer
319+ } else {
320+ &mut self.overlay
321+ };
322+ renderer.prepare(
294323 device,
295324 queue,
296325 &mut self.fonts,
@@ -307,6 +336,11 @@ impl TextSystem {
307336 Ok(())
308337 }
309338
339+ pub fn render_overlay(&self, pass: &mut wgpu::RenderPass<'_>) -> anyhow::Result<()> {
340+ self.overlay.render(&self.atlas, &self.viewport, pass)?;
341+ Ok(())
342+ }
343+
310344 pub fn trim(&mut self) {
311345 self.atlas.trim();
312346 }
M src/scan.rs View
@@ -14,6 +14,8 @@ pub struct Scan {
1414 /// Files with unrecognised extensions, to sniff in the background.
1515 pub unknown: Vec<PathBuf>,
1616 pub errors: Vec<String>,
17+ /// Directories listed, and whether recursively: what `watch_dir` watches.
18+ pub dirs: Vec<(PathBuf, bool)>,
1719 }
1820
1921 pub struct ScanOptions {
@@ -38,10 +40,12 @@ pub fn scan(args: &[PathBuf], opts: &ScanOptions) -> Scan {
3840 list_dir(&path, opts.recursive, &mut files, &mut out.unknown);
3941 sort(&mut files, opts);
4042 out.files.extend(files);
43+ out.dirs.push((path.clone(), opts.recursive));
4144 } else if opts.siblings && args.len() == 1 {
4245 let parent = path.parent().unwrap_or(Path::new("/"));
4346 let mut files = Vec::new();
4447 list_dir(parent, false, &mut files, &mut out.unknown);
48+ out.dirs.push((parent.to_path_buf(), false));
4549 out.unknown.retain(|u| *u != path);
4650 if !files.contains(&path) {
4751 files.push(path.clone());
A src/state/fuzzy.rs View
@@ -0,0 +1,77 @@
1+//! Fuzzy matching for the palette and filename search (nucleo, smart case).
2+
3+use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
4+use nucleo_matcher::{Config, Matcher, Utf32Str};
5+
6+pub struct Fuzzy {
7+ matcher: Matcher,
8+ pattern: Pattern,
9+ buf: Vec<char>,
10+}
11+
12+impl std::fmt::Debug for Fuzzy {
13+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14+ f.write_str("Fuzzy")
15+ }
16+}
17+
18+impl Fuzzy {
19+ pub fn new(query: &str) -> Self {
20+ Self {
21+ matcher: Matcher::new(Config::DEFAULT),
22+ pattern: Pattern::parse(query, CaseMatching::Smart, Normalization::Smart),
23+ buf: Vec::new(),
24+ }
25+ }
26+
27+ /// Like [`Fuzzy::new`], tuned for paths (separators score as word boundaries).
28+ pub fn for_paths(query: &str) -> Self {
29+ let mut config = Config::DEFAULT;
30+ config.set_match_paths();
31+ Self {
32+ matcher: Matcher::new(config),
33+ ..Self::new(query)
34+ }
35+ }
36+
37+ /// The match score, higher is better; `None` if it doesn't match. An empty query
38+ /// matches everything with score 0.
39+ pub fn score(&mut self, haystack: &str) -> Option<u32> {
40+ let h = Utf32Str::new(haystack, &mut self.buf);
41+ self.pattern.score(h, &mut self.matcher)
42+ }
43+}
44+
45+/// Indices of `items` matching `query`, best first. Ties go to the shorter item, then keep
46+/// their original order (an empty query keeps every item in place).
47+pub fn rank<'a>(query: &str, items: impl IntoIterator<Item = &'a str>) -> Vec<usize> {
48+ let mut f = Fuzzy::new(query);
49+ let empty = query.trim().is_empty();
50+ let mut scored: Vec<(u32, usize, usize)> = items
51+ .into_iter()
52+ .enumerate()
53+ .filter_map(|(i, s)| {
54+ let len = if empty { 0 } else { s.chars().count() };
55+ f.score(s).map(|sc| (sc, len, i))
56+ })
57+ .collect();
58+ scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
59+ scored.into_iter().map(|(_, _, i)| i).collect()
60+}
61+
62+#[cfg(test)]
63+mod tests {
64+ use super::*;
65+
66+ #[test]
67+ fn ranks_best_first() {
68+ let items = ["next-mark", "next", "reload-config", "help"];
69+ let r = rank("nx", items);
70+ assert_eq!(r[0], 1, "{r:?}");
71+ assert!(r.contains(&0));
72+ assert!(!r.contains(&3));
73+ assert_eq!(rank("", items), [0, 1, 2, 3]);
74+ // Smart case: an upper-case letter makes it case-sensitive.
75+ assert_eq!(rank("HELP", items), Vec::<usize>::new());
76+ }
77+}
M src/state/mod.rs View
@@ -1,6 +1,8 @@
11 //! Pure application state: no winit or wgpu types, so everything here is unit-testable.
22
3+pub mod fuzzy;
34 pub mod grid;
5+pub mod overlay;
46 pub mod queue;
57 pub mod view;
68
@@ -8,11 +10,12 @@ use std::collections::HashMap;
810 use std::path::{Path, PathBuf};
911 use std::time::{Duration, Instant};
1012
11−use crate::action::{Action, ModeArg, Page, Switch, Zoom};
13+use crate::action::{Action, Adjust, ModeArg, Page, Step, Switch, Zoom};
1214 use crate::anim::Tween;
1315 use crate::config::theme::Theme;
1416 use crate::config::{Config, Transition};
1517 use grid::{Grid, Layout};
18+use overlay::{Edit, Help, Info, Input, InputKind, PaletteItem, WhichRow};
1619 use queue::{Queue, SortKey};
1720 use view::{Affine, Dir, FitMode, View};
1821
@@ -32,6 +35,15 @@ const THEME_FADE: Duration = Duration::from_millis(250);
3235 const THUMB_FADE: Duration = Duration::from_millis(150);
3336 /// Thumbnail names: Space Grotesk at this size.
3437 pub const GRID_NAME_SIZE: f32 = 12.0;
38+/// Overlay panels: distance from the window edges, inner padding, row height (logical px).
39+pub const PANEL_MARGIN: f32 = 16.0;
40+pub const PANEL_PAD: f32 = 16.0;
41+pub const PANEL_ROW: f32 = 28.0;
42+/// Help's title line above its rows.
43+pub const HELP_HEAD: f32 = 36.0;
44+/// Slideshow delays stay within these bounds.
45+const SLIDESHOW_MIN: f32 = 0.5;
46+const SLIDESHOW_MAX: f32 = 3600.0;
3547
3648 /// Which keymap applies and what is on screen.
3749 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -71,6 +83,10 @@ pub struct Settings {
7183 pub grid_size: u32,
7284 pub grid_gap: u32,
7385 pub grid_names: bool,
86+ pub slideshow_delay: Duration,
87+ /// Status bar templates.
88+ pub bar_left: String,
89+ pub bar_right: String,
7490 }
7591
7692 impl Default for Settings {
@@ -91,6 +107,9 @@ impl Default for Settings {
91107 grid_size: 160,
92108 grid_gap: 16,
93109 grid_names: false,
110+ slideshow_delay: Duration::from_secs(5),
111+ bar_left: "{name}".into(),
112+ bar_right: "{format} {width}×{height} {zoom}% {index}/{count}".into(),
94113 }
95114 }
96115 }
@@ -118,6 +137,11 @@ impl Settings {
118137 grid_size: c.grid.size,
119138 grid_gap: c.grid.gap,
120139 grid_names: c.grid.names,
140+ slideshow_delay: Duration::from_secs_f32(
141+ c.image.slideshow_delay.clamp(SLIDESHOW_MIN, SLIDESHOW_MAX),
142+ ),
143+ bar_left: c.ui.statusbar_left.clone(),
144+ bar_right: c.ui.statusbar_right.clone(),
121145 }
122146 }
123147 }
@@ -141,6 +165,49 @@ impl AlphaBg {
141165 }
142166 }
143167
168+/// Display adjustments, applied in the image shader.
169+#[derive(Clone, Copy, Debug, PartialEq)]
170+pub struct Adjustments {
171+ pub gamma: f32,
172+ /// Added to every channel, -1..1.
173+ pub brightness: f32,
174+ /// Scales around mid-grey.
175+ pub contrast: f32,
176+}
177+
178+impl Default for Adjustments {
179+ fn default() -> Self {
180+ Self {
181+ gamma: 1.0,
182+ brightness: 0.0,
183+ contrast: 1.0,
184+ }
185+ }
186+}
187+
188+impl Adjustments {
189+ /// Apply one step; returns the new value for the status line.
190+ fn step(&mut self, a: Adjust, s: Step) -> f32 {
191+ let d = Self::default();
192+ let (v, reset, up, down, lo, hi) = match a {
193+ Adjust::Gamma => (&mut self.gamma, d.gamma, 1.1, 1.0 / 1.1, 0.1, 10.0),
194+ Adjust::Brightness => (&mut self.brightness, d.brightness, 0.05, -0.05, -1.0, 1.0),
195+ Adjust::Contrast => (&mut self.contrast, d.contrast, 0.1, -0.1, 0.0, 4.0),
196+ };
197+ let mul = a == Adjust::Gamma;
198+ *v = match s {
199+ Step::Reset => reset,
200+ Step::Up if mul => *v * up,
201+ Step::Down if mul => *v * down,
202+ Step::Up => *v + up,
203+ Step::Down => *v + down,
204+ };
205+ // Snap away float drift so stepping back lands exactly on the default.
206+ *v = ((*v * 1000.0).round() / 1000.0).clamp(lo, hi);
207+ *v
208+ }
209+}
210+
144211 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
145212 pub enum MsgKind {
146213 Info,
@@ -231,6 +298,13 @@ pub struct Effects {
231298 pub refresh: bool,
232299 /// Re-sort the queue (stat-based keys need the filesystem, so the loop does it).
233300 pub sort: Option<SortKey>,
301+ /// Open the palette or help: the loop supplies their rows from the live keymap.
302+ pub palette: bool,
303+ pub help: bool,
304+ /// The info panel opened: load the current file's metadata.
305+ pub info: bool,
306+ /// `open <path>` ran: [`AppState::open`] holds the path to scan.
307+ pub open: bool,
234308 pub quit: bool,
235309 }
236310
@@ -264,6 +338,22 @@ pub struct AppState {
264338 pub prompt: Option<String>,
265339 /// User commands still running, oldest first: (job id, name).
266340 pub running: Vec<(u64, String)>,
341+ /// The `:` palette or `/` search line.
342+ pub input: Option<Input>,
343+ /// Keys that continue the pending sequence, once it has waited `which_key_delay`.
344+ pub which_key: Option<Vec<WhichRow>>,
345+ pub help: Option<Help>,
346+ pub info_open: bool,
347+ /// Metadata of the current file, once loaded.
348+ pub info: Option<Info>,
349+ /// Slideshow on: when the next image is due (`None` until the current one shows).
350+ pub slideshow: Option<Option<Instant>>,
351+ pub slideshow_delay: Duration,
352+ pub adjust: Adjustments,
353+ /// The path of the last `open`, for the loop to scan.
354+ pub open: Option<String>,
355+ /// `ui.info_command` output for a file: its last line fills `{info}`.
356+ pub info_line: Option<(PathBuf, String)>,
267357 window: [f32; 2],
268358 scale_factor: f32,
269359 holds: Vec<Hold>,
@@ -272,6 +362,7 @@ pub struct AppState {
272362
273363 impl AppState {
274364 pub fn new(queue: Queue, settings: Settings, bar: bool, now: Instant) -> Self {
365+ let slideshow_delay = settings.slideshow_delay;
275366 Self {
276367 queue,
277368 view: View::new(now),
@@ -294,6 +385,16 @@ impl AppState {
294385 pending: String::new(),
295386 prompt: None,
296387 running: Vec::new(),
388+ input: None,
389+ which_key: None,
390+ help: None,
391+ info_open: false,
392+ info: None,
393+ slideshow: None,
394+ slideshow_delay,
395+ adjust: Adjustments::default(),
396+ open: None,
397+ info_line: None,
297398 window: [1.0, 1.0],
298399 scale_factor: 1.0,
299400 holds: Vec::new(),
@@ -333,6 +434,14 @@ impl AppState {
333434 }
334435 }
335436
437+ /// Help rows that fit on screen at once.
438+ pub fn help_page(&self) -> usize {
439+ let sf = self.ui_scale();
440+ let h = self.window[1] - self.bar_height() - 2.0 * PANEL_MARGIN * sf;
441+ let rows = (h - (2.0 * PANEL_PAD + HELP_HEAD) * sf) / (PANEL_ROW * sf);
442+ rows.floor().max(1.0) as usize
443+ }
444+
336445 pub fn in_grid(&self) -> bool {
337446 matches!(self.mode, Mode::Grid | Mode::Visual)
338447 }
@@ -462,6 +571,9 @@ impl AppState {
462571
463572 /// Apply settings from a (re)loaded config. The view keeps its zoom until the next image.
464573 pub fn set_settings(&mut self, settings: Settings) {
574+ if settings.slideshow_delay != self.settings.slideshow_delay {
575+ self.slideshow_delay = settings.slideshow_delay;
576+ }
465577 self.settings = settings;
466578 self.update_viewport();
467579 }
@@ -661,6 +773,81 @@ impl AppState {
661773 }
662774 None => self.notify("single frame", MsgKind::Info, now),
663775 },
776+ Action::Adjust(a, s) if has_image => {
777+ let v = self.adjust.step(a, s);
778+ let name = Action::Adjust(a, s).name();
779+ self.notify(format!("{name} {v:.2}"), MsgKind::Info, now);
780+ }
781+ Action::Slideshow(s) => {
782+ let on = switch(s, self.slideshow.is_some());
783+ if on == self.slideshow.is_some() {
784+ fx.redraw = false;
785+ } else if on {
786+ // It runs in image mode; the timer starts once the image shows.
787+ fx.mode = self.set_mode(Mode::Image, now);
788+ self.slideshow = Some(self.shown.is_some().then(|| now + self.slideshow_delay));
789+ let secs = self.slideshow_delay.as_secs_f32();
790+ self.notify(format!("slideshow {secs}s"), MsgKind::Info, now);
791+ } else {
792+ self.slideshow = None;
793+ self.notify("slideshow off", MsgKind::Info, now);
794+ }
795+ }
796+ Action::SlideshowDelay { secs, relative } => {
797+ let base = if relative {
798+ self.slideshow_delay.as_secs_f32()
799+ } else {
800+ 0.0
801+ };
802+ let d = (base + secs).clamp(SLIDESHOW_MIN, SLIDESHOW_MAX);
803+ self.slideshow_delay = Duration::from_secs_f32(d);
804+ if let Some(Some(next)) = &mut self.slideshow {
805+ // Restart the count for the image on screen.
806+ *next = now + self.slideshow_delay;
807+ }
808+ self.notify(
809+ format!("slideshow {}s", (d * 10.0).round() / 10.0),
810+ MsgKind::Info,
811+ now,
812+ );
813+ }
814+ Action::Info(s) => {
815+ let open = switch(s, self.info_open);
816+ fx.info = open && !self.info_open;
817+ fx.redraw = open != self.info_open;
818+ self.info_open = open;
819+ }
820+ Action::Help => {
821+ if self.help.take().is_none() {
822+ fx.help = true;
823+ }
824+ }
825+ Action::Palette => fx.palette = true,
826+ Action::Search => {
827+ self.which_key = None;
828+ self.help = None;
829+ let origin = self.queue.current().map(Path::to_path_buf);
830+ self.input = Some(Input::new(InputKind::Search, Vec::new(), origin));
831+ }
832+ Action::Filter(p) => {
833+ fx.navigated = self.queue.set_filter(Some(p));
834+ fx.reordered = true;
835+ if self.queue.is_empty() {
836+ self.notify("no matches", MsgKind::Info, now);
837+ }
838+ }
839+ Action::FilterClear => {
840+ if self.queue.filter().is_some() {
841+ fx.navigated = self.queue.set_filter(None);
842+ fx.reordered = true;
843+ } else {
844+ fx.redraw = false;
845+ }
846+ }
847+ Action::Open(p) => {
848+ self.open = Some(p);
849+ fx.open = true;
850+ }
664851 Action::Fullscreen(s) => fx.fullscreen = Some(s),
665852 Action::Bar(s) => {
666853 let bar = switch(s, self.bar);
@@ -687,7 +874,11 @@ impl AppState {
687874 // The event loop runs commands: they spawn processes and may wait for a `y`.
688875 Action::Run(_) => fx.redraw = false,
689876 Action::Echo(msg) => self.notify(msg, MsgKind::Info, now),
690− Action::Cancel => self.message = None,
877+ Action::Cancel => {
878+ self.message = None;
879+ self.help = None;
880+ self.which_key = None;
881+ }
691882 Action::Quit | Action::QuitNoOutput => fx.quit = true,
692883 Action::Zoom(_)
693884 | Action::Fit(_)
@@ -703,24 +894,8 @@ impl AppState {
703894 | Action::Scroll(..)
704895 | Action::Play(_)
705896 | Action::NextFrame
706− | Action::PrevFrame => fx.redraw = false,
707− // Parsed and bindable now; they arrive with later milestones.
708− other @ (Action::Adjust(..)
709− | Action::Slideshow(_)
710− | Action::SlideshowDelay { .. }
711− | Action::Info(_)
712− | Action::Help
713− | Action::Palette
714− | Action::Search
715− | Action::Filter(_)
716− | Action::FilterClear
717− | Action::Open(_)) => {
718− self.notify(
719− format!("{} not available yet", other.name()),
720− MsgKind::Info,
721− now,
722− );
723− }
897+ | Action::PrevFrame
898+ | Action::Adjust(..) => fx.redraw = false,
724899 }
725900 if fx.navigated && self.in_grid() {
726901 self.grid_follow(now, true);
@@ -728,6 +903,171 @@ impl AppState {
728903 fx
729904 }
730905
906+ /// Open the palette with its rows (built by the loop from the keymap and commands).
907+ pub fn open_palette(&mut self, items: Vec<PaletteItem>) {
908+ self.which_key = None;
909+ self.help = None;
910+ self.input = Some(Input::new(InputKind::Palette, items, None));
911+ }
912+
913+ fn goto_path(&mut self, path: &Path) -> bool {
914+ match self.queue.files().iter().position(|f| f == path) {
915+ Some(i) => self.queue.goto(i),
916+ None => false,
917+ }
918+ }
919+
920+ /// Re-run the search for the current text: the grid filters live, the image view lists
921+ /// the best matches.
922+ fn search_update(&mut self, now: Instant) -> Effects {
923+ let mut fx = Effects {
924+ redraw: true,
925+ ..Effects::default()
926+ };
927+ let Some(input) = &mut self.input else {
928+ return fx;
929+ };
930+ if self.mode == Mode::Image {
931+ let names: Vec<String> = self.queue.files().iter().map(|p| file_name(p)).collect();
932+ input.matches = fuzzy::rank(&input.text, names.iter().map(String::as_str));
933+ input.selected = 0;
934+ return fx;
935+ }
936+ let text = input.text.clone();
937+ fx.navigated = self.queue.set_search(Some(text));
938+ fx.reordered = true;
939+ self.grid_follow(now, false);
940+ fx
941+ }
942+
943+ /// A key typed at the palette or search line.
944+ pub fn input_edit(&mut self, e: Edit, now: Instant) -> Effects {
945+ let mut fx = Effects {
946+ redraw: true,
947+ ..Effects::default()
948+ };
949+ let in_grid = self.in_grid();
950+ let Some(input) = &mut self.input else {
951+ return fx;
952+ };
953+ let kind = input.kind;
954+ if input.edit_text(&e) {
955+ return match kind {
956+ InputKind::Palette => {
957+ input.rank_items();
958+ fx
959+ }
960+ InputKind::Search => self.search_update(now),
961+ };
962+ }
963+ match (&e, kind, in_grid) {
964+ (Edit::Up | Edit::Down, InputKind::Palette, _)
965+ | (Edit::Up | Edit::Down, InputKind::Search, false) => input.select(e == Edit::Up),
966+ (Edit::Left | Edit::Right | Edit::Up | Edit::Down, InputKind::Search, true) => {
967+ let dir = match e {
968+ Edit::Left => Dir::Left,
969+ Edit::Right => Dir::Right,
970+ Edit::Up => Dir::Up,
971+ _ => Dir::Down,
972+ };
973+ return self.apply(Action::Move(dir, 1), now);
974+ }
975+ (Edit::Complete, InputKind::Palette, _) => {
976+ if let Some(item) = input.selected_item() {
977+ input.text = item.completion();
978+ input.rank_items();
979+ }
980+ }
981+ _ => fx.redraw = false,
982+ }
983+ fx
984+ }
985+
986+ /// `<CR>` at the prompt. Returns the action text to run, if any.
987+ pub fn input_submit(&mut self, now: Instant) -> (Effects, Option<String>) {
988+ let mut fx = Effects {
989+ redraw: true,
990+ ..Effects::default()
991+ };
992+ let Some(input) = self.input.take() else {
993+ return (fx, None);
994+ };
995+ match input.kind {
996+ InputKind::Palette => {
997+ let typed = input.text.trim();
998+ let selected = input.selected_item().cloned();
999+ let parses = typed.parse::<Action>().is_ok();
1000+ let run = match selected {
1001+ Some(sel) if input.moved || !parses => {
1002+ if sel.template {
1003+ // `goto N`: complete it and keep typing.
1004+ let mut input = input;
1005+ input.text = sel.completion();
1006+ input.rank_items();
1007+ self.input = Some(input);
1008+ return (fx, None);
1009+ }
1010+ Some(sel.action)
1011+ }
1012+ _ if typed.is_empty() => None,
1013+ _ => Some(typed.to_string()),
1014+ };
1015+ (fx, run)
1016+ }
1017+ InputKind::Search if self.mode == Mode::Image => {
1018+ if let Some(&i) = input.matches.get(input.selected) {
1019+ fx.navigated = self.queue.goto(i);
1020+ }
1021+ (fx, None)
1022+ }
1023+ InputKind::Search => {
1024+ // Keep the file under the cursor, with the whole queue back around it.
1025+ let cur = self.queue.current().map(Path::to_path_buf);
1026+ self.queue.set_search(None);
1027+ fx.reordered = true;
1028+ fx.navigated = cur.as_deref() != input.origin.as_deref();
1029+ if let Some(c) = cur {
1030+ self.goto_path(&c);
1031+ }
1032+ self.grid_follow(now, false);
1033+ (fx, None)
1034+ }
1035+ }
1036+ }
1037+
1038+ /// `<Esc>` at the prompt: a search puts everything back as it was.
1039+ pub fn input_cancel(&mut self, now: Instant) -> Effects {
1040+ let mut fx = Effects {
1041+ redraw: true,
1042+ ..Effects::default()
1043+ };
1044+ let Some(input) = self.input.take() else {
1045+ return fx;
1046+ };
1047+ if input.kind == InputKind::Search && self.in_grid() {
1048+ self.queue.set_search(None);
1049+ fx.reordered = true;
1050+ if let Some(o) = &input.origin {
1051+ self.goto_path(o);
1052+ }
1053+ fx.navigated = self.queue.current() != input.origin.as_deref();
1054+ self.grid_follow(now, false);
1055+ }
1056+ fx
1057+ }
1058+
1059+ /// The slideshow's next image is due.
1060+ pub fn slideshow_due(&self, now: Instant) -> bool {
1061+ self.mode == Mode::Image && matches!(self.slideshow, Some(Some(t)) if now >= t)
1062+ }
1063+
1064+ /// The slideshow moved on (or couldn't): wait for the next image to show.
1065+ pub fn slideshow_advanced(&mut self, moved: bool) {
1066+ if self.slideshow.is_some() {
1067+ self.slideshow = moved.then_some(None);
1068+ }
1069+ }
1070+
7311071 /// Start or stop a held-key pan.
7321072 pub fn hold_pan(&mut self, dir: Dir, held: bool, now: Instant) {
7331073 if held {
@@ -808,6 +1148,9 @@ impl AppState {
8081148 self.loading_since = None;
8091149 self.holds.clear();
8101150 self.playback = None;
1151+ if let Some(next) = &mut self.slideshow {
1152+ *next = Some(now + self.slideshow_delay);
1153+ }
8111154 }
8121155
8131156 /// The shown image has several frames: play an animation, or page through a document.
@@ -912,6 +1255,11 @@ impl AppState {
9121255 wake(at);
9131256 }
9141257 }
1258+ if self.mode == Mode::Image
1259+ && let Some(Some(t)) = self.slideshow
1260+ {
1261+ wake(t);
1262+ }
9151263 // Animation frames: only while the image is on screen.
9161264 if self.mode == Mode::Image
9171265 && let Some(pb) = &mut self.playback
@@ -951,6 +1299,13 @@ impl AppState {
9511299 }
9521300 }
9531301
1302+pub fn file_name(path: &Path) -> String {
1303+ path.file_name().map_or_else(
1304+ || path.display().to_string(),
1305+ |n| n.to_string_lossy().into(),
1306+ )
1307+}
1308+
9541309 #[cfg(test)]
9551310 mod tests {
9561311 use super::*;
@@ -1025,14 +1380,20 @@ mod tests {
10251380 }
10261381
10271382 #[test]
1028− fn unfinished_actions_say_so() {
1383+ fn overlay_actions_ask_the_loop() {
10291384 let (mut s, now) = state(2);
1030− let fx = s.apply(Action::Palette, now);
1031− assert!(!fx.navigated);
1032− assert_eq!(
1033− s.message.as_ref().map(|m| m.text.as_str()),
1034− Some("palette not available yet")
1035− );
1385+ assert!(s.apply(Action::Palette, now).palette);
1386+ assert!(s.apply(Action::Help, now).help);
1387+ s.help = Some(Help {
1388+ rows: Vec::new(),
1389+ scroll: 0,
1390+ });
1391+ assert!(!s.apply(Action::Help, now).help, "help toggles off");
1392+ assert!(s.help.is_none());
1393+ assert!(s.apply(Action::Info(Switch::Toggle), now).info);
1394+ assert!(!s.apply(Action::Info(Switch::On), now).redraw);
1395+ assert!(s.apply(Action::Open("x.png".into()), now).open);
1396+ assert_eq!(s.open.as_deref(), Some("x.png"));
10361397 assert!(s.apply(Action::Refresh, now).refresh);
10371398 assert!(!s.apply(Action::Run("x".into()), now).redraw);
10381399 s.apply(Action::Echo("hi".into()), now);
@@ -1041,6 +1402,149 @@ mod tests {
10411402 assert_eq!(s.queue.index(), 1);
10421403 }
10431404
1405+ fn palette_items() -> Vec<PaletteItem> {
1406+ [("next", false), ("goto N", true), ("fit width", false)]
1407+ .iter()
1408+ .map(|(a, t)| PaletteItem {
1409+ action: a.to_string(),
1410+ desc: String::new(),
1411+ keys: String::new(),
1412+ template: *t,
1413+ })
1414+ .collect()
1415+ }
1416+
1417+ #[test]
1418+ fn palette_runs_typed_or_selected_actions() {
1419+ let (mut s, now) = state(3);
1420+ let typed = |s: &mut AppState, t: &str| {
1421+ s.open_palette(palette_items());
1422+ s.input_edit(Edit::Insert(t.into()), now);
1423+ s.input_submit(now).1
1424+ };
1425+ assert_eq!(typed(&mut s, "fitw").as_deref(), Some("fit width"));
1426+ // Typed text that parses wins over the best match...
1427+ assert_eq!(typed(&mut s, "goto 3").as_deref(), Some("goto 3"));
1428+ // ...unless the selection was moved by hand.
1429+ s.open_palette(palette_items());
1430+ s.input_edit(Edit::Up, now);
1431+ assert!(s.input_submit(now).1.is_none());
1432+ assert_eq!(
1433+ s.input.as_ref().unwrap().text,
1434+ "goto ",
1435+ "a template completes"
1436+ );
1437+ s.input_edit(Edit::Insert("2".into()), now);
1438+ assert_eq!(s.input_submit(now).1.as_deref(), Some("goto 2"));
1439+ assert!(s.input.is_none());
1440+ // No match: the text runs as typed (and the loop reports it if it doesn't parse).
1441+ assert_eq!(typed(&mut s, "zzzz").as_deref(), Some("zzzz"));
1442+ s.open_palette(palette_items());
1443+ assert!(!s.input_cancel(now).navigated);
1444+ assert!(s.input.is_none());
1445+ }
1446+
1447+ #[test]
1448+ fn search_filters_the_grid_live() {
1449+ let now = Instant::now();
1450+ let files = ["cat.png", "dog.png", "cow.png", "bird.png"]
1451+ .iter()
1452+ .map(PathBuf::from)
1453+ .collect();
1454+ let mut s = AppState::new(Queue::new(files, None, 1), Settings::default(), true, now);
1455+ s.set_window(800.0, 632.0, 1.0);
1456+ s.apply(Action::Mode(ModeArg::Grid), now);
1457+ s.apply(Action::Search, now);
1458+ let fx = s.input_edit(Edit::Insert("c".into()), now);
1459+ assert!(fx.reordered);
1460+ assert_eq!(s.queue.len(), 2);
1461+ s.input_edit(Edit::Right, now);
1462+ assert_eq!(s.queue.current(), Some(Path::new("cow.png")));
1463+ let (fx, run) = s.input_submit(now);
1464+ assert!(run.is_none() && fx.navigated);
1465+ assert_eq!(s.queue.len(), 4);
1466+ assert_eq!(s.queue.current(), Some(Path::new("cow.png")));
1467+ // <Esc> puts back the file the search started on.
1468+ s.apply(Action::Search, now);
1469+ s.input_edit(Edit::Insert("bi".into()), now);
1470+ assert_eq!(s.queue.current(), Some(Path::new("bird.png")));
1471+ s.input_cancel(now);
1472+ assert_eq!(s.queue.current(), Some(Path::new("cow.png")));
1473+ assert_eq!(s.queue.len(), 4);
1474+ // In image mode the search lists matches and jumps to the selected one.
1475+ s.apply(Action::Mode(ModeArg::Image), now);
1476+ s.apply(Action::Search, now);
1477+ s.input_edit(Edit::Insert("dg".into()), now);
1478+ assert_eq!(s.queue.len(), 4);
1479+ assert!(s.input_submit(now).0.navigated);
1480+ assert_eq!(s.queue.current(), Some(Path::new("dog.png")));
1481+ }
1482+
1483+ #[test]
1484+ fn filter_action_narrows_the_queue() {
1485+ let (mut s, now) = state(12);
1486+ assert!(s.apply(Action::Filter("1*".into()), now).reordered);
1487+ assert_eq!(s.queue.len(), 3, "1, 10, 11");
1488+ s.apply(Action::Filter("zzz".into()), now);
1489+ assert!(s.queue.is_empty());
1490+ assert_eq!(s.message.as_ref().unwrap().text, "no matches");
1491+ assert!(s.apply(Action::FilterClear, now).reordered);
1492+ assert_eq!(s.queue.len(), 12);
1493+ assert!(!s.apply(Action::FilterClear, now).redraw);
1494+ }
1495+
1496+ #[test]
1497+ fn slideshow_advances_after_the_delay() {
1498+ let (mut s, now) = state(2);
1499+ s.apply(Action::Mode(ModeArg::Grid), now);
1500+ s.apply(Action::Slideshow(Switch::On), now);
1501+ assert_eq!(s.mode, Mode::Image, "slideshows run in image mode");
1502+ assert!(
1503+ !s.slideshow_due(now + Duration::from_secs(60)),
1504+ "waits for an image"
1505+ );
1506+ s.show(Path::new("0.png"), [10, 10], "PNG", now);
1507+ let d = s.slideshow_delay;
1508+ s.message = None;
1509+ assert_eq!(s.tick(now).1, Some(now + d));
1510+ assert!(!s.slideshow_due(now + d / 2));
1511+ assert!(s.slideshow_due(now + d));
1512+ s.slideshow_advanced(true);
1513+ assert!(
1514+ !s.slideshow_due(now + d * 2),
1515+ "re-armed when the next image shows"
1516+ );
1517+ s.apply(
1518+ Action::SlideshowDelay {
1519+ secs: -100.0,
1520+ relative: true,
1521+ },
1522+ now,
1523+ );
1524+ assert_eq!(s.slideshow_delay, Duration::from_secs_f32(SLIDESHOW_MIN));
1525+ s.apply(Action::Slideshow(Switch::Toggle), now);
1526+ assert!(s.slideshow.is_none());
1527+ }
1528+
1529+ #[test]
1530+ fn adjustments_step_and_reset() {
1531+ let (mut s, now) = state(1);
1532+ s.show(Path::new("0.png"), [10, 10], "PNG", now);
1533+ s.apply(Action::Adjust(Adjust::Gamma, Step::Up), now);
1534+ s.apply(Action::Adjust(Adjust::Gamma, Step::Up), now);
1535+ assert_eq!(s.adjust.gamma, 1.21);
1536+ assert_eq!(s.message.as_ref().unwrap().text, "gamma 1.21");
1537+ s.apply(Action::Adjust(Adjust::Gamma, Step::Down), now);
1538+ s.apply(Action::Adjust(Adjust::Gamma, Step::Down), now);
1539+ assert_eq!(s.adjust.gamma, 1.0);
1540+ for _ in 0..30 {
1541+ s.apply(Action::Adjust(Adjust::Brightness, Step::Up), now);
1542+ }
1543+ assert_eq!(s.adjust.brightness, 1.0);
1544+ s.apply(Action::Adjust(Adjust::Brightness, Step::Reset), now);
1545+ assert_eq!(s.adjust, Adjustments::default());
1546+ }
1547+
10441548 #[test]
10451549 fn grid_mode_moves_the_queue_cursor() {
10461550 let (mut s, now) = state(40);
A src/state/overlay.rs View
@@ -0,0 +1,237 @@
1+//! Overlay state: the `:` palette, `/` search, which-key, help and the info panel.
2+
3+use std::path::PathBuf;
4+
5+use super::fuzzy;
6+
7+/// Rows shown at most in the palette and the image-mode search list.
8+pub const LIST_ROWS: usize = 8;
9+
10+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11+pub enum InputKind {
12+ Palette,
13+ Search,
14+}
15+
16+/// One palette row: an action (or a template like `goto N` to complete), what it does and
17+/// the keys bound to it.
18+#[derive(Clone, Debug, PartialEq)]
19+pub struct PaletteItem {
20+ pub action: String,
21+ pub desc: String,
22+ pub keys: String,
23+ /// `action` has a placeholder argument (`goto N`): `<CR>` completes it instead of running.
24+ pub template: bool,
25+}
26+
27+impl PaletteItem {
28+ /// What `<Tab>` puts on the line: the action, or a template's name and a space.
29+ pub fn completion(&self) -> String {
30+ if self.template {
31+ let name = self.action.split_whitespace().next().unwrap_or("");
32+ format!("{name} ")
33+ } else {
34+ self.action.clone()
35+ }
36+ }
37+}
38+
39+/// An edit to the prompt line.
40+#[derive(Clone, Debug, PartialEq, Eq)]
41+pub enum Edit {
42+ Insert(String),
43+ Backspace,
44+ DeleteWord,
45+ Clear,
46+ /// Towards worse matches (drawn above), or the next file in the grid.
47+ Up,
48+ Down,
49+ Left,
50+ Right,
51+ /// Complete the selected palette item into the line.
52+ Complete,
53+}
54+
55+/// The prompt line and what it matches.
56+#[derive(Debug)]
57+pub struct Input {
58+ pub kind: InputKind,
59+ pub text: String,
60+ pub items: Vec<PaletteItem>,
61+ /// Matching palette items, or queue indices in image-mode search; best first.
62+ pub matches: Vec<usize>,
63+ pub selected: usize,
64+ /// The selection was moved by hand, so `<CR>` takes it over the typed text.
65+ pub moved: bool,
66+ /// The file and filter before a search, for `<Esc>`.
67+ pub origin: Option<PathBuf>,
68+}
69+
70+impl Input {
71+ pub fn new(kind: InputKind, items: Vec<PaletteItem>, origin: Option<PathBuf>) -> Self {
72+ let mut i = Self {
73+ kind,
74+ text: String::new(),
75+ items,
76+ matches: Vec::new(),
77+ selected: 0,
78+ moved: false,
79+ origin,
80+ };
81+ i.rank_items();
82+ i
83+ }
84+
85+ pub fn prefix(&self) -> &'static str {
86+ match self.kind {
87+ InputKind::Palette => ":",
88+ InputKind::Search => "/",
89+ }
90+ }
91+
92+ /// Re-rank palette items against the text.
93+ pub fn rank_items(&mut self) {
94+ if self.kind != InputKind::Palette {
95+ return;
96+ }
97+ let hay: Vec<String> = self
98+ .items
99+ .iter()
100+ .map(|i| format!("{} {}", i.action, i.desc))
101+ .collect();
102+ self.matches = fuzzy::rank(&self.text, hay.iter().map(String::as_str));
103+ self.selected = 0;
104+ self.moved = false;
105+ }
106+
107+ /// Apply a text edit. Returns whether the text changed.
108+ pub fn edit_text(&mut self, e: &Edit) -> bool {
109+ let before = self.text.len();
110+ match e {
111+ Edit::Insert(s) => self.text.extend(s.chars().filter(|c| !c.is_control())),
112+ Edit::Backspace => {
113+ self.text.pop();
114+ }
115+ Edit::DeleteWord => {
116+ let t = self.text.trim_end();
117+ let cut = t.rfind(char::is_whitespace).map_or(0, |i| i + 1);
118+ self.text.truncate(cut);
119+ }
120+ Edit::Clear => self.text.clear(),
121+ _ => return false,
122+ }
123+ self.text.len() != before || matches!(e, Edit::Insert(_))
124+ }
125+
126+ /// Move the selection through the match list.
127+ pub fn select(&mut self, up: bool) {
128+ let n = self.matches.len();
129+ if n == 0 {
130+ return;
131+ }
132+ self.selected = if up {
133+ (self.selected + 1).min(n - 1)
134+ } else {
135+ self.selected.saturating_sub(1)
136+ };
137+ self.moved = true;
138+ }
139+
140+ pub fn selected_item(&self) -> Option<&PaletteItem> {
141+ self.matches
142+ .get(self.selected)
143+ .and_then(|&i| self.items.get(i))
144+ }
145+
146+ /// The first row of the visible window of matches, keeping the selection in view.
147+ pub fn window_start(&self) -> usize {
148+ self.selected.saturating_sub(LIST_ROWS - 1)
149+ }
150+}
151+
152+/// One which-key row.
153+#[derive(Clone, Debug, PartialEq)]
154+pub struct WhichRow {
155+ pub key: String,
156+ pub desc: String,
157+ /// More keys follow this one.
158+ pub prefix: bool,
159+}
160+
161+#[derive(Clone, Debug, PartialEq)]
162+pub enum HelpRow {
163+ Header(String),
164+ Binding { keys: String, desc: String },
165+}
166+
167+#[derive(Debug)]
168+pub struct Help {
169+ pub rows: Vec<HelpRow>,
170+ /// First row shown.
171+ pub scroll: usize,
172+}
173+
174+impl Help {
175+ /// Scroll by `delta` rows, keeping `page` rows on screen.
176+ pub fn scroll_by(&mut self, delta: isize, page: usize) {
177+ let max = self.rows.len().saturating_sub(page);
178+ self.scroll = (self.scroll as isize + delta).clamp(0, max as isize) as usize;
179+ }
180+}
181+
182+/// Metadata rows of a file for the info panel.
183+#[derive(Clone, Debug, PartialEq)]
184+pub struct Info {
185+ pub path: PathBuf,
186+ pub rows: Vec<(String, String)>,
187+}
188+
189+#[cfg(test)]
190+mod tests {
191+ use super::*;
192+
193+ fn items() -> Vec<PaletteItem> {
194+ ["next", "next-mark", "goto N", "help"]
195+ .iter()
196+ .map(|a| PaletteItem {
197+ action: a.to_string(),
198+ desc: String::new(),
199+ keys: String::new(),
200+ template: a.contains('N'),
201+ })
202+ .collect()
203+ }
204+
205+ #[test]
206+ fn typing_ranks_and_selects() {
207+ let mut i = Input::new(InputKind::Palette, items(), None);
208+ assert_eq!(i.matches.len(), 4);
209+ assert!(i.edit_text(&Edit::Insert("nxt".into())));
210+ i.rank_items();
211+ assert_eq!(i.selected_item().unwrap().action, "next");
212+ i.select(true);
213+ assert_eq!(i.selected_item().unwrap().action, "next-mark");
214+ assert!(i.moved);
215+ i.select(true);
216+ assert_eq!(i.selected, 1, "clamped to the matches");
217+ i.edit_text(&Edit::Insert(" more words".into()));
218+ i.edit_text(&Edit::DeleteWord);
219+ assert_eq!(i.text, "nxt more ");
220+ i.edit_text(&Edit::Clear);
221+ assert!(!i.edit_text(&Edit::Backspace));
222+ assert_eq!(i.items[2].completion(), "goto ");
223+ assert_eq!(i.items[1].completion(), "next-mark");
224+ }
225+
226+ #[test]
227+ fn help_scroll_clamps() {
228+ let mut h = Help {
229+ rows: vec![HelpRow::Header("X".into()); 10],
230+ scroll: 0,
231+ };
232+ h.scroll_by(-3, 4);
233+ assert_eq!(h.scroll, 0);
234+ h.scroll_by(100, 4);
235+ assert_eq!(h.scroll, 6);
236+ }
237+}
M src/state/queue.rs View
@@ -4,6 +4,8 @@ use std::cmp::Ordering;
44 use std::collections::HashSet;
55 use std::path::{Path, PathBuf};
66
7+use super::fuzzy::Fuzzy;
8+
79 #[derive(
810 Clone,
911 Copy,
@@ -33,6 +35,41 @@ pub struct Queue {
3335 marks: HashSet<PathBuf>,
3436 /// Bumped on every change to the marks, so watchers can spot one.
3537 marks_version: u64,
38+ /// `filter <pattern>`: a glob or a plain substring of the file name.
39+ filter: Option<String>,
40+ /// Live `/` search: fuzzy over file names.
41+ search: Option<String>,
42+ /// Every file, in order, while a filter or search hides some (`files` is then the subset).
43+ full: Option<Vec<PathBuf>>,
44+ /// The current file when a filter left nothing shown, to return to afterwards.
45+ lost: Option<PathBuf>,
46+}
47+
48+/// Decides which files a filter and search let through.
49+struct Matcher {
50+ /// Lower-cased pattern and whether it is a glob.
51+ pattern: Option<(String, bool)>,
52+ fuzzy: Option<Fuzzy>,
53+}
54+
55+impl Matcher {
56+ fn is_match(&mut self, path: &Path) -> bool {
57+ let name = path
58+ .file_name()
59+ .map_or_else(|| path.to_string_lossy(), |n| n.to_string_lossy());
60+ if let Some((pat, glob)) = &self.pattern {
61+ let lower = name.to_lowercase();
62+ let ok = if *glob {
63+ glob_match(pat, &lower)
64+ } else {
65+ lower.contains(pat.as_str())
66+ };
67+ if !ok {
68+ return false;
69+ }
70+ }
71+ self.fuzzy.as_mut().is_none_or(|f| f.score(&name).is_some())
72+ }
3673 }
3774
3875 impl Queue {
@@ -46,6 +83,10 @@ impl Queue {
4683 index: 0,
4784 marks: HashSet::new(),
4885 marks_version: 0,
86+ filter: None,
87+ search: None,
88+ full: None,
89+ lost: None,
4990 };
5091 q.index = q.clamp(index);
5192 q
@@ -96,8 +137,17 @@ impl Queue {
96137
97138 /// Remove `path`; the current position moves to the following file.
98139 pub fn remove(&mut self, path: &Path) -> bool {
140+ let hidden = self.full.as_mut().is_some_and(|full| {
141+ full.iter()
142+ .position(|f| f == path)
143+ .map(|i| full.remove(i))
144+ .is_some()
145+ });
99146 let Some(i) = self.files.iter().position(|f| f == path) else {
100− return false;
147+ if hidden && self.marks.remove(path) {
148+ self.marks_version += 1;
149+ }
150+ return hidden;
101151 };
102152 let removed = self.files.remove(i);
103153 if self.marks.remove(&removed) {
@@ -113,14 +163,20 @@ impl Queue {
113163 /// Insert paths in natural order, keeping the current file selected.
114164 pub fn insert_sorted(&mut self, paths: impl IntoIterator<Item = PathBuf>) {
115165 let current = self.current().map(Path::to_path_buf);
166+ let mut m = self.matcher();
167+ let insert = |list: &mut Vec<PathBuf>, p: &PathBuf| {
168+ if !list.contains(p) {
169+ let at = list.partition_point(|f| natural_cmp_path(f, p) != Ordering::Greater);
170+ list.insert(at, p.clone());
171+ }
172+ };
116173 for p in paths {
117− if self.files.contains(&p) {
118− continue;
174+ if let Some(full) = &mut self.full {
175+ insert(full, &p);
176+ }
177+ if m.as_mut().is_none_or(|m| m.is_match(&p)) {
178+ insert(&mut self.files, &p);
119179 }
120− let at = self
121− .files
122− .partition_point(|f| natural_cmp_path(f, &p) != Ordering::Greater);
123− self.files.insert(at, p);
124180 }
125181 if let Some(cur) = current
126182 && let Some(i) = self.files.iter().position(|f| *f == cur)
@@ -151,12 +207,96 @@ impl Queue {
151207 out
152208 }
153209
210+ /// The files shown: all of them, or those a filter or search lets through.
154211 pub fn files(&self) -> &[PathBuf] {
155212 &self.files
156213 }
157214
215+ /// Every file, including those a filter hides.
216+ pub fn all_files(&self) -> &[PathBuf] {
217+ self.full.as_deref().unwrap_or(&self.files)
218+ }
219+
220+ pub fn filter(&self) -> Option<&str> {
221+ self.filter.as_deref()
222+ }
223+
224+ /// Show only files whose name matches `pattern`: a glob (`*.png`) when it has `*`, `?`
225+ /// or `[`, otherwise a substring; case-insensitive either way. Returns whether the
226+ /// current file changed.
227+ pub fn set_filter(&mut self, pattern: Option<String>) -> bool {
228+ self.filter = pattern.filter(|p| !p.is_empty());
229+ self.refilter()
230+ }
231+
232+ /// Narrow the files to fuzzy matches of `query` (the live `/` search).
233+ pub fn set_search(&mut self, query: Option<String>) -> bool {
234+ self.search = query.filter(|q| !q.is_empty());
235+ self.refilter()
236+ }
237+
238+ fn matcher(&self) -> Option<Matcher> {
239+ if self.filter.is_none() && self.search.is_none() {
240+ return None;
241+ }
242+ Some(Matcher {
243+ pattern: self.filter.as_ref().map(|p| {
244+ let glob = p.contains(['*', '?', '[']);
245+ (p.to_lowercase(), glob)
246+ }),
247+ fuzzy: self.search.as_deref().map(Fuzzy::for_paths),
248+ })
249+ }
250+
251+ /// Recompute the shown files. The current file stays selected if it is still shown,
252+ /// otherwise the next shown one after it. Returns whether the current file changed.
253+ fn refilter(&mut self) -> bool {
254+ let cur = self
255+ .current()
256+ .map(Path::to_path_buf)
257+ .or_else(|| self.lost.take());
258+ let Some(mut m) = self.matcher() else {
259+ if let Some(full) = self.full.take() {
260+ self.files = full;
261+ }
262+ return self.reselect(cur.as_deref());
263+ };
264+ let full = self.full.get_or_insert_with(|| self.files.clone());
265+ self.files = full.iter().filter(|p| m.is_match(p)).cloned().collect();
266+ self.reselect(cur.as_deref())
267+ }
268+
269+ /// Select `cur`, or the first shown file after it in the full order.
270+ fn reselect(&mut self, cur: Option<&Path>) -> bool {
271+ let Some(cur) = cur else {
272+ self.index = 0;
273+ return !self.files.is_empty();
274+ };
275+ if self.files.is_empty() {
276+ self.index = 0;
277+ self.lost = Some(cur.to_path_buf());
278+ return true;
279+ }
280+ let all = self.all_files();
281+ let from = all.iter().position(|f| f == cur).unwrap_or(0);
282+ let shown: HashSet<&PathBuf> = self.files.iter().collect();
283+ let next = all[from..]
284+ .iter()
285+ .chain(all[..from].iter().rev())
286+ .find(|f| shown.contains(f))
287+ .cloned();
288+ let i = next
289+ .and_then(|n| self.files.iter().position(|f| *f == n))
290+ .unwrap_or(0);
291+ self.index = self.clamp(i);
292+ self.current() != Some(cur)
293+ }
294+
158295 /// Reverse the order, keeping the current file selected.
159296 pub fn reverse(&mut self) {
297+ if let Some(full) = &mut self.full {
298+ full.reverse();
299+ }
160300 self.files.reverse();
161301 self.index = self.clamp(self.files.len().saturating_sub(1) - self.index);
162302 }
@@ -165,17 +305,10 @@ impl Queue {
165305 /// Files removed meanwhile stay removed; files added meanwhile go at the end.
166306 pub fn reorder(&mut self, order: Vec<PathBuf>) {
167307 let current = self.current().map(Path::to_path_buf);
168− let have: HashSet<&PathBuf> = self.files.iter().collect();
169− let mut files: Vec<PathBuf> = order.into_iter().filter(|f| have.contains(f)).collect();
170− let placed: HashSet<&PathBuf> = files.iter().collect();
171− let missing: Vec<PathBuf> = self
172− .files
173− .iter()
174− .filter(|f| !placed.contains(f))
175− .cloned()
176− .collect();
177− files.extend(missing);
178− self.files = files;
308+ if let Some(full) = &mut self.full {
309+ *full = reordered(full, &order);
310+ }
311+ self.files = reordered(&self.files, &order);
179312 if let Some(cur) = current
180313 && let Some(i) = self.files.iter().position(|f| *f == cur)
181314 {
@@ -209,8 +342,9 @@ impl Queue {
209342 changed
210343 }
211344
345+ /// Mark every shown file.
212346 pub fn mark_all(&mut self) {
213− self.marks = self.files.iter().cloned().collect();
347+ self.marks.extend(self.files.iter().cloned());
214348 self.marks_version += 1;
215349 }
216350
@@ -219,19 +353,19 @@ impl Queue {
219353 self.marks_version += 1;
220354 }
221355
356+ /// Invert the marks of the shown files.
222357 pub fn invert_marks(&mut self) {
223358 self.marks_version += 1;
224− self.marks = self
225− .files
226− .iter()
227− .filter(|f| !self.marks.contains(*f))
228− .cloned()
229− .collect();
359+ for f in &self.files {
360+ if !self.marks.remove(f) {
361+ self.marks.insert(f.clone());
362+ }
363+ }
230364 }
231365
232− /// Marked files in queue order.
366+ /// Marked files in queue order, including any a filter hides.
233367 pub fn marked(&self) -> Vec<&Path> {
234− self.files
368+ self.all_files()
235369 .iter()
236370 .filter(|f| self.marks.contains(*f))
237371 .map(PathBuf::as_path)
@@ -275,6 +409,87 @@ impl Queue {
275409 }
276410 }
277411
412+/// `list` in the order of `order`; files `order` lacks go at the end, files `list` lacks are
413+/// dropped.
414+fn reordered(list: &[PathBuf], order: &[PathBuf]) -> Vec<PathBuf> {
415+ let have: HashSet<&PathBuf> = list.iter().collect();
416+ let mut out: Vec<PathBuf> = order.iter().filter(|f| have.contains(f)).cloned().collect();
417+ let placed: HashSet<&PathBuf> = out.iter().collect();
418+ let missing: Vec<PathBuf> = list
419+ .iter()
420+ .filter(|f| !placed.contains(f))
421+ .cloned()
422+ .collect();
423+ out.extend(missing);
424+ out
425+}
426+
427+/// Shell-style wildcard match: `*`, `?` and `[abc]` / `[a-z]` / `[!x]`.
428+pub fn glob_match(pattern: &str, text: &str) -> bool {
429+ let p: Vec<char> = pattern.chars().collect();
430+ let t: Vec<char> = text.chars().collect();
431+ // Iterative matching with backtracking to the last `*`.
432+ let (mut pi, mut ti) = (0, 0);
433+ let mut star: Option<(usize, usize)> = None;
434+ while ti < t.len() {
435+ let step = match p.get(pi) {
436+ Some('*') => {
437+ star = Some((pi, ti));
438+ pi += 1;
439+ continue;
440+ }
441+ Some('?') => Some(1),
442+ Some('[') => class(&p[pi..], t[ti]),
443+ Some(c) if *c == t[ti] => Some(1),
444+ _ => None,
445+ };
446+ match step {
447+ Some(n) if p[pi] == '[' => {
448+ pi += n;
449+ ti += 1;
450+ }
451+ Some(_) => {
452+ pi += 1;
453+ ti += 1;
454+ }
455+ None => match star {
456+ Some((sp, st)) => {
457+ pi = sp + 1;
458+ ti = st + 1;
459+ star = Some((sp, st + 1));
460+ }
461+ None => return false,
462+ },
463+ }
464+ }
465+ p[pi..].iter().all(|c| *c == '*')
466+}
467+
468+/// Match `c` against the class at the start of `p` (`[...]`). Returns the class's length if
469+/// it matches. An unclosed `[` matches itself literally.
470+fn class(p: &[char], c: char) -> Option<usize> {
471+ let Some(end) = p.iter().skip(2).position(|x| *x == ']').map(|i| i + 2) else {
472+ return (c == '[').then_some(1);
473+ };
474+ let body = &p[1..end];
475+ let (neg, body) = match body.first() {
476+ Some('!' | '^') => (true, &body[1..]),
477+ _ => (false, body),
478+ };
479+ let mut hit = false;
480+ let mut i = 0;
481+ while i < body.len() {
482+ if i + 2 < body.len() && body[i + 1] == '-' {
483+ hit |= (body[i]..=body[i + 2]).contains(&c);
484+ i += 3;
485+ } else {
486+ hit |= body[i] == c;
487+ i += 1;
488+ }
489+ }
490+ (hit != neg).then_some(end + 1)
491+}
492+
278493 /// Sort `files` in place. `meta` supplies (mtime seconds, size) for the stat-based keys.
279494 pub fn sort_files(
280495 files: &mut [PathBuf],
@@ -491,6 +706,55 @@ mod tests {
491706 assert_eq!(q.find_mark(true, 1, true), None);
492707 }
493708
709+ #[test]
710+ fn globs() {
711+ assert!(glob_match("*.png", "a.png"));
712+ assert!(!glob_match("*.png", "a.png.jpg"));
713+ assert!(glob_match("img?.*", "img1.jpg"));
714+ assert!(glob_match("[a-c]*", "beach.jpg"));
715+ assert!(!glob_match("[!a-c]*", "beach.jpg"));
716+ assert!(glob_match("*a*b*", "xxaYYbzz"));
717+ assert!(glob_match("[", "["));
718+ assert!(glob_match("", ""));
719+ assert!(!glob_match("", "a"));
720+ }
721+
722+ #[test]
723+ fn filters_hide_and_restore() {
724+ let mut q = Queue::new(
725+ paths(&["a.png", "b.jpg", "c.png", "d.jpg"]),
726+ Some(Path::new("b.jpg")),
727+ 0,
728+ );
729+ q.set_mark(1, true);
730+ // The current file is hidden: the next shown one after it is selected.
731+ assert!(q.set_filter(Some("*.PNG".into())));
732+ assert_eq!(q.files(), paths(&["a.png", "c.png"]));
733+ assert_eq!(q.current(), Some(Path::new("c.png")));
734+ assert_eq!(q.all_files().len(), 4);
735+ // Hidden marks still count and print.
736+ assert_eq!(q.marked(), [Path::new("b.jpg")]);
737+ q.insert_sorted(paths(&["e.png", "f.jpg"]));
738+ assert_eq!(q.files(), paths(&["a.png", "c.png", "e.png"]));
739+ q.remove(Path::new("f.jpg"));
740+ q.remove(Path::new("b.jpg"));
741+ assert_eq!(q.marked_count(), 0);
742+ // Search narrows within the filter; substring filters are plain text.
743+ q.set_search(Some("ep".into()));
744+ assert_eq!(q.files(), paths(&["e.png"]));
745+ q.set_search(None);
746+ q.set_filter(Some("c".into()));
747+ assert_eq!(q.files(), paths(&["c.png"]));
748+ assert!(!q.set_filter(None));
749+ assert_eq!(q.files(), paths(&["a.png", "c.png", "d.jpg", "e.png"]));
750+ assert_eq!(q.current(), Some(Path::new("c.png")));
751+ // Nothing matches: an empty view, then everything back.
752+ q.set_filter(Some("zzz".into()));
753+ assert!(q.is_empty());
754+ q.set_filter(None);
755+ assert_eq!(q.current(), Some(Path::new("c.png")));
756+ }
757+
494758 #[test]
495759 fn random_sort_is_a_permutation() {
496760 let mut v = paths(&["a", "b", "c", "d", "e"]);
M src/ui/mod.rs View
@@ -2,6 +2,8 @@
22 //!
33 //! Each frame the UI turns state into a [`Scene`]; the renderer only draws it.
44
5+pub mod template;
6+
57 use std::path::Path;
68 use std::time::Instant;
79
@@ -11,21 +13,33 @@ use crate::render::image::AlphaStyle;
1113 use crate::render::prims::Prim;
1214 use crate::render::text::{Align, FontRole, TextItem};
1315 use crate::render::{ImageDraw, Scene};
14−use crate::state::{AlphaBg, AppState, BAR_HEIGHT, GRID_NAME_SIZE, Mode, MsgKind, ThumbState};
16+use crate::state::overlay::{HelpRow, InputKind, LIST_ROWS};
17+use crate::state::{
18+ AlphaBg, AppState, BAR_HEIGHT, GRID_NAME_SIZE, HELP_HEAD, Mode, MsgKind, PANEL_MARGIN,
19+ PANEL_PAD, PANEL_ROW, ThumbState,
20+};
1521
1622 /// Space Mono label tracking.
1723 const LABEL_TRACKING: f32 = 0.08;
1824
19−pub fn file_name(path: &Path) -> String {
20− path.file_name().map_or_else(
21− || path.display().to_string(),
22− |n| n.to_string_lossy().into(),
23− )
25+pub use crate::state::file_name;
26+
27+/// Key notation for display: `<lt>`, `<Bar>` and `<Bslash>` as the characters themselves.
28+pub fn pretty_keys(s: &str) -> String {
29+ s.replace("<lt>", "<")
30+ .replace("<Bar>", "|")
31+ .replace("<Bslash>", "\\")
2432 }
2533
34+/// Space Mono caps: labels and data.
2635 fn label(text: &str, color: Rgba, sf: f32, x: f32, y: f32, align: Align) -> TextItem {
36+ mono(&text.to_uppercase(), color, sf, x, y, align)
37+}
38+
39+/// Space Mono as written: key names, where `g` and `G` differ, and action grammar.
40+fn mono(text: &str, color: Rgba, sf: f32, x: f32, y: f32, align: Align) -> TextItem {
2741 TextItem {
28− spans: vec![(text.to_uppercase(), color)],
42+ spans: vec![(text.to_string(), color)],
2943 role: FontRole::Mono,
3044 size: (11.0 * sf).round(),
3145 weight: 400,
@@ -38,6 +52,22 @@ fn label(text: &str, color: Rgba, sf: f32, x: f32, y: f32, align: Align) -> Text
3852 }
3953 }
4054
55+/// Space Grotesk 14: names and descriptions.
56+fn body(text: &str, color: Rgba, sf: f32, x: f32, y: f32, align: Align) -> TextItem {
57+ TextItem {
58+ spans: vec![(text.to_string(), color)],
59+ role: FontRole::Ui,
60+ size: (14.0 * sf).round(),
61+ weight: 400,
62+ tracking: 0.0,
63+ x,
64+ y,
65+ align,
66+ max_width: None,
67+ clip: None,
68+ }
69+}
70+
4171 /// Longest message shown before truncation, in characters.
4272 const MAX_MESSAGE: usize = 72;
4373
@@ -54,12 +84,21 @@ pub fn scene<'a>(
5484 let mut scene = Scene {
5585 clear: theme.background,
5686 image_clip: [0.0, 0.0, w, h - bar_h],
57− alpha: alpha_style(state.alpha_bg, theme, sf),
87+ alpha: AlphaStyle {
88+ adjust: [
89+ state.adjust.gamma,
90+ state.adjust.brightness,
91+ state.adjust.contrast,
92+ ],
93+ ..alpha_style(state.alpha_bg, theme, sf)
94+ },
5895 images: Vec::new(),
5996 thumbs: Vec::new(),
6097 under: Vec::new(),
6198 prims: Vec::new(),
6299 texts: Vec::new(),
100+ overlay_prims: Vec::new(),
101+ overlay_texts: Vec::new(),
63102 };
64103
65104 if state.in_grid() {
@@ -68,7 +107,14 @@ pub fn scene<'a>(
68107 image(&mut scene, state, now);
69108 }
70109 if state.queue.is_empty() {
71− empty_state(&mut scene, theme, sf, [w, h - bar_h]);
110+ let hint = if state.queue.all_files().is_empty() {
111+ None
112+ } else if state.input.is_some() {
113+ Some("<Esc> brings everything back")
114+ } else {
115+ Some("filter-clear shows everything")
116+ };
117+ empty_state(&mut scene, theme, sf, [w, h - bar_h], hint);
72118 }
73119 if bar_h > 0.0 {
74120 status_bar(&mut scene, state, theme, now, [w, h], bar_h, measure);
@@ -77,6 +123,30 @@ pub fn scene<'a>(
77123 let bar_h = (BAR_HEIGHT * sf).round();
78124 status_bar(&mut scene, state, theme, now, [w, h], bar_h, measure);
79125 }
126+
127+ // Overlays, anchored to the bottom (above the bar) or the right edge.
128+ let o = Overlay {
129+ sf,
130+ m: (PANEL_MARGIN * sf).round(),
131+ pad: (PANEL_PAD * sf).round(),
132+ row: (PANEL_ROW * sf).round(),
133+ w,
134+ bottom: h - bar_h,
135+ };
136+ // Panels share one text layer, so they never overlap: help takes the right edge alone,
137+ // and the info panel stays in the upper part, clear of the bottom panels.
138+ if state.help.is_some() {
139+ help_panel(&mut scene, state, theme, &o);
140+ } else if state.info_open {
141+ info_panel(&mut scene, state, theme, &o);
142+ }
143+ if state.input.is_some() {
144+ prompt_panel(&mut scene, state, theme, &o, measure);
145+ } else if state.which_key.is_some() && state.help.is_none() {
146+ which_key_panel(&mut scene, state, theme, &o);
147+ } else {
148+ count_numerals(&mut scene, state, theme, &o);
149+ }
80150 scene
81151 }
82152
@@ -207,6 +277,7 @@ fn alpha_style(bg: AlphaBg, theme: &Theme, sf: f32) -> AlphaStyle {
207277 };
208278 let cell = if mode == 2 { 8.0 } else { 16.0 };
209279 AlphaStyle {
280+ adjust: [1.0, 0.0, 1.0],
210281 mode,
211282 bg: theme.image_background.linear(),
212283 dot: dot.linear(),
@@ -215,12 +286,17 @@ fn alpha_style(bg: AlphaBg, theme: &Theme, sf: f32) -> AlphaStyle {
215286 }
216287 }
217288
218−/// Doto `NO IMAGES` and one hint line.
219−fn empty_state(scene: &mut Scene, theme: &Theme, sf: f32, area: [f32; 2]) {
289+/// Doto `NO IMAGES` (or `NO MATCHES` behind a filter) and one hint line.
290+fn empty_state(scene: &mut Scene, theme: &Theme, sf: f32, area: [f32; 2], hint: Option<&str>) {
220291 let cx = area[0] * 0.5;
221292 let cy = area[1] * 0.5;
293+ let title = if hint.is_some() {
294+ "NO MATCHES"
295+ } else {
296+ "NO IMAGES"
297+ };
222298 scene.texts.push(TextItem {
223− spans: vec![("NO IMAGES".into(), theme.text_secondary)],
299+ spans: vec![(title.into(), theme.text_secondary)],
224300 role: FontRole::Display,
225301 size: (48.0 * sf).round(),
226302 weight: 700,
@@ -231,7 +307,7 @@ fn empty_state(scene: &mut Scene, theme: &Theme, sf: f32, area: [f32; 2]) {
231307 max_width: None,
232308 clip: None,
233309 });
234− let hint = "remage <file|dir>…";
310+ let hint = hint.unwrap_or("remage <file|dir>…");
235311 scene.texts.push(label(
236312 hint,
237313 theme.text_disabled,
@@ -242,6 +318,47 @@ fn empty_state(scene: &mut Scene, theme: &Theme, sf: f32, area: [f32; 2]) {
242318 ));
243319 }
244320
321+/// A template placeholder's value for the current state: `Some("")` when it has none right
322+/// now (the segment drops out), `None` for unknown names.
323+pub fn var(state: &AppState, name: &str) -> Option<String> {
324+ let q = &state.queue;
325+ let cur = q.current();
326+ let lossy = |s: &std::ffi::OsStr| s.to_string_lossy().into_owned();
327+ let shown = state
328+ .shown
329+ .as_ref()
330+ .filter(|s| state.mode == Mode::Image && Some(s.path.as_path()) == cur);
331+ let v = match name {
332+ "name" => cur.map(file_name),
333+ "path" => cur.map(|p| p.display().to_string()),
334+ "dir" => cur.and_then(Path::parent).map(|d| d.display().to_string()),
335+ "stem" => cur.and_then(Path::file_stem).map(lossy),
336+ "ext" => cur.and_then(Path::extension).map(lossy),
337+ "format" => shown.map(|s| s.format.to_string()),
338+ "width" => shown.map(|s| s.size[0].to_string()),
339+ "height" => shown.map(|s| s.size[1].to_string()),
340+ "zoom" => shown.map(|_| format!("{}", (state.view.scale() * 100.0).round())),
341+ "index" => (!q.is_empty()).then(|| {
342+ let digits = q.len().to_string().len().max(3);
343+ format!("{:0digits$}", q.index() + 1)
344+ }),
345+ "count" => (!q.is_empty()).then(|| q.len().to_string()),
346+ "marked" => (q.marked_count() > 0).then(|| q.marked_count().to_string()),
347+ "frame" => shown
348+ .and(state.playback.as_ref())
349+ .map(|pb| format!("{}/{}", pb.index + 1, pb.len())),
350+ "mode" => Some(state.mode.name().to_string()),
351+ "filter" => q.filter().map(str::to_string),
352+ "info" => state
353+ .info_line
354+ .as_ref()
355+ .filter(|(p, _)| Some(p.as_path()) == cur)
356+ .map(|(_, l)| l.clone()),
357+ _ => return None,
358+ };
359+ Some(v.unwrap_or_default())
360+}
361+
245362 fn status_bar(
246363 scene: &mut Scene,
247364 state: &AppState,
@@ -258,6 +375,7 @@ fn status_bar(
258375 [0.0, h - bar_h, w, bar_h],
259376 theme.background.linear(),
260377 ));
378+ let var = |n: &str| var(state, n);
261379
262380 let mut spans: Vec<(String, Rgba)> = Vec::new();
263381 if !state.pending.is_empty() {
@@ -296,27 +414,30 @@ fn status_bar(
296414 spans.push((format!("[VISUAL {}]", r.len()), theme.text_secondary));
297415 }
298416 let mut info = Vec::new();
299− if let Some(s) = state
300− .shown
417+ if let Some(f) = state.queue.filter() {
418+ info.push(format!("FILTER {f}"));
419+ }
420+ if state.slideshow.is_some() {
421+ info.push(format!(
422+ "SLIDESHOW {}S",
423+ (state.slideshow_delay.as_secs_f32() * 10.0).round() / 10.0
424+ ));
425+ }
426+ if let Some(pb) = state
427+ .playback
301428 .as_ref()
302− .filter(|s| state.mode == Mode::Image && Some(s.path.as_path()) == state.queue.current())
429+ .filter(|_| state.mode == Mode::Image)
303430 {
304− if let Some(pb) = &state.playback {
305− let at = format!("{}/{}", pb.index + 1, pb.len());
306− if !pb.animated {
307− info.push(format!("PAGE {at}"));
308− } else if !pb.playing {
309− info.push(format!("FRAME {at}"));
310− }
431+ let at = format!("{}/{}", pb.index + 1, pb.len());
432+ if !pb.animated {
433+ info.push(format!("PAGE {at}"));
434+ } else if !pb.playing {
435+ info.push(format!("FRAME {at}"));
311436 }
312− info.push(s.format.to_string());
313− info.push(format!("{}×{}", s.size[0], s.size[1]));
314− info.push(format!("{}%", (state.view.scale() * 100.0).round()));
315437 }
316− if !state.queue.is_empty() {
317− let count = state.queue.len();
318− let digits = count.to_string().len().max(3);
319− info.push(format!("{:0digits$}/{count}", state.queue.index() + 1));
438+ let right = template::expand(&state.settings.bar_right, &var);
439+ if !right.is_empty() {
440+ info.push(right);
320441 }
321442 if !info.is_empty() {
322443 spans.push((info.join(" ").to_uppercase(), theme.text_secondary));
@@ -378,20 +499,401 @@ fn status_bar(
378499 return;
379500 }
380501
381− // The filename takes whatever the right side leaves, ellipsised in the middle.
382− if let Some(cur) = state.queue.current() {
502+ // The left side (the filename by default) takes whatever the right leaves, ellipsised in
503+ // the middle. An `info_command` line replaces it unless a template places `{info}`.
504+ let s = &state.settings;
505+ let placed = s.bar_left.contains("{info}") || s.bar_right.contains("{info}");
506+ let left = match var("info") {
507+ Some(line) if !placed && !line.is_empty() => line,
508+ _ => template::expand(&s.bar_left, &var),
509+ };
510+ if !left.is_empty() {
383511 let room = (w - 2.0 * pad - right_w - 32.0 * sf).max(0.0);
384− scene.texts.push(TextItem {
385− spans: vec![(file_name(cur), theme.text_primary)],
386− role: FontRole::Ui,
387− size: (14.0 * sf).round(),
388− weight: 400,
389− tracking: 0.0,
390− x: pad,
391− y,
392− align: Align::Left,
393− max_width: Some(room),
394− clip: None,
395− });
512+ let mut item = body(&left, theme.text_primary, sf, pad, y, Align::Left);
513+ item.max_width = Some(room);
514+ scene.texts.push(item);
515+ }
516+}
517+
518+/// Overlay geometry for this frame, in physical pixels.
519+struct Overlay {
520+ sf: f32,
521+ m: f32,
522+ pad: f32,
523+ row: f32,
524+ w: f32,
525+ /// The bottom of the area above the status bar.
526+ bottom: f32,
527+}
528+
529+impl Overlay {
530+ /// A panel: `surface`, a 1 px `border_visible` border, 12 px radius.
531+ fn panel(&self, scene: &mut Scene, theme: &Theme, r: [f32; 4]) {
532+ let line = self.sf.round().max(1.0);
533+ let radius = 12.0 * self.sf;
534+ scene
535+ .overlay_prims
536+ .push(Prim::rounded(r, theme.surface.linear(), radius));
537+ scene.overlay_prims.push(Prim::outline(
538+ r,
539+ theme.border_visible.linear(),
540+ radius,
541+ line,
542+ ));
543+ }
544+
545+ /// The selected row: `surface_raised` with a 2 px accent bar on the left.
546+ fn selection(&self, scene: &mut Scene, theme: &Theme, r: [f32; 4]) {
547+ let sf = self.sf;
548+ scene
549+ .overlay_prims
550+ .push(Prim::rounded(r, theme.surface_raised.linear(), 6.0 * sf));
551+ let bar = (2.0 * sf).round().max(1.0);
552+ scene.overlay_prims.push(Prim::rect(
553+ [r[0], r[1] + 6.0 * sf, bar, r[3] - 12.0 * sf],
554+ theme.accent.linear(),
555+ ));
556+ }
557+}
558+
559+fn clipped(mut t: TextItem, max: f32) -> TextItem {
560+ t.max_width = Some(max.max(0.0));
561+ t
562+}
563+
564+/// The `:` palette or `/` search: the line at the bottom, matches above it, best nearest.
565+fn prompt_panel(
566+ scene: &mut Scene,
567+ state: &AppState,
568+ theme: &Theme,
569+ o: &Overlay,
570+ measure: &mut dyn FnMut(&TextItem) -> f32,
571+) {
572+ let Some(input) = &state.input else { return };
573+ let sf = o.sf;
574+ let pw = (o.w - 2.0 * o.m).min(720.0 * sf).max(0.0);
575+ let x = o.m;
576+ let line_h = (40.0 * sf).round();
577+ let image_search = input.kind == InputKind::Search && state.mode == Mode::Image;
578+ let listed = input.kind == InputKind::Palette || image_search;
579+ let start = input.window_start();
580+ let shown = if listed {
581+ input.matches.len().saturating_sub(start).min(LIST_ROWS)
582+ } else {
583+ 0
584+ };
585+ let gap = 8.0 * sf;
586+ let list_h = if shown > 0 {
587+ shown as f32 * o.row + gap
588+ } else {
589+ 0.0
590+ };
591+ let ph = line_h + list_h;
592+ let y0 = o.bottom - o.m - ph;
593+ o.panel(scene, theme, [x, y0, pw, ph]);
594+
595+ // The line: prompt character, text, caret; the match count on the right.
596+ let ly = y0 + list_h + line_h * 0.5;
597+ let tx = x + o.pad;
598+ scene.overlay_texts.push(mono(
599+ input.prefix(),
600+ theme.text_secondary,
601+ sf,
602+ tx,
603+ ly,
604+ Align::Left,
605+ ));
606+ let text_x = tx + 16.0 * sf;
607+ let count = if listed {
608+ format!("{}", input.matches.len())
609+ } else {
610+ format!("{}/{}", state.queue.len(), state.queue.all_files().len())
611+ };
612+ let count_item = label(
613+ &count,
614+ theme.text_disabled,
615+ sf,
616+ x + pw - o.pad,
617+ ly,
618+ Align::Right,
619+ );
620+ let count_w = measure(&count_item);
621+ scene.overlay_texts.push(count_item);
622+ let room = pw - (text_x - x) - o.pad - count_w - 16.0 * sf;
623+ let text = body(&input.text, theme.text_display, sf, text_x, ly, Align::Left);
624+ let text_w = if input.text.is_empty() {
625+ 0.0
626+ } else {
627+ measure(&text).min(room)
628+ };
629+ if !input.text.is_empty() {
630+ scene.overlay_texts.push(clipped(text, room));
631+ }
632+ let caret_h = (16.0 * sf).round();
633+ scene.overlay_prims.push(Prim::rect(
634+ [
635+ text_x + text_w + sf,
636+ ly - caret_h * 0.5,
637+ sf.round().max(1.0),
638+ caret_h,
639+ ],
640+ theme.text_display.linear(),
641+ ));
642+
643+ // Matches, bottom-up from the line.
644+ let inner = pw - 2.0 * 8.0 * sf;
645+ for k in 0..shown {
646+ let idx = start + k;
647+ let Some(&m) = input.matches.get(idx) else {
648+ break;
649+ };
650+ let ry = y0 + list_h - gap - (k + 1) as f32 * o.row;
651+ let rx = x + 8.0 * sf;
652+ if idx == input.selected {
653+ o.selection(scene, theme, [rx, ry, inner, o.row]);
654+ }
655+ let cy = ry + o.row * 0.5;
656+ let lx = rx + 16.0 * sf;
657+ let right = rx + inner - 8.0 * sf;
658+ match input.kind {
659+ InputKind::Palette => {
660+ let Some(item) = input.items.get(m) else {
661+ continue;
662+ };
663+ let keys_w = inner * 0.2;
664+ let action_x = rx + inner * 0.55;
665+ scene.overlay_texts.push(clipped(
666+ body(&item.desc, theme.text_primary, sf, lx, cy, Align::Left),
667+ action_x - lx - 16.0 * sf,
668+ ));
669+ scene.overlay_texts.push(clipped(
670+ mono(
671+ &item.action,
672+ theme.text_disabled,
673+ sf,
674+ action_x,
675+ cy,
676+ Align::Left,
677+ ),
678+ right - keys_w - action_x - 16.0 * sf,
679+ ));
680+ if !item.keys.is_empty() {
681+ scene.overlay_texts.push(clipped(
682+ mono(
683+ &item.keys,
684+ theme.text_secondary,
685+ sf,
686+ right,
687+ cy,
688+ Align::Right,
689+ ),
690+ keys_w,
691+ ));
692+ }
693+ }
694+ InputKind::Search => {
695+ let Some(p) = state.queue.get(m) else {
696+ continue;
697+ };
698+ let digits = state.queue.len().to_string().len().max(3);
699+ scene.overlay_texts.push(clipped(
700+ body(&file_name(p), theme.text_primary, sf, lx, cy, Align::Left),
701+ inner * 0.8,
702+ ));
703+ scene.overlay_texts.push(label(
704+ &format!("{:0digits$}", m + 1),
705+ theme.text_secondary,
706+ sf,
707+ right,
708+ cy,
709+ Align::Right,
710+ ));
711+ }
712+ }
713+ }
714+}
715+
716+/// Keys that continue the pending sequence, in columns above the bar.
717+fn which_key_panel(scene: &mut Scene, state: &AppState, theme: &Theme, o: &Overlay) {
718+ let Some(rows) = &state.which_key else { return };
719+ if rows.is_empty() {
720+ return;
721+ }
722+ let sf = o.sf;
723+ let room = (o.w - 2.0 * o.m).max(0.0);
724+ let col_w = 280.0 * sf;
725+ let cols = (((room - 2.0 * o.pad) / col_w).floor() as usize).clamp(1, rows.len());
726+ let pw = (cols as f32 * col_w + 2.0 * o.pad).min(room);
727+ let per_col = rows.len().div_ceil(cols).min(12);
728+ let head = 32.0 * sf;
729+ let ph = 2.0 * o.pad + head + per_col as f32 * o.row - 8.0 * sf;
730+ let (x0, y0) = (o.m, o.bottom - o.m - ph);
731+ o.panel(scene, theme, [x0, y0, pw, ph]);
732+ scene.overlay_texts.push(mono(
733+ &state.pending,
734+ theme.text_secondary,
735+ sf,
736+ x0 + o.pad,
737+ y0 + o.pad + 8.0 * sf,
738+ Align::Left,
739+ ));
740+ let key_w = 72.0 * sf;
741+ for (i, r) in rows.iter().take(per_col * cols).enumerate() {
742+ let (c, k) = (i / per_col, i % per_col);
743+ let x = x0 + o.pad + c as f32 * col_w;
744+ let cy = y0 + o.pad + head + k as f32 * o.row + o.row * 0.5 - 4.0 * sf;
745+ scene.overlay_texts.push(clipped(
746+ mono(&r.key, theme.text_display, sf, x, cy, Align::Left),
747+ key_w - 8.0 * sf,
748+ ));
749+ let color = if r.prefix {
750+ theme.text_disabled
751+ } else {
752+ theme.text_secondary
753+ };
754+ scene.overlay_texts.push(clipped(
755+ body(&r.desc, color, sf, x + key_w, cy, Align::Left),
756+ col_w - key_w - 16.0 * sf,
757+ ));
758+ }
759+}
760+
761+/// The key reference, on the right edge.
762+fn help_panel(scene: &mut Scene, state: &AppState, theme: &Theme, o: &Overlay) {
763+ let Some(help) = &state.help else { return };
764+ let sf = o.sf;
765+ let pw = (o.w - 2.0 * o.m).min(520.0 * sf).max(0.0);
766+ let ph = (o.bottom - 2.0 * o.m).max(0.0);
767+ let (x0, y0) = (o.w - o.m - pw, o.m);
768+ o.panel(scene, theme, [x0, y0, pw, ph]);
769+ let page = state.help_page();
770+ let title = format!("Keys · {}", state.mode.name());
771+ let head_y = y0 + o.pad + 8.0 * sf;
772+ scene.overlay_texts.push(label(
773+ &title,
774+ theme.text_secondary,
775+ sf,
776+ x0 + o.pad,
777+ head_y,
778+ Align::Left,
779+ ));
780+ if help.rows.len() > page {
781+ let last = (help.scroll + page).min(help.rows.len());
782+ scene.overlay_texts.push(label(
783+ &format!("{}–{} / {}", help.scroll + 1, last, help.rows.len()),
784+ theme.text_disabled,
785+ sf,
786+ x0 + pw - o.pad,
787+ head_y,
788+ Align::Right,
789+ ));
790+ }
791+ let key_w = 136.0 * sf;
792+ let top = y0 + o.pad + HELP_HEAD * sf;
793+ for (k, row) in help.rows.iter().skip(help.scroll).take(page).enumerate() {
794+ let cy = top + k as f32 * o.row + o.row * 0.5;
795+ let x = x0 + o.pad;
796+ match row {
797+ HelpRow::Header(h) => {
798+ scene.overlay_texts.push(label(
799+ h,
800+ theme.text_disabled,
801+ sf,
802+ x,
803+ cy + 4.0 * sf,
804+ Align::Left,
805+ ));
806+ }
807+ HelpRow::Binding { keys, desc } => {
808+ scene.overlay_texts.push(clipped(
809+ mono(keys, theme.text_secondary, sf, x, cy, Align::Left),
810+ key_w - 12.0 * sf,
811+ ));
812+ scene.overlay_texts.push(clipped(
813+ body(desc, theme.text_primary, sf, x + key_w, cy, Align::Left),
814+ pw - 2.0 * o.pad - key_w,
815+ ));
816+ }
817+ }
818+ }
819+}
820+
821+/// File and image metadata, top right.
822+fn info_panel(scene: &mut Scene, state: &AppState, theme: &Theme, o: &Overlay) {
823+ let sf = o.sf;
824+ let cur = state.queue.current();
825+ let mut rows: Vec<(String, String)> = match (&state.info, cur) {
826+ (Some(info), Some(c)) if info.path == c => info.rows.clone(),
827+ (_, Some(_)) => vec![("".into(), "[LOADING]".into())],
828+ (_, None) => return,
829+ };
830+ // What the decoder found, after the file rows.
831+ if let Some(s) = state
832+ .shown
833+ .as_ref()
834+ .filter(|s| Some(s.path.as_path()) == cur && state.info.is_some())
835+ {
836+ let at = rows
837+ .iter()
838+ .position(|(l, _)| l == "Modified")
839+ .map_or(rows.len(), |i| i + 1);
840+ let [w, h] = s.size;
841+ let mp = w as f64 * h as f64 / 1e6;
842+ let mut add = vec![
843+ ("Format".to_string(), s.format.to_string()),
844+ ("Pixels".to_string(), format!("{w} × {h} ({mp:.1} MP)")),
845+ ];
846+ if let Some(pb) = &state.playback {
847+ let what = if pb.animated { "Frames" } else { "Pages" };
848+ add.push((what.to_string(), pb.len().to_string()));
849+ }
850+ rows.splice(at..at, add);
851+ }
852+ let pw = (o.w - 2.0 * o.m).min(400.0 * sf).max(0.0);
853+ let max_rows = (((o.bottom * 0.55 - o.m - 2.0 * o.pad) / o.row).floor() as usize).max(1);
854+ let n = rows.len().min(max_rows);
855+ let ph = 2.0 * o.pad + n as f32 * o.row - 8.0 * sf;
856+ let (x0, y0) = (o.w - o.m - pw, o.m);
857+ o.panel(scene, theme, [x0, y0, pw, ph]);
858+ let label_w = 112.0 * sf;
859+ for (k, (l, v)) in rows.iter().take(n).enumerate() {
860+ let cy = y0 + o.pad + k as f32 * o.row + o.row * 0.5 - 4.0 * sf;
861+ scene.overlay_texts.push(label(
862+ l,
863+ theme.text_secondary,
864+ sf,
865+ x0 + o.pad,
866+ cy,
867+ Align::Left,
868+ ));
869+ scene.overlay_texts.push(clipped(
870+ body(v, theme.text_primary, sf, x0 + pw - o.pad, cy, Align::Right),
871+ pw - 2.0 * o.pad - label_w,
872+ ));
873+ }
874+}
875+
876+/// A count being typed shows as large Doto numerals in the corner.
877+fn count_numerals(scene: &mut Scene, state: &AppState, theme: &Theme, o: &Overlay) {
878+ let digits: String = state
879+ .pending
880+ .chars()
881+ .take_while(char::is_ascii_digit)
882+ .collect();
883+ if digits.is_empty() {
884+ return;
396885 }
886+ let size = (72.0 * o.sf).round();
887+ scene.overlay_texts.push(TextItem {
888+ spans: vec![(digits, theme.text_display)],
889+ role: FontRole::Display,
890+ size,
891+ weight: 700,
892+ tracking: 0.0,
893+ x: o.w - o.m - 8.0 * o.sf,
894+ y: o.bottom - o.m - size * 0.5,
895+ align: Align::Right,
896+ max_width: None,
897+ clip: None,
898+ });
397899 }
A src/ui/template.rs View
@@ -0,0 +1,90 @@
1+//! `{placeholder}` templates for the status bar and window title.
2+//!
3+//! A template is split into segments at runs of two or more spaces. A segment whose
4+//! placeholders are all known but one of them is empty drops out with its separator, so
5+//! `{format} {width}×{height} {index}/{count}` shows just `012/240` in the grid. Unknown
6+//! names stay as written.
7+
8+/// Expand `tpl`, looking placeholders up with `var` (`None` = not a placeholder).
9+pub fn expand(tpl: &str, var: &dyn Fn(&str) -> Option<String>) -> String {
10+ let mut out: Vec<String> = Vec::new();
11+ for seg in segments(tpl) {
12+ if let Some(s) = expand_segment(seg, var) {
13+ out.push(s);
14+ }
15+ }
16+ out.join(" ")
17+}
18+
19+/// Split at runs of two or more spaces.
20+fn segments(tpl: &str) -> Vec<&str> {
21+ let mut out = Vec::new();
22+ let mut rest = tpl.trim();
23+ while let Some(i) = rest.find(" ") {
24+ out.push(&rest[..i]);
25+ rest = rest[i..].trim_start();
26+ }
27+ if !rest.is_empty() {
28+ out.push(rest);
29+ }
30+ out
31+}
32+
33+/// One segment, or `None` if a placeholder in it expanded to nothing.
34+fn expand_segment(seg: &str, var: &dyn Fn(&str) -> Option<String>) -> Option<String> {
35+ let mut out = String::with_capacity(seg.len());
36+ let mut rest = seg;
37+ while let Some(open) = rest.find('{') {
38+ out.push_str(&rest[..open]);
39+ let after = &rest[open + 1..];
40+ match after.find('}') {
41+ Some(close) => {
42+ let name = &after[..close];
43+ match var(name) {
44+ Some(v) if v.is_empty() => return None,
45+ Some(v) => out.push_str(&v),
46+ None => {
47+ out.push('{');
48+ out.push_str(name);
49+ out.push('}');
50+ }
51+ }
52+ rest = &after[close + 1..];
53+ }
54+ None => {
55+ out.push_str(&rest[open..]);
56+ rest = "";
57+ }
58+ }
59+ }
60+ out.push_str(rest);
61+ (!out.is_empty()).then_some(out)
62+}
63+
64+#[cfg(test)]
65+mod tests {
66+ use super::*;
67+
68+ fn vars(name: &str) -> Option<String> {
69+ match name {
70+ "name" => Some("a.png".into()),
71+ "format" | "width" | "height" => Some(String::new()),
72+ "index" => Some("012".into()),
73+ "count" => Some("240".into()),
74+ _ => None,
75+ }
76+ }
77+
78+ #[test]
79+ fn empty_segments_drop_out() {
80+ assert_eq!(
81+ expand("{format} {width}×{height} {index}/{count}", &vars),
82+ "012/240"
83+ );
84+ assert_eq!(expand("{name} — remage", &vars), "a.png — remage");
85+ // Unknown placeholders survive; single spaces don't split.
86+ assert_eq!(expand("{name} {nope} x", &vars), "a.png {nope} x");
87+ assert_eq!(expand("open {", &vars), "open {");
88+ assert_eq!(expand("", &vars), "");
89+ }
90+}
A src/watch.rs View
@@ -0,0 +1,168 @@
1+//! `general.watch_dir`: watch the queue's directories and report files that appear, change or
2+//! vanish, so the queue follows the disk live.
3+
4+use std::collections::HashSet;
5+use std::path::{Path, PathBuf};
6+use std::time::{Duration, SystemTime};
7+
8+use crossbeam_channel::{Receiver, RecvTimeoutError, Sender};
9+use notify::event::{AccessKind, AccessMode};
10+use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
11+
12+use crate::decode;
13+
14+/// Quiet time before reporting, so a file being written arrives once, complete.
15+const DEBOUNCE: Duration = Duration::from_millis(300);
16+
17+/// What changed in the watched directories.
18+#[derive(Debug, Default)]
19+pub struct Changes {
20+ /// Image files that exist now, with their mtime: new ones join the queue, known ones
21+ /// reload if their mtime moved.
22+ pub present: Vec<(PathBuf, SystemTime)>,
23+ /// Paths that no longer exist.
24+ pub gone: Vec<PathBuf>,
25+}
26+
27+enum Msg {
28+ Fs(notify::Event),
29+ Dirs(Vec<(PathBuf, bool)>),
30+}
31+
32+/// Handle to the watch thread.
33+pub struct DirWatch {
34+ tx: Sender<Msg>,
35+}
36+
37+impl DirWatch {
38+ /// Watch these directories instead (after `open`). `true` = recursively.
39+ pub fn set(&self, dirs: Vec<(PathBuf, bool)>) {
40+ let _ = self.tx.send(Msg::Dirs(dirs));
41+ }
42+}
43+
44+pub fn spawn(dirs: Vec<(PathBuf, bool)>, deliver: impl Fn(Changes) + Send + 'static) -> DirWatch {
45+ let (tx, rx) = crossbeam_channel::unbounded();
46+ let fs_tx = tx.clone();
47+ let _ = tx.send(Msg::Dirs(dirs));
48+ std::thread::Builder::new()
49+ .name("remage-watch".into())
50+ .spawn(move || {
51+ let watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
52+ if let Ok(ev) = res {
53+ let _ = fs_tx.send(Msg::Fs(ev));
54+ }
55+ });
56+ match watcher {
57+ Ok(w) => run(w, &rx, deliver),
58+ Err(e) => log::warn!("directory watcher: {e}"),
59+ }
60+ })
61+ .ok();
62+ DirWatch { tx }
63+}
64+
65+fn run(mut w: RecommendedWatcher, rx: &Receiver<Msg>, deliver: impl Fn(Changes)) {
66+ let mut watched: Vec<(PathBuf, bool)> = Vec::new();
67+ let mut touched: HashSet<PathBuf> = HashSet::new();
68+ loop {
69+ let msg = if touched.is_empty() {
70+ rx.recv().map_err(|_| RecvTimeoutError::Disconnected)
71+ } else {
72+ rx.recv_timeout(DEBOUNCE)
73+ };
74+ match msg {
75+ Ok(Msg::Dirs(dirs)) => {
76+ for (d, _) in &watched {
77+ let _ = w.unwatch(d);
78+ }
79+ for (d, rec) in &dirs {
80+ let mode = if *rec {
81+ RecursiveMode::Recursive
82+ } else {
83+ RecursiveMode::NonRecursive
84+ };
85+ if let Err(e) = w.watch(d, mode) {
86+ log::warn!("watch {}: {e}", d.display());
87+ }
88+ }
89+ log::debug!("watching {} directories", dirs.len());
90+ watched = dirs;
91+ touched.clear();
92+ }
93+ Ok(Msg::Fs(ev)) => {
94+ if relevant(&ev.kind) {
95+ touched.extend(ev.paths.into_iter().filter(|p| visible(p)));
96+ }
97+ }
98+ Err(RecvTimeoutError::Timeout) => {
99+ let changes = classify(touched.drain());
100+ if !changes.present.is_empty() || !changes.gone.is_empty() {
101+ deliver(changes);
102+ }
103+ }
104+ Err(RecvTimeoutError::Disconnected) => return,
105+ }
106+ }
107+}
108+
109+fn relevant(kind: &EventKind) -> bool {
110+ match kind {
111+ EventKind::Access(AccessKind::Close(AccessMode::Write)) => true,
112+ EventKind::Access(_) => false,
113+ _ => true,
114+ }
115+}
116+
117+/// Hidden files stay out, as in the initial scan.
118+fn visible(p: &Path) -> bool {
119+ p.file_name()
120+ .is_some_and(|n| !n.as_encoded_bytes().starts_with(b"."))
121+}
122+
123+fn classify(paths: impl Iterator<Item = PathBuf>) -> Changes {
124+ let mut c = Changes::default();
125+ for p in paths {
126+ match std::fs::metadata(&p) {
127+ Ok(m) if m.is_file() => {
128+ if decode::is_known_extension(&p) || decode::sniff(&p) {
129+ let t = m.modified().unwrap_or(SystemTime::UNIX_EPOCH);
130+ c.present.push((p, t));
131+ }
132+ }
133+ Ok(_) => {}
134+ Err(_) => c.gone.push(p),
135+ }
136+ }
137+ c
138+}
139+
140+#[cfg(test)]
141+mod tests {
142+ use super::*;
143+
144+ #[test]
145+ fn reports_new_and_removed_files() {
146+ let dir = std::env::temp_dir().join(format!("remage-watch-{}", std::process::id()));
147+ std::fs::create_dir_all(&dir).unwrap();
148+ let (tx, rx) = crossbeam_channel::unbounded();
149+ let _w = spawn(vec![(dir.clone(), false)], move |c| {
150+ let _ = tx.send(c);
151+ });
152+ std::thread::sleep(Duration::from_millis(200));
153+ std::fs::write(dir.join("a.png"), b"x").unwrap();
154+ std::fs::write(dir.join(".hidden.png"), b"x").unwrap();
155+ std::fs::write(dir.join("notes.txt"), b"x").unwrap();
156+ let c = rx.recv_timeout(Duration::from_secs(5)).unwrap();
157+ let names: Vec<_> = c
158+ .present
159+ .iter()
160+ .map(|(p, _)| p.file_name().unwrap())
161+ .collect();
162+ assert_eq!(names, ["a.png"]);
163+ std::fs::remove_file(dir.join("a.png")).unwrap();
164+ let c = rx.recv_timeout(Duration::from_secs(5)).unwrap();
165+ assert_eq!(c.gone, [dir.join("a.png")]);
166+ std::fs::remove_dir_all(&dir).unwrap();
167+ }
168+}