diff --git a/tabbed-wlroots.c b/tabbed-wlroots.c index 9d8feee..55f0df2 100644 --- a/tabbed-wlroots.c +++ b/tabbed-wlroots.c @@ -180,6 +180,11 @@ struct tabbed_server { struct wl_list ptrdevs; }; +/* Requested output resolution from --screen-mode WxH[@Hz]; 0/0/0 means "let + the output pick its preferred mode". Refresh is stored in mHz. */ +static int mode_req_w, mode_req_h; +static int mode_req_refresh; + /* ===================== Host-side (nested) state ===================== */ /* One cached ASCII glyph in the overlay's font atlas texture. */ @@ -1666,8 +1671,53 @@ server.output = output; wlr_output_state_init(&state); wlr_output_state_set_enabled(&state, true); struct wlr_output_mode *mode = NULL; - if (server.standalone && !server.standalone_headless) - mode = wlr_output_preferred_mode(output); + if (server.standalone && !server.standalone_headless) { + if (mode_req_w > 0) { + /* --screen-mode WxH[@Hz]: pick an exact-resolution mode, highest + refresh when none was requested. A request the output can't + satisfy is a startup error: print every reported mode and exit + (before any client launches or the socket is advertised). */ + struct wlr_output_mode *m; + int best_refresh = 0; + wl_list_for_each(m, &output->modes, link) { + if (m->width != mode_req_w || m->height != mode_req_h) + continue; + if (mode_req_refresh > 0) { + if (m->refresh == mode_req_refresh) { + mode = m; + break; + } + continue; + } + if (m->refresh > best_refresh) { + best_refresh = m->refresh; + mode = m; + } + } + if (!mode) { + fprintf(stderr, "tabbed-wlroots: no %dx%d", + mode_req_w, mode_req_h); + if (mode_req_refresh > 0) + fprintf(stderr, "@%u.%03u", mode_req_refresh / 1000, + mode_req_refresh % 1000); + if (wl_list_empty(&output->modes)) + fprintf(stderr, " mode; output reports no modes\n"); + else + fprintf(stderr, " mode; available:\n"); + wl_list_for_each(m, &output->modes, link) { + fprintf(stderr, " %dx%d@%u.%03u%s%s\n", + m->width, m->height, m->refresh / 1000, + m->refresh % 1000, + m->preferred ? " (preferred)" : "", + (m->width == mode_req_w && m->height == mode_req_h) + ? " (requested size)" : ""); + } + exit(1); + } + } else { + mode = wlr_output_preferred_mode(output); + } + } if (mode) { w = mode->width; h = mode->height; @@ -4528,6 +4578,37 @@ static bool backends_include(const char *env, const char *name) { /* ===================== Main ===================== */ +/* Parse a --screen-mode value: WxH or WxH@Hz (the Hz is matched at the + mode's mHz precision, so "60" == 60.000). Stores into mode_req_w/h/ + refresh; returns false on a malformed value. */ +static bool parse_screen_mode(const char *val) { + int w = 0, h = 0; + const char *p = val; + for (; *p >= '0' && *p <= '9'; p++) + w = w * 10 + (*p - '0'); + if (*p != 'x') + return false; + p++; + for (; *p >= '0' && *p <= '9'; p++) + h = h * 10 + (*p - '0'); + if (w <= 0 || h <= 0) + return false; + unsigned refresh_mhz = 0; + if (*p == '@') { + char *end = NULL; + double hz = strtod(p + 1, &end); + if (end == p + 1 || hz <= 0.0 || *end != '\0') + return false; + refresh_mhz = (unsigned)(hz * 1000.0 + 0.5); + } else if (*p != '\0') { + return false; + } + mode_req_w = w; + mode_req_h = h; + mode_req_refresh = (int)refresh_mhz; + return true; +} + int main(int argc, char *argv[]) { snprintf(tabbed_socket, sizeof(tabbed_socket), "tabbed-wayland-%d", (int)getpid()); @@ -4550,27 +4631,63 @@ int main(int argc, char *argv[]) { } /* Ctrl+Shift+T opens a terminal named by $TERMINAL when set, else the - alacritty default. Every command-line argument is one tab launcher. - Handle all of this up front so a missing command can be reported and - the process can exit before any window is created. */ + 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. 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]) term_cmd = env_term; } + char **appv = calloc((size_t)argc, sizeof(*appv)); + if (!appv) { + fprintf(stderr, "tabbed-wlroots: out of memory\n"); + return 1; + } + int napps = 0; for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { fprintf(stderr, - "usage: %s [prog...]\n" + "usage: %s [--screen-mode WxH[@Hz]] [prog...]\n" + " Select the native output mode (standalone only): " + "e.g. --screen-mode 1920x1080\n" + " or --screen-mode 1920x1080@60. A request nothing " + "supports prints every mode and exits.\n" " Each is one shell command; quote it when it " "takes args, e.g. \"firefox -no-remote\".\n" " With no arguments, a single terminal tab opens " "($TERMINAL, default alacritty).\n" " -h / --help print this help.\n", argv[0]); + free(appv); return 0; } + const char *val = NULL; + if (strcmp(argv[i], "--screen-mode") == 0) { + if (i + 1 >= argc) { + fprintf(stderr, "tabbed-wlroots: --screen-mode needs a value, " + "e.g. --screen-mode 1920x1080\n"); + free(appv); + return 2; + } + val = argv[++i]; + } else if (strncmp(argv[i], "--screen-mode=", 14) == 0) { + val = argv[i] + 14; + } + if (val) { + if (!parse_screen_mode(val)) { + fprintf(stderr, "tabbed-wlroots: bad --screen-mode '%s' " + "(use WxH or WxH@Hz, e.g. 1920x1080 or " + "1920x1080@60)\n", val); + free(appv); + return 2; + } + continue; + } + appv[napps++] = argv[i]; } - int total_apps = argc - 1; + int total_apps = napps; bool default_tab = false; if (total_apps == 0) { /* No arguments: open one terminal tab, as Ctrl+Shift+T would. */ @@ -4578,10 +4695,11 @@ int main(int argc, char *argv[]) { total_apps = 1; } for (int i = 0; i < total_apps; i++) { - const char *cmd = default_tab ? term_cmd : argv[i + 1]; + const char *cmd = default_tab ? term_cmd : appv[i]; if (!cmd_in_path(cmd)) { fprintf(stderr, "tabbed-wlroots: '%s' not found in PATH%s\n", cmd, default_tab ? " (set $TERMINAL)" : ""); + free(appv); return 1; } } @@ -4820,8 +4938,10 @@ int main(int argc, char *argv[]) { outputs of its own, so add ours manually; DRM emits its outputs via new_output. new_output fires during backend start. */ if (!server.standalone || server.standalone_headless) { - host.width = 1280; - host.height = 800; + int w = server.standalone && mode_req_w > 0 ? mode_req_w : 1280; + int h = server.standalone && mode_req_h > 0 ? mode_req_h : 800; + host.width = w; + host.height = h; wlr_headless_add_output(server.backend, host.width, host.height); } @@ -4861,7 +4981,7 @@ int main(int argc, char *argv[]) { remove_socket_file(); return 1; } - app->cmd = strdup(default_tab ? term_cmd : argv[i + 1]); + app->cmd = strdup(default_tab ? term_cmd : appv[i]); app->pid = -1; server.apps[i] = app; } diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 9bf6001..d8e145d 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -718,6 +718,57 @@ def test_standalone_headless_last_tab_exits_cleanly(standalone_session): assert rc == 0, f"rc={rc} (139 = SIGSEGV); log tail:\n{s.read_log()[-800:]}" +def test_standalone_headless_screen_mode_sets_output_size(): + """--screen-mode WxH sizes the output; on the headless backend it becomes + the add_output() size, observable through the dumped frame dimensions.""" + s = _spawn_standalone({}, "--screen-mode", "480x320", "build/wltest '#ff0000'") + try: + s.wait_app_active(0) + assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" + w, h, _ = s.ppm_rgb(s.dump_host()) + assert (w, h) == (480, 320), \ + f"output should honour --screen-mode 480x320, got {w}x{h}" + finally: + s.close() + + +def test_standalone_headless_screen_mode_not_consumed_as_app(): + """--screen-mode is stripped from argv, so it must not become a tab + launcher (no 'starts with -' warning) while both real apps still boot.""" + s = _spawn_standalone({}, "--screen-mode", "640x480", + "build/wltest '#ff0000'", "build/wltest '#0000ff'") + try: + s.wait_any_active() + assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" + assert "starts with '-'" not in s.read_log(), \ + f"--screen-mode leaked into the app list:\n{s.read_log()[-400:]}" + finally: + s.close() + + +def test_standalone_headless_screen_mode_bad_value_exits(tmp_path): + """A malformed --screen-mode value is a startup error (rc 2) naming the + problem, before any window or backend work.""" + env = dict(os.environ) + for case in (["--screen-mode"], ["--screen-mode=abc"], ["--screen-mode", "1280"]): + 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, + ) + out, _ = proc.communicate(timeout=15) + assert proc.returncode == 2, \ + f"--screen-mode {case} should exit rc 2; rc={proc.returncode}\n{out}" + assert b"--screen-mode" in out, \ + f"error should mention --screen-mode:\n{out}" + + def test_standalone_headless_overlay_font_initialized(standalone_session): """Standalone builds the overlay glyph atlas with the wlr renderer, so tab titles are drawn next to their icons. Regressed when overlay_font_init()