Skip to main content

max / shop

Emit a region of the buffer to a user-configured runner The primitive that replaces scrollback search, URL opening and paging: shop addresses a region and hands it over, and any program does the rest. Only a terminal can hold the buffer — once a line scrolls off, nothing else has it — so that is what shop keeps. The grid half landed in 3007de3. This is the binary half: an [emit] runner, the three regions worth naming, and the actions to reach them. Why the runner is configured and not detected. Shop is the bottom of the stack; it IS the thing that runs programs, so it cannot shell out without answering "and where does the output appear?". That answer is the user's — display-popup, new-pane, or a plain window — so it is one config key and shop carries no code for any particular multiplexer. It does not read $TMUX and quietly behave differently: a window opening somewhere surprising should be explained by a line of config, not by a heuristic found in the source. A file, not a pipe. stdin does not survive tmux display-popup or a detached window, so piping would work for exactly one runner. `{file}` substitutes into each argv element as a substring, which is what the tmux form needs, and the argv is an array rather than a shell string so quoting is never shop's problem. The capture lands in $XDG_RUNTIME_DIR — per-user and already 0700, so a transcript does not sit in a world-readable /tmp — and is not deleted, because an editor can outlive the terminal that opened it. One direction. Kakoune's `|` replaces the selection with the program's output; shop's is `>`. The scrollback is a transcript of something that already happened and the shell owns the PTY. That is what makes fire-and-forget legitimate, and the spawn reaps on a detached thread so a long-lived window does not collect a zombie per emit. Only emit_buffer takes a default chord (ctrl+shift+e). It is the motivating case, and one key is the least shop can take to make the feature reachable; emit_screen and emit_selection ship unbound, since every default binding is a key the program inside shop never sees. All three are ordinary actions, so `none` gives the one default back. write_capture takes its directory as an argument rather than reading the environment, so the tests do not mutate process-global state that parallel tests share. Twelve tests over the parsing, the substitution and the file naming. Workspace green at 263. Closes shop e356d861.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-04 00:25 UTC
Signed with PGP, not checked
Commit: c1f9d1599e4b605632049ddce7b419ac273fa6ff
Parent: 30f9aa3
7 files changed, +457 insertions, -1 deletion
M Cargo.toml +1
@@ -13,6 +13,7 @@
13 13 ]
14 14
15 15 [workspace.dependencies]
16 + tempfile = "3"
16 17 smithay-client-toolkit = { version = "0.21", features = ["system"] }
17 18 wayland-client = { version = "0.31", features = ["system"] }
18 19 # Keysym names only. shop-xkb takes this rather than SCTK so the key-encoding
M README.md +40
@@ -62,6 +62,16 @@
62 62 scroll_page_down = "shift+page_down"
63 63 copy = "ctrl+shift+c"
64 64 paste = "none" # shop stops taking it; tmux sees it again
65 + emit_buffer = "ctrl+shift+e"
66 + emit_screen = "ctrl+shift+p" # unbound by default
67 + emit_selection = "none"
68 +
69 + # Where an emitted region goes. Defaults to "new-window": a new shop window
70 + # running $VISUAL, $EDITOR, or less on the capture.
71 + [emit]
72 + runner = ["hx", "{file}"]
73 + # tmux: ["tmux", "display-popup", "-E", "hx {file}"]
74 + # zellij: ["zellij", "action", "new-pane", "--", "hx", "{file}"]
65 75 ```
66 76
67 77 Modifiers are `ctrl`, `shift`, `alt` and `logo`, in any order, with the key
@@ -102,12 +112,42 @@
102 112 of terminal bugs, so it is deliberate future work rather than a side effect of
103 113 having scrollback at all.
104 114
115 + ## Emit
116 +
117 + Shop has no scrollback search, no URL opener and no pager. It has one primitive
118 + that makes those somebody else's program: address a region of the buffer and
119 + hand it over.
120 +
121 + `Ctrl+Shift+E` writes the whole buffer, scrollback included, to a file and opens
122 + it. `emit_screen` takes what is visible and `emit_selection` takes the
123 + selection; both are actions like any other and ship unbound, because every
124 + default binding is a key the program inside shop never sees.
125 +
126 + What opens it is the `[emit] runner`. Shop is the bottom of the stack -- it *is*
127 + the thing that runs programs -- so it cannot shell out without answering "and
128 + where does the output appear?", and that answer is yours. One key, and shop
129 + carries no code for any particular multiplexer. Shop does not read `$TMUX` and
130 + quietly behave differently; a window that opens somewhere surprising should be
131 + explained by a line of config rather than a heuristic.
132 +
133 + The region goes to a file and `{file}` is substituted into each argument, rather
134 + than being piped: stdin does not survive `tmux display-popup` or a detached
135 + window, so a pipe would work for exactly one runner. The file lands in
136 + `$XDG_RUNTIME_DIR` and is not deleted -- an editor can outlive the terminal that
137 + opened it.
138 +
139 + It only goes one way. Kakoune's `|` replaces the selection with the program's
140 + output; shop's primitive is `>`. The scrollback is a transcript of something
141 + that already happened, and the shell owns the PTY.
142 +
105 143 ## Non-goals
106 144
107 145 - Cross-platform. Wayland Linux only. No X11, no macOS, no Windows.
108 146 - Tabs, splits, panes. A window is a window. On Alloy that is sway's job, and
109 147 Alloy ships no multiplexer; elsewhere, bring your own if you want panes.
110 148 - Config DSL. TOML is enough.
149 + - Scrollback search, URL opening, paging. See Emit: any program does these
150 + given the bytes, and shop's job is to be the thing that has them.
111 151
112 152 ## License
113 153
@@ -36,3 +36,6 @@
36 36 tracing-subscriber.workspace = true
37 37 makeover.workspace = true
38 38 toml.workspace = true
39 +
40 + [dev-dependencies]
41 + tempfile.workspace = true
@@ -38,16 +38,25 @@
38 38 ScrollPageDown,
39 39 Copy,
40 40 Paste,
41 + /// Hand the whole buffer, scrollback included, to the emit runner.
42 + EmitBuffer,
43 + /// Hand the visible screen to the emit runner.
44 + EmitScreen,
45 + /// Hand the current selection to the emit runner.
46 + EmitSelection,
41 47 }
42 48
43 49 impl Action {
44 50 /// Every action, so the config parser can reject an unknown key by name
45 51 /// rather than ignoring it.
46 - const ALL: [Self; 4] = [
52 + const ALL: [Self; 7] = [
47 53 Self::ScrollPageUp,
48 54 Self::ScrollPageDown,
49 55 Self::Copy,
50 56 Self::Paste,
57 + Self::EmitBuffer,
58 + Self::EmitScreen,
59 + Self::EmitSelection,
51 60 ];
52 61
53 62 pub(crate) fn name(self) -> &'static str {
@@ -56,6 +65,9 @@
56 65 Self::ScrollPageDown => "scroll_page_down",
57 66 Self::Copy => "copy",
58 67 Self::Paste => "paste",
68 + Self::EmitBuffer => "emit_buffer",
69 + Self::EmitScreen => "emit_screen",
70 + Self::EmitSelection => "emit_selection",
59 71 }
60 72 }
61 73
@@ -124,6 +136,13 @@
124 136 (Chord::new(shift, Keysym::Page_Down), Action::ScrollPageDown),
125 137 (Chord::new(ctrl_shift, Keysym::c), Action::Copy),
126 138 (Chord::new(ctrl_shift, Keysym::v), Action::Paste),
139 + // Only the buffer emit gets a default chord. It is the motivating
140 + // case — the reason shop has no scrollback search — and one new
141 + // key is the least shop can take to make the feature reachable.
142 + // emit_screen and emit_selection ship unbound: they are cheap to
143 + // bind and expensive to take, since every default here is a key
144 + // the shell never sees.
145 + (Chord::new(ctrl_shift, Keysym::e), Action::EmitBuffer),
127 146 ];
128 147 Self {
129 148 map: map.into_iter().collect(),
@@ -10,6 +10,7 @@
10 10 use std::sync::Arc;
11 11
12 12 mod clipboard;
13 + mod emit;
13 14 mod keys;
14 15 mod theme;
15 16 use clipboard::{MIME_UTF8, OFFERED_MIMES, sanitize_paste};
@@ -148,6 +149,7 @@
148 149 let config = Config::load().with_theme(theme_arg);
149 150 let scrollback_lines = config.scrollback_lines;
150 151 let bindings = config.bindings.clone();
152 + let runner = config.runner.clone();
151 153 let palette = Palette::load(&config);
152 154 let (spawn_cmd, spawn_args) =
153 155 spawn_target(exec_argv, exec_cmd.clone(), std::env::var("SHELL").ok());
@@ -292,6 +294,8 @@
292 294 shm,
293 295 modifiers: Modifiers::default(),
294 296 bindings,
297 + runner,
298 + emit_seq: 0,
295 299 pointer_at: (0.0, 0.0),
296 300 selection: None,
297 301 dragging: false,
@@ -735,6 +739,10 @@
735 739 /// Which chords shop consumes instead of forwarding, defaults already
736 740 /// merged with the user's `[keys]` table.
737 741 bindings: crate::keys::Bindings,
742 + /// Where an emitted region goes, and the counter that keeps two captures
743 + /// from one window out of each other's file.
744 + runner: crate::emit::Runner,
745 + emit_seq: u64,
738 746 /// Pointer position in surface-local logical pixels, from the last event
739 747 /// that carried one. Every event does, so this is always current.
740 748 pointer_at: (f64, f64),
@@ -1758,10 +1766,32 @@
1758 1766 // is not what the user asked for.
1759 1767 }
1760 1768 Action::Paste => self.paste_clipboard(),
1769 + // Addressed absolutely: row 0 is the oldest row still in
1770 + // scrollback, so a range does not shift under the user when
1771 + // output arrives between binding the key and reading it.
1772 + Action::EmitBuffer => {
1773 + let text = self.grid.text_range(0, self.grid.abs_rows());
1774 + self.emit(&text);
1775 + }
1776 + Action::EmitScreen => {
1777 + let top = self.grid.abs_row_of_view(0);
1778 + let text = self.grid.text_range(top, top + self.grid.rows() as usize);
1779 + self.emit(&text);
1780 + }
1781 + Action::EmitSelection => {
1782 + let text = self.selection_text().unwrap_or_default();
1783 + self.emit(&text);
1784 + }
1761 1785 }
1762 1786 true
1763 1787 }
1764 1788
1789 + /// Hand a captured region to the configured runner.
1790 + fn emit(&mut self, text: &str) {
1791 + self.emit_seq += 1;
1792 + emit::emit(&self.runner, text, self.emit_seq);
1793 + }
1794 +
1765 1795 /// Run a viewport move and ask for a frame if it went anywhere.
1766 1796 ///
1767 1797 /// The redraw has to be explicit: moving the viewport changes no cell, so
@@ -199,6 +199,8 @@
199 199 /// present one is an edit on top of them, so there is never a state where
200 200 /// the binding set is unknown. See [`crate::keys`].
201 201 pub bindings: crate::keys::Bindings,
202 + /// Where a captured region goes. See [`crate::emit`].
203 + pub runner: crate::emit::Runner,
202 204 }
203 205
204 206 impl Config {
@@ -252,6 +254,13 @@
252 254 crate::keys::Bindings::default,
253 255 crate::keys::Bindings::from_table,
254 256 ),
257 + runner: table
258 + .get("emit")
259 + .and_then(toml::Value::as_table)
260 + .map_or_else(
261 + crate::emit::Runner::default,
262 + crate::emit::Runner::from_table,
263 + ),
255 264 }
256 265 }
257 266
@@ -1,0 +1,354 @@
1 + //! Handing a region of the buffer to another program.
2 + //!
3 + //! The one thing only a terminal can do is own the buffer: once a line scrolls
4 + //! off, nothing else has it. Searching that text, paging it, opening a URL in
5 + //! it — any program can do those given the bytes. So shop keeps the addressing
6 + //! and delegates the rest, which is why it has no scrollback search.
7 + //!
8 + //! # Why the runner is configured, and not detected
9 + //!
10 + //! A terminal is the bottom of the stack. Kakoune composes for free because a
11 + //! shell exists underneath it; shop IS the thing that runs programs, so it
12 + //! cannot shell out without answering "and where does the output appear?".
13 + //!
14 + //! That answer is the user's. The tmux user wants `display-popup`, the zellij
15 + //! user wants `action new-pane`, the sway user wants something else, and only
16 + //! they know. One config key, and shop writes zero code per multiplexer:
17 + //!
18 + //! ```toml
19 + //! [emit]
20 + //! runner = ["hx", "{file}"]
21 + //! # tmux: ["tmux", "display-popup", "-E", "hx {file}"]
22 + //! # zellij: ["zellij", "action", "new-pane", "--", "hx", "{file}"]
23 + //! ```
24 + //!
25 + //! Shop does NOT look at `$TMUX` and quietly change behaviour. A window that
26 + //! opens somewhere unexpected should be explained by one key in a config file,
27 + //! not by a heuristic the user has to read the source to discover.
28 + //!
29 + //! # A file, not a pipe
30 + //!
31 + //! stdin does not survive `tmux display-popup` or a detached window spawn, so
32 + //! piping would work for exactly one runner and break every other. Shop writes
33 + //! the region to a file and substitutes `{file}`, which works for runners
34 + //! nobody has thought of yet and survives shop exiting.
35 + //!
36 + //! The file is not deleted. Under `new-window` shop does not own the child, and
37 + //! an editor can outlive the terminal that opened it.
38 + //!
39 + //! # One direction
40 + //!
41 + //! Kakoune's `|` replaces the selection with the program's output. Shop cannot
42 + //! and must not: the scrollback is a transcript of something that already
43 + //! happened, and the shell owns the PTY. Shop's primitive is `>`, not `|`.
44 + //! That is what makes fire-and-forget legitimate.
45 +
46 + use std::io::Write;
47 + use std::path::{Path, PathBuf};
48 + use std::process::Command;
49 +
50 + use tracing::warn;
51 +
52 + /// The placeholder a runner uses to say where the file goes.
53 + const FILE_PLACEHOLDER: &str = "{file}";
54 +
55 + /// The config spelling of [`Runner::NewWindow`].
56 + const NEW_WINDOW: &str = "new-window";
57 +
58 + /// Where a captured region goes.
59 + #[derive(Debug, Clone, Default, PartialEq, Eq)]
60 + pub(crate) enum Runner {
61 + /// Spawn a new shop window running the user's editor on the file.
62 + ///
63 + /// The default because it needs no multiplexer and works on a bare
64 + /// desktop: `shop -e` already does the work.
65 + #[default]
66 + NewWindow,
67 + /// Run this argv, with `{file}` substituted into each element.
68 + ///
69 + /// An argv array rather than a shell string, so quoting never becomes
70 + /// shop's problem: the user's editor can live in a path with a space in it
71 + /// and nobody has to think about it.
72 + Argv(Vec<String>),
73 + }
74 +
75 + impl Runner {
76 + /// Read the `[emit]` table's `runner` key.
77 + ///
78 + /// A malformed value warns and falls back to the default, on the same
79 + /// grounds as a bad theme: this is the program somebody opens to fix the
80 + /// mistake, so it has to come up.
81 + pub(crate) fn from_table(table: &toml::Table) -> Self {
82 + let Some(value) = table.get("runner") else {
83 + return Self::default();
84 + };
85 + if let Some(name) = value.as_str() {
86 + if name.eq_ignore_ascii_case(NEW_WINDOW) {
87 + return Self::NewWindow;
88 + }
89 + warn!(
90 + runner = %name,
91 + "a string runner must be \"new-window\"; give an argv array instead, \
92 + e.g. runner = [\"hx\", \"{{file}}\"]",
93 + );
94 + return Self::default();
95 + }
96 + let Some(array) = value.as_array() else {
97 + warn!("[emit] runner must be \"new-window\" or an argv array");
98 + return Self::default();
99 + };
100 + let argv: Option<Vec<String>> = array
101 + .iter()
102 + .map(|v| v.as_str().map(str::to_owned))
103 + .collect();
104 + let Some(argv) = argv.filter(|a| !a.is_empty()) else {
105 + warn!("[emit] runner must be a non-empty array of strings");
106 + return Self::default();
107 + };
108 + if !argv.iter().any(|a| a.contains(FILE_PLACEHOLDER)) {
109 + // Not fatal: a runner could read a fixed path. Almost always a
110 + // typo, though, and a runner that opens on nothing is a confusing
111 + // way to find that out.
112 + warn!(
113 + "[emit] runner names no {FILE_PLACEHOLDER}; the captured text will not be \
114 + passed to it",
115 + );
116 + }
117 + Self::Argv(argv)
118 + }
119 +
120 + /// The argv to spawn for `file`.
121 + ///
122 + /// `None` when shop cannot locate its own binary, which is the only way
123 + /// `new-window` can fail to produce one.
124 + fn argv(&self, file: &Path) -> Option<Vec<String>> {
125 + let file = file.to_string_lossy();
126 + match self {
127 + Self::NewWindow => {
128 + let exe = std::env::current_exe()
129 + .inspect_err(|error| {
130 + warn!(%error, "cannot find shop's own binary to open a new window");
131 + })
132 + .ok()?;
133 + // current_exe, not "shop" on PATH: a shop built from a checkout
134 + // and run in place should open itself, not whatever else is
135 + // installed under that name.
136 + Some(vec![
137 + exe.to_string_lossy().into_owned(),
138 + "-e".into(),
139 + editor(),
140 + file.into_owned(),
141 + ])
142 + }
143 + // Substring substitution, not whole-element: the tmux form puts the
144 + // placeholder inside a larger argument (`-E`, `"hx {file}"`).
145 + Self::Argv(argv) => Some(
146 + argv.iter()
147 + .map(|a| a.replace(FILE_PLACEHOLDER, &file))
148 + .collect(),
149 + ),
150 + }
151 + }
152 + }
153 +
154 + /// The program `new-window` opens the capture in.
155 + ///
156 + /// `$VISUAL` then `$EDITOR`, the long-standing pair, then `less`. A transcript
157 + /// is something to read and search, so a pager is a defensible floor when the
158 + /// user has expressed no preference — and unlike an editor, `less` is on
159 + /// essentially every machine that has a terminal on it.
160 + fn editor() -> String {
161 + for key in ["VISUAL", "EDITOR"] {
162 + if let Some(value) = std::env::var_os(key)
163 + .map(|v| v.to_string_lossy().into_owned())
164 + .filter(|v| !v.trim().is_empty())
165 + {
166 + return value;
167 + }
168 + }
169 + "less".into()
170 + }
171 +
172 + /// Write `text` somewhere the runner can read it.
173 + ///
174 + /// `$XDG_RUNTIME_DIR` when there is one: it is per-user, already mode 0700, and
175 + /// cleared at logout, so a transcript of somebody's terminal does not sit in a
176 + /// world-readable `/tmp` until the next reboot. Falls back to the system temp
177 + /// dir, which is better than not emitting at all.
178 + fn capture_dir() -> PathBuf {
179 + std::env::var_os("XDG_RUNTIME_DIR")
180 + .map(PathBuf::from)
181 + .filter(|d| d.is_absolute())
182 + .unwrap_or_else(std::env::temp_dir)
183 + }
184 +
185 + /// Write `text` into `dir` under a name no other capture will take.
186 + ///
187 + /// The directory is a parameter rather than read here so this is testable
188 + /// without mutating the process environment, which tests running in parallel
189 + /// share.
190 + fn write_capture(dir: &Path, text: &str, pid: u32, seq: u64) -> std::io::Result<PathBuf> {
191 + // pid plus a counter: two windows emitting at once must not collide, and
192 + // neither must two emits from the same window.
193 + let path = dir.join(format!("shop-{pid}-{seq}.txt"));
194 + let mut file = std::fs::File::create(&path)?;
195 + file.write_all(text.as_bytes())?;
196 + Ok(path)
197 + }
198 +
199 + /// Write the captured text and hand it to the runner.
200 + ///
201 + /// Fire and forget: shop does not wait, does not read the child's output, and
202 + /// does not care when it exits. The emit is one-directional, so there is
203 + /// nothing to come back.
204 + ///
205 + /// `seq` distinguishes captures from one window; the caller advances it.
206 + pub(crate) fn emit(runner: &Runner, text: &str, seq: u64) {
207 + if text.is_empty() {
208 + // Nothing selected, or an empty screen. Spawning an editor on an empty
209 + // file would be a confusing way to say so.
210 + return;
211 + }
212 + let path = match write_capture(&capture_dir(), text, std::process::id(), seq) {
213 + Ok(path) => path,
214 + Err(error) => {
215 + warn!(%error, "cannot write the captured region");
216 + return;
217 + }
218 + };
219 + let Some(argv) = runner.argv(&path) else {
220 + return;
221 + };
222 + let (program, args) = argv.split_first().expect("argv is never empty");
223 + match Command::new(program).args(args).spawn() {
224 + Ok(child) => {
225 + // Reap on a detached thread. Dropping a Child does not wait, so a
226 + // long-lived terminal would otherwise collect a zombie per emit.
227 + // A thread costs nothing at this rate and keeps the loop free of
228 + // SIGCHLD handling.
229 + std::thread::spawn(move || {
230 + let mut child = child;
231 + let _ = child.wait();
232 + });
233 + tracing::info!(path = %path.display(), program, "emitted a region");
234 + }
235 + Err(error) => {
236 + warn!(%error, program, "cannot run the emit runner");
237 + }
238 + }
239 + }
240 +
241 + #[cfg(test)]
242 + mod tests {
243 + use super::*;
244 +
245 + fn table(src: &str) -> toml::Table {
246 + src.parse().expect("test config parses")
247 + }
248 +
249 + #[test]
250 + fn an_absent_runner_is_a_new_window() {
251 + assert_eq!(Runner::from_table(&table("")), Runner::NewWindow);
252 + }
253 +
254 + #[test]
255 + fn new_window_is_named_by_string_in_any_case() {
256 + for spec in ["new-window", "New-Window", "NEW-WINDOW"] {
257 + let t = table(&format!("runner = {spec:?}"));
258 + assert_eq!(Runner::from_table(&t), Runner::NewWindow, "{spec}");
259 + }
260 + }
261 +
262 + #[test]
263 + fn an_argv_array_is_taken_as_written() {
264 + let t = table(r#"runner = ["hx", "{file}"]"#);
265 + assert_eq!(
266 + Runner::from_table(&t),
267 + Runner::Argv(vec!["hx".into(), "{file}".into()]),
268 + );
269 + }
270 +
271 + #[test]
272 + fn a_bad_runner_falls_back_rather_than_refusing_to_start() {
273 + // Each of these is a config mistake; none of them may cost the user
274 + // their terminal.
275 + for src in [
276 + r#"runner = "hx""#, // a bare program, not new-window
277 + "runner = 7", // not a string or array
278 + "runner = []", // empty
279 + r#"runner = ["hx", 7]"#, // not all strings
280 + ] {
281 + assert_eq!(Runner::from_table(&table(src)), Runner::NewWindow, "{src}");
282 + }
283 + }
284 +
285 + #[test]
286 + fn a_runner_without_the_placeholder_is_still_honoured() {
287 + // Warned about, because it is nearly always a typo, but a runner that
288 + // reads a fixed path is the user's business.
289 + let t = table(r#"runner = ["true"]"#);
290 + assert_eq!(Runner::from_table(&t), Runner::Argv(vec!["true".into()]));
291 + }
292 +
293 + #[test]
294 + fn the_placeholder_is_substituted_in_every_element() {
295 + let runner = Runner::Argv(vec!["a".into(), "{file}".into(), "{file}.bak".into()]);
296 + let argv = runner.argv(Path::new("/run/x.txt")).unwrap();
297 + assert_eq!(argv, ["a", "/run/x.txt", "/run/x.txt.bak"]);
298 + }
299 +
300 + #[test]
301 + fn the_placeholder_substitutes_inside_a_larger_argument() {
302 + // The tmux form: the placeholder sits inside the string passed to -E.
303 + let runner = Runner::Argv(vec![
304 + "tmux".into(),
305 + "display-popup".into(),
306 + "-E".into(),
307 + "hx {file}".into(),
308 + ]);
309 + let argv = runner.argv(Path::new("/run/x.txt")).unwrap();
310 + assert_eq!(argv[3], "hx /run/x.txt");
311 + }
312 +
313 + #[test]
314 + fn new_window_opens_shop_on_the_file() {
315 + let argv = Runner::NewWindow.argv(Path::new("/run/x.txt")).unwrap();
316 + assert!(argv[0].contains("shop"), "spawns shop itself: {argv:?}");
317 + assert_eq!(argv[1], "-e");
318 + assert_eq!(argv[3], "/run/x.txt");
319 + }
320 +
321 + #[test]
322 + fn a_capture_reads_back_exactly() {
323 + let dir = tempfile::tempdir().unwrap();
324 + let path = write_capture(dir.path(), "hello\n", 1234, 7).unwrap();
325 + assert!(path.starts_with(dir.path()), "{}", path.display());
326 + assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello\n");
327 + }
328 +
329 + #[test]
330 + fn two_captures_from_one_window_do_not_collide() {
331 + let dir = tempfile::tempdir().unwrap();
332 + let a = write_capture(dir.path(), "one", 1234, 1).unwrap();
333 + let b = write_capture(dir.path(), "two", 1234, 2).unwrap();
334 + assert_ne!(a, b);
335 + assert_eq!(std::fs::read_to_string(&a).unwrap(), "one");
336 + assert_eq!(std::fs::read_to_string(&b).unwrap(), "two");
337 + }
338 +
339 + #[test]
340 + fn two_windows_do_not_collide() {
341 + let dir = tempfile::tempdir().unwrap();
342 + let a = write_capture(dir.path(), "one", 1, 1).unwrap();
343 + let b = write_capture(dir.path(), "two", 2, 1).unwrap();
344 + assert_ne!(a, b);
345 + }
346 +
347 + #[test]
348 + fn the_capture_dir_is_absolute() {
349 + // XDG_RUNTIME_DIR is only honoured when absolute; the temp-dir fallback
350 + // always is. A relative path would put captures wherever shop happened
351 + // to be started from.
352 + assert!(capture_dir().is_absolute());
353 + }
354 + }