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
```
tabbed-wlroots [prog...]
tabbed-wlroots [--start-cmd-index N] [prog...]
```
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
single terminal tab is opened (see `$TERMINAL` below). `tabbed-wlroots -h`
prints this usage.
single terminal tab is opened (see `$TERMINAL` below). `--start-cmd-index
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
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
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
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
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_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 ===================== */
/* 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
alacritty default. Every command-line argument is one tab launcher,
except --screen-mode WxH[@Hz], which selects the native output mode and
is consumed here, and --modifier alt|super, which picks the modifier
prefix for the tab-management chords. Handle all of this up front so a
missing command (or a bad request) can be reported and the process can
exit before any window is created. */
is consumed here, --modifier alt|super, which picks the modifier
prefix for the tab-management chords, and --start-cmd-index N, which
picks the command (1-based) that starts focused. Handle all of this up
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");
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) {
fprintf(stderr,
"usage: %s [--screen-mode WxH[@Hz]] [--modifier alt|super] "
"[prog...]\n"
"[--start-cmd-index N] [prog...]\n"
" Select the native output mode (standalone only): "
"e.g. --screen-mode 1920x1080\n"
" or --screen-mode 1920x1080@60. A request nothing "
@ -5372,6 +5378,8 @@ int main(int argc, char *argv[]) {
" --modifier picks the tab-management modifier "
"(default alt); 'super' suits standalone/DRM and "
"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 "
"takes args, e.g. \"firefox -no-remote\".\n"
" With no arguments, a single terminal tab opens "
@ -5423,6 +5431,39 @@ int main(int argc, char *argv[]) {
return 2;
}
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 (!parse_screen_mode(val)) {
@ -5443,6 +5484,13 @@ int main(int argc, char *argv[]) {
default_tab = true;
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++) {
const char *cmd = default_tab ? term_cmd : appv[i];
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
waits only on that one tab. */
set_active_app(server.apps[total_apps - 1]);
waits only on that one tab. --start-cmd-index picks it (1-based);
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
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"
finally:
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}"