Skip to main content

max / shop

Take -e, so shop can be somebody's $TERMINAL `-e PROGRAM [ARGS...]` takes the rest of argv as a program and its arguments with no shell in between, which is the xterm convention and not optional for a terminal meant to be spawned by anything else. Every desktop entry with Terminal=true and every script launching a TUI writes `$TERMINAL -e prog arg`; without this shop ignored the flag and opened a bare shell, so a launcher looked broken rather than unsupported. Alloy's own alloy-menu is the immediate caller. Kept distinct from --exec, which hands a single string to `sh -c`. That is what a benchmark wants and what a launcher must not have, since the arguments would need quoting nobody applies. Also refreshes the README: selection, clipboard and shop-xkb have landed, so the two things it named as gating daily use no longer are.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 19:02 UTC
Signed with PGP, not checked
Commit: 1a32ff7809fa8fff429f2dacc8c8be63caa20a4c
Parent: f2638bf
2 files changed, +102 insertions, -19 deletions
M README.md +15 -12
@@ -10,16 +10,19 @@
10 10
11 11 ## Status
12 12
13 - Pre-v0.1, and not yet daily-drivable. The binary opens a Wayland window, runs a
14 - shell on a PTY, and renders its output through `shop-vt`, which is wired in, so
15 - escape sequences are interpreted rather than printed. Working: the Kitty
16 - graphics protocol, truecolor, cursor shapes via DECSCUSR, and a palette
17 - resolved from a makeover theme.
13 + Pre-v0.1. The binary opens a Wayland window, runs a shell on a PTY, and renders
14 + its output through `shop-vt`, which is wired in, so escape sequences are
15 + interpreted rather than printed. Working: the Kitty graphics protocol,
16 + truecolor, cursor shapes via DECSCUSR, a palette resolved from a makeover
17 + theme, mouse selection with clipboard and primary selection, and key encoding
18 + through `shop-xkb`.
18 19
19 - Two things gate daily use, and they are what the work goes into next: there is
20 - no selection or clipboard, and keyboard handling still sits inline in the
21 - binary rather than in a `shop-xkb` crate. Also absent, and not gating: sixel,
22 - scrollback search, hyperlinks.
20 + Absent: scrollback, and with it the scroll wheel and scrollback search. Also
21 + absent: sixel (Kitty graphics covers the same ground), hyperlinks, and an
22 + I-beam pointer over the grid.
23 +
24 + `-e PROGRAM [ARGS...]` runs a program instead of the login shell, as every
25 + terminal does. `--exec 'CMD'` is the shell-string form, for benchmarks.
23 26
24 27 ## Layout
25 28
@@ -27,9 +30,9 @@
27 30 - `crates/kitty-graphics/` — the public library, package name `kittygfx`:
28 31 a rendering-agnostic Kitty graphics protocol parser. Headed for crates.io,
29 32 not published yet.
30 - - `crates/shop-{vt,grid,render,wayland,pty}/` — present. `shop-xkb` (input)
31 - and `shop-sixel` are not written yet; each crate is added as its component
32 - is implemented. See design hub.
33 + - `crates/shop-{vt,grid,render,wayland,pty,xkb}/` — present. `shop-sixel` is
34 + not written yet; each crate is added as its component is implemented. See
35 + design hub.
33 36
34 37 ## Config
35 38
@@ -110,6 +110,21 @@
110 110 .iter()
111 111 .position(|a| a == "--exec")
112 112 .and_then(|i| args.get(i + 1).cloned());
113 + // `-e PROGRAM [ARGS...]` takes the rest of argv as a program and its
114 + // arguments, with no shell in between. This is the xterm convention, and
115 + // it is not optional for a terminal that means to be somebody's
116 + // `$TERMINAL`: every desktop entry with `Terminal=true`, and every script
117 + // that spawns a TUI, writes `$TERMINAL -e prog arg`. A terminal that
118 + // ignores it opens a bare shell and looks like the launcher is broken.
119 + //
120 + // Distinct from `--exec` on purpose: that one is a single string handed to
121 + // `sh -c`, which is what a benchmark wants and what a launcher must not
122 + // have, because the arguments would need quoting nobody applies.
123 + let exec_argv: Option<Vec<String>> = args
124 + .iter()
125 + .position(|a| a == "-e")
126 + .map(|i| args[i + 1..].to_vec())
127 + .filter(|argv| !argv.is_empty());
113 128 // `--record PATH` tees the PTY output byte-for-byte to PATH, for
114 129 // feeding into the kitty-graphics-testkit corpus via capture-apc.
115 130 let record_path = args
@@ -123,13 +138,8 @@
123 138 .position(|a| a == "--theme")
124 139 .and_then(|i| args.get(i + 1).cloned());
125 140 let palette = Palette::load(&Config::load().with_theme(theme_arg));
126 - let (spawn_cmd, spawn_args): (String, Vec<String>) = match &exec_cmd {
127 - Some(cmd) => ("/bin/sh".into(), vec!["-c".into(), cmd.clone()]),
128 - None => (
129 - std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".into()),
130 - Vec::new(),
131 - ),
132 - };
141 + let (spawn_cmd, spawn_args) =
142 + spawn_target(exec_argv, exec_cmd.clone(), std::env::var("SHELL").ok());
133 143 let spawn_args_refs: Vec<&str> = spawn_args.iter().map(String::as_str).collect();
134 144 let cols_initial = grid_cols(INITIAL.0);
135 145 let rows_initial = grid_rows(INITIAL.1);
@@ -574,6 +584,29 @@
574 584 }
575 585 }
576 586
587 + /// What to spawn on the PTY, given the two exec flags and `$SHELL`.
588 + ///
589 + /// `-e` wins over `--exec`: it is the one a launcher passes, so if both
590 + /// somehow arrive the launcher's intent is the one to honour. With neither,
591 + /// the user's login shell, and `/bin/bash` if even that is unset — a terminal
592 + /// that opens no shell is not a fallback anyone can use.
593 + fn spawn_target(
594 + exec_argv: Option<Vec<String>>,
595 + exec_cmd: Option<String>,
596 + shell: Option<String>,
597 + ) -> (String, Vec<String>) {
598 + match (exec_argv, exec_cmd) {
599 + (Some(argv), _) => {
600 + let mut argv = argv.into_iter();
601 + // The filter on the parse guarantees a first element.
602 + let program = argv.next().unwrap_or_else(|| "/bin/sh".into());
603 + (program, argv.collect())
604 + }
605 + (None, Some(cmd)) => ("/bin/sh".into(), vec!["-c".into(), cmd]),
606 + (None, None) => (shell.unwrap_or_else(|| "/bin/bash".into()), Vec::new()),
607 + }
608 + }
609 +
577 610 fn grid_cols(px_w: u32) -> u16 {
578 611 let usable = (px_w as f32 - 2.0 * PAD_X).max(CELL_ADVANCE);
579 612 (usable / CELL_ADVANCE) as u16
@@ -1729,6 +1762,53 @@
1729 1762 Point::new(row, col)
1730 1763 }
1731 1764
1765 + fn argv(items: &[&str]) -> Vec<String> {
1766 + items.iter().map(|s| (*s).to_string()).collect()
1767 + }
1768 +
1769 + #[test]
1770 + fn with_no_flags_the_login_shell_runs() {
1771 + let (cmd, args) = spawn_target(None, None, Some("/usr/bin/nu".into()));
1772 + assert_eq!(cmd, "/usr/bin/nu");
1773 + assert!(args.is_empty());
1774 + }
1775 +
1776 + #[test]
1777 + fn a_missing_shell_still_opens_something() {
1778 + let (cmd, _) = spawn_target(None, None, None);
1779 + assert_eq!(cmd, "/bin/bash");
1780 + }
1781 +
1782 + #[test]
1783 + fn dash_e_takes_the_program_and_its_arguments_verbatim() {
1784 + // What a .desktop entry with Terminal=true produces, via alloy-menu.
1785 + let (cmd, args) = spawn_target(Some(argv(&["helix", "/etc/fstab"])), None, None);
1786 + assert_eq!(cmd, "helix");
1787 + assert_eq!(args, argv(&["/etc/fstab"]));
1788 + }
1789 +
1790 + #[test]
1791 + fn dash_e_does_not_go_through_a_shell() {
1792 + // The arguments are argv entries, not a string to be re-split, so
1793 + // anything the shell would have mangled arrives intact.
1794 + let (cmd, args) = spawn_target(Some(argv(&["grep", "a b", "*.txt"])), None, None);
1795 + assert_eq!(cmd, "grep");
1796 + assert_eq!(args, argv(&["a b", "*.txt"]));
1797 + }
1798 +
1799 + #[test]
1800 + fn exec_runs_its_string_through_a_shell() {
1801 + let (cmd, args) = spawn_target(None, Some("ls | wc -l".into()), None);
1802 + assert_eq!(cmd, "/bin/sh");
1803 + assert_eq!(args, argv(&["-c", "ls | wc -l"]));
1804 + }
1805 +
1806 + #[test]
1807 + fn dash_e_wins_over_exec() {
1808 + let (cmd, _) = spawn_target(Some(argv(&["btop"])), Some("ls".into()), None);
1809 + assert_eq!(cmd, "btop");
1810 + }
1811 +
1732 1812 #[test]
1733 1813 fn the_origin_cell_starts_after_the_padding() {
1734 1814 assert_eq!(