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
+2915 −143
21 files changed
- M src/action.rs +66 −2
- M src/app.rs +597 −12
- M src/cli.rs +22 −0
- M src/config/default.toml +9 −3
- A src/decode/meta.rs +170 −0
- M src/decode/mod.rs +1 −0
- M src/input/keymap.rs +5 −4
- M src/main.rs +14 −0
- M src/render/image.rs +8 −1
- M src/render/mod.rs +12 −4
- M src/render/prims.rs +18 −13
- M src/render/shaders/image.wgsl +10 −1
- M src/render/text.rs +39 −5
- M src/scan.rs +4 −0
- A src/state/fuzzy.rs +77 −0
- M src/state/mod.rs +531 −27
- A src/state/overlay.rs +237 −0
- M src/state/queue.rs +291 −27
- M src/ui/mod.rs +546 −44
- A src/ui/template.rs +90 −0
- A src/watch.rs +168 −0
| @@ -647,8 +647,65 @@ impl Action { | |||
| 647 | 647 | } | |
| 648 | 648 | } | |
| 649 | 649 | ||
| 650 | + | /// The help section this action is listed under. | |
| 651 | + | pub fn group(&self) -> &'static str { | |
| 652 | + | match self { | |
| 653 | + | Action::Next(_) | |
| 654 | + | | Action::Prev(_) | |
| 655 | + | | Action::First | |
| 656 | + | | Action::Last | |
| 657 | + | | Action::Goto(_) | |
| 658 | + | | Action::Random | |
| 659 | + | | Action::NextMark | |
| 660 | + | | Action::PrevMark => "Navigation", | |
| 661 | + | Action::Mark(_) | Action::MarkAll | Action::UnmarkAll | Action::InvertMarks => "Marks", | |
| 662 | + | Action::Move(..) | |
| 663 | + | | Action::Page(_) | |
| 664 | + | | Action::RowStart | |
| 665 | + | | Action::RowEnd | |
| 666 | + | | Action::Scroll(..) | |
| 667 | + | | Action::GridSize(_) | |
| 668 | + | | Action::Visual => "Grid", | |
| 669 | + | Action::Zoom(_) | |
| 670 | + | | Action::Fit(_) | |
| 671 | + | | Action::Pan(_) | |
| 672 | + | | Action::PanEdge(_) | |
| 673 | + | | Action::Center | |
| 674 | + | | Action::Rotate(_) | |
| 675 | + | | Action::Flip(_) | |
| 676 | + | | Action::FilterToggle | |
| 677 | + | | Action::AlphaBgCycle | |
| 678 | + | | Action::Adjust(..) => "View", | |
| 679 | + | Action::Play(_) | |
| 680 | + | | Action::NextFrame | |
| 681 | + | | Action::PrevFrame | |
| 682 | + | | Action::Slideshow(_) | |
| 683 | + | | Action::SlideshowDelay { .. } => "Playback", | |
| 684 | + | Action::Mode(_) | |
| 685 | + | | Action::Fullscreen(_) | |
| 686 | + | | Action::Bar(_) | |
| 687 | + | | Action::Info(_) | |
| 688 | + | | Action::Help | |
| 689 | + | | Action::Palette | |
| 690 | + | | Action::Search => "Interface", | |
| 691 | + | Action::Sort(_) | |
| 692 | + | | Action::Reverse | |
| 693 | + | | Action::Filter(_) | |
| 694 | + | | Action::FilterClear | |
| 695 | + | | Action::Remove | |
| 696 | + | | Action::Reload | |
| 697 | + | | Action::Refresh | |
| 698 | + | | Action::Open(_) => "Queue", | |
| 699 | + | Action::Run(_) | |
| 700 | + | | Action::ReloadConfig | |
| 701 | + | | Action::Echo(_) | |
| 702 | + | | Action::Cancel | |
| 703 | + | | Action::Quit | |
| 704 | + | | Action::QuitNoOutput => "App", | |
| 705 | + | } | |
| 706 | + | } | |
| 707 | + | ||
| 650 | 708 | /// A one-line description of this exact action, for which-key, help and the palette. | |
| 651 | − | #[allow(dead_code)] // which-key, help and the palette arrive in M6 | |
| 652 | 709 | pub fn description(&self) -> String { | |
| 653 | 710 | let toggle = |s: &Switch, what: &str| match s { | |
| 654 | 711 | Switch::Toggle => format!("Toggle {what}"), | |
| @@ -676,7 +733,14 @@ impl Action { | |||
| 676 | 733 | FitMode::Original => "Original size".into(), | |
| 677 | 734 | }, | |
| 678 | 735 | 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 | + | ), | |
| 680 | 744 | Action::Move(d, _) => format!("Move {}", dir_name(*d)), | |
| 681 | 745 | Action::Scroll(d, _) => format!("Scroll {}", dir_name(*d)), | |
| 682 | 746 | Action::Page(p) => match p { | |
| @@ -14,22 +14,25 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}; | |||
| 14 | 14 | use winit::keyboard::{ModifiersState, PhysicalKey}; | |
| 15 | 15 | use winit::window::{Fullscreen, Window, WindowId}; | |
| 16 | 16 | ||
| 17 | − | use crate::action::{Action, ModeArg, Switch, Zoom}; | |
| 17 | + | use crate::action::{Action, CATALOG, ModeArg, Switch, Zoom}; | |
| 18 | 18 | use crate::cache::ImageCache; | |
| 19 | 19 | use crate::config::watch::ConfigWatch; | |
| 20 | 20 | use crate::config::{Config, LoadError, Loaded, Output, Targets, suggest}; | |
| 21 | 21 | use crate::decode::{self, DecodedImage}; | |
| 22 | 22 | use crate::exec; | |
| 23 | 23 | use crate::input::keymap::{KeyTables, Keymap}; | |
| 24 | − | use crate::input::keys::{Key, Scroll}; | |
| 24 | + | use crate::input::keys::{Code, Key, KeySeq, Named, Scroll}; | |
| 25 | 25 | use crate::input::{self}; | |
| 26 | 26 | use crate::ipc::{self, Query, Request, Topic}; | |
| 27 | 27 | use crate::render::{Renderer, text}; | |
| 28 | 28 | use crate::scan; | |
| 29 | + | use crate::state::overlay::{Edit, Help, HelpRow, Info, InputKind, PaletteItem, WhichRow}; | |
| 30 | + | use crate::state::queue::Queue; | |
| 29 | 31 | use crate::state::view::{Dir, FitMode}; | |
| 30 | 32 | use crate::state::{AppState, Effects, Mode, MsgKind, Settings}; | |
| 31 | 33 | use crate::thumbs::{self, CacheOptions, Thumb, ThumbError}; | |
| 32 | 34 | use crate::ui; | |
| 35 | + | use crate::watch::{self, DirWatch}; | |
| 33 | 36 | use crate::worker::{JobKey, Pool, Priority}; | |
| 34 | 37 | ||
| 35 | 38 | /// GPU upload budget per event-loop turn, so preloads never cause a hitch. | |
| @@ -86,6 +89,14 @@ pub enum UserEvent { | |||
| 86 | 89 | Refreshed(Vec<(PathBuf, Option<SystemTime>)>), | |
| 87 | 90 | /// A request on the IPC socket. | |
| 88 | 91 | 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 }, | |
| 89 | 100 | } | |
| 90 | 101 | ||
| 91 | 102 | /// Command-line settings that beat the config, across reloads too. | |
| @@ -94,10 +105,14 @@ pub struct Overrides { | |||
| 94 | 105 | pub fit: Option<FitMode>, | |
| 95 | 106 | pub zoom: Option<f32>, | |
| 96 | 107 | pub bar: Option<bool>, | |
| 108 | + | pub slideshow_delay: Option<Duration>, | |
| 97 | 109 | } | |
| 98 | 110 | ||
| 99 | 111 | impl Overrides { | |
| 100 | 112 | pub fn apply(&self, s: &mut Settings) { | |
| 113 | + | if let Some(d) = self.slideshow_delay { | |
| 114 | + | s.slideshow_delay = d; | |
| 115 | + | } | |
| 101 | 116 | if let Some(fit) = self.fit { | |
| 102 | 117 | s.fit = fit; | |
| 103 | 118 | } | |
| @@ -116,6 +131,8 @@ pub struct Options { | |||
| 116 | 131 | pub private: bool, | |
| 117 | 132 | /// The IPC socket, passed to commands as `$REMAGE_SOCKET`. | |
| 118 | 133 | pub socket: Option<PathBuf>, | |
| 134 | + | /// Directories the queue came from, for `watch_dir`. | |
| 135 | + | pub dirs: Vec<(PathBuf, bool)>, | |
| 119 | 136 | } | |
| 120 | 137 | ||
| 121 | 138 | /// Where a chain of actions came from, which decides what some of them act on. | |
| @@ -224,6 +241,14 @@ pub struct App { | |||
| 224 | 241 | refine_failed: HashSet<PathBuf>, | |
| 225 | 242 | /// `quit-no-output` ran: `-o` prints nothing. | |
| 226 | 243 | 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>, | |
| 227 | 252 | pub error: Option<anyhow::Error>, | |
| 228 | 253 | } | |
| 229 | 254 | ||
| @@ -275,8 +300,13 @@ impl App { | |||
| 275 | 300 | refining: None, | |
| 276 | 301 | refine_failed: HashSet::new(), | |
| 277 | 302 | no_output: false, | |
| 303 | + | which_key_since: None, | |
| 304 | + | dir_watch: None, | |
| 305 | + | info_loading: None, | |
| 306 | + | info_line_for: None, | |
| 278 | 307 | error: None, | |
| 279 | 308 | }; | |
| 309 | + | app.set_dir_watch(); | |
| 280 | 310 | // Decode the first image while the window and GPU come up. | |
| 281 | 311 | app.sync(true); | |
| 282 | 312 | app | |
| @@ -427,6 +457,7 @@ impl App { | |||
| 427 | 457 | } | |
| 428 | 458 | let wanted = self.window_paths(); | |
| 429 | 459 | self.update_title(); | |
| 460 | + | self.request_info(); | |
| 430 | 461 | let Some(cur) = wanted.first().cloned() else { | |
| 431 | 462 | self.state.clear_shown(); | |
| 432 | 463 | self.uploads.clear(); | |
| @@ -618,26 +649,365 @@ impl App { | |||
| 618 | 649 | self.uploads.retain(|(p, _)| p != path); | |
| 619 | 650 | self.state.show(path, img.source_size, img.format, now); | |
| 620 | 651 | self.state.set_frames(img.delays(), img.animated, now); | |
| 652 | + | self.request_info_line(); | |
| 621 | 653 | self.request_redraw(); | |
| 622 | 654 | } | |
| 623 | 655 | ||
| 624 | 656 | fn update_title(&self) { | |
| 625 | 657 | 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 | + | } | |
| 635 | 662 | None => "remage".into(), | |
| 636 | 663 | }; | |
| 637 | 664 | g.window.set_title(&title); | |
| 638 | 665 | } | |
| 639 | 666 | } | |
| 640 | 667 | ||
| 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 | + | ||
| 641 | 1011 | /// Show a config error until the config is fixed. | |
| 642 | 1012 | pub fn config_failed(&mut self, message: &str) { | |
| 643 | 1013 | self.state.notify_for( | |
| @@ -666,6 +1036,7 @@ impl App { | |||
| 666 | 1036 | if old.grid.size != new.grid.size { | |
| 667 | 1037 | self.state.grid.size = new.grid.size; | |
| 668 | 1038 | } | |
| 1039 | + | let watch_changed = old.general.watch_dir != new.general.watch_dir; | |
| 669 | 1040 | if old.grid.thumbnail_cache != new.grid.thumbnail_cache { | |
| 670 | 1041 | self.thumb_cache = CacheOptions::new(new.grid.thumbnail_cache, self.opts.private); | |
| 671 | 1042 | } | |
| @@ -695,6 +1066,11 @@ impl App { | |||
| 695 | 1066 | } | |
| 696 | 1067 | g.renderer.set_font_families(font_families(new)); | |
| 697 | 1068 | } | |
| 1069 | + | if watch_changed { | |
| 1070 | + | self.set_dir_watch(); | |
| 1071 | + | } | |
| 1072 | + | self.info_line_for = None; | |
| 1073 | + | self.request_info_line(); | |
| 698 | 1074 | self.update_title(); | |
| 699 | 1075 | // Silent on routine changes (a new wallpaper palette); confirm fixes and manual reloads. | |
| 700 | 1076 | if std::mem::take(&mut self.config_error) || std::mem::take(&mut self.reload_requested) { | |
| @@ -935,7 +1311,7 @@ impl App { | |||
| 935 | 1311 | fn refresh(&mut self, targets: Option<Arc<[PathBuf]>>) { | |
| 936 | 1312 | let mut files: Vec<PathBuf> = match targets { | |
| 937 | 1313 | Some(t) => t.to_vec(), | |
| 938 | − | None => self.state.queue.files().to_vec(), | |
| 1314 | + | None => self.state.queue.all_files().to_vec(), | |
| 939 | 1315 | }; | |
| 940 | 1316 | if let Some(cur) = self.state.queue.current() | |
| 941 | 1317 | && !files.iter().any(|f| f == cur) | |
| @@ -1059,6 +1435,8 @@ impl App { | |||
| 1059 | 1435 | "bar": s.bar, | |
| 1060 | 1436 | "fullscreen": fullscreen, | |
| 1061 | 1437 | "skipped": s.skipped, | |
| 1438 | + | "filter": s.queue.filter(), | |
| 1439 | + | "slideshow": s.slideshow.is_some(), | |
| 1062 | 1440 | "running": s.running.iter().map(|(_, n)| n.as_str()).collect::<Vec<_>>(), | |
| 1063 | 1441 | "pid": std::process::id(), | |
| 1064 | 1442 | }) | |
| @@ -1146,6 +1524,23 @@ impl App { | |||
| 1146 | 1524 | if fx.reload { | |
| 1147 | 1525 | self.reload(); | |
| 1148 | 1526 | } | |
| 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 | + | } | |
| 1149 | 1544 | if fx.reload_config { | |
| 1150 | 1545 | self.reload_requested = true; | |
| 1151 | 1546 | self.watch.reload(); | |
| @@ -1182,6 +1577,15 @@ impl App { | |||
| 1182 | 1577 | if pending != self.state.pending { | |
| 1183 | 1578 | self.state.pending = pending; | |
| 1184 | 1579 | 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 | + | } | |
| 1185 | 1589 | } | |
| 1186 | 1590 | self.ambiguous_since = self | |
| 1187 | 1591 | .keymap | |
| @@ -1214,6 +1618,18 @@ impl App { | |||
| 1214 | 1618 | } | |
| 1215 | 1619 | return; | |
| 1216 | 1620 | } | |
| 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 | + | } | |
| 1217 | 1633 | if event.repeat { | |
| 1218 | 1634 | // Repeats bypass the trie: only a completed, repeatable binding repeats. | |
| 1219 | 1635 | if let Some((k, actions)) = &self.repeat | |
| @@ -1249,6 +1665,128 @@ impl App { | |||
| 1249 | 1665 | } | |
| 1250 | 1666 | } | |
| 1251 | 1667 | ||
| 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 | + | ||
| 1252 | 1790 | /// A mouse button or wheel notch: bound actions run at the cursor, bypassing sequences. | |
| 1253 | 1791 | fn on_mouse_key(&mut self, key: Key, event_loop: &ActiveEventLoop) { | |
| 1254 | 1792 | self.keymap.cancel(); | |
| @@ -1267,6 +1805,17 @@ impl App { | |||
| 1267 | 1805 | fn on_wheel(&mut self, delta: [f32; 2], pixels: bool, event_loop: &ActiveEventLoop) { | |
| 1268 | 1806 | let now = Instant::now(); | |
| 1269 | 1807 | 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 | + | } | |
| 1270 | 1819 | if dy != 0.0 { | |
| 1271 | 1820 | let dir = if dy > 0.0 { Scroll::Up } else { Scroll::Down }; | |
| 1272 | 1821 | let key = input::scroll(dir, self.modifiers); | |
| @@ -1578,6 +2127,21 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 1578 | 2127 | UserEvent::Command { id, event } => self.on_command(id, event, event_loop), | |
| 1579 | 2128 | UserEvent::Refreshed(found) => self.on_refreshed(found), | |
| 1580 | 2129 | 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), | |
| 1581 | 2145 | } | |
| 1582 | 2146 | } | |
| 1583 | 2147 | ||
| @@ -1692,8 +2256,29 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 1692 | 2256 | self.decode(&cur, Priority::Preload); | |
| 1693 | 2257 | } | |
| 1694 | 2258 | } | |
| 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 | + | } | |
| 1695 | 2276 | self.publish(); | |
| 1696 | 2277 | 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 | + | } | |
| 1697 | 2282 | if let Some(t) = self.grid_rest { | |
| 1698 | 2283 | let at = t + GRID_REST; | |
| 1699 | 2284 | next = Some(next.map_or(at, |n| n.min(at))); | |
| @@ -57,6 +57,16 @@ pub struct Cli { | |||
| 57 | 57 | #[arg(short = 'b', long)] | |
| 58 | 58 | pub no_bar: bool, | |
| 59 | 59 | ||
| 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 | + | ||
| 60 | 70 | /// How images fit the window | |
| 61 | 71 | #[arg(short = 's', long, value_enum, value_name = "MODE")] | |
| 62 | 72 | pub fit: Option<FitMode>, | |
| @@ -159,6 +169,18 @@ mod tests { | |||
| 159 | 169 | assert!(c.stdin && c.print_marked && c.null && c.command.is_none()); | |
| 160 | 170 | } | |
| 161 | 171 | ||
| 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 | + | ||
| 162 | 184 | #[test] | |
| 163 | 185 | fn geometry() { | |
| 164 | 186 | assert_eq!(parse_geometry("800x600"), Ok((800, 600))); | |
| @@ -20,7 +20,7 @@ watch_dir = true # add and drop files that appear or vanish on disk | |||
| 20 | 20 | ||
| 21 | 21 | [window] | |
| 22 | 22 | decorations = false | |
| 23 | − | title = "{name} — remage" # {name} {index} {count} | |
| 23 | + | title = "{name} — remage" # placeholders as in ui.statusbar_left | |
| 24 | 24 | class = "remage" # Wayland app_id / X11 class | |
| 25 | 25 | fullscreen = false | |
| 26 | 26 | ||
| @@ -44,11 +44,17 @@ names = false # filenames under thumbnails | |||
| 44 | 44 | ||
| 45 | 45 | [ui] | |
| 46 | 46 | statusbar = true | |
| 47 | − | which_key_delay_ms = 300 | |
| 47 | + | which_key_delay_ms = 300 # pending keys wait this long before which-key shows | |
| 48 | 48 | 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. | |
| 49 | 53 | statusbar_left = "{name}" | |
| 50 | 54 | 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 = "" | |
| 52 | 58 | font_ui = "Space Grotesk" | |
| 53 | 59 | font_mono = "Space Mono" | |
| 54 | 60 | font_display = "Doto" | |
| @@ -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 | + | } | |
| @@ -11,6 +11,7 @@ mod heif; | |||
| 11 | 11 | mod image_rs; | |
| 12 | 12 | #[cfg(feature = "jxl")] | |
| 13 | 13 | mod jxl; | |
| 14 | + | pub mod meta; | |
| 14 | 15 | #[cfg(feature = "raw")] | |
| 15 | 16 | mod raw; | |
| 16 | 17 | #[cfg(feature = "svg")] | |
| @@ -36,7 +36,6 @@ impl Run { | |||
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | /// A next key that would continue the pending sequence. | |
| 39 | − | #[allow(dead_code)] // which-key arrives in M6 | |
| 40 | 39 | #[derive(Clone, Debug, PartialEq)] | |
| 41 | 40 | pub struct Candidate { | |
| 42 | 41 | pub key: Key, | |
| @@ -209,11 +208,15 @@ impl Keymap { | |||
| 209 | 208 | } | |
| 210 | 209 | ||
| 211 | 210 | /// A key sequence (not a count) is waiting for more keys. | |
| 212 | − | #[allow(dead_code)] // which-key arrives in M6 | |
| 213 | 211 | pub fn is_pending(&self) -> bool { | |
| 214 | 212 | !self.pending.is_empty() | |
| 215 | 213 | } | |
| 216 | 214 | ||
| 215 | + | /// The keys of the pending sequence. | |
| 216 | + | pub fn pending_keys(&self) -> &[Key] { | |
| 217 | + | &self.pending | |
| 218 | + | } | |
| 219 | + | ||
| 217 | 220 | pub fn cancel(&mut self) { | |
| 218 | 221 | self.pending.clear(); | |
| 219 | 222 | self.count = None; | |
| @@ -234,7 +237,6 @@ impl Keymap { | |||
| 234 | 237 | } | |
| 235 | 238 | ||
| 236 | 239 | /// The keys that continue the pending sequence, for which-key. | |
| 237 | − | #[allow(dead_code)] // which-key arrives in M6 | |
| 238 | 240 | pub fn candidates(&self, mode: Mode) -> Vec<Candidate> { | |
| 239 | 241 | let Some(node) = self.root(mode).walk(&self.pending) else { | |
| 240 | 242 | return Vec::new(); | |
| @@ -257,7 +259,6 @@ impl Keymap { | |||
| 257 | 259 | } | |
| 258 | 260 | ||
| 259 | 261 | /// The sequences bound to exactly `action` in a mode, shortest first. | |
| 260 | − | #[allow(dead_code)] // the palette and help arrive in M6 | |
| 261 | 262 | pub fn keys_for(&self, mode: Mode, action: &Action) -> Vec<KeySeq> { | |
| 262 | 263 | let mut keys: Vec<KeySeq> = self | |
| 263 | 264 | .bindings(mode) | |
| @@ -15,6 +15,7 @@ mod scan; | |||
| 15 | 15 | mod state; | |
| 16 | 16 | mod thumbs; | |
| 17 | 17 | mod ui; | |
| 18 | + | mod watch; | |
| 18 | 19 | mod worker; | |
| 19 | 20 | ||
| 20 | 21 | use std::ffi::OsString; | |
| @@ -103,6 +104,11 @@ fn main() -> anyhow::Result<()> { | |||
| 103 | 104 | fit: cli.fit, | |
| 104 | 105 | zoom: cli.zoom.map(|z| z / 100.0), | |
| 105 | 106 | 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), | |
| 106 | 112 | }; | |
| 107 | 113 | let mut settings = Settings::from_config(cfg); | |
| 108 | 114 | overrides.apply(&mut settings); | |
| @@ -116,6 +122,13 @@ fn main() -> anyhow::Result<()> { | |||
| 116 | 122 | state.theme = loaded.theme.clone(); | |
| 117 | 123 | state.nearest = cfg.image.filter.nearest(); | |
| 118 | 124 | 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 | + | } | |
| 119 | 132 | if let Some(err) = scan.errors.first() { | |
| 120 | 133 | state.notify(format!("error: {err}"), MsgKind::Error, Instant::now()); | |
| 121 | 134 | } | |
| @@ -163,6 +176,7 @@ fn main() -> anyhow::Result<()> { | |||
| 163 | 176 | overrides, | |
| 164 | 177 | private: cli.private, | |
| 165 | 178 | socket: server.as_ref().map(|s| s.path().to_path_buf()), | |
| 179 | + | dirs: scan.dirs, | |
| 166 | 180 | }; | |
| 167 | 181 | let mut app = App::new(opts, state, proxy, loaded, watch); | |
| 168 | 182 | if let Some(e) = config_error { | |
| @@ -18,11 +18,15 @@ struct Uniforms { | |||
| 18 | 18 | bg: [f32; 4], | |
| 19 | 19 | dot: [f32; 4], | |
| 20 | 20 | params: [f32; 4], | |
| 21 | + | /// Gamma, brightness, contrast, and 1 when any differs from neutral. | |
| 22 | + | adjust: [f32; 4], | |
| 21 | 23 | } | |
| 22 | 24 | ||
| 23 | − | /// How the area behind transparent pixels is painted. | |
| 25 | + | /// How images are painted: the area behind transparent pixels, and display adjustments. | |
| 24 | 26 | #[derive(Clone, Copy, Debug)] | |
| 25 | 27 | pub struct AlphaStyle { | |
| 28 | + | /// Gamma, brightness, contrast. | |
| 29 | + | pub adjust: [f32; 3], | |
| 26 | 30 | /// 0 solid, 1 dots, 2 checker. | |
| 27 | 31 | pub mode: u32, | |
| 28 | 32 | pub bg: [f32; 4], | |
| @@ -435,6 +439,8 @@ impl ImagePipeline { | |||
| 435 | 439 | alpha: f32, | |
| 436 | 440 | style: &AlphaStyle, | |
| 437 | 441 | ) { | |
| 442 | + | let adjust = style.adjust; | |
| 443 | + | let on = adjust != [1.0, 0.0, 1.0]; | |
| 438 | 444 | let [iw, ih] = [gpu.size[0] as f32, gpu.size[1] as f32]; | |
| 439 | 445 | let m = affine.m; | |
| 440 | 446 | for tile in &gpu.tiles { | |
| @@ -451,6 +457,7 @@ impl ImagePipeline { | |||
| 451 | 457 | bg: style.bg, | |
| 452 | 458 | dot: style.dot, | |
| 453 | 459 | params: [alpha, style.mode as f32, style.cell, style.radius], | |
| 460 | + | adjust: [adjust[0], adjust[1], adjust[2], on as u32 as f32], | |
| 454 | 461 | }; | |
| 455 | 462 | queue.write_buffer(&tile.uniforms, 0, bytemuck::bytes_of(&u)); | |
| 456 | 463 | } | |
| @@ -41,6 +41,9 @@ pub struct Scene<'a> { | |||
| 41 | 41 | pub under: Vec<Prim>, | |
| 42 | 42 | pub prims: Vec<Prim>, | |
| 43 | 43 | 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>, | |
| 44 | 47 | } | |
| 45 | 48 | ||
| 46 | 49 | pub struct Renderer { | |
| @@ -305,12 +308,15 @@ impl Renderer { | |||
| 305 | 308 | self.prims.prepare( | |
| 306 | 309 | &self.device, | |
| 307 | 310 | &self.queue, | |
| 308 | − | &scene.under, | |
| 309 | − | &scene.prims, | |
| 311 | + | [&scene.under, &scene.prims, &scene.overlay_prims], | |
| 310 | 312 | viewport, | |
| 311 | 313 | ); | |
| 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 | + | )?; | |
| 314 | 320 | ||
| 315 | 321 | let mut encoder = self | |
| 316 | 322 | .device | |
| @@ -351,6 +357,8 @@ impl Renderer { | |||
| 351 | 357 | } | |
| 352 | 358 | self.prims.draw_over(&mut pass); | |
| 353 | 359 | self.text.render(&mut pass)?; | |
| 360 | + | self.prims.draw_overlay(&mut pass); | |
| 361 | + | self.text.render_overlay(&mut pass)?; | |
| 354 | 362 | } | |
| 355 | 363 | Ok(encoder.finish()) | |
| 356 | 364 | } | |
| @@ -60,9 +60,8 @@ pub struct PrimPipeline { | |||
| 60 | 60 | bind_group: wgpu::BindGroup, | |
| 61 | 61 | instances: wgpu::Buffer, | |
| 62 | 62 | 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], | |
| 66 | 65 | } | |
| 67 | 66 | ||
| 68 | 67 | const ATTRS: [wgpu::VertexAttribute; 4] = | |
| @@ -147,21 +146,20 @@ impl PrimPipeline { | |||
| 147 | 146 | bind_group, | |
| 148 | 147 | instances: instance_buffer(device, capacity), | |
| 149 | 148 | capacity, | |
| 150 | − | split: 0, | |
| 151 | − | count: 0, | |
| 149 | + | ends: [0; 3], | |
| 152 | 150 | } | |
| 153 | 151 | } | |
| 154 | 152 | ||
| 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. | |
| 156 | 155 | pub fn prepare( | |
| 157 | 156 | &mut self, | |
| 158 | 157 | device: &wgpu::Device, | |
| 159 | 158 | queue: &wgpu::Queue, | |
| 160 | − | under: &[Prim], | |
| 161 | − | over: &[Prim], | |
| 159 | + | layers: [&[Prim]; 3], | |
| 162 | 160 | viewport: [f32; 2], | |
| 163 | 161 | ) { | |
| 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(); | |
| 165 | 163 | queue.write_buffer( | |
| 166 | 164 | &self.globals, | |
| 167 | 165 | 0, | |
| @@ -174,8 +172,11 @@ impl PrimPipeline { | |||
| 174 | 172 | if !prims.is_empty() { | |
| 175 | 173 | queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(&prims)); | |
| 176 | 174 | } | |
| 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 | + | } | |
| 179 | 180 | } | |
| 180 | 181 | ||
| 181 | 182 | fn draw_range(&self, pass: &mut wgpu::RenderPass<'_>, range: std::ops::Range<u32>) { | |
| @@ -189,10 +190,14 @@ impl PrimPipeline { | |||
| 189 | 190 | } | |
| 190 | 191 | ||
| 191 | 192 | 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]); | |
| 193 | 194 | } | |
| 194 | 195 | ||
| 195 | 196 | 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]); | |
| 197 | 202 | } | |
| 198 | 203 | } | |
| @@ -9,6 +9,8 @@ struct Uniforms { | |||
| 9 | 9 | dot_color: vec4<f32>, | |
| 10 | 10 | // x: opacity, y: alpha-bg mode (0 solid, 1 dots, 2 checker), z: cell px, w: dot radius px | |
| 11 | 11 | params: vec4<f32>, | |
| 12 | + | // x: gamma, y: brightness, z: contrast, w: 1 when any is not neutral | |
| 13 | + | adjust: vec4<f32>, | |
| 12 | 14 | }; | |
| 13 | 15 | ||
| 14 | 16 | @group(0) @binding(0) var<uniform> u: Uniforms; | |
| @@ -36,7 +38,14 @@ fn vs(@builtin(vertex_index) i: u32) -> VOut { | |||
| 36 | 38 | ||
| 37 | 39 | @fragment | |
| 38 | 40 | 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 | + | } | |
| 40 | 49 | var bg = u.bg.rgb; | |
| 41 | 50 | let mode = u32(u.params.y); | |
| 42 | 51 | let cell = u.params.z; | |
| @@ -104,6 +104,8 @@ pub struct TextSystem { | |||
| 104 | 104 | cache: Cache, | |
| 105 | 105 | atlas: TextAtlas, | |
| 106 | 106 | renderer: TextRenderer, | |
| 107 | + | /// Text above overlay panels, sharing the atlas. | |
| 108 | + | overlay: TextRenderer, | |
| 107 | 109 | viewport: Viewport, | |
| 108 | 110 | format: wgpu::TextureFormat, | |
| 109 | 111 | shaped: HashMap<Key, Shaped>, | |
| @@ -146,6 +148,8 @@ impl TextSystem { | |||
| 146 | 148 | let mut atlas = TextAtlas::new(device, queue, &cache, format); | |
| 147 | 149 | let renderer = | |
| 148 | 150 | TextRenderer::new(&mut atlas, device, wgpu::MultisampleState::default(), None); | |
| 151 | + | let overlay = | |
| 152 | + | TextRenderer::new(&mut atlas, device, wgpu::MultisampleState::default(), None); | |
| 149 | 153 | let viewport = Viewport::new(device, &cache); | |
| 150 | 154 | Self { | |
| 151 | 155 | fonts, | |
| @@ -153,6 +157,7 @@ impl TextSystem { | |||
| 153 | 157 | cache, | |
| 154 | 158 | atlas, | |
| 155 | 159 | renderer, | |
| 160 | + | overlay, | |
| 156 | 161 | viewport, | |
| 157 | 162 | format, | |
| 158 | 163 | shaped: HashMap::new(), | |
| @@ -178,6 +183,12 @@ impl TextSystem { | |||
| 178 | 183 | wgpu::MultisampleState::default(), | |
| 179 | 184 | None, | |
| 180 | 185 | ); | |
| 186 | + | self.overlay = TextRenderer::new( | |
| 187 | + | &mut self.atlas, | |
| 188 | + | device, | |
| 189 | + | wgpu::MultisampleState::default(), | |
| 190 | + | None, | |
| 191 | + | ); | |
| 181 | 192 | self.shaped.clear(); | |
| 182 | 193 | } | |
| 183 | 194 | ||
| @@ -234,15 +245,15 @@ impl TextSystem { | |||
| 234 | 245 | self.shaped.get(&key).map_or(0.0, |s| s.width) | |
| 235 | 246 | } | |
| 236 | 247 | ||
| 237 | − | /// Shape and upload this frame's text. | |
| 248 | + | /// Shape and upload this frame's text: the base layer and the overlay layer. | |
| 238 | 249 | pub fn prepare( | |
| 239 | 250 | &mut self, | |
| 240 | 251 | device: &wgpu::Device, | |
| 241 | 252 | queue: &wgpu::Queue, | |
| 242 | − | items: &[TextItem], | |
| 253 | + | layers: [&[TextItem]; 2], | |
| 243 | 254 | size: [u32; 2], | |
| 244 | 255 | ) -> 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()); | |
| 246 | 257 | // Keep what this frame (including any `measure` calls) used, and reset for the next. | |
| 247 | 258 | self.shaped.retain(|_, s| std::mem::take(&mut s.used)); | |
| 248 | 259 | self.viewport.update( | |
| @@ -252,8 +263,21 @@ impl TextSystem { | |||
| 252 | 263 | height: size[1], | |
| 253 | 264 | }, | |
| 254 | 265 | ); | |
| 266 | + | for (layer, (items, keys)) in layers.iter().zip(&keys).enumerate() { | |
| 267 | + | self.prepare_layer(device, queue, layer, items, keys)?; | |
| 268 | + | } | |
| 269 | + | Ok(()) | |
| 270 | + | } | |
| 255 | 271 | ||
| 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)| { | |
| 257 | 281 | let s = self.shaped.get(key)?; | |
| 258 | 282 | let width = s.width; | |
| 259 | 283 | let left = match item.align { | |
| @@ -290,7 +314,12 @@ impl TextSystem { | |||
| 290 | 314 | custom_glyphs: &[], | |
| 291 | 315 | }) | |
| 292 | 316 | }); | |
| 293 | − | self.renderer.prepare( | |
| 317 | + | let renderer = if layer == 0 { | |
| 318 | + | &mut self.renderer | |
| 319 | + | } else { | |
| 320 | + | &mut self.overlay | |
| 321 | + | }; | |
| 322 | + | renderer.prepare( | |
| 294 | 323 | device, | |
| 295 | 324 | queue, | |
| 296 | 325 | &mut self.fonts, | |
| @@ -307,6 +336,11 @@ impl TextSystem { | |||
| 307 | 336 | Ok(()) | |
| 308 | 337 | } | |
| 309 | 338 | ||
| 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 | + | ||
| 310 | 344 | pub fn trim(&mut self) { | |
| 311 | 345 | self.atlas.trim(); | |
| 312 | 346 | } | |
| @@ -14,6 +14,8 @@ pub struct Scan { | |||
| 14 | 14 | /// Files with unrecognised extensions, to sniff in the background. | |
| 15 | 15 | pub unknown: Vec<PathBuf>, | |
| 16 | 16 | pub errors: Vec<String>, | |
| 17 | + | /// Directories listed, and whether recursively: what `watch_dir` watches. | |
| 18 | + | pub dirs: Vec<(PathBuf, bool)>, | |
| 17 | 19 | } | |
| 18 | 20 | ||
| 19 | 21 | pub struct ScanOptions { | |
| @@ -38,10 +40,12 @@ pub fn scan(args: &[PathBuf], opts: &ScanOptions) -> Scan { | |||
| 38 | 40 | list_dir(&path, opts.recursive, &mut files, &mut out.unknown); | |
| 39 | 41 | sort(&mut files, opts); | |
| 40 | 42 | out.files.extend(files); | |
| 43 | + | out.dirs.push((path.clone(), opts.recursive)); | |
| 41 | 44 | } else if opts.siblings && args.len() == 1 { | |
| 42 | 45 | let parent = path.parent().unwrap_or(Path::new("/")); | |
| 43 | 46 | let mut files = Vec::new(); | |
| 44 | 47 | list_dir(parent, false, &mut files, &mut out.unknown); | |
| 48 | + | out.dirs.push((parent.to_path_buf(), false)); | |
| 45 | 49 | out.unknown.retain(|u| *u != path); | |
| 46 | 50 | if !files.contains(&path) { | |
| 47 | 51 | files.push(path.clone()); | |
| @@ -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 | + | } | |
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | //! Pure application state: no winit or wgpu types, so everything here is unit-testable. | |
| 2 | 2 | ||
| 3 | + | pub mod fuzzy; | |
| 3 | 4 | pub mod grid; | |
| 5 | + | pub mod overlay; | |
| 4 | 6 | pub mod queue; | |
| 5 | 7 | pub mod view; | |
| 6 | 8 | ||
| @@ -8,11 +10,12 @@ use std::collections::HashMap; | |||
| 8 | 10 | use std::path::{Path, PathBuf}; | |
| 9 | 11 | use std::time::{Duration, Instant}; | |
| 10 | 12 | ||
| 11 | − | use crate::action::{Action, ModeArg, Page, Switch, Zoom}; | |
| 13 | + | use crate::action::{Action, Adjust, ModeArg, Page, Step, Switch, Zoom}; | |
| 12 | 14 | use crate::anim::Tween; | |
| 13 | 15 | use crate::config::theme::Theme; | |
| 14 | 16 | use crate::config::{Config, Transition}; | |
| 15 | 17 | use grid::{Grid, Layout}; | |
| 18 | + | use overlay::{Edit, Help, Info, Input, InputKind, PaletteItem, WhichRow}; | |
| 16 | 19 | use queue::{Queue, SortKey}; | |
| 17 | 20 | use view::{Affine, Dir, FitMode, View}; | |
| 18 | 21 | ||
| @@ -32,6 +35,15 @@ const THEME_FADE: Duration = Duration::from_millis(250); | |||
| 32 | 35 | const THUMB_FADE: Duration = Duration::from_millis(150); | |
| 33 | 36 | /// Thumbnail names: Space Grotesk at this size. | |
| 34 | 37 | 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; | |
| 35 | 47 | ||
| 36 | 48 | /// Which keymap applies and what is on screen. | |
| 37 | 49 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| @@ -71,6 +83,10 @@ pub struct Settings { | |||
| 71 | 83 | pub grid_size: u32, | |
| 72 | 84 | pub grid_gap: u32, | |
| 73 | 85 | pub grid_names: bool, | |
| 86 | + | pub slideshow_delay: Duration, | |
| 87 | + | /// Status bar templates. | |
| 88 | + | pub bar_left: String, | |
| 89 | + | pub bar_right: String, | |
| 74 | 90 | } | |
| 75 | 91 | ||
| 76 | 92 | impl Default for Settings { | |
| @@ -91,6 +107,9 @@ impl Default for Settings { | |||
| 91 | 107 | grid_size: 160, | |
| 92 | 108 | grid_gap: 16, | |
| 93 | 109 | 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(), | |
| 94 | 113 | } | |
| 95 | 114 | } | |
| 96 | 115 | } | |
| @@ -118,6 +137,11 @@ impl Settings { | |||
| 118 | 137 | grid_size: c.grid.size, | |
| 119 | 138 | grid_gap: c.grid.gap, | |
| 120 | 139 | 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(), | |
| 121 | 145 | } | |
| 122 | 146 | } | |
| 123 | 147 | } | |
| @@ -141,6 +165,49 @@ impl AlphaBg { | |||
| 141 | 165 | } | |
| 142 | 166 | } | |
| 143 | 167 | ||
| 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 | + | ||
| 144 | 211 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 145 | 212 | pub enum MsgKind { | |
| 146 | 213 | Info, | |
| @@ -231,6 +298,13 @@ pub struct Effects { | |||
| 231 | 298 | pub refresh: bool, | |
| 232 | 299 | /// Re-sort the queue (stat-based keys need the filesystem, so the loop does it). | |
| 233 | 300 | 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, | |
| 234 | 308 | pub quit: bool, | |
| 235 | 309 | } | |
| 236 | 310 | ||
| @@ -264,6 +338,22 @@ pub struct AppState { | |||
| 264 | 338 | pub prompt: Option<String>, | |
| 265 | 339 | /// User commands still running, oldest first: (job id, name). | |
| 266 | 340 | 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)>, | |
| 267 | 357 | window: [f32; 2], | |
| 268 | 358 | scale_factor: f32, | |
| 269 | 359 | holds: Vec<Hold>, | |
| @@ -272,6 +362,7 @@ pub struct AppState { | |||
| 272 | 362 | ||
| 273 | 363 | impl AppState { | |
| 274 | 364 | pub fn new(queue: Queue, settings: Settings, bar: bool, now: Instant) -> Self { | |
| 365 | + | let slideshow_delay = settings.slideshow_delay; | |
| 275 | 366 | Self { | |
| 276 | 367 | queue, | |
| 277 | 368 | view: View::new(now), | |
| @@ -294,6 +385,16 @@ impl AppState { | |||
| 294 | 385 | pending: String::new(), | |
| 295 | 386 | prompt: None, | |
| 296 | 387 | 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, | |
| 297 | 398 | window: [1.0, 1.0], | |
| 298 | 399 | scale_factor: 1.0, | |
| 299 | 400 | holds: Vec::new(), | |
| @@ -333,6 +434,14 @@ impl AppState { | |||
| 333 | 434 | } | |
| 334 | 435 | } | |
| 335 | 436 | ||
| 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 | + | ||
| 336 | 445 | pub fn in_grid(&self) -> bool { | |
| 337 | 446 | matches!(self.mode, Mode::Grid | Mode::Visual) | |
| 338 | 447 | } | |
| @@ -462,6 +571,9 @@ impl AppState { | |||
| 462 | 571 | ||
| 463 | 572 | /// Apply settings from a (re)loaded config. The view keeps its zoom until the next image. | |
| 464 | 573 | 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 | + | } | |
| 465 | 577 | self.settings = settings; | |
| 466 | 578 | self.update_viewport(); | |
| 467 | 579 | } | |
| @@ -661,6 +773,81 @@ impl AppState { | |||
| 661 | 773 | } | |
| 662 | 774 | None => self.notify("single frame", MsgKind::Info, now), | |
| 663 | 775 | }, | |
| 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 | + | } | |
| 664 | 851 | Action::Fullscreen(s) => fx.fullscreen = Some(s), | |
| 665 | 852 | Action::Bar(s) => { | |
| 666 | 853 | let bar = switch(s, self.bar); | |
| @@ -687,7 +874,11 @@ impl AppState { | |||
| 687 | 874 | // The event loop runs commands: they spawn processes and may wait for a `y`. | |
| 688 | 875 | Action::Run(_) => fx.redraw = false, | |
| 689 | 876 | 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 | + | } | |
| 691 | 882 | Action::Quit | Action::QuitNoOutput => fx.quit = true, | |
| 692 | 883 | Action::Zoom(_) | |
| 693 | 884 | | Action::Fit(_) | |
| @@ -703,24 +894,8 @@ impl AppState { | |||
| 703 | 894 | | Action::Scroll(..) | |
| 704 | 895 | | Action::Play(_) | |
| 705 | 896 | | 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, | |
| 724 | 899 | } | |
| 725 | 900 | if fx.navigated && self.in_grid() { | |
| 726 | 901 | self.grid_follow(now, true); | |
| @@ -728,6 +903,171 @@ impl AppState { | |||
| 728 | 903 | fx | |
| 729 | 904 | } | |
| 730 | 905 | ||
| 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 | + | ||
| 731 | 1071 | /// Start or stop a held-key pan. | |
| 732 | 1072 | pub fn hold_pan(&mut self, dir: Dir, held: bool, now: Instant) { | |
| 733 | 1073 | if held { | |
| @@ -808,6 +1148,9 @@ impl AppState { | |||
| 808 | 1148 | self.loading_since = None; | |
| 809 | 1149 | self.holds.clear(); | |
| 810 | 1150 | self.playback = None; | |
| 1151 | + | if let Some(next) = &mut self.slideshow { | |
| 1152 | + | *next = Some(now + self.slideshow_delay); | |
| 1153 | + | } | |
| 811 | 1154 | } | |
| 812 | 1155 | ||
| 813 | 1156 | /// The shown image has several frames: play an animation, or page through a document. | |
| @@ -912,6 +1255,11 @@ impl AppState { | |||
| 912 | 1255 | wake(at); | |
| 913 | 1256 | } | |
| 914 | 1257 | } | |
| 1258 | + | if self.mode == Mode::Image | |
| 1259 | + | && let Some(Some(t)) = self.slideshow | |
| 1260 | + | { | |
| 1261 | + | wake(t); | |
| 1262 | + | } | |
| 915 | 1263 | // Animation frames: only while the image is on screen. | |
| 916 | 1264 | if self.mode == Mode::Image | |
| 917 | 1265 | && let Some(pb) = &mut self.playback | |
| @@ -951,6 +1299,13 @@ impl AppState { | |||
| 951 | 1299 | } | |
| 952 | 1300 | } | |
| 953 | 1301 | ||
| 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 | + | ||
| 954 | 1309 | #[cfg(test)] | |
| 955 | 1310 | mod tests { | |
| 956 | 1311 | use super::*; | |
| @@ -1025,14 +1380,20 @@ mod tests { | |||
| 1025 | 1380 | } | |
| 1026 | 1381 | ||
| 1027 | 1382 | #[test] | |
| 1028 | − | fn unfinished_actions_say_so() { | |
| 1383 | + | fn overlay_actions_ask_the_loop() { | |
| 1029 | 1384 | 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")); | |
| 1036 | 1397 | assert!(s.apply(Action::Refresh, now).refresh); | |
| 1037 | 1398 | assert!(!s.apply(Action::Run("x".into()), now).redraw); | |
| 1038 | 1399 | s.apply(Action::Echo("hi".into()), now); | |
| @@ -1041,6 +1402,149 @@ mod tests { | |||
| 1041 | 1402 | assert_eq!(s.queue.index(), 1); | |
| 1042 | 1403 | } | |
| 1043 | 1404 | ||
| 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 | + | ||
| 1044 | 1548 | #[test] | |
| 1045 | 1549 | fn grid_mode_moves_the_queue_cursor() { | |
| 1046 | 1550 | let (mut s, now) = state(40); | |
| @@ -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 | + | } | |
| @@ -4,6 +4,8 @@ use std::cmp::Ordering; | |||
| 4 | 4 | use std::collections::HashSet; | |
| 5 | 5 | use std::path::{Path, PathBuf}; | |
| 6 | 6 | ||
| 7 | + | use super::fuzzy::Fuzzy; | |
| 8 | + | ||
| 7 | 9 | #[derive( | |
| 8 | 10 | Clone, | |
| 9 | 11 | Copy, | |
| @@ -33,6 +35,41 @@ pub struct Queue { | |||
| 33 | 35 | marks: HashSet<PathBuf>, | |
| 34 | 36 | /// Bumped on every change to the marks, so watchers can spot one. | |
| 35 | 37 | 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 | + | } | |
| 36 | 73 | } | |
| 37 | 74 | ||
| 38 | 75 | impl Queue { | |
| @@ -46,6 +83,10 @@ impl Queue { | |||
| 46 | 83 | index: 0, | |
| 47 | 84 | marks: HashSet::new(), | |
| 48 | 85 | marks_version: 0, | |
| 86 | + | filter: None, | |
| 87 | + | search: None, | |
| 88 | + | full: None, | |
| 89 | + | lost: None, | |
| 49 | 90 | }; | |
| 50 | 91 | q.index = q.clamp(index); | |
| 51 | 92 | q | |
| @@ -96,8 +137,17 @@ impl Queue { | |||
| 96 | 137 | ||
| 97 | 138 | /// Remove `path`; the current position moves to the following file. | |
| 98 | 139 | 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 | + | }); | |
| 99 | 146 | 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; | |
| 101 | 151 | }; | |
| 102 | 152 | let removed = self.files.remove(i); | |
| 103 | 153 | if self.marks.remove(&removed) { | |
| @@ -113,14 +163,20 @@ impl Queue { | |||
| 113 | 163 | /// Insert paths in natural order, keeping the current file selected. | |
| 114 | 164 | pub fn insert_sorted(&mut self, paths: impl IntoIterator<Item = PathBuf>) { | |
| 115 | 165 | 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 | + | }; | |
| 116 | 173 | 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); | |
| 119 | 179 | } | |
| 120 | − | let at = self | |
| 121 | − | .files | |
| 122 | − | .partition_point(|f| natural_cmp_path(f, &p) != Ordering::Greater); | |
| 123 | − | self.files.insert(at, p); | |
| 124 | 180 | } | |
| 125 | 181 | if let Some(cur) = current | |
| 126 | 182 | && let Some(i) = self.files.iter().position(|f| *f == cur) | |
| @@ -151,12 +207,96 @@ impl Queue { | |||
| 151 | 207 | out | |
| 152 | 208 | } | |
| 153 | 209 | ||
| 210 | + | /// The files shown: all of them, or those a filter or search lets through. | |
| 154 | 211 | pub fn files(&self) -> &[PathBuf] { | |
| 155 | 212 | &self.files | |
| 156 | 213 | } | |
| 157 | 214 | ||
| 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 | + | ||
| 158 | 295 | /// Reverse the order, keeping the current file selected. | |
| 159 | 296 | pub fn reverse(&mut self) { | |
| 297 | + | if let Some(full) = &mut self.full { | |
| 298 | + | full.reverse(); | |
| 299 | + | } | |
| 160 | 300 | self.files.reverse(); | |
| 161 | 301 | self.index = self.clamp(self.files.len().saturating_sub(1) - self.index); | |
| 162 | 302 | } | |
| @@ -165,17 +305,10 @@ impl Queue { | |||
| 165 | 305 | /// Files removed meanwhile stay removed; files added meanwhile go at the end. | |
| 166 | 306 | pub fn reorder(&mut self, order: Vec<PathBuf>) { | |
| 167 | 307 | 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); | |
| 179 | 312 | if let Some(cur) = current | |
| 180 | 313 | && let Some(i) = self.files.iter().position(|f| *f == cur) | |
| 181 | 314 | { | |
| @@ -209,8 +342,9 @@ impl Queue { | |||
| 209 | 342 | changed | |
| 210 | 343 | } | |
| 211 | 344 | ||
| 345 | + | /// Mark every shown file. | |
| 212 | 346 | pub fn mark_all(&mut self) { | |
| 213 | − | self.marks = self.files.iter().cloned().collect(); | |
| 347 | + | self.marks.extend(self.files.iter().cloned()); | |
| 214 | 348 | self.marks_version += 1; | |
| 215 | 349 | } | |
| 216 | 350 | ||
| @@ -219,19 +353,19 @@ impl Queue { | |||
| 219 | 353 | self.marks_version += 1; | |
| 220 | 354 | } | |
| 221 | 355 | ||
| 356 | + | /// Invert the marks of the shown files. | |
| 222 | 357 | pub fn invert_marks(&mut self) { | |
| 223 | 358 | 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 | + | } | |
| 230 | 364 | } | |
| 231 | 365 | ||
| 232 | − | /// Marked files in queue order. | |
| 366 | + | /// Marked files in queue order, including any a filter hides. | |
| 233 | 367 | pub fn marked(&self) -> Vec<&Path> { | |
| 234 | − | self.files | |
| 368 | + | self.all_files() | |
| 235 | 369 | .iter() | |
| 236 | 370 | .filter(|f| self.marks.contains(*f)) | |
| 237 | 371 | .map(PathBuf::as_path) | |
| @@ -275,6 +409,87 @@ impl Queue { | |||
| 275 | 409 | } | |
| 276 | 410 | } | |
| 277 | 411 | ||
| 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 | + | ||
| 278 | 493 | /// Sort `files` in place. `meta` supplies (mtime seconds, size) for the stat-based keys. | |
| 279 | 494 | pub fn sort_files( | |
| 280 | 495 | files: &mut [PathBuf], | |
| @@ -491,6 +706,55 @@ mod tests { | |||
| 491 | 706 | assert_eq!(q.find_mark(true, 1, true), None); | |
| 492 | 707 | } | |
| 493 | 708 | ||
| 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 | + | ||
| 494 | 758 | #[test] | |
| 495 | 759 | fn random_sort_is_a_permutation() { | |
| 496 | 760 | let mut v = paths(&["a", "b", "c", "d", "e"]); | |
| @@ -2,6 +2,8 @@ | |||
| 2 | 2 | //! | |
| 3 | 3 | //! Each frame the UI turns state into a [`Scene`]; the renderer only draws it. | |
| 4 | 4 | ||
| 5 | + | pub mod template; | |
| 6 | + | ||
| 5 | 7 | use std::path::Path; | |
| 6 | 8 | use std::time::Instant; | |
| 7 | 9 | ||
| @@ -11,21 +13,33 @@ use crate::render::image::AlphaStyle; | |||
| 11 | 13 | use crate::render::prims::Prim; | |
| 12 | 14 | use crate::render::text::{Align, FontRole, TextItem}; | |
| 13 | 15 | 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 | + | }; | |
| 15 | 21 | ||
| 16 | 22 | /// Space Mono label tracking. | |
| 17 | 23 | const LABEL_TRACKING: f32 = 0.08; | |
| 18 | 24 | ||
| 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>", "\\") | |
| 24 | 32 | } | |
| 25 | 33 | ||
| 34 | + | /// Space Mono caps: labels and data. | |
| 26 | 35 | 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 { | |
| 27 | 41 | TextItem { | |
| 28 | − | spans: vec![(text.to_uppercase(), color)], | |
| 42 | + | spans: vec![(text.to_string(), color)], | |
| 29 | 43 | role: FontRole::Mono, | |
| 30 | 44 | size: (11.0 * sf).round(), | |
| 31 | 45 | weight: 400, | |
| @@ -38,6 +52,22 @@ fn label(text: &str, color: Rgba, sf: f32, x: f32, y: f32, align: Align) -> Text | |||
| 38 | 52 | } | |
| 39 | 53 | } | |
| 40 | 54 | ||
| 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 | + | ||
| 41 | 71 | /// Longest message shown before truncation, in characters. | |
| 42 | 72 | const MAX_MESSAGE: usize = 72; | |
| 43 | 73 | ||
| @@ -54,12 +84,21 @@ pub fn scene<'a>( | |||
| 54 | 84 | let mut scene = Scene { | |
| 55 | 85 | clear: theme.background, | |
| 56 | 86 | 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 | + | }, | |
| 58 | 95 | images: Vec::new(), | |
| 59 | 96 | thumbs: Vec::new(), | |
| 60 | 97 | under: Vec::new(), | |
| 61 | 98 | prims: Vec::new(), | |
| 62 | 99 | texts: Vec::new(), | |
| 100 | + | overlay_prims: Vec::new(), | |
| 101 | + | overlay_texts: Vec::new(), | |
| 63 | 102 | }; | |
| 64 | 103 | ||
| 65 | 104 | if state.in_grid() { | |
| @@ -68,7 +107,14 @@ pub fn scene<'a>( | |||
| 68 | 107 | image(&mut scene, state, now); | |
| 69 | 108 | } | |
| 70 | 109 | 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); | |
| 72 | 118 | } | |
| 73 | 119 | if bar_h > 0.0 { | |
| 74 | 120 | status_bar(&mut scene, state, theme, now, [w, h], bar_h, measure); | |
| @@ -77,6 +123,30 @@ pub fn scene<'a>( | |||
| 77 | 123 | let bar_h = (BAR_HEIGHT * sf).round(); | |
| 78 | 124 | status_bar(&mut scene, state, theme, now, [w, h], bar_h, measure); | |
| 79 | 125 | } | |
| 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 | + | } | |
| 80 | 150 | scene | |
| 81 | 151 | } | |
| 82 | 152 | ||
| @@ -207,6 +277,7 @@ fn alpha_style(bg: AlphaBg, theme: &Theme, sf: f32) -> AlphaStyle { | |||
| 207 | 277 | }; | |
| 208 | 278 | let cell = if mode == 2 { 8.0 } else { 16.0 }; | |
| 209 | 279 | AlphaStyle { | |
| 280 | + | adjust: [1.0, 0.0, 1.0], | |
| 210 | 281 | mode, | |
| 211 | 282 | bg: theme.image_background.linear(), | |
| 212 | 283 | dot: dot.linear(), | |
| @@ -215,12 +286,17 @@ fn alpha_style(bg: AlphaBg, theme: &Theme, sf: f32) -> AlphaStyle { | |||
| 215 | 286 | } | |
| 216 | 287 | } | |
| 217 | 288 | ||
| 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>) { | |
| 220 | 291 | let cx = area[0] * 0.5; | |
| 221 | 292 | let cy = area[1] * 0.5; | |
| 293 | + | let title = if hint.is_some() { | |
| 294 | + | "NO MATCHES" | |
| 295 | + | } else { | |
| 296 | + | "NO IMAGES" | |
| 297 | + | }; | |
| 222 | 298 | scene.texts.push(TextItem { | |
| 223 | − | spans: vec![("NO IMAGES".into(), theme.text_secondary)], | |
| 299 | + | spans: vec![(title.into(), theme.text_secondary)], | |
| 224 | 300 | role: FontRole::Display, | |
| 225 | 301 | size: (48.0 * sf).round(), | |
| 226 | 302 | weight: 700, | |
| @@ -231,7 +307,7 @@ fn empty_state(scene: &mut Scene, theme: &Theme, sf: f32, area: [f32; 2]) { | |||
| 231 | 307 | max_width: None, | |
| 232 | 308 | clip: None, | |
| 233 | 309 | }); | |
| 234 | − | let hint = "remage <file|dir>…"; | |
| 310 | + | let hint = hint.unwrap_or("remage <file|dir>…"); | |
| 235 | 311 | scene.texts.push(label( | |
| 236 | 312 | hint, | |
| 237 | 313 | theme.text_disabled, | |
| @@ -242,6 +318,47 @@ fn empty_state(scene: &mut Scene, theme: &Theme, sf: f32, area: [f32; 2]) { | |||
| 242 | 318 | )); | |
| 243 | 319 | } | |
| 244 | 320 | ||
| 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 | + | ||
| 245 | 362 | fn status_bar( | |
| 246 | 363 | scene: &mut Scene, | |
| 247 | 364 | state: &AppState, | |
| @@ -258,6 +375,7 @@ fn status_bar( | |||
| 258 | 375 | [0.0, h - bar_h, w, bar_h], | |
| 259 | 376 | theme.background.linear(), | |
| 260 | 377 | )); | |
| 378 | + | let var = |n: &str| var(state, n); | |
| 261 | 379 | ||
| 262 | 380 | let mut spans: Vec<(String, Rgba)> = Vec::new(); | |
| 263 | 381 | if !state.pending.is_empty() { | |
| @@ -296,27 +414,30 @@ fn status_bar( | |||
| 296 | 414 | spans.push((format!("[VISUAL {}]", r.len()), theme.text_secondary)); | |
| 297 | 415 | } | |
| 298 | 416 | 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 | |
| 301 | 428 | .as_ref() | |
| 302 | − | .filter(|s| state.mode == Mode::Image && Some(s.path.as_path()) == state.queue.current()) | |
| 429 | + | .filter(|_| state.mode == Mode::Image) | |
| 303 | 430 | { | |
| 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}")); | |
| 311 | 436 | } | |
| 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())); | |
| 315 | 437 | } | |
| 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); | |
| 320 | 441 | } | |
| 321 | 442 | if !info.is_empty() { | |
| 322 | 443 | spans.push((info.join(" ").to_uppercase(), theme.text_secondary)); | |
| @@ -378,20 +499,401 @@ fn status_bar( | |||
| 378 | 499 | return; | |
| 379 | 500 | } | |
| 380 | 501 | ||
| 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() { | |
| 383 | 511 | 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; | |
| 396 | 885 | } | |
| 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 | + | }); | |
| 397 | 899 | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |