| 1 |
1 |
|
//! shop — Wayland terminal emulator.
|
| 2 |
2 |
|
//!
|
| 3 |
|
- |
//! Milestone: PTY loop. calloop-driven event loop with two sources — the
|
| 4 |
|
- |
//! wayland event queue and the PTY master fd. Bytes read from the shell are
|
| 5 |
|
- |
//! appended to a rolling buffer and rendered as raw text (no VT parser yet,
|
| 6 |
|
- |
//! so escape sequences will show as garbage — vim/htop won't look right, but
|
| 7 |
|
- |
//! `ls`, `echo`, `printf` will).
|
|
3 |
+ |
//! calloop-driven event loop with two sources — the wayland event queue and
|
|
4 |
+ |
//! the PTY master fd. Bytes read from the shell go through `shop_vt` into a
|
|
5 |
+ |
//! `shop_grid::Grid`, which `shop_render` draws.
|
|
6 |
+ |
//!
|
|
7 |
+ |
//! <!-- wiki: shop-overview -->
|
|
8 |
+ |
//!
|
|
9 |
+ |
//! This header used to describe the PTY-loop milestone and say there was no
|
|
10 |
+ |
//! VT parser yet. That stopped being true long before anyone corrected it,
|
|
11 |
+ |
//! which is the failure mode a status line in a doc comment invites: it is
|
|
12 |
+ |
//! read on the way past and updated by nobody.
|
| 8 |
13 |
|
|
| 9 |
14 |
|
use std::os::fd::{AsFd, AsRawFd};
|
| 10 |
15 |
|
use std::sync::Arc;
|
| 116 |
121 |
|
}
|
| 117 |
122 |
|
}
|
| 118 |
123 |
|
|
|
124 |
+ |
/// What argv asked the terminal to do, once it is known to be a request to
|
|
125 |
+ |
/// open a window at all.
|
|
126 |
+ |
#[derive(Debug, Default, PartialEq, Eq)]
|
|
127 |
+ |
struct Cli {
|
|
128 |
+ |
/// `--exec CMD` runs `sh -c CMD` instead of the interactive shell — useful
|
|
129 |
+ |
/// for benchmarks and one-shot invocations. Shop exits when the child
|
|
130 |
+ |
/// exits (PTY EOF triggers the read=0 branch).
|
|
131 |
+ |
exec_cmd: Option<String>,
|
|
132 |
+ |
/// `-e PROGRAM [ARGS...]` takes the rest of argv as a program and its
|
|
133 |
+ |
/// arguments, with no shell in between. This is the xterm convention, and
|
|
134 |
+ |
/// it is not optional for a terminal that means to be somebody's
|
|
135 |
+ |
/// `$TERMINAL`: every desktop entry with `Terminal=true`, and every script
|
|
136 |
+ |
/// that spawns a TUI, writes `$TERMINAL -e prog arg`. A terminal that
|
|
137 |
+ |
/// ignores it opens a bare shell and looks like the launcher is broken.
|
|
138 |
+ |
///
|
|
139 |
+ |
/// Distinct from `--exec` on purpose: that one is a single string handed to
|
|
140 |
+ |
/// `sh -c`, which is what a benchmark wants and what a launcher must not
|
|
141 |
+ |
/// have, because the arguments would need quoting nobody applies.
|
|
142 |
+ |
exec_argv: Option<Vec<String>>,
|
|
143 |
+ |
/// `--record PATH` tees the PTY output byte-for-byte to PATH, for
|
|
144 |
+ |
/// feeding into the kitty-graphics-testkit corpus via capture-apc.
|
|
145 |
+ |
record_path: Option<String>,
|
|
146 |
+ |
/// `--theme ID` overrides the config file for one run, which is how you look
|
|
147 |
+ |
/// at a theme before committing to it.
|
|
148 |
+ |
theme: Option<String>,
|
|
149 |
+ |
}
|
|
150 |
+ |
|
|
151 |
+ |
/// Every way argv can end the process before a window exists.
|
|
152 |
+ |
#[derive(Debug, PartialEq, Eq)]
|
|
153 |
+ |
enum CliExit {
|
|
154 |
+ |
/// Asked a question we can answer on stdout. Exit 0.
|
|
155 |
+ |
Answer(String),
|
|
156 |
+ |
/// Asked for something we do not have. Exit 2, message on stderr.
|
|
157 |
+ |
Reject(String),
|
|
158 |
+ |
}
|
|
159 |
+ |
|
|
160 |
+ |
const HELP: &str = "\
|
|
161 |
+ |
shop, a Wayland terminal emulator.
|
|
162 |
+ |
|
|
163 |
+ |
Usage: shop [OPTIONS] [-e PROGRAM [ARGS...]]
|
|
164 |
+ |
|
|
165 |
+ |
Options:
|
|
166 |
+ |
-e PROGRAM [ARGS...] Run PROGRAM with no shell in between. Takes the rest
|
|
167 |
+ |
of the command line, so it goes last.
|
|
168 |
+ |
--exec CMD Run CMD through sh -c instead of the login shell.
|
|
169 |
+ |
--theme ID Use theme ID for this run, ignoring the config file.
|
|
170 |
+ |
--record PATH Tee the terminal output to PATH as raw bytes.
|
|
171 |
+ |
-h, --help Print this help.
|
|
172 |
+ |
-V, --version Print the version.
|
|
173 |
+ |
|
|
174 |
+ |
Config is read from ~/.config/shop/config.toml.";
|
|
175 |
+ |
|
|
176 |
+ |
/// Read argv, or say why we are not opening a window.
|
|
177 |
+ |
///
|
|
178 |
+ |
/// Written by hand rather than with a parser crate because the grammar is four
|
|
179 |
+ |
/// flags and one convention, and the one convention is the part a crate gets
|
|
180 |
+ |
/// wrong: `-e` swallows the remainder of the command line, child flags and all,
|
|
181 |
+ |
/// so nothing after it is ours to interpret.
|
|
182 |
+ |
///
|
|
183 |
+ |
/// The rejection half is the point. Silently ignoring an unknown flag means
|
|
184 |
+ |
/// `shop --version` opens a terminal, and a typo'd `--theme` in a desktop entry
|
|
185 |
+ |
/// looks like the theme is broken rather than like the entry is.
|
|
186 |
+ |
fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, CliExit> {
|
|
187 |
+ |
let mut cli = Cli::default();
|
|
188 |
+ |
let mut args = args.into_iter().skip(1);
|
|
189 |
+ |
while let Some(arg) = args.next() {
|
|
190 |
+ |
// Takes the rest verbatim, so stop reading argv as ours here. Empty is
|
|
191 |
+ |
// not an error: `-e` with nothing after it is a launcher that built a
|
|
192 |
+ |
// command line and found no command, and a shell is the useful answer.
|
|
193 |
+ |
if arg == "-e" {
|
|
194 |
+ |
let argv: Vec<String> = args.by_ref().collect();
|
|
195 |
+ |
cli.exec_argv = Some(argv).filter(|a| !a.is_empty());
|
|
196 |
+ |
return Ok(cli);
|
|
197 |
+ |
}
|
|
198 |
+ |
let mut value = |flag: &str| {
|
|
199 |
+ |
args.next()
|
|
200 |
+ |
.ok_or_else(|| CliExit::Reject(format!("{flag} needs a value")))
|
|
201 |
+ |
};
|
|
202 |
+ |
match arg.as_str() {
|
|
203 |
+ |
"-h" | "--help" => return Err(CliExit::Answer(HELP.into())),
|
|
204 |
+ |
"-V" | "--version" => {
|
|
205 |
+ |
return Err(CliExit::Answer(format!(
|
|
206 |
+ |
"shop {}",
|
|
207 |
+ |
env!("CARGO_PKG_VERSION")
|
|
208 |
+ |
)));
|
|
209 |
+ |
}
|
|
210 |
+ |
"--exec" => cli.exec_cmd = Some(value("--exec")?),
|
|
211 |
+ |
"--theme" => cli.theme = Some(value("--theme")?),
|
|
212 |
+ |
"--record" => cli.record_path = Some(value("--record")?),
|
|
213 |
+ |
other => {
|
|
214 |
+ |
return Err(CliExit::Reject(format!(
|
|
215 |
+ |
"unknown option {other}\nTry 'shop --help'."
|
|
216 |
+ |
)));
|
|
217 |
+ |
}
|
|
218 |
+ |
}
|
|
219 |
+ |
}
|
|
220 |
+ |
Ok(cli)
|
|
221 |
+ |
}
|
|
222 |
+ |
|
| 119 |
223 |
|
fn main() -> anyhow::Result<()> {
|
|
224 |
+ |
// Before the subscriber, so `shop --help` prints help and nothing else.
|
|
225 |
+ |
let cli = match parse_args(std::env::args()) {
|
|
226 |
+ |
Ok(cli) => cli,
|
|
227 |
+ |
Err(CliExit::Answer(text)) => {
|
|
228 |
+ |
println!("{text}");
|
|
229 |
+ |
return Ok(());
|
|
230 |
+ |
}
|
|
231 |
+ |
Err(CliExit::Reject(message)) => {
|
|
232 |
+ |
eprintln!("shop: {message}");
|
|
233 |
+ |
std::process::exit(2);
|
|
234 |
+ |
}
|
|
235 |
+ |
};
|
|
236 |
+ |
|
| 120 |
237 |
|
tracing_subscriber::fmt()
|
| 121 |
238 |
|
.with_env_filter(
|
| 122 |
239 |
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
| 126 |
243 |
|
|
| 127 |
244 |
|
let font_data: Vec<u8> = FONT_BYTES.to_vec();
|
| 128 |
245 |
|
|
| 129 |
|
- |
// `--exec CMD` runs `sh -c CMD` instead of the interactive shell — useful
|
| 130 |
|
- |
// for benchmarks and one-shot invocations. Shop exits when the child
|
| 131 |
|
- |
// exits (PTY EOF triggers the read=0 branch).
|
| 132 |
|
- |
let args: Vec<String> = std::env::args().collect();
|
| 133 |
|
- |
let exec_cmd = args
|
| 134 |
|
- |
.iter()
|
| 135 |
|
- |
.position(|a| a == "--exec")
|
| 136 |
|
- |
.and_then(|i| args.get(i + 1).cloned());
|
| 137 |
|
- |
// `-e PROGRAM [ARGS...]` takes the rest of argv as a program and its
|
| 138 |
|
- |
// arguments, with no shell in between. This is the xterm convention, and
|
| 139 |
|
- |
// it is not optional for a terminal that means to be somebody's
|
| 140 |
|
- |
// `$TERMINAL`: every desktop entry with `Terminal=true`, and every script
|
| 141 |
|
- |
// that spawns a TUI, writes `$TERMINAL -e prog arg`. A terminal that
|
| 142 |
|
- |
// ignores it opens a bare shell and looks like the launcher is broken.
|
| 143 |
|
- |
//
|
| 144 |
|
- |
// Distinct from `--exec` on purpose: that one is a single string handed to
|
| 145 |
|
- |
// `sh -c`, which is what a benchmark wants and what a launcher must not
|
| 146 |
|
- |
// have, because the arguments would need quoting nobody applies.
|
| 147 |
|
- |
let exec_argv: Option<Vec<String>> = args
|
| 148 |
|
- |
.iter()
|
| 149 |
|
- |
.position(|a| a == "-e")
|
| 150 |
|
- |
.map(|i| args[i + 1..].to_vec())
|
| 151 |
|
- |
.filter(|argv| !argv.is_empty());
|
| 152 |
|
- |
// `--record PATH` tees the PTY output byte-for-byte to PATH, for
|
| 153 |
|
- |
// feeding into the kitty-graphics-testkit corpus via capture-apc.
|
| 154 |
|
- |
let record_path = args
|
| 155 |
|
- |
.iter()
|
| 156 |
|
- |
.position(|a| a == "--record")
|
| 157 |
|
- |
.and_then(|i| args.get(i + 1).cloned());
|
| 158 |
|
- |
// `--theme ID` overrides the config file for one run, which is how you look
|
| 159 |
|
- |
// at a theme before committing to it.
|
| 160 |
|
- |
let theme_arg = args
|
| 161 |
|
- |
.iter()
|
| 162 |
|
- |
.position(|a| a == "--theme")
|
| 163 |
|
- |
.and_then(|i| args.get(i + 1).cloned());
|
|
246 |
+ |
let Cli {
|
|
247 |
+ |
exec_cmd,
|
|
248 |
+ |
exec_argv,
|
|
249 |
+ |
record_path,
|
|
250 |
+ |
theme: theme_arg,
|
|
251 |
+ |
} = cli;
|
| 164 |
252 |
|
let config = Config::load().with_theme(theme_arg);
|
| 165 |
253 |
|
let scrollback_lines = config.scrollback_lines;
|
| 166 |
254 |
|
let bindings = config.bindings.clone();
|
| 2335 |
2423 |
|
assert_eq!(wheel_action(0, true, true, NORMAL), WheelAction::Nothing);
|
| 2336 |
2424 |
|
}
|
| 2337 |
2425 |
|
|
|
2426 |
+ |
/// argv as the process actually receives it, program name and all.
|
|
2427 |
+ |
fn cmdline(rest: &[&str]) -> Vec<String> {
|
|
2428 |
+ |
std::iter::once("shop".to_string())
|
|
2429 |
+ |
.chain(rest.iter().copied().map(String::from))
|
|
2430 |
+ |
.collect()
|
|
2431 |
+ |
}
|
|
2432 |
+ |
|
|
2433 |
+ |
#[test]
|
|
2434 |
+ |
fn a_bare_invocation_asks_for_nothing() {
|
|
2435 |
+ |
assert_eq!(parse_args(cmdline(&[])), Ok(Cli::default()));
|
|
2436 |
+ |
}
|
|
2437 |
+ |
|
|
2438 |
+ |
#[test]
|
|
2439 |
+ |
fn help_and_version_answer_instead_of_opening_a_window() {
|
|
2440 |
+ |
// The defect this exists for: both used to fall through the argv scan
|
|
2441 |
+ |
// and spawn a shell, so `shop --version` opened a terminal.
|
|
2442 |
+ |
for flag in ["-h", "--help"] {
|
|
2443 |
+ |
assert_eq!(
|
|
2444 |
+ |
parse_args(cmdline(&[flag])),
|
|
2445 |
+ |
Err(CliExit::Answer(HELP.into()))
|
|
2446 |
+ |
);
|
|
2447 |
+ |
}
|
|
2448 |
+ |
let version = Err(CliExit::Answer(format!(
|
|
2449 |
+ |
"shop {}",
|
|
2450 |
+ |
env!("CARGO_PKG_VERSION")
|
|
2451 |
+ |
)));
|
|
2452 |
+ |
for flag in ["-V", "--version"] {
|
|
2453 |
+ |
assert_eq!(parse_args(cmdline(&[flag])), version);
|
|
2454 |
+ |
}
|
|
2455 |
+ |
}
|
|
2456 |
+ |
|
|
2457 |
+ |
#[test]
|
|
2458 |
+ |
fn an_unknown_option_is_refused_by_name() {
|
|
2459 |
+ |
let Err(CliExit::Reject(message)) = parse_args(cmdline(&["--colour", "red"])) else {
|
|
2460 |
+ |
panic!("an unknown option must not open a window");
|
|
2461 |
+ |
};
|
|
2462 |
+ |
assert!(message.contains("--colour"), "{message}");
|
|
2463 |
+ |
}
|
|
2464 |
+ |
|
|
2465 |
+ |
#[test]
|
|
2466 |
+ |
fn a_flag_with_no_value_is_refused() {
|
|
2467 |
+ |
for flag in ["--exec", "--theme", "--record"] {
|
|
2468 |
+ |
let Err(CliExit::Reject(message)) = parse_args(cmdline(&[flag])) else {
|
|
2469 |
+ |
panic!("{flag} without a value must not open a window");
|
|
2470 |
+ |
};
|
|
2471 |
+ |
assert!(message.contains(flag), "{message}");
|
|
2472 |
+ |
}
|
|
2473 |
+ |
}
|
|
2474 |
+ |
|
|
2475 |
+ |
#[test]
|
|
2476 |
+ |
fn the_value_flags_read_their_values() {
|
|
2477 |
+ |
let cli = parse_args(cmdline(&[
|
|
2478 |
+ |
"--theme",
|
|
2479 |
+ |
"akari-night",
|
|
2480 |
+ |
"--exec",
|
|
2481 |
+ |
"ls | wc -l",
|
|
2482 |
+ |
"--record",
|
|
2483 |
+ |
"/tmp/out.bin",
|
|
2484 |
+ |
]))
|
|
2485 |
+ |
.expect("every flag here is one we have");
|
|
2486 |
+ |
assert_eq!(cli.theme.as_deref(), Some("akari-night"));
|
|
2487 |
+ |
assert_eq!(cli.exec_cmd.as_deref(), Some("ls | wc -l"));
|
|
2488 |
+ |
assert_eq!(cli.record_path.as_deref(), Some("/tmp/out.bin"));
|
|
2489 |
+ |
assert_eq!(cli.exec_argv, None);
|
|
2490 |
+ |
}
|
|
2491 |
+ |
|
|
2492 |
+ |
#[test]
|
|
2493 |
+ |
fn dash_e_takes_the_rest_of_the_line_including_flags_we_know() {
|
|
2494 |
+ |
// The reason parsing stops at -e rather than continuing: --theme here
|
|
2495 |
+ |
// is helix's argument, not ours, and rejecting or eating it would
|
|
2496 |
+ |
// break every `$TERMINAL -e prog --flag` in the desktop.
|
|
2497 |
+ |
let cli = parse_args(cmdline(&["-e", "helix", "--theme", "dark", "f.rs"]))
|
|
2498 |
+ |
.expect("-e swallows the remainder");
|
|
2499 |
+ |
assert_eq!(
|
|
2500 |
+ |
cli.exec_argv,
|
|
2501 |
+ |
Some(argv(&["helix", "--theme", "dark", "f.rs"]))
|
|
2502 |
+ |
);
|
|
2503 |
+ |
assert_eq!(cli.theme, None);
|
|
2504 |
+ |
}
|
|
2505 |
+ |
|
|
2506 |
+ |
#[test]
|
|
2507 |
+ |
fn dash_e_with_nothing_after_it_is_a_shell_not_an_error() {
|
|
2508 |
+ |
// A launcher that built a command line and found no command. Opening a
|
|
2509 |
+ |
// shell is more useful than refusing, and it is what the old scan did.
|
|
2510 |
+ |
let cli = parse_args(cmdline(&["-e"])).expect("-e alone is not a refusal");
|
|
2511 |
+ |
assert_eq!(cli.exec_argv, None);
|
|
2512 |
+ |
}
|
|
2513 |
+ |
|
|
2514 |
+ |
#[test]
|
|
2515 |
+ |
fn flags_before_dash_e_are_still_ours() {
|
|
2516 |
+ |
let cli = parse_args(cmdline(&["--theme", "akari-night", "-e", "btop"]))
|
|
2517 |
+ |
.expect("both halves parse");
|
|
2518 |
+ |
assert_eq!(cli.theme.as_deref(), Some("akari-night"));
|
|
2519 |
+ |
assert_eq!(cli.exec_argv, Some(argv(&["btop"])));
|
|
2520 |
+ |
}
|
|
2521 |
+ |
|
| 2338 |
2522 |
|
#[test]
|
| 2339 |
2523 |
|
fn with_no_flags_the_login_shell_runs() {
|
| 2340 |
2524 |
|
let (cmd, args) = spawn_target(None, None, Some("/usr/bin/nu".into()));
|