add --start-cmd-index to choose the startup-focused command

The last command-line command is active-and-black from the first frame by
default; --start-cmd-index N (1-based) instead makes command N the one the
program starts focused on.  The 1-based user value is converted to the
program's 0-based index as soon as it is parsed; a value outside 1..N is a
startup error (rc 2) naming the flag.
This commit is contained in:
Andrew Hamilton 2026-08-19 09:21:46 +10:00
parent a543576d45
commit 9683f17719
3 changed files with 115 additions and 12 deletions

View file

@ -64,13 +64,14 @@ install works too.
## Usage ## Usage
``` ```
tabbed-wlroots [prog...] tabbed-wlroots [--start-cmd-index N] [prog...]
``` ```
Each `<prog>` is one shell command; quote it when it takes arguments, e.g. Each `<prog>` is one shell command; quote it when it takes arguments, e.g.
`tabbed-wlroots "foot -e htop" "firefox -no-remote"`. With no arguments, a `tabbed-wlroots "foot -e htop" "firefox -no-remote"`. With no arguments, a
single terminal tab is opened (see `$TERMINAL` below). `tabbed-wlroots -h` single terminal tab is opened (see `$TERMINAL` below). `--start-cmd-index
prints this usage. N` starts focused on command `N` (1-based; the last command by default).
`tabbed-wlroots -h` prints this usage.
Every argument starts a new tab. There is no tab-count limit. Client apps run Every argument starts a new tab. There is no tab-count limit. Client apps run
as children of tabbed-wlroots and connect to its private Wayland socket, so as children of tabbed-wlroots and connect to its private Wayland socket, so
@ -106,7 +107,10 @@ the **last** command's tab is the active one, so you wait only on that
single tab: when it maps, the window reveals it and nothing steals focus single tab: when it maps, the window reveals it and nothing steals focus
before that, so you don't flicker through every intermediate tab. This before that, so you don't flicker through every intermediate tab. This
"last command wins" rule also means the tab you see on startup is always "last command wins" rule also means the tab you see on startup is always
the one from the last argument. the one from the last argument. Pass `--start-cmd-index N` to start focused
on a different command instead: `N` is 1-based (the first command is 1),
and that command's tab is black-and-active from the first frame just like
the default last one.
If a command never connects — e.g. `sleep 999` — the rest of the command If a command never connects — e.g. `sleep 999` — the rest of the command
line still starts (they all launched together) and the window shows the line still starts (they all launched together) and the window shows the

View file

