remageab1dc3b
Add user commands, IPC socket and remage msg
Commands run async with confirm prompts, output modes, after/refresh and chained bindings. The socket speaks newline-delimited JSON with queries and event subscriptions. Adds -i/-0/-o.
+1934 −55
10 files changed
- M CLAUDE.md +19 −3
- M src/app.rs +504 −26
- M src/cli.rs +62 −2
- M src/config/default.toml +14 −5
- M src/exec.rs +628 −0
- M src/ipc.rs +557 −1
- M src/main.rs +80 −10
- M src/state/mod.rs +24 −3
- M src/state/queue.rs +19 −4
- M src/ui/mod.rs +27 −1
| @@ -268,12 +268,19 @@ stdin = "files" | |||
| 268 | 268 | ||
| 269 | 269 | **Command fields:** | |
| 270 | 270 | - **Placeholders:** `{file} {files} {dir} {name} {stem} {ext} {index} {count} {width} {height}`. | |
| 271 | + | `{file}`, `{dir}`, `{name}`, `{stem}`, `{ext}`, `{index}`, `{width}` and `{height}` describe the | |
| 272 | + | current image; `{files}` and `{count}` (and `REMAGE_COUNT`) describe the targets. Only these | |
| 273 | + | names are substituted, so `awk '{print $1}'` survives. In an exec array, an element containing | |
| 274 | + | `{files}` repeats once per target (`--in={files}` → `--in=a --in=b`), and a leading `~/` in the | |
| 275 | + | template expands. In a shell string every value is shell-quoted, so don't quote placeholders. | |
| 276 | + | A command that mentions file placeholders (or reads stdin) refuses to run with no targets. | |
| 271 | 277 | - **`targets`:** `auto` (marked files if any, otherwise the current one; the default), `current`, | |
| 272 | 278 | `marked` or `all`. | |
| 273 | 279 | - **`stdin`:** `none`, `files` (newline-separated) or `files0` (NUL-separated). | |
| 274 | 280 | - **Environment:** `REMAGE_FILE`, `REMAGE_INDEX`, `REMAGE_COUNT`, `REMAGE_MODE`, `REMAGE_SOCKET`, | |
| 275 | 281 | `REMAGE_PID`. | |
| 276 | − | - **`confirm`:** turns the command into an inline y/n prompt. remage has no modal dialogs. | |
| 282 | + | - **`confirm`:** turns the command into an inline y/n prompt. remage has no modal dialogs. `y` | |
| 283 | + | runs it and any other key (or `cancel`) drops it. The prompt shows even with the bar hidden. | |
| 277 | 284 | - **`output`:** | |
| 278 | 285 | - `status` (default): the last stdout line goes to the status line. | |
| 279 | 286 | - `actions`: every stdout line is run as an action. | |
| @@ -282,7 +289,10 @@ stdin = "files" | |||
| 282 | 289 | or `[ERROR: <code> <first stderr line>]`. | |
| 283 | 290 | - **`after`:** actions to run on success. The default is `refresh`, which re-stats the targets, | |
| 284 | 291 | drops missing files and re-decodes changed ones. That way delete and rotate scripts work with no | |
| 285 | − | extra setup. | |
| 292 | + | extra setup. "Changed" means the mtime differs from the one recorded when the file was decoded | |
| 293 | + | or thumbnailed. A bare `refresh` (key, IPC) re-checks the whole queue. | |
| 294 | + | - **Chains:** in a binding array, the actions after a `run` (and the command's `after`) wait for | |
| 295 | + | it to exit successfully; a failure or a declined prompt drops the rest. | |
| 286 | 296 | - **Unbound commands** still appear in the `:` palette. | |
| 287 | 297 | ||
| 288 | 298 | **IPC socket.** It lives at `$XDG_RUNTIME_DIR/remage/<pid>.sock` and speaks newline-delimited JSON. | |
| @@ -293,6 +303,12 @@ stdin = "files" | |||
| 293 | 303 | - **Replies:** `{"ok":true,"data":…}` or `{"ok":false,"error":"…"}`. | |
| 294 | 304 | - **Client:** `remage msg <action>`, `remage msg --query marked` and `remage msg --subscribe image`. | |
| 295 | 305 | It targets `$REMAGE_SOCKET`, otherwise the newest instance; `--pid` picks one explicitly. | |
| 306 | + | Strings and string lists print one per line, anything else as JSON; errors go to stderr with | |
| 307 | + | exit 1. Subscriptions print one JSON event per line. | |
| 308 | + | - **Details:** `"action"` also takes an array. Events are `image` (`path`, 1-based `index`, | |
| 309 | + | `count`), `marks` (`count`, `files`) and `mode`. The socket is 0600 in a 0700 directory, | |
| 310 | + | sockets of dead pids are cleaned up at startup, and the socket is removed on exit once any | |
| 311 | + | in-flight reply (to a `quit`) has gone out. | |
| 296 | 312 | - **Uses:** Hyprland binds, long-running watchers and other tools driving the viewer. | |
| 297 | 313 | ||
| 298 | 314 | ### Config | |
| @@ -460,7 +476,7 @@ Each milestone should end with a usable binary. | |||
| 460 | 476 | - **M3 Grid + marks** ✓ (2026-11-04). Cached grid opens in ~85 ms, 0% CPU when idle. | |
| 461 | 477 | - Freedesktop thumbnails, worker priorities and the GPU atlas. | |
| 462 | 478 | - Grid navigation, visual mode, marks and smooth scrolling. | |
| 463 | − | - **M4 Commands + IPC** | |
| 479 | + | - **M4 Commands + IPC** ✓ (2026-11-04). Tested end to end over the socket and with `wtype`. | |
| 464 | 480 | - `[commands]` with async exec, confirm, output modes and `after`/`refresh`. | |
| 465 | 481 | - The socket, `remage msg` and subscriptions; `-i`/`-o`/`-0`. | |
| 466 | 482 | - **M5 Formats + fidelity** | |
| @@ -4,7 +4,9 @@ use std::collections::{HashMap, HashSet, VecDeque}; | |||
| 4 | 4 | use std::path::{Path, PathBuf}; | |
| 5 | 5 | use std::sync::atomic::{AtomicU32, Ordering}; | |
| 6 | 6 | use std::sync::{Arc, Mutex}; | |
| 7 | − | use std::time::{Duration, Instant}; | |
| 7 | + | use std::time::{Duration, Instant, SystemTime}; | |
| 8 | + | ||
| 9 | + | use serde_json::{Value, json}; | |
| 8 | 10 | ||
| 9 | 11 | use winit::application::ApplicationHandler; | |
| 10 | 12 | use winit::dpi::LogicalSize; | |
| @@ -16,11 +18,13 @@ use winit::window::{Fullscreen, Window, WindowId}; | |||
| 16 | 18 | use crate::action::{Action, ModeArg, Switch, Zoom}; | |
| 17 | 19 | use crate::cache::ImageCache; | |
| 18 | 20 | use crate::config::watch::ConfigWatch; | |
| 19 | − | use crate::config::{Config, LoadError, Loaded}; | |
| 21 | + | use crate::config::{Config, LoadError, Loaded, Output, Targets, suggest}; | |
| 20 | 22 | use crate::decode::{self, DecodedImage}; | |
| 23 | + | use crate::exec; | |
| 21 | 24 | use crate::input::keymap::{KeyTables, Keymap}; | |
| 22 | 25 | use crate::input::keys::{Key, Scroll}; | |
| 23 | 26 | use crate::input::{self}; | |
| 27 | + | use crate::ipc::{self, Query, Request, Topic}; | |
| 24 | 28 | use crate::render::{Renderer, text}; | |
| 25 | 29 | use crate::scan; | |
| 26 | 30 | use crate::state::view::{Dir, FitMode}; | |
| @@ -51,6 +55,8 @@ const DOUBLE_CLICK: Duration = Duration::from_millis(400); | |||
| 51 | 55 | pub enum UserEvent { | |
| 52 | 56 | Decoded { | |
| 53 | 57 | path: PathBuf, | |
| 58 | + | /// The file's mtime when decoding started, for `refresh`. | |
| 59 | + | mtime: Option<SystemTime>, | |
| 54 | 60 | result: Result<Arc<DecodedImage>, String>, | |
| 55 | 61 | }, | |
| 56 | 62 | /// Unknown-extension files that turned out to be images. | |
| @@ -65,8 +71,15 @@ pub enum UserEvent { | |||
| 65 | 71 | Thumb { | |
| 66 | 72 | path: PathBuf, | |
| 67 | 73 | px: u32, | |
| 74 | + | mtime: Option<SystemTime>, | |
| 68 | 75 | result: Result<Thumb, ThumbError>, | |
| 69 | 76 | }, | |
| 77 | + | /// Output or the exit of a user command. | |
| 78 | + | Command { id: u64, event: exec::Event }, | |
| 79 | + | /// `refresh` re-checked these files: their mtime, or `None` if they are gone. | |
| 80 | + | Refreshed(Vec<(PathBuf, Option<SystemTime>)>), | |
| 81 | + | /// A request on the IPC socket. | |
| 82 | + | Ipc(ipc::Incoming), | |
| 70 | 83 | } | |
| 71 | 84 | ||
| 72 | 85 | /// Command-line settings that beat the config, across reloads too. | |
| @@ -95,6 +108,43 @@ pub struct Options { | |||
| 95 | 108 | pub overrides: Overrides, | |
| 96 | 109 | /// Never write the shared thumbnail cache (`-p`). | |
| 97 | 110 | pub private: bool, | |
| 111 | + | /// The IPC socket, passed to commands as `$REMAGE_SOCKET`. | |
| 112 | + | pub socket: Option<PathBuf>, | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Where a chain of actions came from, which decides what some of them act on. | |
| 116 | + | #[derive(Clone, Debug, Default)] | |
| 117 | + | struct Ctx { | |
| 118 | + | /// The cursor, for mouse-triggered zoom. | |
| 119 | + | anchor: Option<[f32; 2]>, | |
| 120 | + | /// A command's targets, which its `refresh` re-checks (otherwise the whole queue). | |
| 121 | + | targets: Option<Arc<[PathBuf]>>, | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// A user command from spawn to exit. | |
| 125 | + | struct Job { | |
| 126 | + | name: String, | |
| 127 | + | output: Output, | |
| 128 | + | targets: Arc<[PathBuf]>, | |
| 129 | + | /// The command's `after` actions, then whatever followed `run` in its binding: they wait | |
| 130 | + | /// for a successful exit. | |
| 131 | + | then: Vec<Action>, | |
| 132 | + | /// stdout lines run as actions so far. | |
| 133 | + | lines: usize, | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /// An IPC connection streaming events. | |
| 137 | + | struct Subscriber { | |
| 138 | + | topics: Vec<Topic>, | |
| 139 | + | tx: crossbeam_channel::Sender<String>, | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// What subscribers last heard about. | |
| 143 | + | #[derive(Clone, Debug, PartialEq)] | |
| 144 | + | struct Published { | |
| 145 | + | image: (Option<PathBuf>, usize, usize), | |
| 146 | + | marks: u64, | |
| 147 | + | mode: Mode, | |
| 98 | 148 | } | |
| 99 | 149 | ||
| 100 | 150 | /// Thumbnail jobs for the current cell size. | |
| @@ -153,6 +203,16 @@ pub struct App { | |||
| 153 | 203 | held: HashMap<PhysicalKey, Dir>, | |
| 154 | 204 | started: Instant, | |
| 155 | 205 | shown_window: bool, | |
| 206 | + | /// File mtimes when they were last decoded or thumbnailed, so `refresh` spots changes. | |
| 207 | + | stamps: HashMap<PathBuf, SystemTime>, | |
| 208 | + | jobs: HashMap<u64, Job>, | |
| 209 | + | next_job: u64, | |
| 210 | + | /// A command waiting for `y` at its confirm prompt. | |
| 211 | + | confirm: Option<(Job, exec::Prepared)>, | |
| 212 | + | subscribers: Vec<Subscriber>, | |
| 213 | + | published: Option<Published>, | |
| 214 | + | /// `quit-no-output` ran: `-o` prints nothing. | |
| 215 | + | no_output: bool, | |
| 156 | 216 | pub error: Option<anyhow::Error>, | |
| 157 | 217 | } | |
| 158 | 218 | ||
| @@ -196,6 +256,13 @@ impl App { | |||
| 196 | 256 | held: HashMap::new(), | |
| 197 | 257 | started: Instant::now(), | |
| 198 | 258 | shown_window: false, | |
| 259 | + | stamps: HashMap::new(), | |
| 260 | + | jobs: HashMap::new(), | |
| 261 | + | next_job: 1, | |
| 262 | + | confirm: None, | |
| 263 | + | subscribers: Vec::new(), | |
| 264 | + | published: None, | |
| 265 | + | no_output: false, | |
| 199 | 266 | error: None, | |
| 200 | 267 | }; | |
| 201 | 268 | // Decode the first image while the window and GPU come up. | |
| @@ -220,12 +287,17 @@ impl App { | |||
| 220 | 287 | self.pool | |
| 221 | 288 | .submit(JobKey::Decode(path.clone()), prio, move || { | |
| 222 | 289 | let t = Instant::now(); | |
| 290 | + | let mtime = mtime(&path); | |
| 223 | 291 | let result = decode::decode(&path, max_dim.load(Ordering::Relaxed)) | |
| 224 | 292 | .map(Arc::new) | |
| 225 | 293 | .map_err(|e| e.to_string()); | |
| 226 | 294 | log::debug!("decoded {} in {:.1?}", path.display(), t.elapsed()); | |
| 227 | 295 | lock(&arriving).insert(path.clone()); | |
| 228 | − | let _ = proxy.send_event(UserEvent::Decoded { path, result }); | |
| 296 | + | let _ = proxy.send_event(UserEvent::Decoded { | |
| 297 | + | path, | |
| 298 | + | mtime, | |
| 299 | + | result, | |
| 300 | + | }); | |
| 229 | 301 | }); | |
| 230 | 302 | } | |
| 231 | 303 | ||
| @@ -377,19 +449,36 @@ impl App { | |||
| 377 | 449 | self.pool | |
| 378 | 450 | .submit(JobKey::Thumb(path.clone()), prio, move || { | |
| 379 | 451 | let t = Instant::now(); | |
| 452 | + | let mtime = mtime(&path); | |
| 380 | 453 | let result = thumbs::load(&path, px, &cache); | |
| 381 | 454 | log::trace!("thumbnail {} in {:.1?}", path.display(), t.elapsed()); | |
| 382 | − | let _ = proxy.send_event(UserEvent::Thumb { path, px, result }); | |
| 455 | + | let _ = proxy.send_event(UserEvent::Thumb { | |
| 456 | + | path, | |
| 457 | + | px, | |
| 458 | + | mtime, | |
| 459 | + | result, | |
| 460 | + | }); | |
| 383 | 461 | }); | |
| 384 | 462 | } | |
| 385 | 463 | ||
| 386 | − | fn on_thumb(&mut self, path: PathBuf, px: u32, result: Result<Thumb, ThumbError>) { | |
| 464 | + | fn on_thumb( | |
| 465 | + | &mut self, | |
| 466 | + | path: PathBuf, | |
| 467 | + | px: u32, | |
| 468 | + | mtime: Option<SystemTime>, | |
| 469 | + | result: Result<Thumb, ThumbError>, | |
| 470 | + | ) { | |
| 387 | 471 | self.thumbs.requested.remove(&path); | |
| 388 | 472 | if px != self.thumbs.cell { | |
| 389 | 473 | return; | |
| 390 | 474 | } | |
| 391 | 475 | match result { | |
| 392 | − | Ok(t) => self.thumbs.arrivals.push_back((path, t)), | |
| 476 | + | Ok(t) => { | |
| 477 | + | if let Some(m) = mtime { | |
| 478 | + | self.stamps.insert(path.clone(), m); | |
| 479 | + | } | |
| 480 | + | self.thumbs.arrivals.push_back((path, t)); | |
| 481 | + | } | |
| 393 | 482 | Err(e) => { | |
| 394 | 483 | // Undecodable: it leaves the queue, as in image mode. | |
| 395 | 484 | log::warn!("{}: {e}", path.display()); | |
| @@ -514,13 +603,21 @@ impl App { | |||
| 514 | 603 | self.request_redraw(); | |
| 515 | 604 | } | |
| 516 | 605 | ||
| 517 | − | fn on_decoded(&mut self, path: PathBuf, result: Result<Arc<DecodedImage>, String>) { | |
| 606 | + | fn on_decoded( | |
| 607 | + | &mut self, | |
| 608 | + | path: PathBuf, | |
| 609 | + | mtime: Option<SystemTime>, | |
| 610 | + | result: Result<Arc<DecodedImage>, String>, | |
| 611 | + | ) { | |
| 518 | 612 | lock(&self.arriving).remove(&path); | |
| 519 | 613 | let now = Instant::now(); | |
| 520 | 614 | let is_current = self.state.queue.current() == Some(path.as_path()); | |
| 521 | 615 | let in_grid = self.state.in_grid(); | |
| 522 | 616 | match result { | |
| 523 | 617 | Ok(img) => { | |
| 618 | + | if let Some(m) = mtime { | |
| 619 | + | self.stamps.insert(path.clone(), m); | |
| 620 | + | } | |
| 524 | 621 | let wanted = self.window_paths(); | |
| 525 | 622 | let keep: Vec<&Path> = wanted.iter().map(PathBuf::as_path).collect(); | |
| 526 | 623 | self.cache.insert(path.clone(), img.clone(), &keep); | |
| @@ -559,23 +656,371 @@ impl App { | |||
| 559 | 656 | } | |
| 560 | 657 | } | |
| 561 | 658 | ||
| 562 | − | /// Run actions in order; `anchor` is the cursor for mouse-triggered ones. | |
| 563 | − | fn run(&mut self, actions: Vec<Action>, anchor: Option<[f32; 2]>, el: &ActiveEventLoop) { | |
| 564 | − | for action in actions { | |
| 565 | − | if action == Action::Cancel { | |
| 566 | − | self.keymap.cancel(); | |
| 567 | − | self.state.pending.clear(); | |
| 568 | − | self.config_error = false; | |
| 659 | + | /// Run actions in order. A `run` suspends the rest of the chain until its command exits | |
| 660 | + | /// successfully. Errors are shown on the status line; the first is also returned for IPC. | |
| 661 | + | fn run(&mut self, actions: Vec<Action>, ctx: &Ctx, el: &ActiveEventLoop) -> Result<(), String> { | |
| 662 | + | let mut rest = actions.into_iter(); | |
| 663 | + | while let Some(action) = rest.next() { | |
| 664 | + | match &action { | |
| 665 | + | Action::Run(name) => return self.start_command(name, rest.collect()), | |
| 666 | + | Action::Cancel => { | |
| 667 | + | self.keymap.cancel(); | |
| 668 | + | self.state.pending.clear(); | |
| 669 | + | self.config_error = false; | |
| 670 | + | self.answer(false); | |
| 671 | + | } | |
| 672 | + | Action::QuitNoOutput => self.no_output = true, | |
| 673 | + | _ => {} | |
| 569 | 674 | } | |
| 570 | − | let fx = self.state.apply_at(action, anchor, Instant::now()); | |
| 571 | − | self.effects(fx, el); | |
| 675 | + | let fx = self.state.apply_at(action, ctx.anchor, Instant::now()); | |
| 676 | + | self.effects(fx, ctx, el); | |
| 572 | 677 | if fx.quit { | |
| 573 | − | return; | |
| 678 | + | break; | |
| 679 | + | } | |
| 680 | + | } | |
| 681 | + | Ok(()) | |
| 682 | + | } | |
| 683 | + | ||
| 684 | + | /// Start `[commands.<name>]`, or ask first when it has a `confirm`. | |
| 685 | + | fn start_command(&mut self, name: &str, then: Vec<Action>) -> Result<(), String> { | |
| 686 | + | let now = Instant::now(); | |
| 687 | + | let fail = |state: &mut AppState, msg: String| { | |
| 688 | + | state.notify(format!("error: {msg}"), MsgKind::Error, now); | |
| 689 | + | Err(msg) | |
| 690 | + | }; | |
| 691 | + | let Some(cmd) = self.config.commands.get(name).cloned() else { | |
| 692 | + | let hint = suggest(name, self.config.commands.keys().map(String::as_str)) | |
| 693 | + | .map(|s| format!(", did you mean `{s}`?")) | |
| 694 | + | .unwrap_or_default(); | |
| 695 | + | self.request_redraw(); | |
| 696 | + | return fail(&mut self.state, format!("no command named `{name}`{hint}")); | |
| 697 | + | }; | |
| 698 | + | let q = &self.state.queue; | |
| 699 | + | let targets: Arc<[PathBuf]> = | |
| 700 | + | exec::targets(cmd.targets, q.current(), &q.marked(), q.files()).into(); | |
| 701 | + | if targets.is_empty() && exec::needs_files(&cmd) { | |
| 702 | + | let why = if cmd.targets == Targets::Marked { | |
| 703 | + | "no marked files" | |
| 704 | + | } else { | |
| 705 | + | "no images" | |
| 706 | + | }; | |
| 707 | + | self.request_redraw(); | |
| 708 | + | return fail(&mut self.state, format!("{name}: {why}")); | |
| 709 | + | } | |
| 710 | + | let size = self | |
| 711 | + | .state | |
| 712 | + | .shown | |
| 713 | + | .as_ref() | |
| 714 | + | .filter(|s| Some(s.path.as_path()) == q.current()) | |
| 715 | + | .map(|s| s.size); | |
| 716 | + | let ctx = exec::Context { | |
| 717 | + | current: q.current(), | |
| 718 | + | index: q.index(), | |
| 719 | + | targets: &targets, | |
| 720 | + | size, | |
| 721 | + | mode: self.state.mode.name(), | |
| 722 | + | socket: self.opts.socket.as_deref(), | |
| 723 | + | }; | |
| 724 | + | let prepared = exec::prepare(&cmd, &ctx); | |
| 725 | + | let question = cmd.confirm.as_deref().map(|c| exec::expand_text(c, &ctx)); | |
| 726 | + | let mut chain = cmd.after.0.unwrap_or_default(); | |
| 727 | + | chain.extend(then); | |
| 728 | + | let job = Job { | |
| 729 | + | name: name.to_string(), | |
| 730 | + | output: cmd.output, | |
| 731 | + | targets, | |
| 732 | + | then: chain, | |
| 733 | + | lines: 0, | |
| 734 | + | }; | |
| 735 | + | match question { | |
| 736 | + | Some(q) => { | |
| 737 | + | self.answer(false); | |
| 738 | + | self.state.prompt = Some(q); | |
| 739 | + | self.confirm = Some((job, prepared)); | |
| 740 | + | self.request_redraw(); | |
| 574 | 741 | } | |
| 742 | + | None => self.spawn_job(job, prepared), | |
| 575 | 743 | } | |
| 744 | + | Ok(()) | |
| 745 | + | } | |
| 746 | + | ||
| 747 | + | /// Settle a confirm prompt: `y` runs the command, anything else drops it. | |
| 748 | + | fn answer(&mut self, yes: bool) { | |
| 749 | + | let Some((job, prepared)) = self.confirm.take() else { | |
| 750 | + | return; | |
| 751 | + | }; | |
| 752 | + | self.state.prompt = None; | |
| 753 | + | if yes { | |
| 754 | + | self.spawn_job(job, prepared); | |
| 755 | + | } | |
| 756 | + | self.request_redraw(); | |
| 576 | 757 | } | |
| 577 | 758 | ||
| 578 | − | fn effects(&mut self, fx: Effects, event_loop: &ActiveEventLoop) { | |
| 759 | + | fn spawn_job(&mut self, job: Job, prepared: exec::Prepared) { | |
| 760 | + | let id = self.next_job; | |
| 761 | + | self.next_job += 1; | |
| 762 | + | log::info!( | |
| 763 | + | "run {}: {} {:?}", | |
| 764 | + | job.name, | |
| 765 | + | prepared.program.to_string_lossy(), | |
| 766 | + | prepared.args | |
| 767 | + | ); | |
| 768 | + | let proxy = self.proxy.clone(); | |
| 769 | + | exec::spawn(&job.name, prepared, job.output, move |event| { | |
| 770 | + | let _ = proxy.send_event(UserEvent::Command { id, event }); | |
| 771 | + | }); | |
| 772 | + | self.state.running.push((id, job.name.clone())); | |
| 773 | + | self.jobs.insert(id, job); | |
| 774 | + | self.request_redraw(); | |
| 775 | + | } | |
| 776 | + | ||
| 777 | + | fn on_command(&mut self, id: u64, event: exec::Event, el: &ActiveEventLoop) { | |
| 778 | + | let now = Instant::now(); | |
| 779 | + | match event { | |
| 780 | + | exec::Event::Line(line) => { | |
| 781 | + | let Some(job) = self.jobs.get_mut(&id) else { | |
| 782 | + | return; | |
| 783 | + | }; | |
| 784 | + | job.lines += 1; | |
| 785 | + | let name = job.name.clone(); | |
| 786 | + | let ctx = Ctx { | |
| 787 | + | anchor: None, | |
| 788 | + | targets: Some(job.targets.clone()), | |
| 789 | + | }; | |
| 790 | + | match line.parse::<Action>() { | |
| 791 | + | Ok(a) => { | |
| 792 | + | let _ = self.run(vec![a], &ctx, el); | |
| 793 | + | } | |
| 794 | + | Err(e) => { | |
| 795 | + | log::warn!("{name}: {line}: {e}"); | |
| 796 | + | self.state | |
| 797 | + | .notify(format!("error: {name}: {e}"), MsgKind::Error, now); | |
| 798 | + | self.request_redraw(); | |
| 799 | + | } | |
| 800 | + | } | |
| 801 | + | } | |
| 802 | + | exec::Event::Done(outcome) => { | |
| 803 | + | let Some(job) = self.jobs.remove(&id) else { | |
| 804 | + | return; | |
| 805 | + | }; | |
| 806 | + | self.state.running.retain(|(j, _)| *j != id); | |
| 807 | + | self.request_redraw(); | |
| 808 | + | if !outcome.success() { | |
| 809 | + | let msg = format!("error: {}", outcome.error()); | |
| 810 | + | log::warn!("{}: {msg}", job.name); | |
| 811 | + | self.state.notify(msg, MsgKind::Error, now); | |
| 812 | + | return; | |
| 813 | + | } | |
| 814 | + | log::debug!("{}: done", job.name); | |
| 815 | + | match (job.output, outcome.last_line) { | |
| 816 | + | (Output::Status, Some(line)) => self.state.notify(line, MsgKind::Info, now), | |
| 817 | + | // Actions speak for themselves. | |
| 818 | + | (Output::Actions, _) if job.lines > 0 => {} | |
| 819 | + | _ => self.state.notify("done", MsgKind::Info, now), | |
| 820 | + | } | |
| 821 | + | let ctx = Ctx { | |
| 822 | + | anchor: None, | |
| 823 | + | targets: Some(job.targets), | |
| 824 | + | }; | |
| 825 | + | let _ = self.run(job.then, &ctx, el); | |
| 826 | + | } | |
| 827 | + | } | |
| 828 | + | } | |
| 829 | + | ||
| 830 | + | /// Re-check `targets` (default: the whole queue, plus the current file) on disk, off-thread. | |
| 831 | + | fn refresh(&mut self, targets: Option<Arc<[PathBuf]>>) { | |
| 832 | + | let mut files: Vec<PathBuf> = match targets { | |
| 833 | + | Some(t) => t.to_vec(), | |
| 834 | + | None => self.state.queue.files().to_vec(), | |
| 835 | + | }; | |
| 836 | + | if let Some(cur) = self.state.queue.current() | |
| 837 | + | && !files.iter().any(|f| f == cur) | |
| 838 | + | { | |
| 839 | + | files.push(cur.to_path_buf()); | |
| 840 | + | } | |
| 841 | + | let proxy = self.proxy.clone(); | |
| 842 | + | std::thread::spawn(move || { | |
| 843 | + | let found = files | |
| 844 | + | .into_iter() | |
| 845 | + | .map(|p| { | |
| 846 | + | let m = std::fs::metadata(&p) | |
| 847 | + | .ok() | |
| 848 | + | .map(|m| m.modified().unwrap_or(SystemTime::UNIX_EPOCH)); | |
| 849 | + | (p, m) | |
| 850 | + | }) | |
| 851 | + | .collect(); | |
| 852 | + | let _ = proxy.send_event(UserEvent::Refreshed(found)); | |
| 853 | + | }); | |
| 854 | + | } | |
| 855 | + | ||
| 856 | + | /// Drop files that vanished and reload ones that changed since they were loaded. | |
| 857 | + | fn on_refreshed(&mut self, found: Vec<(PathBuf, Option<SystemTime>)>) { | |
| 858 | + | let cur = self.state.queue.current().map(Path::to_path_buf); | |
| 859 | + | let showing = |s: &AppState, p: &Path| { | |
| 860 | + | s.mode == Mode::Image && s.shown.as_ref().is_some_and(|s| s.path == p) | |
| 861 | + | }; | |
| 862 | + | let mut navigated = false; | |
| 863 | + | let mut stale = Vec::new(); | |
| 864 | + | let mut gone = 0; | |
| 865 | + | for (path, mtime) in found { | |
| 866 | + | match mtime { | |
| 867 | + | None => { | |
| 868 | + | if self.state.queue.remove(&path) { | |
| 869 | + | gone += 1; | |
| 870 | + | navigated |= cur.as_ref() == Some(&path); | |
| 871 | + | } | |
| 872 | + | self.cache.remove(&path); | |
| 873 | + | self.stamps.remove(&path); | |
| 874 | + | self.state.thumbs.remove(&path); | |
| 875 | + | } | |
| 876 | + | Some(t) if self.stamps.get(&path).is_some_and(|s| *s != t) => stale.push(path), | |
| 877 | + | Some(_) => {} | |
| 878 | + | } | |
| 879 | + | } | |
| 880 | + | let mut reload_current = false; | |
| 881 | + | for path in &stale { | |
| 882 | + | self.cache.remove(path); | |
| 883 | + | self.stamps.remove(path); | |
| 884 | + | self.state.thumbs.remove(path); | |
| 885 | + | self.uploads.retain(|(p, _)| p != path); | |
| 886 | + | if cur.as_ref() == Some(path) && showing(&self.state, path) { | |
| 887 | + | // Keep it on screen until the new version replaces it. | |
| 888 | + | reload_current = true; | |
| 889 | + | } else if let Some(g) = &mut self.gfx { | |
| 890 | + | g.renderer.retain(|p| p != path); | |
| 891 | + | } | |
| 892 | + | } | |
| 893 | + | if gone + stale.len() > 0 { | |
| 894 | + | log::debug!("refresh: {gone} gone, {} changed", stale.len()); | |
| 895 | + | } | |
| 896 | + | if reload_current && let Some(cur) = &cur { | |
| 897 | + | self.decode(cur, Priority::Current); | |
| 898 | + | } | |
| 899 | + | if gone + stale.len() > 0 { | |
| 900 | + | self.sync(navigated); | |
| 901 | + | self.request_redraw(); | |
| 902 | + | } | |
| 903 | + | } | |
| 904 | + | ||
| 905 | + | fn on_ipc(&mut self, inc: ipc::Incoming, el: &ActiveEventLoop) { | |
| 906 | + | let reply = match inc.request { | |
| 907 | + | Request::Actions(actions) => match self.run(actions, &Ctx::default(), el) { | |
| 908 | + | Ok(()) => ipc::ok(Value::Null), | |
| 909 | + | Err(e) => ipc::error(&e), | |
| 910 | + | }, | |
| 911 | + | Request::Query(q) => ipc::ok(self.query(q)), | |
| 912 | + | Request::Subscribe(topics) => match inc.events { | |
| 913 | + | Some(tx) => { | |
| 914 | + | if self.published.is_none() { | |
| 915 | + | self.published = Some(self.snapshot()); | |
| 916 | + | } | |
| 917 | + | self.subscribers.push(Subscriber { topics, tx }); | |
| 918 | + | ipc::ok(Value::Null) | |
| 919 | + | } | |
| 920 | + | None => ipc::error("no event channel"), | |
| 921 | + | }, | |
| 922 | + | }; | |
| 923 | + | let _ = inc.reply.send(reply); | |
| 924 | + | } | |
| 925 | + | ||
| 926 | + | fn query(&self, q: Query) -> Value { | |
| 927 | + | let s = &self.state; | |
| 928 | + | let paths = |ps: &mut dyn Iterator<Item = &Path>| -> Value { | |
| 929 | + | ps.map(ipc::path_json).collect::<Vec<_>>().into() | |
| 930 | + | }; | |
| 931 | + | match q { | |
| 932 | + | Query::Current => s.queue.current().map_or(Value::Null, ipc::path_json), | |
| 933 | + | Query::Marked => paths(&mut s.queue.marked().into_iter()), | |
| 934 | + | Query::Queue => paths(&mut s.queue.files().iter().map(PathBuf::as_path)), | |
| 935 | + | Query::State => { | |
| 936 | + | let shown = s | |
| 937 | + | .shown | |
| 938 | + | .as_ref() | |
| 939 | + | .filter(|sh| Some(sh.path.as_path()) == s.queue.current()); | |
| 940 | + | let fullscreen = self | |
| 941 | + | .gfx | |
| 942 | + | .as_ref() | |
| 943 | + | .is_some_and(|g| g.window.fullscreen().is_some()); | |
| 944 | + | json!({ | |
| 945 | + | "mode": s.mode.name(), | |
| 946 | + | "current": s.queue.current().map(ipc::path_json), | |
| 947 | + | "index": (!s.queue.is_empty()).then(|| s.queue.index() + 1), | |
| 948 | + | "count": s.queue.len(), | |
| 949 | + | "marked": s.queue.marked_count(), | |
| 950 | + | "width": shown.map(|sh| sh.size[0]), | |
| 951 | + | "height": shown.map(|sh| sh.size[1]), | |
| 952 | + | "format": shown.map(|sh| sh.format), | |
| 953 | + | "zoom": shown.map(|_| (s.view.scale() * 1000.0).round() / 10.0), | |
| 954 | + | "fit": s.settings.fit, | |
| 955 | + | "bar": s.bar, | |
| 956 | + | "fullscreen": fullscreen, | |
| 957 | + | "skipped": s.skipped, | |
| 958 | + | "running": s.running.iter().map(|(_, n)| n.as_str()).collect::<Vec<_>>(), | |
| 959 | + | "pid": std::process::id(), | |
| 960 | + | }) | |
| 961 | + | } | |
| 962 | + | } | |
| 963 | + | } | |
| 964 | + | ||
| 965 | + | fn snapshot(&self) -> Published { | |
| 966 | + | let q = &self.state.queue; | |
| 967 | + | Published { | |
| 968 | + | image: (q.current().map(Path::to_path_buf), q.index(), q.len()), | |
| 969 | + | marks: q.marks_version(), | |
| 970 | + | mode: self.state.mode, | |
| 971 | + | } | |
| 972 | + | } | |
| 973 | + | ||
| 974 | + | /// Tell subscribers what changed since they last heard. | |
| 975 | + | fn publish(&mut self) { | |
| 976 | + | if self.subscribers.is_empty() { | |
| 977 | + | self.published = None; | |
| 978 | + | return; | |
| 979 | + | } | |
| 980 | + | let now = self.snapshot(); | |
| 981 | + | let Some(before) = self.published.replace(now.clone()) else { | |
| 982 | + | return; | |
| 983 | + | }; | |
| 984 | + | let q = &self.state.queue; | |
| 985 | + | let mut events = Vec::new(); | |
| 986 | + | if before.image != now.image { | |
| 987 | + | let data = json!({ | |
| 988 | + | "path": q.current().map(ipc::path_json), | |
| 989 | + | "index": (!q.is_empty()).then(|| q.index() + 1), | |
| 990 | + | "count": q.len(), | |
| 991 | + | }); | |
| 992 | + | events.push((Topic::Image, data)); | |
| 993 | + | } | |
| 994 | + | if before.marks != now.marks { | |
| 995 | + | let files: Vec<Value> = q.marked().into_iter().map(ipc::path_json).collect(); | |
| 996 | + | events.push(( | |
| 997 | + | Topic::Marks, | |
| 998 | + | json!({ "count": files.len(), "files": files }), | |
| 999 | + | )); | |
| 1000 | + | } | |
| 1001 | + | if before.mode != now.mode { | |
| 1002 | + | events.push((Topic::Mode, json!(now.mode.name()))); | |
| 1003 | + | } | |
| 1004 | + | for (topic, data) in events { | |
| 1005 | + | let line = ipc::event(topic, data); | |
| 1006 | + | self.subscribers | |
| 1007 | + | .retain(|s| !s.topics.contains(&topic) || s.tx.send(line.clone()).is_ok()); | |
| 1008 | + | } | |
| 1009 | + | } | |
| 1010 | + | ||
| 1011 | + | /// Marked files for `-o`, unless the user quit with `quit-no-output`. | |
| 1012 | + | pub fn marked_output(&self) -> Option<Vec<PathBuf>> { | |
| 1013 | + | (!self.no_output).then(|| { | |
| 1014 | + | self.state | |
| 1015 | + | .queue | |
| 1016 | + | .marked() | |
| 1017 | + | .into_iter() | |
| 1018 | + | .map(Path::to_path_buf) | |
| 1019 | + | .collect() | |
| 1020 | + | }) | |
| 1021 | + | } | |
| 1022 | + | ||
| 1023 | + | fn effects(&mut self, fx: Effects, ctx: &Ctx, event_loop: &ActiveEventLoop) { | |
| 579 | 1024 | if fx.quit { | |
| 580 | 1025 | event_loop.exit(); | |
| 581 | 1026 | return; | |
| @@ -601,6 +1046,9 @@ impl App { | |||
| 601 | 1046 | self.reload_requested = true; | |
| 602 | 1047 | self.watch.reload(); | |
| 603 | 1048 | } | |
| 1049 | + | if fx.refresh { | |
| 1050 | + | self.refresh(ctx.targets.clone()); | |
| 1051 | + | } | |
| 604 | 1052 | if let Some(key) = fx.sort { | |
| 605 | 1053 | let mut files = self.state.queue.files().to_vec(); | |
| 606 | 1054 | let proxy = self.proxy.clone(); | |
| @@ -653,13 +1101,22 @@ impl App { | |||
| 653 | 1101 | } | |
| 654 | 1102 | return; | |
| 655 | 1103 | } | |
| 1104 | + | if self.confirm.is_some() { | |
| 1105 | + | // The prompt takes the next key: `y` runs the command, anything else cancels. | |
| 1106 | + | if !event.repeat | |
| 1107 | + | && let Some(key) = input::key(&event.logical_key, self.modifiers) | |
| 1108 | + | { | |
| 1109 | + | self.answer(key == Key::plain('y') || key == Key::plain('Y')); | |
| 1110 | + | } | |
| 1111 | + | return; | |
| 1112 | + | } | |
| 656 | 1113 | if event.repeat { | |
| 657 | 1114 | // Repeats bypass the trie: only a completed, repeatable binding repeats. | |
| 658 | 1115 | if let Some((k, actions)) = &self.repeat | |
| 659 | 1116 | && *k == event.physical_key | |
| 660 | 1117 | { | |
| 661 | 1118 | let actions = actions.clone(); | |
| 662 | − | self.run(actions, None, event_loop); | |
| 1119 | + | let _ = self.run(actions, &Ctx::default(), event_loop); | |
| 663 | 1120 | } | |
| 664 | 1121 | return; | |
| 665 | 1122 | } | |
| @@ -684,7 +1141,7 @@ impl App { | |||
| 684 | 1141 | if !actions.is_empty() && actions.iter().all(Action::repeats) { | |
| 685 | 1142 | self.repeat = Some((event.physical_key, actions.clone())); | |
| 686 | 1143 | } | |
| 687 | − | self.run(actions, None, event_loop); | |
| 1144 | + | let _ = self.run(actions, &Ctx::default(), event_loop); | |
| 688 | 1145 | } | |
| 689 | 1146 | } | |
| 690 | 1147 | ||
| @@ -694,7 +1151,11 @@ impl App { | |||
| 694 | 1151 | self.update_pending(Instant::now()); | |
| 695 | 1152 | if let Some(actions) = self.keymap.lookup(self.state.mode, &[key]) { | |
| 696 | 1153 | let actions = actions.to_vec(); | |
| 697 | − | self.run(actions, Some(self.cursor), event_loop); | |
| 1154 | + | let ctx = Ctx { | |
| 1155 | + | anchor: Some(self.cursor), | |
| 1156 | + | ..Ctx::default() | |
| 1157 | + | }; | |
| 1158 | + | let _ = self.run(actions, &ctx, event_loop); | |
| 698 | 1159 | } | |
| 699 | 1160 | } | |
| 700 | 1161 | ||
| @@ -780,7 +1241,7 @@ impl App { | |||
| 780 | 1241 | if double { | |
| 781 | 1242 | actions.push(Action::Mode(ModeArg::Image)); | |
| 782 | 1243 | } | |
| 783 | − | self.run(actions, None, event_loop); | |
| 1244 | + | let _ = self.run(actions, &Ctx::default(), event_loop); | |
| 784 | 1245 | } | |
| 785 | 1246 | ||
| 786 | 1247 | fn redraw(&mut self) { | |
| @@ -896,6 +1357,10 @@ fn font_families(c: &Config) -> [String; 3] { | |||
| 896 | 1357 | ] | |
| 897 | 1358 | } | |
| 898 | 1359 | ||
| 1360 | + | fn mtime(path: &Path) -> Option<SystemTime> { | |
| 1361 | + | std::fs::metadata(path).and_then(|m| m.modified()).ok() | |
| 1362 | + | } | |
| 1363 | + | ||
| 899 | 1364 | fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> { | |
| 900 | 1365 | m.lock().unwrap_or_else(|e| e.into_inner()) | |
| 901 | 1366 | } | |
| @@ -977,9 +1442,13 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 977 | 1442 | self.sync(false); | |
| 978 | 1443 | } | |
| 979 | 1444 | ||
| 980 | − | fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: UserEvent) { | |
| 1445 | + | fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { | |
| 981 | 1446 | match event { | |
| 982 | − | UserEvent::Decoded { path, result } => self.on_decoded(path, result), | |
| 1447 | + | UserEvent::Decoded { | |
| 1448 | + | path, | |
| 1449 | + | mtime, | |
| 1450 | + | result, | |
| 1451 | + | } => self.on_decoded(path, mtime, result), | |
| 983 | 1452 | UserEvent::Found(paths) => { | |
| 984 | 1453 | log::debug!("sniffing found {} more images", paths.len()); | |
| 985 | 1454 | let was_empty = self.state.queue.is_empty(); | |
| @@ -1006,7 +1475,15 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 1006 | 1475 | self.state.queue.reorder(files); | |
| 1007 | 1476 | self.sync(false); | |
| 1008 | 1477 | } | |
| 1009 | − | UserEvent::Thumb { path, px, result } => self.on_thumb(path, px, result), | |
| 1478 | + | UserEvent::Thumb { | |
| 1479 | + | path, | |
| 1480 | + | px, | |
| 1481 | + | mtime, | |
| 1482 | + | result, | |
| 1483 | + | } => self.on_thumb(path, px, mtime, result), | |
| 1484 | + | UserEvent::Command { id, event } => self.on_command(id, event, event_loop), | |
| 1485 | + | UserEvent::Refreshed(found) => self.on_refreshed(found), | |
| 1486 | + | UserEvent::Ipc(inc) => self.on_ipc(inc, event_loop), | |
| 1010 | 1487 | } | |
| 1011 | 1488 | } | |
| 1012 | 1489 | ||
| @@ -1107,7 +1584,7 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 1107 | 1584 | let runs = self.keymap.timeout(self.state.mode); | |
| 1108 | 1585 | self.update_pending(now); | |
| 1109 | 1586 | for run in runs { | |
| 1110 | − | self.run(run.counted(), None, event_loop); | |
| 1587 | + | let _ = self.run(run.counted(), &Ctx::default(), event_loop); | |
| 1111 | 1588 | } | |
| 1112 | 1589 | } | |
| 1113 | 1590 | if let Some(t) = self.grid_rest | |
| @@ -1121,6 +1598,7 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 1121 | 1598 | self.decode(&cur, Priority::Preload); | |
| 1122 | 1599 | } | |
| 1123 | 1600 | } | |
| 1601 | + | self.publish(); | |
| 1124 | 1602 | let (redraw, mut next) = self.state.tick(now); | |
| 1125 | 1603 | if let Some(t) = self.grid_rest { | |
| 1126 | 1604 | let at = t + GRID_REST; | |
| @@ -2,17 +2,37 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use std::path::PathBuf; | |
| 4 | 4 | ||
| 5 | − | use clap::Parser; | |
| 5 | + | use clap::{Parser, Subcommand}; | |
| 6 | 6 | ||
| 7 | 7 | use crate::state::queue::SortKey; | |
| 8 | 8 | use crate::state::view::FitMode; | |
| 9 | 9 | ||
| 10 | 10 | #[derive(Parser, Debug)] | |
| 11 | − | #[command(name = "remage", version, about = "Keyboard-centric image viewer")] | |
| 11 | + | #[command( | |
| 12 | + | name = "remage", | |
| 13 | + | version, | |
| 14 | + | about = "Keyboard-centric image viewer", | |
| 15 | + | args_conflicts_with_subcommands = true | |
| 16 | + | )] | |
| 12 | 17 | pub struct Cli { | |
| 18 | + | #[command(subcommand)] | |
| 19 | + | pub command: Option<Sub>, | |
| 20 | + | ||
| 13 | 21 | /// Files or directories to open [default: .] | |
| 14 | 22 | pub paths: Vec<PathBuf>, | |
| 15 | 23 | ||
| 24 | + | /// Read file paths from stdin, one per line | |
| 25 | + | #[arg(short = 'i', long)] | |
| 26 | + | pub stdin: bool, | |
| 27 | + | ||
| 28 | + | /// Paths on stdin and printed by -o are NUL-separated | |
| 29 | + | #[arg(short = '0', long)] | |
| 30 | + | pub null: bool, | |
| 31 | + | ||
| 32 | + | /// Print the marked files on exit | |
| 33 | + | #[arg(short = 'o', long)] | |
| 34 | + | pub print_marked: bool, | |
| 35 | + | ||
| 16 | 36 | /// Descend into directories | |
| 17 | 37 | #[arg(short, long)] | |
| 18 | 38 | pub recursive: bool, | |
| @@ -78,6 +98,32 @@ pub struct Cli { | |||
| 78 | 98 | pub check_config: bool, | |
| 79 | 99 | } | |
| 80 | 100 | ||
| 101 | + | #[derive(Subcommand, Debug)] | |
| 102 | + | pub enum Sub { | |
| 103 | + | /// Send an action or query to a running remage | |
| 104 | + | Msg { | |
| 105 | + | /// The instance to talk to [default: $REMAGE_SOCKET, else the newest] | |
| 106 | + | #[arg(long, value_name = "PID")] | |
| 107 | + | pid: Option<u32>, | |
| 108 | + | ||
| 109 | + | /// Print current, marked, queue or state | |
| 110 | + | #[arg(long, value_name = "Q", conflicts_with_all = ["subscribe", "action"])] | |
| 111 | + | query: Option<String>, | |
| 112 | + | ||
| 113 | + | /// Stream events (image, marks, mode) as JSON lines | |
| 114 | + | #[arg(long, value_name = "EV", conflicts_with = "action")] | |
| 115 | + | subscribe: Vec<String>, | |
| 116 | + | ||
| 117 | + | /// The action, e.g. `goto 5` | |
| 118 | + | #[arg( | |
| 119 | + | value_name = "ACTION", | |
| 120 | + | trailing_var_arg = true, | |
| 121 | + | allow_hyphen_values = true | |
| 122 | + | )] | |
| 123 | + | action: Vec<String>, | |
| 124 | + | }, | |
| 125 | + | } | |
| 126 | + | ||
| 81 | 127 | fn parse_geometry(s: &str) -> Result<(u32, u32), String> { | |
| 82 | 128 | let (w, h) = s | |
| 83 | 129 | .split_once(['x', 'X']) | |
| @@ -99,6 +145,20 @@ mod tests { | |||
| 99 | 145 | Cli::command().debug_assert(); | |
| 100 | 146 | } | |
| 101 | 147 | ||
| 148 | + | #[test] | |
| 149 | + | fn msg_subcommand() { | |
| 150 | + | let c = Cli::try_parse_from(["remage", "msg", "goto", "5"]).unwrap(); | |
| 151 | + | match c.command { | |
| 152 | + | Some(Sub::Msg { action, .. }) => assert_eq!(action, ["goto", "5"]), | |
| 153 | + | other => panic!("{other:?}"), | |
| 154 | + | } | |
| 155 | + | let c = Cli::try_parse_from(["remage", "msg", "slideshow-delay", "-1"]).unwrap(); | |
| 156 | + | assert!(matches!(c.command, Some(Sub::Msg { action, .. }) if action[1] == "-1")); | |
| 157 | + | assert!(Cli::try_parse_from(["remage", "msg", "--query", "x", "next"]).is_err()); | |
| 158 | + | let c = Cli::try_parse_from(["remage", "-io0", "a.png"]).unwrap(); | |
| 159 | + | assert!(c.stdin && c.print_marked && c.null && c.command.is_none()); | |
| 160 | + | } | |
| 161 | + | ||
| 102 | 162 | #[test] | |
| 103 | 163 | fn geometry() { | |
| 104 | 164 | assert_eq!(parse_geometry("800x600"), Ok((800, 600))); | |
| @@ -178,16 +178,25 @@ preset = "nothing-dark" # nothing-dark | nothing-light | |||
| 178 | 178 | # | |
| 179 | 179 | # [commands.copy] | |
| 180 | 180 | # desc = "Copy image" | |
| 181 | − | # run = "wl-copy --type image/png < {file}" # string: sh -c, placeholders shell-quoted | |
| 181 | + | # run = "wl-copy --type image/png < {file}" # string: sh -c, placeholders shell-quoted for you | |
| 182 | 182 | # | |
| 183 | 183 | # [commands.delete] | |
| 184 | 184 | # desc = "Delete files" | |
| 185 | 185 | # run = ["rm", "--", "{files}"] | |
| 186 | − | # confirm = "Delete {count} file(s)?" | |
| 186 | + | # confirm = "Delete {count} file(s)?" # asks inline; y runs it, any other key cancels | |
| 187 | 187 | # | |
| 188 | − | # Fields: targets = auto | current | marked | all, stdin = none | files | files0, | |
| 189 | − | # output = status | actions | ignore, after = actions run on success (default "refresh"). | |
| 190 | − | # Placeholders: {file} {files} {dir} {name} {stem} {ext} {index} {count} {width} {height}. | |
| 188 | + | # [commands.legacy] # an nsxiv key-handler works unchanged | |
| 189 | + | # run = ["~/.config/nsxiv/exec/key-handler", "w"] | |
| 190 | + | # stdin = "files" | |
| 191 | + | # | |
| 192 | + | # Fields: targets = auto (marked, else current) | current | marked | all, | |
| 193 | + | # stdin = none | files | files0, output = status (last stdout line) | actions (each line runs | |
| 194 | + | # as an action) | ignore, after = actions run on success (default "refresh": drop deleted | |
| 195 | + | # files, reload changed ones). | |
| 196 | + | # Placeholders: {file} {dir} {name} {stem} {ext} {index} {width} {height} describe the current | |
| 197 | + | # image; {files} and {count} the targets. In an array, an element containing {files} repeats | |
| 198 | + | # once per target. Commands also see $REMAGE_FILE $REMAGE_INDEX $REMAGE_COUNT $REMAGE_MODE | |
| 199 | + | # $REMAGE_SOCKET $REMAGE_PID. In a binding array, actions after a `run` wait for it to succeed. | |
| 191 | 200 | [commands] | |
| 192 | 201 | ||
| 193 | 202 | # External decoders for anything else; they write PNG, PPM or TIFF to stdout. | |
| @@ -1 +1,629 @@ | |||
| 1 | 1 | //! User commands: placeholder expansion, spawning, confirmation and output handling. | |
| 2 | + | //! | |
| 3 | + | //! Placeholders describe the current image (`{file} {dir} {name} {stem} {ext} {index} {width} | |
| 4 | + | //! {height}`) and the targets (`{files} {count}`). In a shell string (`sh -c`) every value is | |
| 5 | + | //! shell-quoted; in an exec array an element containing `{files}` repeats once per target. | |
| 6 | + | //! Anything else in braces is left alone, so `awk '{print $1}'` survives. | |
| 7 | + | ||
| 8 | + | use std::ffi::{OsStr, OsString}; | |
| 9 | + | use std::io::{BufRead, BufReader, Read, Write}; | |
| 10 | + | use std::os::unix::ffi::{OsStrExt, OsStringExt}; | |
| 11 | + | use std::os::unix::process::ExitStatusExt; | |
| 12 | + | use std::path::{Path, PathBuf}; | |
| 13 | + | use std::process::{ExitStatus, Stdio}; | |
| 14 | + | ||
| 15 | + | use crate::config::{Command, CommandLine, Output, Stdin, Targets}; | |
| 16 | + | ||
| 17 | + | /// What a command can see of the viewer when it starts. | |
| 18 | + | #[derive(Clone, Debug, Default)] | |
| 19 | + | pub struct Context<'a> { | |
| 20 | + | pub current: Option<&'a Path>, | |
| 21 | + | /// 0-based position of `current` in the queue. | |
| 22 | + | pub index: usize, | |
| 23 | + | pub targets: &'a [PathBuf], | |
| 24 | + | /// Pixel size of the current image, when known. | |
| 25 | + | pub size: Option<[u32; 2]>, | |
| 26 | + | pub mode: &'a str, | |
| 27 | + | pub socket: Option<&'a Path>, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// A command ready to spawn. | |
| 31 | + | #[derive(Clone, Debug, PartialEq)] | |
| 32 | + | pub struct Prepared { | |
| 33 | + | pub program: OsString, | |
| 34 | + | pub args: Vec<OsString>, | |
| 35 | + | pub env: Vec<(&'static str, OsString)>, | |
| 36 | + | pub stdin: Option<Vec<u8>>, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 40 | + | enum Var { | |
| 41 | + | File, | |
| 42 | + | Files, | |
| 43 | + | Dir, | |
| 44 | + | Name, | |
| 45 | + | Stem, | |
| 46 | + | Ext, | |
| 47 | + | Index, | |
| 48 | + | Count, | |
| 49 | + | Width, | |
| 50 | + | Height, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | const VARS: &[(&str, Var)] = &[ | |
| 54 | + | ("file", Var::File), | |
| 55 | + | ("files", Var::Files), | |
| 56 | + | ("dir", Var::Dir), | |
| 57 | + | ("name", Var::Name), | |
| 58 | + | ("stem", Var::Stem), | |
| 59 | + | ("ext", Var::Ext), | |
| 60 | + | ("index", Var::Index), | |
| 61 | + | ("count", Var::Count), | |
| 62 | + | ("width", Var::Width), | |
| 63 | + | ("height", Var::Height), | |
| 64 | + | ]; | |
| 65 | + | ||
| 66 | + | enum Piece<'t> { | |
| 67 | + | Lit(&'t str), | |
| 68 | + | Var(Var), | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// Split a template into literal text and known placeholders. | |
| 72 | + | fn pieces(template: &str) -> Vec<Piece<'_>> { | |
| 73 | + | let mut out = Vec::new(); | |
| 74 | + | let mut rest = template; | |
| 75 | + | while let Some(open) = rest.find('{') { | |
| 76 | + | let var = rest[open + 1..].find('}').and_then(|close| { | |
| 77 | + | let name = &rest[open + 1..open + 1 + close]; | |
| 78 | + | VARS.iter() | |
| 79 | + | .find(|(n, _)| *n == name) | |
| 80 | + | .map(|(_, v)| (*v, open + close + 2)) | |
| 81 | + | }); | |
| 82 | + | match var { | |
| 83 | + | Some((v, end)) => { | |
| 84 | + | if open > 0 { | |
| 85 | + | out.push(Piece::Lit(&rest[..open])); | |
| 86 | + | } | |
| 87 | + | out.push(Piece::Var(v)); | |
| 88 | + | rest = &rest[end..]; | |
| 89 | + | } | |
| 90 | + | None => { | |
| 91 | + | out.push(Piece::Lit(&rest[..=open])); | |
| 92 | + | rest = &rest[open + 1..]; | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | if !rest.is_empty() { | |
| 97 | + | out.push(Piece::Lit(rest)); | |
| 98 | + | } | |
| 99 | + | out | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | fn mentions(template: &str, f: impl Fn(Var) -> bool) -> bool { | |
| 103 | + | pieces(template) | |
| 104 | + | .iter() | |
| 105 | + | .any(|p| matches!(p, Piece::Var(v) if f(*v))) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// The value of a single-valued placeholder. | |
| 109 | + | fn value(v: Var, ctx: &Context) -> OsString { | |
| 110 | + | let cur = ctx.current; | |
| 111 | + | let os = |s: Option<&OsStr>| s.map(OsStr::to_os_string).unwrap_or_default(); | |
| 112 | + | match v { | |
| 113 | + | Var::File => os(cur.map(Path::as_os_str)), | |
| 114 | + | Var::Dir => os(cur.and_then(Path::parent).map(Path::as_os_str)), | |
| 115 | + | Var::Name => os(cur.and_then(Path::file_name)), | |
| 116 | + | Var::Stem => os(cur.and_then(Path::file_stem)), | |
| 117 | + | Var::Ext => os(cur.and_then(Path::extension)), | |
| 118 | + | Var::Index if cur.is_some() => (ctx.index + 1).to_string().into(), | |
| 119 | + | Var::Count => ctx.targets.len().to_string().into(), | |
| 120 | + | Var::Width => ctx | |
| 121 | + | .size | |
| 122 | + | .map(|s| s[0].to_string()) | |
| 123 | + | .unwrap_or_default() | |
| 124 | + | .into(), | |
| 125 | + | Var::Height => ctx | |
| 126 | + | .size | |
| 127 | + | .map(|s| s[1].to_string()) | |
| 128 | + | .unwrap_or_default() | |
| 129 | + | .into(), | |
| 130 | + | Var::Index | Var::Files => OsString::new(), | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /// Quote for `sh`: safe words stay bare, everything else goes in single quotes. | |
| 135 | + | pub fn shell_quote(s: &OsStr) -> Vec<u8> { | |
| 136 | + | let b = s.as_bytes(); | |
| 137 | + | let safe = |c: &u8| c.is_ascii_alphanumeric() || b"_-./,:@%+=".contains(c); | |
| 138 | + | if !b.is_empty() && b.iter().all(safe) { | |
| 139 | + | return b.to_vec(); | |
| 140 | + | } | |
| 141 | + | let mut out = Vec::with_capacity(b.len() + 2); | |
| 142 | + | out.push(b'\''); | |
| 143 | + | for &c in b { | |
| 144 | + | if c == b'\'' { | |
| 145 | + | out.extend_from_slice(b"'\\''"); | |
| 146 | + | } else { | |
| 147 | + | out.push(c); | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | out.push(b'\''); | |
| 151 | + | out | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// A shell script with every placeholder quoted; `{files}` becomes the quoted targets. | |
| 155 | + | fn expand_shell(template: &str, ctx: &Context) -> OsString { | |
| 156 | + | let mut out = Vec::new(); | |
| 157 | + | for p in pieces(template) { | |
| 158 | + | match p { | |
| 159 | + | Piece::Lit(s) => out.extend_from_slice(s.as_bytes()), | |
| 160 | + | Piece::Var(Var::Files) => { | |
| 161 | + | for (i, f) in ctx.targets.iter().enumerate() { | |
| 162 | + | if i > 0 { | |
| 163 | + | out.push(b' '); | |
| 164 | + | } | |
| 165 | + | out.extend(shell_quote(f.as_os_str())); | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | Piece::Var(v) => out.extend(shell_quote(&value(v, ctx))), | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | OsString::from_vec(out) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// One exec argument; with `{files}` in it, one argument per target. | |
| 175 | + | fn expand_arg(template: &str, ctx: &Context) -> Vec<OsString> { | |
| 176 | + | let pieces = pieces(template); | |
| 177 | + | let build = |file: Option<&Path>| { | |
| 178 | + | let mut out = OsString::new(); | |
| 179 | + | for p in &pieces { | |
| 180 | + | match p { | |
| 181 | + | Piece::Lit(s) => out.push(s), | |
| 182 | + | Piece::Var(Var::Files) => out.push(file.map_or(OsStr::new(""), Path::as_os_str)), | |
| 183 | + | Piece::Var(v) => out.push(value(*v, ctx)), | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | out | |
| 187 | + | }; | |
| 188 | + | if pieces.iter().any(|p| matches!(p, Piece::Var(Var::Files))) { | |
| 189 | + | ctx.targets.iter().map(|f| build(Some(f))).collect() | |
| 190 | + | } else { | |
| 191 | + | vec![build(None)] | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /// Plain text for the confirm prompt and messages: no quoting, lossy. | |
| 196 | + | pub fn expand_text(template: &str, ctx: &Context) -> String { | |
| 197 | + | let mut out = String::new(); | |
| 198 | + | for p in pieces(template) { | |
| 199 | + | match p { | |
| 200 | + | Piece::Lit(s) => out.push_str(s), | |
| 201 | + | Piece::Var(Var::Files) => { | |
| 202 | + | let names: Vec<_> = ctx | |
| 203 | + | .targets | |
| 204 | + | .iter() | |
| 205 | + | .map(|f| crate::ui::file_name(f)) | |
| 206 | + | .collect(); | |
| 207 | + | out.push_str(&names.join(" ")); | |
| 208 | + | } | |
| 209 | + | Piece::Var(v) => out.push_str(&value(v, ctx).to_string_lossy()), | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | out | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | /// `~/…` at the start of an exec element means the home directory. | |
| 216 | + | fn tilde(s: &str) -> std::borrow::Cow<'_, str> { | |
| 217 | + | match (s.strip_prefix("~/"), std::env::var_os("HOME")) { | |
| 218 | + | (Some(rest), Some(home)) => format!("{}/{rest}", home.to_string_lossy()).into(), | |
| 219 | + | _ => s.into(), | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | /// Whether the command needs at least one file to make sense. | |
| 224 | + | pub fn needs_files(cmd: &Command) -> bool { | |
| 225 | + | let files = |v: Var| v != Var::Count; | |
| 226 | + | cmd.stdin != Stdin::None | |
| 227 | + | || match &cmd.run { | |
| 228 | + | CommandLine::Shell(s) => mentions(s, files), | |
| 229 | + | CommandLine::Exec(v) => v.iter().any(|a| mentions(a, files)), | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | /// The files a command acts on. `marked` is in queue order. | |
| 234 | + | pub fn targets( | |
| 235 | + | t: Targets, | |
| 236 | + | current: Option<&Path>, | |
| 237 | + | marked: &[&Path], | |
| 238 | + | all: &[PathBuf], | |
| 239 | + | ) -> Vec<PathBuf> { | |
| 240 | + | let cur = || current.map(Path::to_path_buf).into_iter().collect(); | |
| 241 | + | let marks = || marked.iter().map(|p| p.to_path_buf()).collect(); | |
| 242 | + | match t { | |
| 243 | + | Targets::Auto if marked.is_empty() => cur(), | |
| 244 | + | Targets::Auto | Targets::Marked => marks(), | |
| 245 | + | Targets::Current => cur(), | |
| 246 | + | Targets::All => all.to_vec(), | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | pub fn prepare(cmd: &Command, ctx: &Context) -> Prepared { | |
| 251 | + | let (program, args) = match &cmd.run { | |
| 252 | + | CommandLine::Shell(s) => ( | |
| 253 | + | OsString::from("sh"), | |
| 254 | + | vec!["-c".into(), expand_shell(s, ctx)], | |
| 255 | + | ), | |
| 256 | + | CommandLine::Exec(v) => { | |
| 257 | + | let mut all = v.iter().flat_map(|a| expand_arg(&tilde(a), ctx)); | |
| 258 | + | let program = all.next().unwrap_or_default(); | |
| 259 | + | (program, all.collect()) | |
| 260 | + | } | |
| 261 | + | }; | |
| 262 | + | let sep = match cmd.stdin { | |
| 263 | + | Stdin::None => None, | |
| 264 | + | Stdin::Files => Some(b'\n'), | |
| 265 | + | Stdin::Files0 => Some(0), | |
| 266 | + | }; | |
| 267 | + | let stdin = sep.map(|sep| { | |
| 268 | + | let mut buf = Vec::new(); | |
| 269 | + | for f in ctx.targets { | |
| 270 | + | buf.extend_from_slice(f.as_os_str().as_bytes()); | |
| 271 | + | buf.push(sep); | |
| 272 | + | } | |
| 273 | + | buf | |
| 274 | + | }); | |
| 275 | + | let mut env = vec![ | |
| 276 | + | ("REMAGE_FILE", value(Var::File, ctx)), | |
| 277 | + | ("REMAGE_INDEX", value(Var::Index, ctx)), | |
| 278 | + | ("REMAGE_COUNT", value(Var::Count, ctx)), | |
| 279 | + | ("REMAGE_MODE", ctx.mode.into()), | |
| 280 | + | ("REMAGE_PID", std::process::id().to_string().into()), | |
| 281 | + | ]; | |
| 282 | + | if let Some(s) = ctx.socket { | |
| 283 | + | env.push(("REMAGE_SOCKET", s.as_os_str().to_os_string())); | |
| 284 | + | } | |
| 285 | + | Prepared { | |
| 286 | + | program, | |
| 287 | + | args, | |
| 288 | + | env, | |
| 289 | + | stdin, | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | /// Progress of a running command. | |
| 294 | + | #[derive(Debug)] | |
| 295 | + | pub enum Event { | |
| 296 | + | /// A stdout line, with `output = "actions"`. | |
| 297 | + | Line(String), | |
| 298 | + | Done(Outcome), | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | #[derive(Debug)] | |
| 302 | + | pub struct Outcome { | |
| 303 | + | /// The exit status, or why the command couldn't start. | |
| 304 | + | pub status: Result<ExitStatus, String>, | |
| 305 | + | /// The last non-empty stdout line, with `output = "status"`. | |
| 306 | + | pub last_line: Option<String>, | |
| 307 | + | pub first_stderr: Option<String>, | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | impl Outcome { | |
| 311 | + | pub fn success(&self) -> bool { | |
| 312 | + | self.status.as_ref().is_ok_and(ExitStatus::success) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | /// `<code> <first stderr line>` for the status line. | |
| 316 | + | pub fn error(&self) -> String { | |
| 317 | + | let code = match &self.status { | |
| 318 | + | Err(e) => return e.clone(), | |
| 319 | + | Ok(s) => match (s.code(), s.signal()) { | |
| 320 | + | (Some(c), _) => c.to_string(), | |
| 321 | + | (None, Some(sig)) => format!("signal {sig}"), | |
| 322 | + | (None, None) => "failed".into(), | |
| 323 | + | }, | |
| 324 | + | }; | |
| 325 | + | match &self.first_stderr { | |
| 326 | + | Some(line) => format!("{code} {line}"), | |
| 327 | + | None => code, | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /// Lines are read lossily and capped so a runaway command can't eat memory. | |
| 333 | + | const MAX_LINE: usize = 64 << 10; | |
| 334 | + | ||
| 335 | + | fn read_line(r: &mut impl BufRead, buf: &mut Vec<u8>) -> Option<String> { | |
| 336 | + | buf.clear(); | |
| 337 | + | match r.by_ref().take(MAX_LINE as u64).read_until(b'\n', buf) { | |
| 338 | + | Ok(0) | Err(_) => None, | |
| 339 | + | Ok(_) => { | |
| 340 | + | if buf.last() != Some(&b'\n') && buf.len() == MAX_LINE { | |
| 341 | + | // Skip the rest of an over-long line. | |
| 342 | + | let mut sink = Vec::new(); | |
| 343 | + | let _ = r.read_until(b'\n', &mut sink); | |
| 344 | + | } | |
| 345 | + | Some(String::from_utf8_lossy(buf).trim_end().to_string()) | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | /// Run `p` on its own threads; `sink` gets stdout lines (for `actions`) and then the outcome. | |
| 351 | + | pub fn spawn(name: &str, p: Prepared, output: Output, sink: impl Fn(Event) + Send + 'static) { | |
| 352 | + | let label = name.to_string(); | |
| 353 | + | let run = move || { | |
| 354 | + | let mut cmd = std::process::Command::new(&p.program); | |
| 355 | + | cmd.args(&p.args) | |
| 356 | + | .envs(p.env.iter().map(|(k, v)| (k, v))) | |
| 357 | + | .stdin(if p.stdin.is_some() { | |
| 358 | + | Stdio::piped() | |
| 359 | + | } else { | |
| 360 | + | Stdio::null() | |
| 361 | + | }) | |
| 362 | + | .stdout(if output == Output::Ignore { | |
| 363 | + | Stdio::null() | |
| 364 | + | } else { | |
| 365 | + | Stdio::piped() | |
| 366 | + | }) | |
| 367 | + | .stderr(Stdio::piped()); | |
| 368 | + | let mut child = match cmd.spawn() { | |
| 369 | + | Ok(c) => c, | |
| 370 | + | Err(e) => { | |
| 371 | + | let program = p.program.to_string_lossy(); | |
| 372 | + | return sink(Event::Done(Outcome { | |
| 373 | + | status: Err(format!("{program}: {e}")), | |
| 374 | + | last_line: None, | |
| 375 | + | first_stderr: None, | |
| 376 | + | })); | |
| 377 | + | } | |
| 378 | + | }; | |
| 379 | + | log::debug!("command {label}: pid {}", child.id()); | |
| 380 | + | if let (Some(mut w), Some(data)) = (child.stdin.take(), p.stdin) { | |
| 381 | + | // Its own thread: a child that writes before reading must not deadlock us. | |
| 382 | + | std::thread::spawn(move || { | |
| 383 | + | let _ = w.write_all(&data); | |
| 384 | + | }); | |
| 385 | + | } | |
| 386 | + | let stderr = child.stderr.take().map(|e| { | |
| 387 | + | std::thread::spawn(move || { | |
| 388 | + | let mut r = BufReader::new(e); | |
| 389 | + | let mut buf = Vec::new(); | |
| 390 | + | let mut first = None; | |
| 391 | + | while let Some(line) = read_line(&mut r, &mut buf) { | |
| 392 | + | if first.is_none() && !line.trim().is_empty() { | |
| 393 | + | first = Some(line.trim().to_string()); | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | first | |
| 397 | + | }) | |
| 398 | + | }); | |
| 399 | + | let mut last_line = None; | |
| 400 | + | if let Some(out) = child.stdout.take() { | |
| 401 | + | let mut r = BufReader::new(out); | |
| 402 | + | let mut buf = Vec::new(); | |
| 403 | + | while let Some(line) = read_line(&mut r, &mut buf) { | |
| 404 | + | if line.trim().is_empty() { | |
| 405 | + | continue; | |
| 406 | + | } | |
| 407 | + | match output { | |
| 408 | + | Output::Actions => sink(Event::Line(line)), | |
| 409 | + | _ => last_line = Some(line.trim().to_string()), | |
| 410 | + | } | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | let status = child.wait().map_err(|e| e.to_string()); | |
| 414 | + | let first_stderr = stderr.and_then(|t| t.join().ok()).flatten(); | |
| 415 | + | sink(Event::Done(Outcome { | |
| 416 | + | status, | |
| 417 | + | last_line, | |
| 418 | + | first_stderr, | |
| 419 | + | })); | |
| 420 | + | }; | |
| 421 | + | if let Err(e) = std::thread::Builder::new() | |
| 422 | + | .name("remage-command".into()) | |
| 423 | + | .spawn(run) | |
| 424 | + | { | |
| 425 | + | log::error!("command {name}: {e}"); | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | #[cfg(test)] | |
| 430 | + | mod tests { | |
| 431 | + | use super::*; | |
| 432 | + | use crate::config::ActionList; | |
| 433 | + | ||
| 434 | + | fn cmd(run: CommandLine) -> Command { | |
| 435 | + | Command { | |
| 436 | + | desc: String::new(), | |
| 437 | + | run, | |
| 438 | + | targets: Targets::Auto, | |
| 439 | + | stdin: Stdin::None, | |
| 440 | + | confirm: None, | |
| 441 | + | output: Output::Status, | |
| 442 | + | after: ActionList::default(), | |
| 443 | + | } | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | fn ctx<'a>(targets: &'a [PathBuf]) -> Context<'a> { | |
| 447 | + | Context { | |
| 448 | + | current: Some(Path::new("/pix/it's a.png")), | |
| 449 | + | index: 4, | |
| 450 | + | targets, | |
| 451 | + | size: Some([640, 480]), | |
| 452 | + | mode: "image", | |
| 453 | + | socket: Some(Path::new("/run/remage/1.sock")), | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | fn files() -> Vec<PathBuf> { | |
| 458 | + | vec!["/pix/a b.png".into(), "/pix/c.jpg".into()] | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | #[test] | |
| 462 | + | fn quoting() { | |
| 463 | + | let q = |s: &str| String::from_utf8(shell_quote(OsStr::new(s))).unwrap(); | |
| 464 | + | assert_eq!(q("/a/b-c_d.png"), "/a/b-c_d.png"); | |
| 465 | + | assert_eq!(q("a b"), "'a b'"); | |
| 466 | + | assert_eq!(q("it's"), "'it'\\''s'"); | |
| 467 | + | assert_eq!(q(""), "''"); | |
| 468 | + | assert_eq!(q("$(rm -rf ~)"), "'$(rm -rf ~)'"); | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | #[test] | |
| 472 | + | fn shell_strings_quote_every_placeholder() { | |
| 473 | + | let f = files(); | |
| 474 | + | let p = prepare( | |
| 475 | + | &cmd(CommandLine::Shell( | |
| 476 | + | "cp {files} {dir} && echo {name} {stem}.{ext} {index}/{count} {width}x{height} \ | |
| 477 | + | | awk '{print $1}' {nope}" | |
| 478 | + | .into(), | |
| 479 | + | )), | |
| 480 | + | &ctx(&f), | |
| 481 | + | ); | |
| 482 | + | assert_eq!(p.program, "sh"); | |
| 483 | + | assert_eq!( | |
| 484 | + | p.args[1], | |
| 485 | + | "cp '/pix/a b.png' /pix/c.jpg /pix && echo 'it'\\''s a.png' 'it'\\''s a'.png 5/2 \ | |
| 486 | + | 640x480 | awk '{print $1}' {nope}" | |
| 487 | + | ); | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | #[test] | |
| 491 | + | fn exec_arrays_expand_files_per_target() { | |
| 492 | + | let f = files(); | |
| 493 | + | let p = prepare( | |
| 494 | + | &cmd(CommandLine::Exec(vec![ | |
| 495 | + | "rm".into(), | |
| 496 | + | "--".into(), | |
| 497 | + | "{files}".into(), | |
| 498 | + | "--x={files}".into(), | |
| 499 | + | "{file}".into(), | |
| 500 | + | ])), | |
| 501 | + | &ctx(&f), | |
| 502 | + | ); | |
| 503 | + | assert_eq!(p.program, "rm"); | |
| 504 | + | let args: Vec<_> = p.args.iter().map(|a| a.to_str().unwrap()).collect(); | |
| 505 | + | assert_eq!( | |
| 506 | + | args, | |
| 507 | + | [ | |
| 508 | + | "--", | |
| 509 | + | "/pix/a b.png", | |
| 510 | + | "/pix/c.jpg", | |
| 511 | + | "--x=/pix/a b.png", | |
| 512 | + | "--x=/pix/c.jpg", | |
| 513 | + | "/pix/it's a.png" | |
| 514 | + | ] | |
| 515 | + | ); | |
| 516 | + | // `~/` expands in the template, never in file names. | |
| 517 | + | let p = prepare( | |
| 518 | + | &cmd(CommandLine::Exec(vec!["~/bin/x".into(), "{file}".into()])), | |
| 519 | + | &Context { | |
| 520 | + | current: Some(Path::new("~/a.png")), | |
| 521 | + | ..ctx(&f) | |
| 522 | + | }, | |
| 523 | + | ); | |
| 524 | + | assert!(!p.program.to_string_lossy().starts_with('~')); | |
| 525 | + | assert_eq!(p.args[0], "~/a.png"); | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | #[test] | |
| 529 | + | fn stdin_and_env() { | |
| 530 | + | let f = files(); | |
| 531 | + | let mut c = cmd(CommandLine::Exec(vec!["handler".into()])); | |
| 532 | + | assert!(prepare(&c, &ctx(&f)).stdin.is_none()); | |
| 533 | + | assert!(!needs_files(&c)); | |
| 534 | + | c.stdin = Stdin::Files0; | |
| 535 | + | let p = prepare(&c, &ctx(&f)); | |
| 536 | + | assert_eq!(p.stdin.as_deref(), Some(&b"/pix/a b.png\0/pix/c.jpg\0"[..])); | |
| 537 | + | assert!(needs_files(&c)); | |
| 538 | + | let env: std::collections::HashMap<_, _> = p.env.into_iter().collect(); | |
| 539 | + | assert_eq!(env["REMAGE_FILE"], "/pix/it's a.png"); | |
| 540 | + | assert_eq!(env["REMAGE_INDEX"], "5"); | |
| 541 | + | assert_eq!(env["REMAGE_COUNT"], "2"); | |
| 542 | + | assert_eq!(env["REMAGE_MODE"], "image"); | |
| 543 | + | assert_eq!(env["REMAGE_SOCKET"], "/run/remage/1.sock"); | |
| 544 | + | assert!(needs_files(&cmd(CommandLine::Shell("echo {name}".into())))); | |
| 545 | + | assert!(!needs_files(&cmd(CommandLine::Shell( | |
| 546 | + | "echo {count}".into() | |
| 547 | + | )))); | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | #[test] | |
| 551 | + | fn confirm_text() { | |
| 552 | + | let f = files(); | |
| 553 | + | assert_eq!( | |
| 554 | + | expand_text("Delete {count} file(s)? {files}", &ctx(&f)), | |
| 555 | + | "Delete 2 file(s)? a b.png c.jpg" | |
| 556 | + | ); | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | #[test] | |
| 560 | + | fn target_sets() { | |
| 561 | + | let all = files(); | |
| 562 | + | let cur = Some(Path::new("/pix/c.jpg")); | |
| 563 | + | let none: [&Path; 0] = []; | |
| 564 | + | let marked = [Path::new("/pix/a b.png")]; | |
| 565 | + | assert_eq!(targets(Targets::Auto, cur, &none, &all), [all[1].clone()]); | |
| 566 | + | assert_eq!(targets(Targets::Auto, cur, &marked, &all), [all[0].clone()]); | |
| 567 | + | assert_eq!( | |
| 568 | + | targets(Targets::Current, cur, &marked, &all), | |
| 569 | + | [all[1].clone()] | |
| 570 | + | ); | |
| 571 | + | assert!(targets(Targets::Marked, cur, &none, &all).is_empty()); | |
| 572 | + | assert_eq!(targets(Targets::All, None, &none, &all), all); | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | fn run(run: CommandLine, output: Output, stdin: Option<&[u8]>) -> (Vec<String>, Outcome) { | |
| 576 | + | let (tx, rx) = crossbeam_channel::unbounded(); | |
| 577 | + | let mut p = prepare(&cmd(run), &ctx(&[])); | |
| 578 | + | p.stdin = stdin.map(<[u8]>::to_vec); | |
| 579 | + | spawn("test", p, output, move |e| { | |
| 580 | + | let _ = tx.send(e); | |
| 581 | + | }); | |
| 582 | + | let mut lines = Vec::new(); | |
| 583 | + | loop { | |
| 584 | + | match rx.recv_timeout(std::time::Duration::from_secs(10)) { | |
| 585 | + | Ok(Event::Line(l)) => lines.push(l), | |
| 586 | + | Ok(Event::Done(o)) => return (lines, o), | |
| 587 | + | Err(e) => panic!("{e}"), | |
| 588 | + | } | |
| 589 | + | } | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | #[test] | |
| 593 | + | fn spawning_reports_output_and_errors() { | |
| 594 | + | let (_, o) = run( | |
| 595 | + | CommandLine::Shell("echo one; echo; echo two; echo $REMAGE_MODE".into()), | |
| 596 | + | Output::Status, | |
| 597 | + | None, | |
| 598 | + | ); | |
| 599 | + | assert!(o.success()); | |
| 600 | + | assert_eq!(o.last_line.as_deref(), Some("image")); | |
| 601 | + | ||
| 602 | + | let (lines, o) = run( | |
| 603 | + | CommandLine::Shell("cat; echo next".into()), | |
| 604 | + | Output::Actions, | |
| 605 | + | Some(b"goto 3\n"), | |
| 606 | + | ); | |
| 607 | + | assert!(o.success() && o.last_line.is_none()); | |
| 608 | + | assert_eq!(lines, ["goto 3", "next"]); | |
| 609 | + | ||
| 610 | + | let (_, o) = run( | |
| 611 | + | CommandLine::Shell("echo oops >&2; echo more >&2; exit 3".into()), | |
| 612 | + | Output::Ignore, | |
| 613 | + | None, | |
| 614 | + | ); | |
| 615 | + | assert!(!o.success()); | |
| 616 | + | assert_eq!(o.error(), "3 oops"); | |
| 617 | + | ||
| 618 | + | let (_, o) = run( | |
| 619 | + | CommandLine::Exec(vec!["/nonexistent/remage-test".into()]), | |
| 620 | + | Output::Status, | |
| 621 | + | None, | |
| 622 | + | ); | |
| 623 | + | assert!( | |
| 624 | + | o.error().starts_with("/nonexistent/remage-test: "), | |
| 625 | + | "{}", | |
| 626 | + | o.error() | |
| 627 | + | ); | |
| 628 | + | } | |
| 629 | + | } | |
| @@ -1 +1,557 @@ | |||
| 1 | − | //! Unix-socket server speaking newline-delimited JSON, with event subscriptions. | |
| 1 | + | //! Unix-socket server speaking newline-delimited JSON, with event subscriptions, and the | |
| 2 | + | //! `remage msg` client. | |
| 3 | + | //! | |
| 4 | + | //! Requests: `{"action":"goto 5"}` (or an array of actions), `{"query":"current"}` and | |
| 5 | + | //! `{"subscribe":["image","marks","mode"]}`, which turns the connection into an event stream. | |
| 6 | + | //! Replies: `{"ok":true,"data":…}` or `{"ok":false,"error":"…"}`; events: | |
| 7 | + | //! `{"event":"image","data":…}`. | |
| 8 | + | ||
| 9 | + | use std::io::{BufRead, BufReader, Read, Write}; | |
| 10 | + | use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; | |
| 11 | + | use std::os::unix::net::{UnixListener, UnixStream}; | |
| 12 | + | use std::path::{Path, PathBuf}; | |
| 13 | + | use std::sync::Arc; | |
| 14 | + | use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 15 | + | use std::time::{Duration, Instant, SystemTime}; | |
| 16 | + | ||
| 17 | + | use crossbeam_channel::{Receiver, Sender}; | |
| 18 | + | use serde_json::{Value, json}; | |
| 19 | + | ||
| 20 | + | use crate::action::Action; | |
| 21 | + | ||
| 22 | + | /// Longest request line accepted. | |
| 23 | + | const MAX_REQUEST: u64 = 1 << 20; | |
| 24 | + | /// How long a connection waits for the event loop to answer. | |
| 25 | + | const REPLY_TIMEOUT: Duration = Duration::from_secs(5); | |
| 26 | + | ||
| 27 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 28 | + | pub enum Query { | |
| 29 | + | Current, | |
| 30 | + | Marked, | |
| 31 | + | Queue, | |
| 32 | + | State, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 36 | + | pub enum Topic { | |
| 37 | + | Image, | |
| 38 | + | Marks, | |
| 39 | + | Mode, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | impl Topic { | |
| 43 | + | pub fn name(self) -> &'static str { | |
| 44 | + | match self { | |
| 45 | + | Topic::Image => "image", | |
| 46 | + | Topic::Marks => "marks", | |
| 47 | + | Topic::Mode => "mode", | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | #[derive(Clone, Debug, PartialEq)] | |
| 53 | + | pub enum Request { | |
| 54 | + | Actions(Vec<Action>), | |
| 55 | + | Query(Query), | |
| 56 | + | Subscribe(Vec<Topic>), | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | fn word<T: Copy>(what: &str, s: &str, opts: &[(&str, T)]) -> Result<T, String> { | |
| 60 | + | opts.iter() | |
| 61 | + | .find(|(k, _)| *k == s) | |
| 62 | + | .map(|(_, v)| *v) | |
| 63 | + | .ok_or_else(|| { | |
| 64 | + | let names: Vec<_> = opts.iter().map(|(k, _)| *k).collect(); | |
| 65 | + | format!("unknown {what} `{s}`: expected {}", names.join(", ")) | |
| 66 | + | }) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | const QUERIES: &[(&str, Query)] = &[ | |
| 70 | + | ("current", Query::Current), | |
| 71 | + | ("marked", Query::Marked), | |
| 72 | + | ("queue", Query::Queue), | |
| 73 | + | ("state", Query::State), | |
| 74 | + | ]; | |
| 75 | + | ||
| 76 | + | const TOPICS: &[(&str, Topic)] = &[ | |
| 77 | + | ("image", Topic::Image), | |
| 78 | + | ("marks", Topic::Marks), | |
| 79 | + | ("mode", Topic::Mode), | |
| 80 | + | ]; | |
| 81 | + | ||
| 82 | + | /// A string or an array of strings. | |
| 83 | + | fn strings(v: &Value, what: &str) -> Result<Vec<String>, String> { | |
| 84 | + | match v { | |
| 85 | + | Value::String(s) => Ok(vec![s.clone()]), | |
| 86 | + | Value::Array(a) => a | |
| 87 | + | .iter() | |
| 88 | + | .map(|e| { | |
| 89 | + | e.as_str() | |
| 90 | + | .map(str::to_string) | |
| 91 | + | .ok_or_else(|| format!("`{what}` must be a string or an array of strings")) | |
| 92 | + | }) | |
| 93 | + | .collect(), | |
| 94 | + | _ => Err(format!("`{what}` must be a string or an array of strings")), | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | pub fn parse_request(line: &str) -> Result<Request, String> { | |
| 99 | + | let v: Value = serde_json::from_str(line).map_err(|e| format!("invalid JSON: {e}"))?; | |
| 100 | + | let Value::Object(obj) = v else { | |
| 101 | + | return Err("expected a JSON object".into()); | |
| 102 | + | }; | |
| 103 | + | let mut keys = obj.keys(); | |
| 104 | + | let (Some(key), None) = (keys.next(), keys.next()) else { | |
| 105 | + | return Err("expected exactly one of `action`, `query`, `subscribe`".into()); | |
| 106 | + | }; | |
| 107 | + | let val = &obj[key]; | |
| 108 | + | match key.as_str() { | |
| 109 | + | "action" => { | |
| 110 | + | let actions = strings(val, "action")? | |
| 111 | + | .iter() | |
| 112 | + | .map(|s| s.parse::<Action>().map_err(|e| e.to_string())) | |
| 113 | + | .collect::<Result<Vec<_>, _>>()?; | |
| 114 | + | if actions.is_empty() { | |
| 115 | + | return Err("no actions".into()); | |
| 116 | + | } | |
| 117 | + | Ok(Request::Actions(actions)) | |
| 118 | + | } | |
| 119 | + | "query" => { | |
| 120 | + | let q = val.as_str().ok_or("`query` must be a string")?; | |
| 121 | + | Ok(Request::Query(word("query", q, QUERIES)?)) | |
| 122 | + | } | |
| 123 | + | "subscribe" => { | |
| 124 | + | let mut topics = Vec::new(); | |
| 125 | + | for t in strings(val, "subscribe")? { | |
| 126 | + | let t = word("event", &t, TOPICS)?; | |
| 127 | + | if !topics.contains(&t) { | |
| 128 | + | topics.push(t); | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | if topics.is_empty() { | |
| 132 | + | return Err("no events to subscribe to".into()); | |
| 133 | + | } | |
| 134 | + | Ok(Request::Subscribe(topics)) | |
| 135 | + | } | |
| 136 | + | other => Err(format!( | |
| 137 | + | "unknown request `{other}`: expected `action`, `query` or `subscribe`" | |
| 138 | + | )), | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // Envelopes are written by hand so `ok` and `event` come first (serde_json sorts keys). | |
| 143 | + | ||
| 144 | + | pub fn ok(data: Value) -> String { | |
| 145 | + | if data.is_null() { | |
| 146 | + | r#"{"ok":true}"#.into() | |
| 147 | + | } else { | |
| 148 | + | format!(r#"{{"ok":true,"data":{data}}}"#) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | pub fn error(msg: &str) -> String { | |
| 153 | + | format!(r#"{{"ok":false,"error":{}}}"#, Value::from(msg)) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | pub fn event(topic: Topic, data: Value) -> String { | |
| 157 | + | format!(r#"{{"event":"{}","data":{data}}}"#, topic.name()) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | pub fn path_json(p: &Path) -> Value { | |
| 161 | + | Value::String(p.to_string_lossy().into_owned()) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /// `$XDG_RUNTIME_DIR/remage`, or a per-user directory in `/tmp`. | |
| 165 | + | pub fn socket_dir() -> PathBuf { | |
| 166 | + | match std::env::var_os("XDG_RUNTIME_DIR").filter(|d| !d.is_empty()) { | |
| 167 | + | Some(d) => PathBuf::from(d).join("remage"), | |
| 168 | + | None => { | |
| 169 | + | let user = std::env::var("USER").unwrap_or_else(|_| "user".into()); | |
| 170 | + | std::env::temp_dir().join(format!("remage-{user}")) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | fn pid_of(path: &Path) -> Option<u32> { | |
| 176 | + | path.file_name()? | |
| 177 | + | .to_str()? | |
| 178 | + | .strip_suffix(".sock")? | |
| 179 | + | .parse() | |
| 180 | + | .ok() | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | fn alive(pid: u32) -> bool { | |
| 184 | + | Path::new("/proc").join(pid.to_string()).exists() | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /// Live instances' sockets, newest first. | |
| 188 | + | fn instances(dir: &Path) -> Vec<PathBuf> { | |
| 189 | + | let Ok(entries) = std::fs::read_dir(dir) else { | |
| 190 | + | return Vec::new(); | |
| 191 | + | }; | |
| 192 | + | let mut found: Vec<(SystemTime, PathBuf)> = entries | |
| 193 | + | .flatten() | |
| 194 | + | .map(|e| e.path()) | |
| 195 | + | .filter(|p| pid_of(p).is_some_and(alive)) | |
| 196 | + | .map(|p| { | |
| 197 | + | let t = std::fs::metadata(&p) | |
| 198 | + | .and_then(|m| m.modified()) | |
| 199 | + | .unwrap_or(SystemTime::UNIX_EPOCH); | |
| 200 | + | (t, p) | |
| 201 | + | }) | |
| 202 | + | .collect(); | |
| 203 | + | found.sort_by_key(|f| std::cmp::Reverse(f.0)); | |
| 204 | + | found.into_iter().map(|(_, p)| p).collect() | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// A request from a connection, with where its reply goes. | |
| 208 | + | pub struct Incoming { | |
| 209 | + | pub request: Request, | |
| 210 | + | pub reply: Sender<String>, | |
| 211 | + | /// For subscriptions: where events go after the reply. | |
| 212 | + | pub events: Option<Sender<String>>, | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | /// The listening socket. Dropping it removes the socket file once replies have gone out. | |
| 216 | + | pub struct Server { | |
| 217 | + | path: PathBuf, | |
| 218 | + | /// Replies handed to connection threads but not yet written. | |
| 219 | + | in_flight: Arc<AtomicUsize>, | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | impl Server { | |
| 223 | + | pub fn path(&self) -> &Path { | |
| 224 | + | &self.path | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | impl Drop for Server { | |
| 229 | + | fn drop(&mut self) { | |
| 230 | + | // A `quit` sent over the socket still gets its reply. | |
| 231 | + | let until = Instant::now() + Duration::from_millis(200); | |
| 232 | + | while self.in_flight.load(Ordering::Acquire) > 0 && Instant::now() < until { | |
| 233 | + | std::thread::sleep(Duration::from_millis(2)); | |
| 234 | + | } | |
| 235 | + | let _ = std::fs::remove_file(&self.path); | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | /// Listen on `<socket_dir>/<pid>.sock`; `deliver` hands each request to the event loop. | |
| 240 | + | pub fn serve(deliver: impl Fn(Incoming) -> bool + Send + Sync + 'static) -> anyhow::Result<Server> { | |
| 241 | + | let dir = socket_dir(); | |
| 242 | + | std::fs::DirBuilder::new() | |
| 243 | + | .recursive(true) | |
| 244 | + | .mode(0o700) | |
| 245 | + | .create(&dir)?; | |
| 246 | + | // Sockets of instances that died without cleaning up. | |
| 247 | + | if let Ok(entries) = std::fs::read_dir(&dir) { | |
| 248 | + | for p in entries.flatten().map(|e| e.path()) { | |
| 249 | + | if pid_of(&p).is_some_and(|pid| !alive(pid)) { | |
| 250 | + | let _ = std::fs::remove_file(&p); | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | let path = dir.join(format!("{}.sock", std::process::id())); | |
| 255 | + | let _ = std::fs::remove_file(&path); | |
| 256 | + | let listener = UnixListener::bind(&path)?; | |
| 257 | + | std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; | |
| 258 | + | let in_flight = Arc::new(AtomicUsize::new(0)); | |
| 259 | + | let deliver = Arc::new(deliver); | |
| 260 | + | let flight = in_flight.clone(); | |
| 261 | + | std::thread::Builder::new() | |
| 262 | + | .name("remage-ipc".into()) | |
| 263 | + | .spawn(move || { | |
| 264 | + | for stream in listener.incoming() { | |
| 265 | + | let Ok(stream) = stream else { continue }; | |
| 266 | + | let deliver = deliver.clone(); | |
| 267 | + | let flight = flight.clone(); | |
| 268 | + | let _ = std::thread::Builder::new() | |
| 269 | + | .name("remage-ipc-conn".into()) | |
| 270 | + | .spawn(move || connection(stream, &*deliver, &flight)); | |
| 271 | + | } | |
| 272 | + | })?; | |
| 273 | + | Ok(Server { path, in_flight }) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | fn connection(stream: UnixStream, deliver: &dyn Fn(Incoming) -> bool, in_flight: &AtomicUsize) { | |
| 277 | + | let Ok(read) = stream.try_clone() else { return }; | |
| 278 | + | let mut reader = BufReader::new(read.take(u64::MAX)); | |
| 279 | + | let mut out = stream; | |
| 280 | + | let mut line = String::new(); | |
| 281 | + | loop { | |
| 282 | + | line.clear(); | |
| 283 | + | reader.get_mut().set_limit(MAX_REQUEST); | |
| 284 | + | match reader.read_line(&mut line) { | |
| 285 | + | Ok(0) | Err(_) => return, | |
| 286 | + | Ok(_) if !line.ends_with('\n') && line.len() as u64 >= MAX_REQUEST => { | |
| 287 | + | let _ = writeln!(out, "{}", error("request too long")); | |
| 288 | + | return; | |
| 289 | + | } | |
| 290 | + | Ok(_) => {} | |
| 291 | + | } | |
| 292 | + | if line.trim().is_empty() { | |
| 293 | + | continue; | |
| 294 | + | } | |
| 295 | + | let request = match parse_request(line.trim()) { | |
| 296 | + | Ok(r) => r, | |
| 297 | + | Err(e) => { | |
| 298 | + | if writeln!(out, "{}", error(&e)).is_err() { | |
| 299 | + | return; | |
| 300 | + | } | |
| 301 | + | continue; | |
| 302 | + | } | |
| 303 | + | }; | |
| 304 | + | let subscribe = matches!(request, Request::Subscribe(_)); | |
| 305 | + | let (reply_tx, reply_rx) = crossbeam_channel::bounded(1); | |
| 306 | + | let (events_tx, events_rx) = if subscribe { | |
| 307 | + | let (tx, rx) = crossbeam_channel::unbounded(); | |
| 308 | + | (Some(tx), Some(rx)) | |
| 309 | + | } else { | |
| 310 | + | (None, None) | |
| 311 | + | }; | |
| 312 | + | in_flight.fetch_add(1, Ordering::AcqRel); | |
| 313 | + | let sent = deliver(Incoming { | |
| 314 | + | request, | |
| 315 | + | reply: reply_tx, | |
| 316 | + | events: events_tx, | |
| 317 | + | }); | |
| 318 | + | let reply = if sent { | |
| 319 | + | reply_rx | |
| 320 | + | .recv_timeout(REPLY_TIMEOUT) | |
| 321 | + | .unwrap_or_else(|_| error("no reply from the viewer")) | |
| 322 | + | } else { | |
| 323 | + | error("the viewer is shutting down") | |
| 324 | + | }; | |
| 325 | + | let written = writeln!(out, "{reply}").is_ok(); | |
| 326 | + | in_flight.fetch_sub(1, Ordering::AcqRel); | |
| 327 | + | if !written { | |
| 328 | + | return; | |
| 329 | + | } | |
| 330 | + | if let Some(rx) = events_rx { | |
| 331 | + | stream_events(&mut out, &rx); | |
| 332 | + | return; | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | fn stream_events(out: &mut UnixStream, rx: &Receiver<String>) { | |
| 338 | + | while let Ok(ev) = rx.recv() { | |
| 339 | + | if writeln!(out, "{ev}").is_err() { | |
| 340 | + | return; | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | /// `remage msg` arguments. | |
| 346 | + | pub struct ClientArgs { | |
| 347 | + | pub pid: Option<u32>, | |
| 348 | + | pub query: Option<String>, | |
| 349 | + | pub subscribe: Vec<String>, | |
| 350 | + | pub action: Vec<String>, | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | fn find_socket(pid: Option<u32>) -> Result<PathBuf, String> { | |
| 354 | + | if let Some(pid) = pid { | |
| 355 | + | let p = socket_dir().join(format!("{pid}.sock")); | |
| 356 | + | return if p.exists() { | |
| 357 | + | Ok(p) | |
| 358 | + | } else { | |
| 359 | + | Err(format!( | |
| 360 | + | "no remage with pid {pid} ({} missing)", | |
| 361 | + | p.display() | |
| 362 | + | )) | |
| 363 | + | }; | |
| 364 | + | } | |
| 365 | + | if let Some(s) = std::env::var_os("REMAGE_SOCKET").filter(|s| !s.is_empty()) { | |
| 366 | + | return Ok(PathBuf::from(s)); | |
| 367 | + | } | |
| 368 | + | instances(&socket_dir()) | |
| 369 | + | .into_iter() | |
| 370 | + | .next() | |
| 371 | + | .ok_or_else(|| "no running remage found".into()) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | /// Run the client; returns the process exit code. | |
| 375 | + | pub fn client(args: ClientArgs) -> i32 { | |
| 376 | + | match client_inner(args) { | |
| 377 | + | Ok(code) => code, | |
| 378 | + | Err(e) => { | |
| 379 | + | eprintln!("remage msg: {e}"); | |
| 380 | + | 1 | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | fn client_inner(args: ClientArgs) -> Result<i32, String> { | |
| 386 | + | let request = if let Some(q) = &args.query { | |
| 387 | + | json!({ "query": q }) | |
| 388 | + | } else if !args.subscribe.is_empty() { | |
| 389 | + | let topics: Vec<&str> = args | |
| 390 | + | .subscribe | |
| 391 | + | .iter() | |
| 392 | + | .flat_map(|s| s.split(',')) | |
| 393 | + | .map(str::trim) | |
| 394 | + | .filter(|s| !s.is_empty()) | |
| 395 | + | .collect(); | |
| 396 | + | json!({ "subscribe": topics }) | |
| 397 | + | } else if !args.action.is_empty() { | |
| 398 | + | json!({ "action": args.action.join(" ") }) | |
| 399 | + | } else { | |
| 400 | + | return Err("nothing to send: give an action, --query or --subscribe".into()); | |
| 401 | + | }; | |
| 402 | + | let path = find_socket(args.pid)?; | |
| 403 | + | let mut stream = UnixStream::connect(&path).map_err(|e| format!("{}: {e}", path.display()))?; | |
| 404 | + | writeln!(stream, "{request}").map_err(|e| e.to_string())?; | |
| 405 | + | let mut reader = BufReader::new(stream); | |
| 406 | + | let mut line = String::new(); | |
| 407 | + | if reader.read_line(&mut line).map_err(|e| e.to_string())? == 0 { | |
| 408 | + | return Err("the viewer closed the connection".into()); | |
| 409 | + | } | |
| 410 | + | let reply: Value = serde_json::from_str(&line).map_err(|e| format!("bad reply: {e}"))?; | |
| 411 | + | if reply["ok"] != Value::Bool(true) { | |
| 412 | + | let msg = reply["error"].as_str().unwrap_or("request failed"); | |
| 413 | + | return Err(msg.to_string()); | |
| 414 | + | } | |
| 415 | + | let stdout = std::io::stdout(); | |
| 416 | + | let mut out = stdout.lock(); | |
| 417 | + | if !args.subscribe.is_empty() { | |
| 418 | + | loop { | |
| 419 | + | line.clear(); | |
| 420 | + | match reader.read_line(&mut line) { | |
| 421 | + | Ok(0) | Err(_) => return Ok(0), | |
| 422 | + | Ok(_) => { | |
| 423 | + | if out.write_all(line.as_bytes()).is_err() || out.flush().is_err() { | |
| 424 | + | return Ok(0); | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | print_data(&mut out, &reply["data"]).map_err(|e| e.to_string())?; | |
| 431 | + | Ok(0) | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | /// Strings and lists of strings print one per line, for shell pipelines; the rest as JSON. | |
| 435 | + | fn print_data(out: &mut impl Write, data: &Value) -> std::io::Result<()> { | |
| 436 | + | match data { | |
| 437 | + | Value::Null => Ok(()), | |
| 438 | + | Value::String(s) => writeln!(out, "{s}"), | |
| 439 | + | Value::Array(a) if a.iter().all(Value::is_string) => { | |
| 440 | + | for s in a.iter().filter_map(Value::as_str) { | |
| 441 | + | writeln!(out, "{s}")?; | |
| 442 | + | } | |
| 443 | + | Ok(()) | |
| 444 | + | } | |
| 445 | + | other => writeln!(out, "{other}"), | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | #[cfg(test)] | |
| 450 | + | mod tests { | |
| 451 | + | use super::*; | |
| 452 | + | ||
| 453 | + | #[test] | |
| 454 | + | fn requests_parse() { | |
| 455 | + | assert_eq!( | |
| 456 | + | parse_request(r#"{"action":"goto 5"}"#), | |
| 457 | + | Ok(Request::Actions(vec![Action::Goto(5)])) | |
| 458 | + | ); | |
| 459 | + | assert_eq!( | |
| 460 | + | parse_request(r#"{"action":["next","mark on"]}"#), | |
| 461 | + | Ok(Request::Actions(vec![ | |
| 462 | + | Action::Next(1), | |
| 463 | + | Action::Mark(crate::action::Switch::On) | |
| 464 | + | ])) | |
| 465 | + | ); | |
| 466 | + | assert_eq!( | |
| 467 | + | parse_request(r#"{"query":"marked"}"#), | |
| 468 | + | Ok(Request::Query(Query::Marked)) | |
| 469 | + | ); | |
| 470 | + | assert_eq!( | |
| 471 | + | parse_request(r#"{"subscribe":["image","mode","image"]}"#), | |
| 472 | + | Ok(Request::Subscribe(vec![Topic::Image, Topic::Mode])) | |
| 473 | + | ); | |
| 474 | + | assert_eq!( | |
| 475 | + | parse_request(r#"{"subscribe":"marks"}"#), | |
| 476 | + | Ok(Request::Subscribe(vec![Topic::Marks])) | |
| 477 | + | ); | |
| 478 | + | let e = |s: &str| parse_request(s).unwrap_err(); | |
| 479 | + | assert!(e("nope").contains("invalid JSON")); | |
| 480 | + | assert!(e("[]").contains("object")); | |
| 481 | + | assert!(e(r#"{"action":"nxt"}"#).contains("did you mean `next`")); | |
| 482 | + | assert!(e(r#"{"query":"all"}"#).contains("expected current")); | |
| 483 | + | assert!(e(r#"{"action":"next","query":"state"}"#).contains("exactly one")); | |
| 484 | + | assert!(e(r#"{"subscribe":[]}"#).contains("no events")); | |
| 485 | + | assert!(e(r#"{"frob":1}"#).contains("unknown request")); | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | #[test] | |
| 489 | + | fn replies() { | |
| 490 | + | assert_eq!(ok(Value::Null), r#"{"ok":true}"#); | |
| 491 | + | assert_eq!(ok(json!(3)), r#"{"ok":true,"data":3}"#); | |
| 492 | + | assert_eq!(error("x"), r#"{"ok":false,"error":"x"}"#); | |
| 493 | + | assert_eq!( | |
| 494 | + | event(Topic::Mode, json!("grid")), | |
| 495 | + | r#"{"event":"mode","data":"grid"}"# | |
| 496 | + | ); | |
| 497 | + | let mut out = Vec::new(); | |
| 498 | + | print_data(&mut out, &json!(["a", "b"])).unwrap(); | |
| 499 | + | print_data(&mut out, &json!({"k": 1})).unwrap(); | |
| 500 | + | assert_eq!(String::from_utf8(out).unwrap(), "a\nb\n{\"k\":1}\n"); | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | #[test] | |
| 504 | + | fn socket_names() { | |
| 505 | + | assert_eq!(pid_of(Path::new("/run/remage/123.sock")), Some(123)); | |
| 506 | + | assert_eq!(pid_of(Path::new("/run/remage/x.sock")), None); | |
| 507 | + | assert!(alive(std::process::id())); | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | #[test] | |
| 511 | + | fn round_trip_over_a_socket() { | |
| 512 | + | let dir = std::env::temp_dir().join(format!("remage-ipc-{}", std::process::id())); | |
| 513 | + | std::fs::create_dir_all(&dir).unwrap(); | |
| 514 | + | let path = dir.join("t.sock"); | |
| 515 | + | let _ = std::fs::remove_file(&path); | |
| 516 | + | let listener = UnixListener::bind(&path).unwrap(); | |
| 517 | + | let flight = Arc::new(AtomicUsize::new(0)); | |
| 518 | + | let f = flight.clone(); | |
| 519 | + | std::thread::spawn(move || { | |
| 520 | + | let (s, _) = listener.accept().unwrap(); | |
| 521 | + | connection( | |
| 522 | + | s, | |
| 523 | + | &|inc: Incoming| { | |
| 524 | + | let reply = match inc.request { | |
| 525 | + | Request::Query(Query::Current) => ok(json!("/a.png")), | |
| 526 | + | Request::Subscribe(_) => { | |
| 527 | + | let ev = inc.events.unwrap(); | |
| 528 | + | let _ = ev.send(event(Topic::Mode, json!("grid"))); | |
| 529 | + | ok(Value::Null) | |
| 530 | + | } | |
| 531 | + | _ => error("unexpected"), | |
| 532 | + | }; | |
| 533 | + | inc.reply.send(reply).is_ok() | |
| 534 | + | }, | |
| 535 | + | &f, | |
| 536 | + | ); | |
| 537 | + | }); | |
| 538 | + | let mut s = UnixStream::connect(&path).unwrap(); | |
| 539 | + | let mut r = BufReader::new(s.try_clone().unwrap()); | |
| 540 | + | let mut line = String::new(); | |
| 541 | + | writeln!(s, "{{\"query\":\"current\"}}\n\nbad").unwrap(); | |
| 542 | + | r.read_line(&mut line).unwrap(); | |
| 543 | + | assert_eq!(line.trim(), r#"{"ok":true,"data":"/a.png"}"#); | |
| 544 | + | line.clear(); | |
| 545 | + | r.read_line(&mut line).unwrap(); | |
| 546 | + | assert!(line.contains("invalid JSON"), "{line}"); | |
| 547 | + | writeln!(s, "{{\"subscribe\":\"mode\"}}").unwrap(); | |
| 548 | + | line.clear(); | |
| 549 | + | r.read_line(&mut line).unwrap(); | |
| 550 | + | assert_eq!(line.trim(), r#"{"ok":true}"#); | |
| 551 | + | line.clear(); | |
| 552 | + | r.read_line(&mut line).unwrap(); | |
| 553 | + | assert_eq!(line.trim(), r#"{"event":"mode","data":"grid"}"#); | |
| 554 | + | assert_eq!(flight.load(Ordering::Acquire), 0); | |
| 555 | + | std::fs::remove_dir_all(&dir).unwrap(); | |
| 556 | + | } | |
| 557 | + | } | |
| @@ -17,7 +17,10 @@ mod thumbs; | |||
| 17 | 17 | mod ui; | |
| 18 | 18 | mod worker; | |
| 19 | 19 | ||
| 20 | − | use std::path::Path; | |
| 20 | + | use std::ffi::OsString; | |
| 21 | + | use std::io::{Read, Write}; | |
| 22 | + | use std::os::unix::ffi::{OsStrExt, OsStringExt}; | |
| 23 | + | use std::path::{Path, PathBuf}; | |
| 21 | 24 | use std::time::Instant; | |
| 22 | 25 | ||
| 23 | 26 | use clap::Parser; | |
| @@ -31,6 +34,20 @@ fn main() -> anyhow::Result<()> { | |||
| 31 | 34 | let started = Instant::now(); | |
| 32 | 35 | env_logger::init(); | |
| 33 | 36 | let cli = cli::Cli::parse(); | |
| 37 | + | if let Some(cli::Sub::Msg { | |
| 38 | + | pid, | |
| 39 | + | query, | |
| 40 | + | subscribe, | |
| 41 | + | action, | |
| 42 | + | }) = cli.command | |
| 43 | + | { | |
| 44 | + | std::process::exit(ipc::client(ipc::ClientArgs { | |
| 45 | + | pid, | |
| 46 | + | query, | |
| 47 | + | subscribe, | |
| 48 | + | action, | |
| 49 | + | })); | |
| 50 | + | } | |
| 34 | 51 | if cli.dump_config { | |
| 35 | 52 | print!("{}", config::dump()); | |
| 36 | 53 | return Ok(()); | |
| @@ -55,15 +72,25 @@ fn main() -> anyhow::Result<()> { | |||
| 55 | 72 | log::debug!("config loaded after {:.1?}", started.elapsed()); | |
| 56 | 73 | let cfg = &loaded.config; | |
| 57 | 74 | ||
| 58 | − | let scan = scan::scan( | |
| 59 | − | &cli.paths, | |
| 60 | − | &scan::ScanOptions { | |
| 61 | − | recursive: cli.recursive || cfg.general.recursive, | |
| 62 | − | siblings: !cli.no_siblings && cfg.general.open_siblings, | |
| 63 | − | sort: cli.sort.unwrap_or(cfg.general.sort), | |
| 64 | − | reverse: cli.reverse || cfg.general.reverse, | |
| 65 | − | }, | |
| 66 | − | ); | |
| 75 | + | let mut paths = cli.paths.clone(); | |
| 76 | + | if cli.stdin { | |
| 77 | + | paths.extend(read_stdin_paths(cli.null)?); | |
| 78 | + | } | |
| 79 | + | let scan = if cli.stdin && paths.is_empty() { | |
| 80 | + | // An empty list on stdin opens nothing, not the current directory. | |
| 81 | + | scan::Scan::default() | |
| 82 | + | } else { | |
| 83 | + | scan::scan( | |
| 84 | + | &paths, | |
| 85 | + | &scan::ScanOptions { | |
| 86 | + | recursive: cli.recursive || cfg.general.recursive, | |
| 87 | + | // A list of files is just those files. | |
| 88 | + | siblings: !cli.stdin && !cli.no_siblings && cfg.general.open_siblings, | |
| 89 | + | sort: cli.sort.unwrap_or(cfg.general.sort), | |
| 90 | + | reverse: cli.reverse || cfg.general.reverse, | |
| 91 | + | }, | |
| 92 | + | ) | |
| 93 | + | }; | |
| 67 | 94 | log::debug!( | |
| 68 | 95 | "scanned {} files ({} to sniff) in {:.1?}", | |
| 69 | 96 | scan.files.len(), | |
| @@ -104,6 +131,23 @@ fn main() -> anyhow::Result<()> { | |||
| 104 | 131 | } | |
| 105 | 132 | }); | |
| 106 | 133 | } | |
| 134 | + | let server = { | |
| 135 | + | let proxy = proxy.clone(); | |
| 136 | + | let proxy = std::sync::Mutex::new(proxy); | |
| 137 | + | match ipc::serve(move |inc| { | |
| 138 | + | let proxy = proxy.lock().unwrap_or_else(|e| e.into_inner()); | |
| 139 | + | proxy.send_event(UserEvent::Ipc(inc)).is_ok() | |
| 140 | + | }) { | |
| 141 | + | Ok(s) => { | |
| 142 | + | log::debug!("IPC socket {}", s.path().display()); | |
| 143 | + | Some(s) | |
| 144 | + | } | |
| 145 | + | Err(e) => { | |
| 146 | + | log::warn!("IPC socket: {e}"); | |
| 147 | + | None | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | }; | |
| 107 | 151 | let watch = { | |
| 108 | 152 | let proxy = proxy.clone(); | |
| 109 | 153 | config::watch::spawn(cli.config.clone(), loaded.watch.clone(), move |r| { | |
| @@ -117,18 +161,44 @@ fn main() -> anyhow::Result<()> { | |||
| 117 | 161 | class: cli.class.unwrap_or_else(|| cfg.window.class.clone()), | |
| 118 | 162 | overrides, | |
| 119 | 163 | private: cli.private, | |
| 164 | + | socket: server.as_ref().map(|s| s.path().to_path_buf()), | |
| 120 | 165 | }; | |
| 121 | 166 | let mut app = App::new(opts, state, proxy, loaded, watch); | |
| 122 | 167 | if let Some(e) = config_error { | |
| 123 | 168 | app.config_failed(&e); | |
| 124 | 169 | } | |
| 125 | 170 | event_loop.run_app(&mut app)?; | |
| 171 | + | if cli.print_marked | |
| 172 | + | && let Some(marked) = app.marked_output() | |
| 173 | + | { | |
| 174 | + | let sep = if cli.null { b'\0' } else { b'\n' }; | |
| 175 | + | let mut out = std::io::stdout().lock(); | |
| 176 | + | for p in marked { | |
| 177 | + | out.write_all(p.as_os_str().as_bytes())?; | |
| 178 | + | out.write_all(&[sep])?; | |
| 179 | + | } | |
| 180 | + | out.flush()?; | |
| 181 | + | } | |
| 182 | + | // Lets in-flight IPC replies (to a `quit`, say) go out, then removes the socket. | |
| 183 | + | drop(server); | |
| 126 | 184 | match app.error.take() { | |
| 127 | 185 | Some(e) => Err(e), | |
| 128 | 186 | None => Ok(()), | |
| 129 | 187 | } | |
| 130 | 188 | } | |
| 131 | 189 | ||
| 190 | + | /// `-i`: paths from stdin, one per line (NUL-separated with `-0`). | |
| 191 | + | fn read_stdin_paths(null: bool) -> anyhow::Result<Vec<PathBuf>> { | |
| 192 | + | let mut buf = Vec::new(); | |
| 193 | + | std::io::stdin().lock().read_to_end(&mut buf)?; | |
| 194 | + | let sep = if null { 0 } else { b'\n' }; | |
| 195 | + | Ok(buf | |
| 196 | + | .split(|b| *b == sep) | |
| 197 | + | .filter(|l| !l.is_empty()) | |
| 198 | + | .map(|l| PathBuf::from(OsString::from_vec(l.to_vec()))) | |
| 199 | + | .collect()) | |
| 200 | + | } | |
| 201 | + | ||
| 132 | 202 | /// `--check-config`: report and exit, non-zero on errors. | |
| 133 | 203 | fn check_config(path: Option<&Path>) -> ! { | |
| 134 | 204 | match config::load(path) { | |
| @@ -42,6 +42,16 @@ pub enum Mode { | |||
| 42 | 42 | Visual, | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | + | impl Mode { | |
| 46 | + | pub fn name(self) -> &'static str { | |
| 47 | + | match self { | |
| 48 | + | Mode::Image => "image", | |
| 49 | + | Mode::Grid => "grid", | |
| 50 | + | Mode::Visual => "visual", | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 45 | 55 | #[derive(Clone, Debug, PartialEq)] | |
| 46 | 56 | pub struct Settings { | |
| 47 | 57 | pub fit: FitMode, | |
| @@ -191,6 +201,8 @@ pub struct Effects { | |||
| 191 | 201 | pub fullscreen: Option<Switch>, | |
| 192 | 202 | pub reload: bool, | |
| 193 | 203 | pub reload_config: bool, | |
| 204 | + | /// Re-check files on disk: drop missing ones, re-decode changed ones. | |
| 205 | + | pub refresh: bool, | |
| 194 | 206 | /// Re-sort the queue (stat-based keys need the filesystem, so the loop does it). | |
| 195 | 207 | pub sort: Option<SortKey>, | |
| 196 | 208 | pub quit: bool, | |
| @@ -220,6 +232,10 @@ pub struct AppState { | |||
| 220 | 232 | pub message: Option<Message>, | |
| 221 | 233 | /// Keys typed so far of an incomplete sequence. | |
| 222 | 234 | pub pending: String, | |
| 235 | + | /// An inline yes/no question (`DELETE 3 FILES?`) waiting for `y`. | |
| 236 | + | pub prompt: Option<String>, | |
| 237 | + | /// User commands still running, oldest first: (job id, name). | |
| 238 | + | pub running: Vec<(u64, String)>, | |
| 223 | 239 | window: [f32; 2], | |
| 224 | 240 | scale_factor: f32, | |
| 225 | 241 | holds: Vec<Hold>, | |
| @@ -247,6 +263,8 @@ impl AppState { | |||
| 247 | 263 | skipped: 0, | |
| 248 | 264 | message: None, | |
| 249 | 265 | pending: String::new(), | |
| 266 | + | prompt: None, | |
| 267 | + | running: Vec::new(), | |
| 250 | 268 | window: [1.0, 1.0], | |
| 251 | 269 | scale_factor: 1.0, | |
| 252 | 270 | holds: Vec::new(), | |
| @@ -617,6 +635,9 @@ impl AppState { | |||
| 617 | 635 | } | |
| 618 | 636 | } | |
| 619 | 637 | Action::ReloadConfig => fx.reload_config = true, | |
| 638 | + | Action::Refresh => fx.refresh = true, | |
| 639 | + | // The event loop runs commands: they spawn processes and may wait for a `y`. | |
| 640 | + | Action::Run(_) => fx.redraw = false, | |
| 620 | 641 | Action::Echo(msg) => self.notify(msg, MsgKind::Info, now), | |
| 621 | 642 | Action::Cancel => self.message = None, | |
| 622 | 643 | Action::Quit | Action::QuitNoOutput => fx.quit = true, | |
| @@ -645,9 +666,7 @@ impl AppState { | |||
| 645 | 666 | | Action::Search | |
| 646 | 667 | | Action::Filter(_) | |
| 647 | 668 | | Action::FilterClear | |
| 648 | − | | Action::Refresh | |
| 649 | − | | Action::Open(_) | |
| 650 | − | | Action::Run(_)) => { | |
| 669 | + | | Action::Open(_)) => { | |
| 651 | 670 | self.notify( | |
| 652 | 671 | format!("{} not available yet", other.name()), | |
| 653 | 672 | MsgKind::Info, | |
| @@ -919,6 +938,8 @@ mod tests { | |||
| 919 | 938 | s.message.as_ref().map(|m| m.text.as_str()), | |
| 920 | 939 | Some("palette not available yet") | |
| 921 | 940 | ); | |
| 941 | + | assert!(s.apply(Action::Refresh, now).refresh); | |
| 942 | + | assert!(!s.apply(Action::Run("x".into()), now).redraw); | |
| 922 | 943 | s.apply(Action::Echo("hi".into()), now); | |
| 923 | 944 | assert_eq!(s.message.as_ref().map(|m| m.text.as_str()), Some("hi")); | |
| 924 | 945 | assert!(s.apply(Action::Random, now).navigated); | |
| @@ -31,6 +31,8 @@ pub struct Queue { | |||
| 31 | 31 | index: usize, | |
| 32 | 32 | /// Marked files, by path so reordering keeps them. | |
| 33 | 33 | marks: HashSet<PathBuf>, | |
| 34 | + | /// Bumped on every change to the marks, so watchers can spot one. | |
| 35 | + | marks_version: u64, | |
| 34 | 36 | } | |
| 35 | 37 | ||
| 36 | 38 | impl Queue { | |
| @@ -43,6 +45,7 @@ impl Queue { | |||
| 43 | 45 | files, | |
| 44 | 46 | index: 0, | |
| 45 | 47 | marks: HashSet::new(), | |
| 48 | + | marks_version: 0, | |
| 46 | 49 | }; | |
| 47 | 50 | q.index = q.clamp(index); | |
| 48 | 51 | q | |
| @@ -97,7 +100,9 @@ impl Queue { | |||
| 97 | 100 | return false; | |
| 98 | 101 | }; | |
| 99 | 102 | let removed = self.files.remove(i); | |
| 100 | − | self.marks.remove(&removed); | |
| 103 | + | if self.marks.remove(&removed) { | |
| 104 | + | self.marks_version += 1; | |
| 105 | + | } | |
| 101 | 106 | if i < self.index { | |
| 102 | 107 | self.index -= 1; | |
| 103 | 108 | } | |
| @@ -186,27 +191,36 @@ impl Queue { | |||
| 186 | 191 | self.marks.len() | |
| 187 | 192 | } | |
| 188 | 193 | ||
| 194 | + | pub fn marks_version(&self) -> u64 { | |
| 195 | + | self.marks_version | |
| 196 | + | } | |
| 197 | + | ||
| 189 | 198 | /// Mark or unmark file `i`. Returns whether anything changed. | |
| 190 | 199 | pub fn set_mark(&mut self, i: usize, on: bool) -> bool { | |
| 191 | 200 | let Some(f) = self.files.get(i) else { | |
| 192 | 201 | return false; | |
| 193 | 202 | }; | |
| 194 | − | if on { | |
| 203 | + | let changed = if on { | |
| 195 | 204 | self.marks.insert(f.clone()) | |
| 196 | 205 | } else { | |
| 197 | 206 | self.marks.remove(f) | |
| 198 | − | } | |
| 207 | + | }; | |
| 208 | + | self.marks_version += changed as u64; | |
| 209 | + | changed | |
| 199 | 210 | } | |
| 200 | 211 | ||
| 201 | 212 | pub fn mark_all(&mut self) { | |
| 202 | 213 | self.marks = self.files.iter().cloned().collect(); | |
| 214 | + | self.marks_version += 1; | |
| 203 | 215 | } | |
| 204 | 216 | ||
| 205 | 217 | pub fn unmark_all(&mut self) { | |
| 206 | 218 | self.marks.clear(); | |
| 219 | + | self.marks_version += 1; | |
| 207 | 220 | } | |
| 208 | 221 | ||
| 209 | 222 | pub fn invert_marks(&mut self) { | |
| 223 | + | self.marks_version += 1; | |
| 210 | 224 | self.marks = self | |
| 211 | 225 | .files | |
| 212 | 226 | .iter() | |
| @@ -216,7 +230,6 @@ impl Queue { | |||
| 216 | 230 | } | |
| 217 | 231 | ||
| 218 | 232 | /// Marked files in queue order. | |
| 219 | − | #[allow(dead_code)] // `-o` and command targets arrive in M4 | |
| 220 | 233 | pub fn marked(&self) -> Vec<&Path> { | |
| 221 | 234 | self.files | |
| 222 | 235 | .iter() | |
| @@ -442,7 +455,9 @@ mod tests { | |||
| 442 | 455 | let mut q = Queue::new(paths(&["a", "b", "c", "d"]), None, 0); | |
| 443 | 456 | assert!(q.set_mark(1, true)); | |
| 444 | 457 | assert!(q.set_mark(3, true)); | |
| 458 | + | let v = q.marks_version(); | |
| 445 | 459 | assert!(!q.set_mark(3, true)); | |
| 460 | + | assert_eq!(q.marks_version(), v); | |
| 446 | 461 | assert_eq!(q.marked_count(), 2); | |
| 447 | 462 | q.reverse(); | |
| 448 | 463 | assert!(q.is_marked(0) && q.is_marked(2)); | |
| @@ -11,7 +11,7 @@ use crate::render::image::AlphaStyle; | |||
| 11 | 11 | use crate::render::prims::Prim; | |
| 12 | 12 | use crate::render::text::{Align, FontRole, TextItem}; | |
| 13 | 13 | use crate::render::{ImageDraw, Scene}; | |
| 14 | − | use crate::state::{AlphaBg, AppState, GRID_NAME_SIZE, Mode, MsgKind, ThumbState}; | |
| 14 | + | use crate::state::{AlphaBg, AppState, BAR_HEIGHT, GRID_NAME_SIZE, Mode, MsgKind, ThumbState}; | |
| 15 | 15 | ||
| 16 | 16 | /// Space Mono label tracking. | |
| 17 | 17 | const LABEL_TRACKING: f32 = 0.08; | |
| @@ -72,6 +72,10 @@ pub fn scene<'a>( | |||
| 72 | 72 | } | |
| 73 | 73 | if bar_h > 0.0 { | |
| 74 | 74 | status_bar(&mut scene, state, theme, now, [w, h], bar_h, measure); | |
| 75 | + | } else if state.prompt.is_some() { | |
| 76 | + | // A question can't hide with the bar: it overlays the image until answered. | |
| 77 | + | let bar_h = (BAR_HEIGHT * sf).round(); | |
| 78 | + | status_bar(&mut scene, state, theme, now, [w, h], bar_h, measure); | |
| 75 | 79 | } | |
| 76 | 80 | scene | |
| 77 | 81 | } | |
| @@ -259,6 +263,16 @@ fn status_bar( | |||
| 259 | 263 | if !state.pending.is_empty() { | |
| 260 | 264 | spans.push((state.pending.clone(), theme.text_disabled)); | |
| 261 | 265 | } | |
| 266 | + | if let Some((_, name)) = state.running.last() { | |
| 267 | + | let more = match state.running.len() { | |
| 268 | + | 1 => String::new(), | |
| 269 | + | n => format!(" +{}", n - 1), | |
| 270 | + | }; | |
| 271 | + | spans.push(( | |
| 272 | + | format!("[RUNNING {}{more}]", name.to_uppercase()), | |
| 273 | + | theme.text_secondary, | |
| 274 | + | )); | |
| 275 | + | } | |
| 262 | 276 | if let Some(m) = &state.message { | |
| 263 | 277 | let color = if m.kind == MsgKind::Error { | |
| 264 | 278 | theme.accent | |
| @@ -344,6 +358,18 @@ fn status_bar( | |||
| 344 | 358 | right_w += gap + count_w + 6.0 * sf + 2.0 * r; | |
| 345 | 359 | } | |
| 346 | 360 | ||
| 361 | + | // A pending question takes the filename's place: `DELETE 3 FILES? Y / N`. | |
| 362 | + | if let Some(q) = &state.prompt { | |
| 363 | + | let mut item = label("", theme.text_display, sf, pad, y, Align::Left); | |
| 364 | + | item.spans = vec![ | |
| 365 | + | (format!("{} ", q.to_uppercase()), theme.text_display), | |
| 366 | + | ("Y / N".into(), theme.text_secondary), | |
| 367 | + | ]; | |
| 368 | + | item.max_width = Some((w - 2.0 * pad - right_w - 32.0 * sf).max(0.0)); | |
| 369 | + | scene.texts.push(item); | |
| 370 | + | return; | |
| 371 | + | } | |
| 372 | + | ||
| 347 | 373 | // The filename takes whatever the right side leaves, ellipsised in the middle. | |
| 348 | 374 | if let Some(cur) = state.queue.current() { | |
| 349 | 375 | let room = (w - 2.0 * pad - right_w - 32.0 * sf).max(0.0); | |