@ -232,6 +232,11 @@ struct tabbed_server {
static int mode_req_w, mode_req_h; static int mode_req_w, mode_req_h;
static int mode_req_refresh; static int mode_req_refresh;
/* The command-line command to start focused (--start-cmd-index), 0-based as
soon as the (1-based) user value is parsed. -1 (unset) means the last
command, keeping the startup contract. */
static int start_cmd_index = -1;
/* ===================== Host-side (nested) state ===================== */ /* ===================== Host-side (nested) state ===================== */
/* One cached ASCII glyph in the overlay's font atlas texture. */ /* One cached ASCII glyph in the overlay's font atlas texture. */
@ -5345,10 +5350,11 @@ int main(int argc, char *argv[]) {
/* Alt+Shift+T opens a terminal named by $TERMINAL when set, else the /* Alt+Shift+T opens a terminal named by $TERMINAL when set, else the
alacritty default. Every command-line argument is one tab launcher, alacritty default. Every command-line argument is one tab launcher,
except --screen-mode WxH[@Hz], which selects the native output mode and except --screen-mode WxH[@Hz], which selects the native output mode and
is consumed here, and --modifier alt|super, which picks the modifier is consumed here, --modifier alt|super, which picks the modifier
prefix for the tab-management chords. Handle all of this up front so a prefix for the tab-management chords, and --start-cmd-index N, which
missing command (or a bad request) can be reported and the process can picks the command (1-based) that starts focused. Handle all of this up
exit before any window is created. */ front so a missing command (or a bad request) can be reported and the
process can exit before any window is created. */
{ {
const char *env_term = getenv("TERMINAL"); const char *env_term = getenv("TERMINAL");
if (env_term && env_term[0]) if (env_term && env_term[0])
@ -5364,7 +5370,7 @@ int main(int argc, char *argv[]) {
if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
fprintf(stderr, fprintf(stderr,
"usage: %s [--screen-mode WxH[@Hz]] [--modifier alt|super] " "usage: %s [--screen-mode WxH[@Hz]] [--modifier alt|super] "
"[prog...]\n" "[--start-cmd-index N] [prog...]\n"
" Select the native output mode (standalone only): " " Select the native output mode (standalone only): "
"e.g. --screen-mode 1920x1080\n" "e.g. --screen-mode 1920x1080\n"
" or --screen-mode 1920x1080@60. A request nothing " " or --screen-mode 1920x1080@60. A request nothing "
@ -5372,6 +5378,8 @@ int main(int argc, char *argv[]) {
" --modifier picks the tab-management modifier " " --modifier picks the tab-management modifier "
"(default alt); 'super' suits standalone/DRM and " "(default alt); 'super' suits standalone/DRM and "
"labwc-style hosts.\n" "labwc-style hosts.\n"
" --start-cmd-index picks the command (1-based) that "
"starts focused; without it the last command does.\n"
" Each <prog> is one shell command; quote it when it " " Each <prog> is one shell command; quote it when it "
"takes args, e.g. \"firefox -no-remote\".\n" "takes args, e.g. \"firefox -no-remote\".\n"
" With no arguments, a single terminal tab opens " " With no arguments, a single terminal tab opens "
@ -5423,6 +5431,39 @@ int main(int argc, char *argv[]) {
return 2; return 2;
} }
continue; continue;
} else if (strcmp(argv[i], "--start-cmd-index") == 0) {
if (i + 1 >= argc) {
fprintf(stderr, "tabbed-wlroots: --start-cmd-index needs a "
"value, e.g. --start-cmd-index 2\n");
free(appv);
return 2;
}
const char *n = argv[++i];
char *end = NULL;
long idx = strtol(n, &end, 10);
if (end == n || *end != '\0' || idx < 1 || idx > 2147483647L) {
fprintf(stderr, "tabbed-wlroots: bad --start-cmd-index '%s' "
"(use a 1-based command number, e.g. --start-cmd-index "
"2)\n", n);
free(appv);
return 2;
}
/* Convert to the program's 0-based index as soon as possible. */
start_cmd_index = (int)idx - 1;
continue;
} else if (strncmp(argv[i], "--start-cmd-index=",
sizeof("--start-cmd-index=") - 1) == 0) {
const char *n = argv[i] + sizeof("--start-cmd-index=") - 1;
char *end = NULL;
long idx = strtol(n, &end, 10);
if (end == n || *end != '\0' || idx < 1 || idx > 2147483647L) {
fprintf(stderr, "tabbed-wlroots: bad --start-cmd-index '%s' "
"(use a 1-based command number)\n", n);
free(appv);
return 2;
}
start_cmd_index = (int)idx - 1;
continue;
} }
if (val) { if (val) {
if (!parse_screen_mode(val)) { if (!parse_screen_mode(val)) {
@ -5443,6 +5484,13 @@ int main(int argc, char *argv[]) {
default_tab = true; default_tab = true;
total_apps = 1; total_apps = 1;
} }
if (start_cmd_index >= 0 && start_cmd_index >= total_apps) {
fprintf(stderr, "tabbed-wlroots: --start-cmd-index %d is out of range: "
"1..%d (%d command%s)\n", start_cmd_index + 1, total_apps,
total_apps, total_apps == 1 ? "" : "s");
free(appv);
return 2;
}
for (int i = 0; i < total_apps; i++) { for (int i = 0; i < total_apps; i++) {
const char *cmd = default_tab ? term_cmd : appv[i]; const char *cmd = default_tab ? term_cmd : appv[i];
if (!cmd_in_path(cmd)) { if (!cmd_in_path(cmd)) {
@ -5758,10 +5806,13 @@ int main(int argc, char *argv[]) {
} }
} }
/* The last command-line command is active from the very first frame: /* The selected command-line command is active from the very first frame:
black, titled by its command line, until the client maps. The user black, titled by its command line, until the client maps. The user
waits only on that one tab. */ waits only on that one tab. --start-cmd-index picks it (1-based);
set_active_app(server.apps[total_apps - 1]); without the flag the last command is the one shown, so the startup tab
is always the one the caller can count on. */
int focus_idx = start_cmd_index >= 0 ? start_cmd_index : total_apps - 1;
set_active_app(server.apps[focus_idx]);
/* Fork every command-line command at once; the process-group match in /* Fork every command-line command at once; the process-group match in
handle_new_toplevel gives each its own slot on arrival. */ handle_new_toplevel gives each its own slot on arrival. */

View file

@ -1805,3 +1805,51 @@ def test_command_never_connecting_drops_tab():
"compositor exited after dropping the never-connecting tab" "compositor exited after dropping the never-connecting tab"
finally: finally:
s.close() s.close()
def test_start_cmd_index_selects_command():
"""--start-cmd-index N (1-based) starts focused on command N instead of
the default last command: index 1 makes the first command the active,
black tab from the very first frame, revealed when it maps."""
if not os.environ.get("WAYLAND_DISPLAY"):
pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)")
s = _nested_session(f"{WL} '{RED}'", f"{WL} '{GREEN}'", f"{WL} '{BLUE}'",
extra_args=["--start-cmd-index", "1"])
try:
assert s.wait_any_active() == 0, \
"--start-cmd-index 1 should make the first command active"
assert s.proc.poll() is None
finally:
s.close()
if s.proc.poll() is None:
s.proc.terminate()
def test_start_cmd_index_errors():
"""A malformed or out-of-range --start-cmd-index is a startup error (rc 2)
naming the flag, before any window or backend work. The index is 1-based,
so 0 is invalid and a value beyond the command count is out of range."""
env = dict(os.environ)
cases = (["--start-cmd-index"],
["--start-cmd-index=abc"],
["--start-cmd-index", "0"],
["--start-cmd-index", "4", "build/wltest '#ff0000'",
"build/wltest '#00ff00'", "build/wltest '#0000ff'"])
for case in cases:
proc = subprocess.Popen(
["env", "-i",
"PATH=" + env["PATH"],
"HOME=" + env.get("HOME", "/"),
"XDG_RUNTIME_DIR=" + env.get("XDG_RUNTIME_DIR", "/tmp"),
"TABBED_MODE=standalone",
"WLR_BACKENDS=headless",
"stdbuf", "-oL",
BIN] + case,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()),
)
out, _ = proc.communicate(timeout=15)
assert proc.returncode == 2, \
f"--start-cmd-index {case} should exit rc 2; rc={proc.returncode}\n{out}"
assert b"--start-cmd-index" in out, \
f"error should mention --start-cmd-index:\n{out}"