Launch CLI tabs in parallel; slot-claim by process group
- All command-line tabs fork at startup; the last one is active and shown black until its first frame maps, matching the existing README contract that the last command's window appears immediately. - Claim incoming toplevels by process group, not exact pid, so wrapped commands (sh -c "foot -e htop") keep their own slot even though the sh wrapper forks the real client. - Closed tabs are fully removed: remove_app() compacts the array, frees the slot, clears parent refs, and prefers the left neighbor as the successor (right on explicit close, mirroring the old behavior on destroy). - Black (pending) tabs are live slots: cycle/reorder walk them, activation logs '[diag] active slot=N (black, awaiting map)', and Alt+Delete closes one outright. A SIGCHLD-latched sweep drops black tabs whose whole process group exits before ever connecting (e.g. /bin/false). - The map-reveal rule keys on the scene node still being disabled, since a slot's toplevel is set at claim time (before its map event). - Update the standalone/headless and nested tests to wait for a mapped frame (the startup tab is active before it maps, so the old 'no tab was ever activated' wait no longer fires), and add two tests covering Alt+Delete on a black active tab and the never-connecting-command sweep.
This commit is contained in:
parent
5d840a09dd
commit
7f756ba8ee
3 changed files with 485 additions and 219 deletions
34
README.md
34
README.md
|
|
@ -94,20 +94,30 @@ Notes:
|
|||
|
||||
### Startup order
|
||||
|
||||
The command-line commands are launched one at a time — each starts only after
|
||||
the previous one's client has connected to the socket — so the tabs appear in
|
||||
exactly the order you wrote them on the command line, no matter which program
|
||||
takes longest to open its window.
|
||||
The command-line commands are all launched at the same time, in the order
|
||||
you wrote them. Each command runs in its own process group, and when its
|
||||
client connects it claims its own slot by that group — so every tab keeps
|
||||
its command-line position even with wrapped commands like `sh -c "foot -e
|
||||
htop"`, no matter which program takes longest to open its window.
|
||||
|
||||
The window stays black while they load (tabs map hidden). When the **last**
|
||||
command's tab is ready it becomes the active tab and the window reveals it;
|
||||
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.
|
||||
Until a client connects, its tab is black and listed in the overlay under
|
||||
its **command line** instead of a window title. From the very first frame
|
||||
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.
|
||||
|
||||
If a command never connects — e.g. `sleep 999` — the rest of the command line
|
||||
never starts and the window stays black; the compositor itself keeps running
|
||||
normally (`Alt+Shift+T` still works) until you close it.
|
||||
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
|
||||
last tab as soon as it is ready. A command whose whole process group exits
|
||||
without ever connecting (e.g. `tabbed-wlroots /bin/false`) drops its tab
|
||||
entirely instead of leaving a permanent black hole.
|
||||
|
||||
Closing a tab takes it out of the list for good: its slot is removed and
|
||||
the remaining tabs reflow into place — there are no holes for later clients
|
||||
to fall into, so externally started windows are always appended, not
|
||||
absorbed into a dead tab's position.
|
||||
|
||||
Later tab openings follow the same no-steal rule: a brand-new tab never yanks
|
||||
the active surface on its own. `Alt+Shift+T` still focuses its fresh
|
||||
|
|
|
|||
588
tabbed-wlroots.c
588
tabbed-wlroots.c
|
|
@ -100,10 +100,11 @@ struct tabbed_app {
|
|||
char *cmd;
|
||||
pid_t pid;
|
||||
/* Process group the *connected* client lives in. Command-line commands
|
||||
are forked one at a time (see maybe_launch_next), so a CLI tab's slot
|
||||
is claimed by its own client in command-line order; sub-windows and
|
||||
external clients claim later. A close must signal the group recorded
|
||||
at claim time rather than the launcher's pid. */
|
||||
are forked all at once at startup (see launch_all_cli), so a CLI tab's
|
||||
slot is claimed by whichever command's client connects -- matched by
|
||||
process group at claim time, not by order; sub-windows and external
|
||||
clients claim later. A close must signal the group recorded at claim
|
||||
time rather than the launcher's pid. */
|
||||
pid_t pgid;
|
||||
/* PID of the *connected* client (from claim-time credentials). Used as
|
||||
the kill target when the client belongs to a process group we do not
|
||||
|
|
@ -129,24 +130,24 @@ struct tabbed_app {
|
|||
struct wlr_texture *icon_wlr;
|
||||
bool icon_resolved; /* true once a load attempt has been made */
|
||||
|
||||
/* Slot created by add_tab() (Alt+Shift+T) awaiting its toplevel. Lets
|
||||
handle_new_toplevel claim exactly this slot instead of an earlier
|
||||
stale free one (a closed tab's hole), so a new terminal keeps its
|
||||
"right of the current tab" position. */
|
||||
/* Slot awaiting its toplevel. True from creation until a client claims
|
||||
the slot in handle_new_toplevel (matched by launch process group), so
|
||||
a CLI tab or a fresh Alt+Shift+T terminal is live-but-black while its
|
||||
command starts: it shows in the overlay with its command line as the
|
||||
title, and cycling can land on it (backdrop + title). Cleared on
|
||||
claim, and again when a black tab is closed. A slot without a
|
||||
toplevel and without pending_claim would only exist transiently, so
|
||||
closed tabs leave no holes: remove_app() drops them outright. */
|
||||
bool pending_claim;
|
||||
|
||||
/* Where a tab's slot came from. ORIGIN_CLI slots hold the command-line
|
||||
commands and are forked one at a time (see maybe_launch_next), so the
|
||||
tab order mirrors the command line. ORIGIN_ADD_TAB is an Alt+Shift+T
|
||||
terminal. ORIGIN_SUB is a second toplevel from an already-connected
|
||||
client and is glued right of its parent tab. ORIGIN_EXTERNAL is a
|
||||
client we did not launch (e.g. started from our own shell). */
|
||||
commands and are forked all at once at startup (see launch_all_cli),
|
||||
so the tab order mirrors the command line. ORIGIN_ADD_TAB is an
|
||||
Alt+Shift+T terminal. ORIGIN_SUB is a second toplevel from an
|
||||
already-connected client and is glued right of its parent tab.
|
||||
ORIGIN_EXTERNAL is a client we did not launch (e.g. started from our
|
||||
own shell). */
|
||||
enum { ORIGIN_CLI, ORIGIN_ADD_TAB, ORIGIN_SUB, ORIGIN_EXTERNAL } origin;
|
||||
/* Stable launch order for ORIGIN_CLI slots. Sub-tab insertion shifts
|
||||
array indices, so the launcher keys on this, not on the array index. */
|
||||
int cli_seq;
|
||||
/* True once this ORIGIN_CLI command has been forked. */
|
||||
bool launched;
|
||||
/* For ORIGIN_SUB: the tab owning the connected client this window
|
||||
belongs to (the "parent" tab its slot is glued right of). */
|
||||
struct tabbed_app *parent_app;
|
||||
|
|
@ -188,12 +189,12 @@ struct tabbed_server {
|
|||
struct wl_listener output_destroy;
|
||||
struct wl_event_source *ctl_source;
|
||||
|
||||
struct wlr_xdg_toplevel *active;
|
||||
/* The last command-line command's slot. While the window is still black
|
||||
it stays inactive; the moment its toplevel maps it is "crowned" active
|
||||
(unless the user already focused something) -- the only map that ever
|
||||
auto-focuses on its own. */
|
||||
struct tabbed_app *crown_app;
|
||||
/* The active tab, by slot. Set to the last command-line command's slot
|
||||
at startup, so that tab is active -- black, with its command line as
|
||||
the overlay title -- from the very first frame and the user only waits
|
||||
for it. A slot that is active while still pending (no toplevel yet)
|
||||
shows the black backdrop and clear seat focus until it maps. */
|
||||
struct tabbed_app *active_app;
|
||||
struct tabbed_app **apps;
|
||||
int num_apps;
|
||||
int apps_cap;
|
||||
|
|
@ -400,15 +401,33 @@ static volatile sig_atomic_t dump_scene_req, dump_host_req;
|
|||
static int dump_seq;
|
||||
static bool client_dumped = false;
|
||||
|
||||
/* Set by sigchld_handler whenever a child exits; the main loop clears it and
|
||||
runs sweep_dead_startup_groups() to drop black tabs whose command exited
|
||||
without ever connecting. A plain flag (set + cleared in the loop) keeps
|
||||
the reaping work out of the signal handler. */
|
||||
static volatile sig_atomic_t sigchld_seen = 0;
|
||||
|
||||
/* A tab is "live" when it can own the show: it has a mapped toplevel, or
|
||||
it is pending (black) -- an unclaimed CLI tab at startup or a fresh
|
||||
Alt+Shift+T terminal. Slots are otherwise removed outright, so a live
|
||||
check is all that's needed to know whether a slot should be shown,
|
||||
cycled to, or handed the active crown. */
|
||||
static bool app_live(struct tabbed_app *app);
|
||||
/* The currently focused toplevel (NULL while the active slot is black). */
|
||||
static struct wlr_xdg_toplevel *active_toplevel(void);
|
||||
static void set_active_app(struct tabbed_app *app);
|
||||
static void set_active_surface(struct wlr_xdg_toplevel *toplevel);
|
||||
static void render_and_present(void);
|
||||
static struct tabbed_app *app_for_toplevel(struct wlr_xdg_toplevel *toplevel);
|
||||
static int app_index(struct tabbed_app *app);
|
||||
static struct tabbed_app *pick_successor(struct tabbed_app *app,
|
||||
int prefer_right);
|
||||
static void remove_app(int idx);
|
||||
static int add_tab(const char *cmd);
|
||||
static int cmd_in_path(const char *cmd);
|
||||
static void insert_app_after(struct tabbed_app *app, struct tabbed_app *anchor);
|
||||
static void launch_client(struct tabbed_app *app);
|
||||
static void maybe_launch_next(void);
|
||||
static void launch_all_cli(void);
|
||||
|
||||
/* Logging levels: quiet by default so a plain launch writes nothing to the
|
||||
launching terminal except fatal errors. TABBED_LOG=1 shows status lines,
|
||||
|
|
@ -554,6 +573,14 @@ static int apps_ensure_cap(int n) {
|
|||
return 0;
|
||||
}
|
||||
|
||||
static bool app_live(struct tabbed_app *app) {
|
||||
return app && (app->toplevel || app->pending_claim);
|
||||
}
|
||||
|
||||
static struct wlr_xdg_toplevel *active_toplevel(void) {
|
||||
return server.active_app ? server.active_app->toplevel : NULL;
|
||||
}
|
||||
|
||||
/* Full shutdown of the nested session: take down the clients first, then
|
||||
free our own allocations and exit. Guarded so the various close paths
|
||||
(host window close, last tab closed) only run once. */
|
||||
|
|
@ -776,9 +803,10 @@ static void draw_texture_rect(GLuint tex, int tex_w, int tex_h,
|
|||
While the host Alt modifier is held, a passive list of tab titles is
|
||||
drawn into the top-left of the host window with the active tab
|
||||
highlighted. Up/Down already cycle tabs while Alt is held, so the
|
||||
highlight follows server.active automatically. Titles are read from the
|
||||
wlr_xdg_toplevel at draw time (terminals retitle themselves), so no
|
||||
per-client listeners are needed. */
|
||||
highlight follows server.active_app automatically. Titles are read from
|
||||
the wlr_xdg_toplevel at draw time (terminals retitle themselves); a
|
||||
still-black tab shows its command line instead. No per-client listeners
|
||||
are needed. */
|
||||
|
||||
static FT_Library overlay_ft_lib;
|
||||
static FT_Face overlay_ft_face;
|
||||
|
|
@ -1402,6 +1430,22 @@ static void fit_text(const char *src, int max_px, char *out, int out_sz)
|
|||
out[n] = '\0';
|
||||
}
|
||||
|
||||
/* Row title for an overlay slot: the mapped toplevel's title, or the
|
||||
command line for a still-black (pending) tab. */
|
||||
static const char *app_row_title(struct tabbed_app *app) {
|
||||
if (app->toplevel && app->toplevel->title && app->toplevel->title[0])
|
||||
return app->toplevel->title;
|
||||
if (app && app->cmd && app->cmd[0])
|
||||
return app->cmd;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* A row in the overlay (both the host-GL and the standalone wlr paths):
|
||||
skip slots that are dead or mid-close; black pending tabs are rows too. */
|
||||
static bool app_overlay_row(struct tabbed_app *app) {
|
||||
return app_live(app) && !app->closing;
|
||||
}
|
||||
|
||||
static void draw_tab_overlay(void) {
|
||||
if (!host.overlay_visible || server.num_apps < 1) return;
|
||||
|
||||
|
|
@ -1412,12 +1456,12 @@ static void draw_tab_overlay(void) {
|
|||
int max_row_w = 0, n_rows = 0;
|
||||
for (int i = 0; i < server.num_apps; i++) {
|
||||
struct tabbed_app *app = server.apps[i];
|
||||
if (!app->toplevel) continue;
|
||||
if (!app_overlay_row(app)) continue;
|
||||
if (!app->icon_resolved)
|
||||
overlay_icon_load(app);
|
||||
const char *title = app->toplevel->title;
|
||||
const char *title = app_row_title(app);
|
||||
char fallback[16];
|
||||
if (!title || !title[0]) {
|
||||
if (!title) {
|
||||
snprintf(fallback, sizeof(fallback), "tab %d", i);
|
||||
title = fallback;
|
||||
}
|
||||
|
|
@ -1445,15 +1489,15 @@ static void draw_tab_overlay(void) {
|
|||
int row = 0;
|
||||
for (int i = 0; i < server.num_apps; i++) {
|
||||
struct tabbed_app *app = server.apps[i];
|
||||
if (!app->toplevel) continue;
|
||||
const char *title = app->toplevel->title;
|
||||
if (!app_overlay_row(app)) continue;
|
||||
const char *title = app_row_title(app);
|
||||
char fallback[16];
|
||||
if (!title || !title[0]) {
|
||||
if (!title) {
|
||||
snprintf(fallback, sizeof(fallback), "tab %d", i);
|
||||
title = fallback;
|
||||
}
|
||||
int ry = box_y + boxpad + row * row_h;
|
||||
bool active = app->toplevel == server.active;
|
||||
bool active = app == server.active_app;
|
||||
char row_text[512];
|
||||
int budget = max_row_w - icon_sz - icon_gap;
|
||||
if (budget < 0) budget = 0;
|
||||
|
|
@ -1477,7 +1521,7 @@ static void draw_tab_overlay(void) {
|
|||
ix, iy, icon_sz, icon_sz,
|
||||
host.width, host.height, tint);
|
||||
} else {
|
||||
/* Generic placeholder: a bordered square for apps with no icon. */
|
||||
/* Generic placeholder for no icon or a still-black tab. */
|
||||
draw_rect(ix, iy, icon_sz, icon_sz, 0.45f, 0.45f, 0.48f, 0.85f);
|
||||
draw_rect(ix + 3, iy + 3, icon_sz - 6, icon_sz - 6,
|
||||
0.07f, 0.07f, 0.09f, 1.0f);
|
||||
|
|
@ -1565,12 +1609,12 @@ static void draw_tab_overlay_pass(struct wlr_render_pass *pass) {
|
|||
int max_row_w = 0, n_rows = 0;
|
||||
for (int i = 0; i < server.num_apps; i++) {
|
||||
struct tabbed_app *app = server.apps[i];
|
||||
if (!app->toplevel) continue;
|
||||
if (!app_overlay_row(app)) continue;
|
||||
if (!app->icon_resolved)
|
||||
overlay_icon_load(app);
|
||||
const char *title = app->toplevel->title;
|
||||
const char *title = app_row_title(app);
|
||||
char fallback[16];
|
||||
if (!title || !title[0]) {
|
||||
if (!title) {
|
||||
snprintf(fallback, sizeof(fallback), "tab %d", i);
|
||||
title = fallback;
|
||||
}
|
||||
|
|
@ -1598,15 +1642,15 @@ static void draw_tab_overlay_pass(struct wlr_render_pass *pass) {
|
|||
int row = 0;
|
||||
for (int i = 0; i < server.num_apps; i++) {
|
||||
struct tabbed_app *app = server.apps[i];
|
||||
if (!app->toplevel) continue;
|
||||
const char *title = app->toplevel->title;
|
||||
if (!app_overlay_row(app)) continue;
|
||||
const char *title = app_row_title(app);
|
||||
char fallback[16];
|
||||
if (!title || !title[0]) {
|
||||
if (!title) {
|
||||
snprintf(fallback, sizeof(fallback), "tab %d", i);
|
||||
title = fallback;
|
||||
}
|
||||
int ry = box_y + boxpad + row * row_h;
|
||||
bool active = app->toplevel == server.active;
|
||||
bool active = app == server.active_app;
|
||||
char row_text[512];
|
||||
int budget = max_row_w - icon_sz - icon_gap;
|
||||
if (budget < 0) budget = 0;
|
||||
|
|
@ -1870,10 +1914,67 @@ server.output = output;
|
|||
}
|
||||
}
|
||||
|
||||
/* Nearest live slot, scanning right first (prefer_right) or left first.
|
||||
The scan stops at the array ends -- it never wraps -- so the reflow after
|
||||
a slot is removed matches the old first/last-tab behavior. NULL when
|
||||
`app` is the only live slot. */
|
||||
static struct tabbed_app *pick_successor(struct tabbed_app *app,
|
||||
int prefer_right) {
|
||||
if (!app) return NULL;
|
||||
int died = app_index(app);
|
||||
if (prefer_right) {
|
||||
for (int i = died + 1; i < server.num_apps; i++)
|
||||
if (app_live(server.apps[i]) && !server.apps[i]->closing)
|
||||
return server.apps[i];
|
||||
for (int i = died - 1; i >= 0; i--)
|
||||
if (app_live(server.apps[i]) && !server.apps[i]->closing)
|
||||
return server.apps[i];
|
||||
} else {
|
||||
for (int i = died - 1; i >= 0; i--)
|
||||
if (app_live(server.apps[i]) && !server.apps[i]->closing)
|
||||
return server.apps[i];
|
||||
for (int i = died + 1; i < server.num_apps; i++)
|
||||
if (app_live(server.apps[i]) && !server.apps[i]->closing)
|
||||
return server.apps[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Drop slot `idx` outright: free the tab's allocations, sever any other
|
||||
slot's parent_app reference to it, and compact server.apps[] so a closed
|
||||
tab leaves no hole that a later client could stumble into. The caller
|
||||
must already have handed the active crown elsewhere; remove_app only
|
||||
defends active_app against a slot that was somehow still on it. */
|
||||
static void remove_app(int idx) {
|
||||
if (idx < 0 || idx >= server.num_apps) return;
|
||||
struct tabbed_app *dead = server.apps[idx];
|
||||
if (server.active_app == dead)
|
||||
server.active_app = NULL;
|
||||
for (int i = 0; i < server.num_apps; i++)
|
||||
if (server.apps[i]->parent_app == dead)
|
||||
server.apps[i]->parent_app = NULL;
|
||||
if (dead->close_timer) {
|
||||
wl_event_source_remove(dead->close_timer);
|
||||
dead->close_timer = NULL;
|
||||
}
|
||||
if (host.egl_ok && dead->icon_tex) {
|
||||
glDeleteTextures(1, &dead->icon_tex);
|
||||
dead->icon_tex = 0;
|
||||
}
|
||||
if (dead->icon_wlr) {
|
||||
wlr_texture_destroy(dead->icon_wlr);
|
||||
dead->icon_wlr = NULL;
|
||||
}
|
||||
free(dead->cmd);
|
||||
free(dead);
|
||||
memmove(&server.apps[idx], &server.apps[idx + 1],
|
||||
(size_t)(server.num_apps - idx - 1) * sizeof(*server.apps));
|
||||
server.num_apps--;
|
||||
}
|
||||
|
||||
static void handle_toplevel_destroy(struct wl_listener *listener, void *data) {
|
||||
(void)data;
|
||||
struct tabbed_app *app = wl_container_of(listener, app, destroy);
|
||||
struct wlr_xdg_toplevel *toplevel = app->toplevel;
|
||||
app->toplevel = NULL;
|
||||
app->tree = NULL;
|
||||
|
||||
|
|
@ -1909,42 +2010,35 @@ static void handle_toplevel_destroy(struct wl_listener *listener, void *data) {
|
|||
app->close_timer = NULL;
|
||||
}
|
||||
|
||||
if (server.active != NULL && server.active == toplevel) {
|
||||
int died = -1;
|
||||
for (int i = 0; i < server.num_apps; i++)
|
||||
if (server.apps[i] == app) { died = i; break; }
|
||||
server.active = NULL;
|
||||
if (died >= 0) {
|
||||
/* Prefer the neighbor to the left (index died-1); if this is the
|
||||
first tab, fall through to the rightmost-side neighbor so the
|
||||
tab that was second becomes the new leftmost. */
|
||||
for (int i = died - 1; i >= 0; i--)
|
||||
if (server.apps[i]->toplevel) {
|
||||
set_active_surface(server.apps[i]->toplevel);
|
||||
return;
|
||||
}
|
||||
for (int i = died + 1; i < server.num_apps; i++)
|
||||
if (server.apps[i]->toplevel) {
|
||||
set_active_surface(server.apps[i]->toplevel);
|
||||
return;
|
||||
}
|
||||
if (server.active_app == app) {
|
||||
server.active_app = NULL;
|
||||
/* Prefer the neighbor to the left (index died-1); if this is the
|
||||
first tab, fall through to the right-side neighbor so the tab
|
||||
that was second becomes the new leftmost. */
|
||||
struct tabbed_app *succ = pick_successor(app, -1);
|
||||
if (succ) {
|
||||
set_active_app(succ);
|
||||
} else {
|
||||
/* last tab closed: close the host window (nested only) and exit */
|
||||
if (host.toplevel) xdg_toplevel_destroy(host.toplevel);
|
||||
if (host.surface) wl_surface_destroy(host.surface);
|
||||
shutdown_cleanup();
|
||||
exit(0);
|
||||
}
|
||||
/* last tab closed: close the host window (nested only) and exit */
|
||||
if (host.toplevel) xdg_toplevel_destroy(host.toplevel);
|
||||
if (host.surface) wl_surface_destroy(host.surface);
|
||||
shutdown_cleanup();
|
||||
exit(0);
|
||||
}
|
||||
/* The slot itself is removed outright: closed tabs leave no hole. */
|
||||
remove_app(app_index(app));
|
||||
}
|
||||
|
||||
/* Grace period after the Alt+Delete SIGTERM before SIGKILL escalation. */
|
||||
#define TABBED_CLOSE_ESCALATE_MS 150
|
||||
|
||||
/* Escalate an Alt+Delete close to SIGKILL if the client ignored the SIGTERM
|
||||
and its toplevel is still up. The callback lives on the app slot, which is
|
||||
never freed at runtime (only in shutdown_cleanup), and handle_toplevel_destroy
|
||||
removes the source once the toplevel dies, so a stale callback can never fire
|
||||
against a reaped or reused pid. */
|
||||
and its toplevel is still up. Live slots are removed at toplevel destroy
|
||||
(which also cancels this timer), so for a mapped tab the callback never
|
||||
fires against a reaped or reused pid. For a black tab there is no
|
||||
toplevel whose destroy would reap the slot, so its removal happens here,
|
||||
right after the SIGKILL. */
|
||||
static int close_tab_escalate(void *data) {
|
||||
struct tabbed_app *app = data;
|
||||
if (app->closing && app_killable(app))
|
||||
|
|
@ -1953,43 +2047,47 @@ static int close_tab_escalate(void *data) {
|
|||
wl_event_source_remove(app->close_timer);
|
||||
app->close_timer = NULL;
|
||||
}
|
||||
if (app->closing && !app->toplevel)
|
||||
remove_app(app_index(app));
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Alt+Delete close of the active tab. The tab vanishes immediately: the
|
||||
active surface moves to the right neighbor (or the left neighbor if the
|
||||
closed tab was the rightmost) in this same call, and the dying client's
|
||||
scene node is disabled. The client's whole process group gets SIGTERM, then
|
||||
SIGKILL after a short grace if it ignores the TERM (see close_tab_escalate).
|
||||
When the last tab is closed the session tears down via handle_toplevel_destroy
|
||||
once the client's socket drops. */
|
||||
active crown moves to the right neighbor (or the left neighbor if the
|
||||
closed tab was the rightmost) in this same call. The client's whole
|
||||
process group gets SIGTERM, then SIGKILL after a short grace if it ignores
|
||||
the TERM (see close_tab_escalate). A closing black tab (still pending)
|
||||
drops out of the overlay and cycle right away and is removed by the
|
||||
escalation; a closing last tab tears the session down here, since nothing
|
||||
else will. */
|
||||
static void close_active_tab(void) {
|
||||
struct tabbed_app *app = app_for_toplevel(server.active);
|
||||
struct tabbed_app *app = server.active_app;
|
||||
if (!app || !app_killable(app)) return;
|
||||
pid_t g = app_signal_group(app);
|
||||
pid_t shown = (g > 0 && g != getpgid(0)) ? g : app->client_pid;
|
||||
|
||||
app->closing = true;
|
||||
|
||||
int died = app_index(app);
|
||||
struct wlr_xdg_toplevel *next = NULL;
|
||||
/* Prefer the neighbor to the right so the tabs shift left; a rightmost
|
||||
tab falls back to its left neighbor. */
|
||||
for (int i = died + 1; i < server.num_apps; i++)
|
||||
if (server.apps[i]->toplevel && !server.apps[i]->closing) {
|
||||
next = server.apps[i]->toplevel;
|
||||
break;
|
||||
}
|
||||
if (!next)
|
||||
for (int i = died - 1; i >= 0; i--)
|
||||
if (server.apps[i]->toplevel && !server.apps[i]->closing) {
|
||||
next = server.apps[i]->toplevel;
|
||||
break;
|
||||
}
|
||||
if (next)
|
||||
set_active_surface(next);
|
||||
/* With no successor this was the last tab; handle_toplevel_destroy exits. */
|
||||
|
||||
pid_t shown = (g > 0 && g != getpgid(0)) ? g : app->client_pid;
|
||||
/* A black tab never fires a toplevel destroy, so drop it from the
|
||||
overlay/cycle immediately; only the escalation timer and the
|
||||
success-removal paths touch it afterwards. */
|
||||
if (!app->toplevel)
|
||||
app->pending_claim = false;
|
||||
|
||||
struct tabbed_app *succ = pick_successor(app, +1);
|
||||
if (succ) {
|
||||
set_active_app(succ);
|
||||
} else {
|
||||
/* Last live tab: tear the session down now. */
|
||||
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: closing tab %d (pid %d)\n",
|
||||
died, (int)shown);
|
||||
if (host.toplevel) xdg_toplevel_destroy(host.toplevel);
|
||||
if (host.surface) wl_surface_destroy(host.surface);
|
||||
shutdown_cleanup();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
kill_app(app, SIGTERM);
|
||||
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: closing tab %d (pid %d)\n",
|
||||
died, (int)shown);
|
||||
|
|
@ -2109,7 +2207,7 @@ static void handle_toplevel_commit(struct wl_listener *listener, void *data) {
|
|||
is_dma,
|
||||
is_dma ? (dma.n_planes ? "" : " planes=0") : "",
|
||||
node_on,
|
||||
server.active == toplevel);
|
||||
server.active_app == app);
|
||||
if (is_dma) {
|
||||
tabbed_log(TABBED_LOG_DEBUG, "[diag] dmabuf planes=%d "
|
||||
"format=0x%x mod=0x%llx\n",
|
||||
|
|
@ -2237,19 +2335,28 @@ static void handle_toplevel_map(struct wl_listener *listener, void *data) {
|
|||
|
||||
/* A new tab never steals focus on its own. The exceptions: a
|
||||
Alt+Shift+T terminal (add_tab), a sub-window whose parent tab is the
|
||||
one currently shown, and the one-shot startup crown of the last
|
||||
command-line command (only while nothing is focused yet). Every other
|
||||
map stays hidden -- the overlay and cycling reach it, but it does not
|
||||
yank the active surface. */
|
||||
one currently shown, and the startup-selected last command-line tab
|
||||
when it finally maps (it has been the active, black tab all along,
|
||||
revealed the moment its first frame arrives). Every other map stays
|
||||
hidden -- the overlay and cycling reach it, but it does not yank the
|
||||
active surface. A slot's toplevel is set at claim time (before its
|
||||
map), so "is it already shown?" is whether the scene node is enabled,
|
||||
not whether active_toplevel() is set. */
|
||||
bool activate = false;
|
||||
if (app->origin == ORIGIN_ADD_TAB)
|
||||
activate = true;
|
||||
else if (app->origin == ORIGIN_SUB && app->parent_app &&
|
||||
app->parent_app->toplevel == server.active)
|
||||
app->parent_app->toplevel == active_toplevel())
|
||||
activate = true;
|
||||
else if (app == server.crown_app && !server.active)
|
||||
else if (app == server.active_app && app->tree &&
|
||||
!app->tree->node.enabled)
|
||||
activate = true;
|
||||
|
||||
/* A slot claimed while it was still black may have been drawn with a
|
||||
placeholder icon; once a real client maps it, re-resolve the icon so
|
||||
the overlay picks up its app_id. */
|
||||
app->icon_resolved = false;
|
||||
|
||||
if (activate) {
|
||||
wlr_xdg_toplevel_set_activated(toplevel, true);
|
||||
set_active_surface(toplevel);
|
||||
|
|
@ -2266,18 +2373,28 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) {
|
|||
&cpid, NULL, NULL);
|
||||
|
||||
struct tabbed_app *app = NULL;
|
||||
/* Prefer a slot prepared by add_tab() (Alt+Shift+T): its position was
|
||||
chosen to sit right next to the tab the user launched it from, so an
|
||||
earlier stale free slot (a closed tab's hole) must not steal it. */
|
||||
for (int i = 0; i < server.num_apps && !app; i++)
|
||||
if (!server.apps[i]->toplevel && server.apps[i]->pending_claim)
|
||||
app = server.apps[i];
|
||||
/* A slot awaiting its toplevel -- an unclaimed command-line command or
|
||||
a fresh Alt+Shift+T terminal -- belongs to exactly ONE client: the one
|
||||
WE launched for it. launch_client() runs each command in its own
|
||||
process group (setpgid(0,0)), so the connected client's group id
|
||||
equals the launcher pid app->pid, including through a shell wrapper
|
||||
(`sh -c "foot -e htop"`). Matching by group -- not exact pid -- is
|
||||
what keeps a wrapped command on its own command-line slot. A client
|
||||
from a group we never created (an external one) never matches, so it
|
||||
can't steal a still-loading tab's position. */
|
||||
pid_t pgid = cpid > 0 ? getpgid(cpid) : -1;
|
||||
for (int i = 0; i < server.num_apps && !app; i++) {
|
||||
struct tabbed_app *a = server.apps[i];
|
||||
if (a->toplevel || !a->pending_claim) continue;
|
||||
if (a->pid > 0 && a->pid == pgid)
|
||||
app = a;
|
||||
}
|
||||
/* A second toplevel from a client that already owns a tab is a sub-window
|
||||
of that tab: give it its own slot glued right after its parent (the
|
||||
protocol parent, else the owning tab), never touching a reserved
|
||||
pending or free CLI slot. Command-line commands are forked one at a
|
||||
time (see maybe_launch_next), so their clients never match here and
|
||||
claim their own slots in command-line order below. */
|
||||
pending slot. Command-line commands are all forked at startup (see
|
||||
launch_all_cli); their clients match the pending loop above, and only
|
||||
then. */
|
||||
if (!app && cpid > 0) {
|
||||
struct tabbed_app *owner = sub_window_owner(cpid);
|
||||
if (owner) {
|
||||
|
|
@ -2294,16 +2411,19 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) {
|
|||
app = sub;
|
||||
}
|
||||
}
|
||||
/* Claim the first free slot: the serial CLI command that just connected
|
||||
lands on its own command-line slot (later commands have not even been
|
||||
forked yet, so nothing can claim ahead of it), or an external client
|
||||
started from our own shell takes the earliest free hole. */
|
||||
/* No pending slot matched (an external client, or one from a group we
|
||||
never created). Closed tabs are removed outright, so there are no
|
||||
free holes to fall back into; this loop only catches a slot that is
|
||||
transiently without a toplevel and not reserved, so it usually falls
|
||||
through to the ORIGIN_EXTERNAL append below. */
|
||||
if (!app)
|
||||
for (int i = 0; i < server.num_apps; i++)
|
||||
if (!server.apps[i]->toplevel) {
|
||||
app = server.apps[i];
|
||||
for (int i = 0; i < server.num_apps; i++) {
|
||||
struct tabbed_app *a = server.apps[i];
|
||||
if (!a->toplevel && !a->pending_claim && !a->closing) {
|
||||
app = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!app) {
|
||||
/* No slot for this toplevel (an external client with every slot
|
||||
taken). Give it its own tab so it appears on top and stays
|
||||
|
|
@ -2318,7 +2438,7 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) {
|
|||
struct tabbed_app *pa = toplevel->parent
|
||||
? app_for_toplevel(toplevel->parent) : NULL;
|
||||
if (!pa)
|
||||
pa = app_for_toplevel(server.active);
|
||||
pa = app_for_toplevel(active_toplevel());
|
||||
insert_app_after(extra, pa);
|
||||
app = extra;
|
||||
}
|
||||
|
|
@ -2332,15 +2452,14 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) {
|
|||
|
||||
/* [claim] ties the app slot to the *connected client's* PID, so tests
|
||||
can kill the tab that is actually active. The slot was chosen above by
|
||||
add_tab()'s pending claim, by a serial-launched CLI command landing on
|
||||
its own slot, by the sub-window/parent rule, or by the free-slot
|
||||
fallback. */
|
||||
/* The client that actually occupies this slot may be the CLI command that
|
||||
forked it, an add_tab() tab, or a fallback-claimed extra. Record its
|
||||
process group so a close signals the right one -- but only when that
|
||||
group is one WE own. An externally started client (e.g. from the
|
||||
compositor's own terminal) shares our process group; adopting it would
|
||||
make close/shutdown group-signal the launcher's whole session. Such
|
||||
the process-group match against a pending command (CLI or add_tab), by
|
||||
the sub-window/parent rule, or by the ORIGIN_EXTERNAL append. */
|
||||
/* The client that actually occupies this slot may be the command we
|
||||
forked, an add_tab() tab, or an external client. Record its process
|
||||
group so a close signals the right one -- but only when that group is
|
||||
one WE own. An externally started client (e.g. from the compositor's
|
||||
own terminal) shares our process group; adopting it would make
|
||||
close/shutdown group-signal the launcher's whole session. Such
|
||||
clients are killed by pid. */
|
||||
app->client_pid = cpid;
|
||||
pid_t cpg = cpid > 0 ? getpgid(cpid) : -1;
|
||||
|
|
@ -2367,10 +2486,6 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) {
|
|||
wl_signal_add(&toplevel->events.request_move, &app->request_move);
|
||||
app->request_resize.notify = handle_request_resize;
|
||||
wl_signal_add(&toplevel->events.request_resize, &app->request_resize);
|
||||
|
||||
/* A command-line tab has connected: unblock the launcher so the next
|
||||
command-line command can fork. */
|
||||
maybe_launch_next();
|
||||
}
|
||||
|
||||
/* ===================== Tab management ===================== */
|
||||
|
|
@ -2494,54 +2609,77 @@ static void handle_new_popup(struct wl_listener *listener, void *data) {
|
|||
create_popup(popup, parent_tree);
|
||||
}
|
||||
|
||||
static void set_active_surface(struct wlr_xdg_toplevel *toplevel) {
|
||||
if (server.active == toplevel) return;
|
||||
/* Make `app` the active slot. A mapped slot shows its surface and takes
|
||||
keyboard focus; a pending (black) slot shows the backdrop and drops the
|
||||
seat focus until it maps. The old active slot is hidden and deactivated.
|
||||
Called with the same app as already active only to promote it black ->
|
||||
mapped (the one case with something still to do), so callers that want a
|
||||
true no-op must guard first. */
|
||||
static void set_active_app(struct tabbed_app *app) {
|
||||
if (!app) return;
|
||||
|
||||
struct tabbed_app *old = app_for_toplevel(server.active);
|
||||
if (old && old->tree)
|
||||
wlr_scene_node_set_enabled(&old->tree->node, false);
|
||||
if (server.active)
|
||||
wlr_xdg_toplevel_set_activated(server.active, false);
|
||||
struct tabbed_app *old = server.active_app;
|
||||
if (old && old != app && old->toplevel) {
|
||||
if (old->tree)
|
||||
wlr_scene_node_set_enabled(&old->tree->node, false);
|
||||
wlr_xdg_toplevel_set_activated(old->toplevel, false);
|
||||
}
|
||||
server.active_app = app;
|
||||
|
||||
server.active = toplevel;
|
||||
wlr_xdg_toplevel_set_activated(toplevel, true);
|
||||
|
||||
struct tabbed_app *app = app_for_toplevel(server.active);
|
||||
if (app && app->tree) {
|
||||
if (app->toplevel) {
|
||||
wlr_xdg_toplevel_set_activated(app->toplevel, true);
|
||||
position_toplevel(app);
|
||||
wlr_scene_node_set_enabled(&app->tree->node, true);
|
||||
if (app->tree)
|
||||
wlr_scene_node_set_enabled(&app->tree->node, true);
|
||||
if (tabbed_log_level() >= TABBED_LOG_DEBUG)
|
||||
tabbed_log(TABBED_LOG_DEBUG, "[diag] active app=%d surface=%dx%d "
|
||||
"node on\n", app_index(app),
|
||||
toplevel->base->surface->current.width,
|
||||
toplevel->base->surface->current.height);
|
||||
app->toplevel->base->surface->current.width,
|
||||
app->toplevel->base->surface->current.height);
|
||||
} else if (tabbed_log_level() >= TABBED_LOG_DEBUG) {
|
||||
tabbed_log(TABBED_LOG_DEBUG, "[diag] active app=%d has NO scene node!\n",
|
||||
app ? app_index(app) : -1);
|
||||
tabbed_log(TABBED_LOG_DEBUG, "[diag] active slot=%d (black, awaiting map)\n",
|
||||
app_index(app));
|
||||
}
|
||||
|
||||
/* In standalone the seat keyboard is the emitting native device (set by
|
||||
the standalone handlers); fall back to the virtual keyboard otherwise
|
||||
so the nested/ctl path keeps using it. */
|
||||
so the nested/ctl path keeps using it. A black active tab has no
|
||||
surface to enter, so clear the focus instead of leaving a stale one. */
|
||||
struct wlr_keyboard *kbd = wlr_seat_get_keyboard(server.seat);
|
||||
if (!kbd) kbd = &server.keyboard;
|
||||
wlr_seat_keyboard_notify_enter(server.seat, toplevel->base->surface,
|
||||
kbd->keycodes, kbd->num_keycodes, &kbd->modifiers);
|
||||
if (app->toplevel)
|
||||
wlr_seat_keyboard_notify_enter(server.seat,
|
||||
app->toplevel->base->surface,
|
||||
kbd->keycodes, kbd->num_keycodes, &kbd->modifiers);
|
||||
else
|
||||
wlr_seat_keyboard_notify_clear_focus(server.seat);
|
||||
needs_redraw = true;
|
||||
}
|
||||
|
||||
static void set_active_surface(struct wlr_xdg_toplevel *toplevel) {
|
||||
struct tabbed_app *app = app_for_toplevel(toplevel);
|
||||
if (!app) return;
|
||||
/* Promoting the already-active black tab to mapped is the one same-app
|
||||
call that must still run; everything else that's a true no-op returns
|
||||
here, so redundant activations emit no [diag] churn. */
|
||||
if (app == server.active_app && app->toplevel && app->tree &&
|
||||
app->tree->node.enabled)
|
||||
return;
|
||||
set_active_app(app);
|
||||
}
|
||||
|
||||
static void cycle_tab(int dir) {
|
||||
if (server.num_apps == 0) return;
|
||||
int idx = -1;
|
||||
for (int i = 0; i < server.num_apps; i++)
|
||||
if (server.apps[i]->toplevel == server.active) {
|
||||
if (server.apps[i] == server.active_app) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
for (int k = 1; k <= server.num_apps; k++) {
|
||||
int j = ((idx < 0 ? 0 : idx) + k * dir + server.num_apps) % server.num_apps;
|
||||
if (server.apps[j]->toplevel && !server.apps[j]->closing) {
|
||||
set_active_surface(server.apps[j]->toplevel);
|
||||
if (app_live(server.apps[j]) && !server.apps[j]->closing) {
|
||||
set_active_app(server.apps[j]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -2550,18 +2688,18 @@ static void cycle_tab(int dir) {
|
|||
/* Move the active tab one position up (dir>0: later in the list) or down
|
||||
(dir<0) within the overlay order, wrapping at the ends. The active
|
||||
surface is unchanged; only its slot in server.apps[] moves, which the
|
||||
overlay and cycling both iterate. Only slots that currently show a
|
||||
toplevel (and aren't mid-close, mirroring cycle_tab) count as positions;
|
||||
overlay and cycling both iterate. Only live slots (mapped or pending
|
||||
black, mirroring cycle_tab) that aren't mid-close count as positions;
|
||||
with fewer than two of them there is nowhere to move. */
|
||||
static void reorder_active_tab(int dir) {
|
||||
int n = 0;
|
||||
for (int i = 0; i < server.num_apps; i++)
|
||||
if (server.apps[i]->toplevel && !server.apps[i]->closing)
|
||||
if (app_live(server.apps[i]) && !server.apps[i]->closing)
|
||||
n++;
|
||||
if (n < 2) return;
|
||||
int from = -1;
|
||||
for (int i = 0; i < server.num_apps; i++)
|
||||
if (server.apps[i]->toplevel == server.active) {
|
||||
if (server.apps[i] == server.active_app) {
|
||||
from = i;
|
||||
break;
|
||||
}
|
||||
|
|
@ -2570,25 +2708,25 @@ static void reorder_active_tab(int dir) {
|
|||
int neighbor = -1;
|
||||
if (dir > 0) {
|
||||
for (int i = from + 1; i < server.num_apps; i++)
|
||||
if (server.apps[i]->toplevel && !server.apps[i]->closing) {
|
||||
if (app_live(server.apps[i]) && !server.apps[i]->closing) {
|
||||
neighbor = i;
|
||||
break;
|
||||
}
|
||||
if (neighbor < 0)
|
||||
for (int i = 0; i < server.num_apps; i++)
|
||||
if (server.apps[i]->toplevel && !server.apps[i]->closing) {
|
||||
if (app_live(server.apps[i]) && !server.apps[i]->closing) {
|
||||
neighbor = i;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
for (int i = from - 1; i >= 0; i--)
|
||||
if (server.apps[i]->toplevel && !server.apps[i]->closing) {
|
||||
if (app_live(server.apps[i]) && !server.apps[i]->closing) {
|
||||
neighbor = i;
|
||||
break;
|
||||
}
|
||||
if (neighbor < 0)
|
||||
for (int i = server.num_apps - 1; i >= 0; i--)
|
||||
if (server.apps[i]->toplevel && !server.apps[i]->closing) {
|
||||
if (app_live(server.apps[i]) && !server.apps[i]->closing) {
|
||||
neighbor = i;
|
||||
break;
|
||||
}
|
||||
|
|
@ -3565,10 +3703,10 @@ static void host_kbd_enter(void *data, struct wl_keyboard *kbd, uint32_t serial,
|
|||
bridge_host_set_selection(server.seat->selection_source);
|
||||
}
|
||||
tabbed_log(TABBED_LOG_DEBUG, "[kbd] enter surface=%p serial=%u active=%p\n",
|
||||
(void *)surface, serial, (void *)server.active);
|
||||
if (server.active)
|
||||
wlr_seat_keyboard_notify_enter(server.seat,
|
||||
server.active->base->surface,
|
||||
(void *)surface, serial, (void *)active_toplevel());
|
||||
struct wlr_xdg_toplevel *tl = active_toplevel();
|
||||
if (tl)
|
||||
wlr_seat_keyboard_notify_enter(server.seat, tl->base->surface,
|
||||
server.keyboard.keycodes, server.keyboard.num_keycodes,
|
||||
&server.keyboard.modifiers);
|
||||
}
|
||||
|
|
@ -4973,39 +5111,82 @@ static void launch_client(struct tabbed_app *app) {
|
|||
}
|
||||
if (pid > 0) {
|
||||
app->pid = pid;
|
||||
app->launched = true;
|
||||
tabbed_log(TABBED_LOG_INFO, "[launch] app=%d pid=%d cmd=%s\n",
|
||||
app_index(app), pid, app->cmd);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fork the next command-line command, one at a time, gated on the previous
|
||||
one's connection. Because only one such client is running at a time, the
|
||||
next connector is unambiguously the next command, so the tab order follows
|
||||
the command-line order with no pid matching. A command that never
|
||||
connects stalls the chain (and, for the last command, the startup crown)
|
||||
indefinitely, leaving the window black -- accepted behaviour. */
|
||||
static void maybe_launch_next(void) {
|
||||
struct tabbed_app *next = NULL;
|
||||
/* Fork every command-line command at startup, in command-line order. Each
|
||||
slot keeps its position because handle_new_toplevel claims it by process
|
||||
group (launch_client runs each command in its own setpgid'd group, whose
|
||||
id equals app->pid), so the tab order mirrors the command line no matter
|
||||
which client connects first. A command that never connects leaves its
|
||||
tab black; a command whose process group exits without ever connecting is
|
||||
swept away later (see sweep_dead_startup_groups). */
|
||||
static void launch_all_cli(void) {
|
||||
for (int i = 0; i < server.num_apps; i++) {
|
||||
struct tabbed_app *a = server.apps[i];
|
||||
if (a->origin != ORIGIN_CLI || a->launched) continue;
|
||||
if (!next || a->cli_seq < next->cli_seq) next = a;
|
||||
if (a->origin != ORIGIN_CLI) continue;
|
||||
launch_client(a);
|
||||
}
|
||||
if (!next) return;
|
||||
if (next->cli_seq > 0) {
|
||||
bool prev_connected = false;
|
||||
for (int i = 0; i < server.num_apps; i++) {
|
||||
struct tabbed_app *a = server.apps[i];
|
||||
if (a->origin == ORIGIN_CLI &&
|
||||
a->cli_seq == next->cli_seq - 1 && a->toplevel) {
|
||||
prev_connected = true;
|
||||
break;
|
||||
}
|
||||
|
||||
/* Drop black tabs whose command's whole process group has exited (e.g.
|
||||
`tabbed-wlroots /bin/false` -- sh runs false, the group empties, and the
|
||||
tab would otherwise stay black forever). Runs from the main loop after a
|
||||
SIGCHLD, never from the handler: it frees slots. Group liveness, not the
|
||||
launcher pid's, decides: `sh -c "cmd &"` returns while cmd is still
|
||||
starting, but cmd holds the group open, so kill(-groupid,0) succeeds and
|
||||
the tab survives. */
|
||||
static void sweep_dead_startup_groups(void) {
|
||||
for (int i = 0; i < server.num_apps; i++) {
|
||||
struct tabbed_app *a = server.apps[i];
|
||||
if (a->toplevel || !a->pending_claim || a->closing || a->pid <= 0)
|
||||
continue;
|
||||
errno = 0;
|
||||
if (kill(-a->pid, 0) == 0 || errno == EPERM)
|
||||
continue; /* the group still has a member */
|
||||
if (errno != ESRCH)
|
||||
continue;
|
||||
if (server.active_app == a) {
|
||||
server.active_app = NULL;
|
||||
struct tabbed_app *succ = pick_successor(a, -1);
|
||||
if (!succ) succ = pick_successor(a, +1);
|
||||
if (succ)
|
||||
set_active_app(succ);
|
||||
else {
|
||||
/* the last tab died before ever connecting: tear down */
|
||||
if (host.toplevel) xdg_toplevel_destroy(host.toplevel);
|
||||
if (host.surface) wl_surface_destroy(host.surface);
|
||||
shutdown_cleanup();
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
if (!prev_connected) return;
|
||||
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: dropping tab %d (pid %d) "
|
||||
"-- command exited without connecting\n", i, (int)a->pid);
|
||||
remove_app(i);
|
||||
i--;
|
||||
}
|
||||
launch_client(next);
|
||||
/* If the sweep's succession handed active to a slot that this same pass
|
||||
then removed, put the crown back on some live tab (or nothing). */
|
||||
if (!server.active_app) {
|
||||
struct tabbed_app *succ = NULL;
|
||||
for (int i = 0; i < server.num_apps; i++)
|
||||
if (app_live(server.apps[i]) && !server.apps[i]->closing) {
|
||||
succ = server.apps[i];
|
||||
break;
|
||||
}
|
||||
if (succ)
|
||||
set_active_app(succ);
|
||||
}
|
||||
}
|
||||
|
||||
/* Drain the SIGCHLD latch and reap dead startup groups; called from both
|
||||
event loops after each dispatch. */
|
||||
static void sweep_dead_startup(void) {
|
||||
if (!sigchld_seen) return;
|
||||
sigchld_seen = 0;
|
||||
sweep_dead_startup_groups();
|
||||
}
|
||||
|
||||
/* Add a new tab launching `cmd` and start it, exactly as if `cmd` had been
|
||||
|
|
@ -5022,7 +5203,7 @@ static int add_tab(const char *cmd) {
|
|||
app->pending_claim = true;
|
||||
app->origin = ORIGIN_ADD_TAB;
|
||||
|
||||
insert_app_after(app, app_for_toplevel(server.active));
|
||||
insert_app_after(app, app_for_toplevel(active_toplevel()));
|
||||
int idx = app_index(app);
|
||||
launch_client(app);
|
||||
return idx;
|
||||
|
|
@ -5042,6 +5223,7 @@ static void crash_handler(int sig, siginfo_t *info, void *ctx) {
|
|||
|
||||
static void sigchld_handler(int sig) {
|
||||
(void)sig;
|
||||
sigchld_seen = 1;
|
||||
int status;
|
||||
pid_t pid;
|
||||
if (tabbed_log_level() >= TABBED_LOG_DEBUG) {
|
||||
|
|
@ -5547,13 +5729,11 @@ int main(int argc, char *argv[]) {
|
|||
app->cmd = strdup(default_tab ? term_cmd : appv[i]);
|
||||
app->pid = -1;
|
||||
app->origin = ORIGIN_CLI;
|
||||
app->cli_seq = i;
|
||||
/* Live-but-black until the command claims the slot with a toplevel. */
|
||||
app->pending_claim = true;
|
||||
server.apps[i] = app;
|
||||
}
|
||||
server.num_apps = total_apps;
|
||||
/* The last command-line command is the one the startup crown singles
|
||||
out once its toplevel maps. */
|
||||
server.crown_app = server.apps[total_apps - 1];
|
||||
|
||||
for (int i = 0; i < server.num_apps; i++) {
|
||||
if (server.apps[i]->cmd[0] == '-') {
|
||||
|
|
@ -5573,9 +5753,15 @@ int main(int argc, char *argv[]) {
|
|||
}
|
||||
}
|
||||
}
|
||||
/* Launch the command-line commands one at a time, gated on each one
|
||||
connecting (see maybe_launch_next). */
|
||||
maybe_launch_next();
|
||||
|
||||
/* The last 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]);
|
||||
|
||||
/* Fork every command-line command at once; the process-group match in
|
||||
handle_new_toplevel gives each its own slot on arrival. */
|
||||
launch_all_cli();
|
||||
|
||||
const char *prefix = tab_prefix == PREFIX_SUPER ? "Super" : "Alt";
|
||||
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: running. "
|
||||
|
|
@ -5594,6 +5780,7 @@ int main(int argc, char *argv[]) {
|
|||
ret = wl_event_loop_dispatch(server.loop, -1);
|
||||
} while (ret == -1 && errno == EINTR);
|
||||
wl_display_flush_clients(server.wl_display);
|
||||
sweep_dead_startup();
|
||||
if (frame_pending || needs_redraw) {
|
||||
frame_pending = false;
|
||||
needs_redraw = false;
|
||||
|
|
@ -5634,6 +5821,7 @@ int main(int argc, char *argv[]) {
|
|||
wl_event_loop_dispatch(server.loop, 0);
|
||||
}
|
||||
wl_display_flush_clients(server.wl_display);
|
||||
sweep_dead_startup();
|
||||
wl_display_dispatch_pending(host.display);
|
||||
|
||||
/* The headless output fires a frame signal at ~60Hz; render then
|
||||
|
|
|
|||
|
|
@ -249,13 +249,15 @@ class Session:
|
|||
pytest.fail(f"active app never changed away from {away_from}")
|
||||
|
||||
def wait_any_active(self, timeout=10.0):
|
||||
"""Wait until the startup crown has activated a tab, return its index.
|
||||
"""Wait until the startup-selected tab has activated (its first map),
|
||||
return its index.
|
||||
|
||||
Only the last command-line command is ever auto-activated on boot
|
||||
(the "crown"); every earlier tab maps hidden with no `active app=`
|
||||
line, so the first active line is the settled tab -- there is no
|
||||
churn to wait out. For the fixtures' N-tab sessions the crown is
|
||||
always index N-1.
|
||||
The last command-line command is active from the very first frame --
|
||||
black, titled by its command line -- and is the only tab that ever
|
||||
auto-activates on boot; every earlier tab maps hidden with no
|
||||
``active app=`` line. The first active line is therefore the settled
|
||||
tab, with no churn to wait out. For the fixtures' N-tab sessions
|
||||
that tab is index N-1.
|
||||
"""
|
||||
rx = re.compile(r"active app=(\d+)")
|
||||
since = time.monotonic()
|
||||
|
|
@ -264,7 +266,23 @@ class Session:
|
|||
if m:
|
||||
return int(m[-1])
|
||||
time.sleep(0.1)
|
||||
pytest.fail("no tab was ever activated (startup crown never fired)")
|
||||
pytest.fail("no tab was ever activated (startup tab never mapped)")
|
||||
|
||||
def wait_active_frame(self, timeout=10.0):
|
||||
"""Wait until the active tab has painted a real frame: the composed
|
||||
(or host-presented) frame's centre is no longer the black backdrop.
|
||||
Guards single-read pixel asserts that need black to mean "overlay
|
||||
shown" and probe areas to mean "pure client colour"."""
|
||||
since = time.monotonic()
|
||||
while time.monotonic() - since < timeout:
|
||||
path = self.dump_host()
|
||||
if path:
|
||||
w, h, px = self.ppm_rgb(path)
|
||||
i = 3 * (h // 2 * w + w // 2)
|
||||
if (px[i], px[i + 1], px[i + 2]) != (0, 0, 0):
|
||||
return
|
||||
time.sleep(0.1)
|
||||
pytest.fail("active tab never painted a frame (centre stayed black)")
|
||||
|
||||
def wait_app_active(self, index, timeout=10.0):
|
||||
"""Wait until the single app `index` reports itself active (for
|
||||
|
|
@ -508,6 +526,7 @@ def test_standalone_headless_alt_overlay_scene(standalone_session):
|
|||
whose centre darkens while Alt is held."""
|
||||
s = standalone_session
|
||||
s.wait_any_active()
|
||||
s.wait_active_frame()
|
||||
|
||||
# Before Alt: the frame should be the client content (no dark overlay).
|
||||
w, h, px = s.ppm_rgb(s.dump_host())
|
||||
|
|
@ -577,6 +596,7 @@ def test_standalone_headless_cursor_visible(standalone_session):
|
|||
through the same flip."""
|
||||
s = standalone_session
|
||||
s.wait_any_active()
|
||||
s.wait_active_frame()
|
||||
assert s.proc.poll() is None
|
||||
|
||||
def patch_at(path, cx, cy, r=22):
|
||||
|
|
@ -636,6 +656,7 @@ def test_standalone_headless_cursor_relative_motion(standalone_session):
|
|||
the cursor."""
|
||||
s = standalone_session
|
||||
s.wait_any_active()
|
||||
s.wait_active_frame()
|
||||
assert s.proc.poll() is None
|
||||
|
||||
def patch_at(path, cx, cy, r=22):
|
||||
|
|
@ -704,6 +725,7 @@ def test_standalone_headless_screen_mode_cursor_stays_aligned():
|
|||
"build/wltest '#ff0000'")
|
||||
try:
|
||||
s.wait_app_active(0)
|
||||
s.wait_active_frame()
|
||||
assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}"
|
||||
|
||||
def patch_at(path, cx, cy, r=22):
|
||||
|
|
@ -1155,6 +1177,7 @@ def test_alt_holds_tab_overlay(session):
|
|||
box pixel appears in the centered window while Alt is held and reverts
|
||||
after release."""
|
||||
session.wait_any_active()
|
||||
session.wait_active_frame()
|
||||
|
||||
def has_dark(w, h, px):
|
||||
for y in range(h // 4, 3 * h // 4):
|
||||
|
|
@ -1737,3 +1760,48 @@ def test_missing_arg_fails():
|
|||
assert r.returncode != 0
|
||||
assert b"definitely-no-such-cmd-xyz" in (r.stdout + r.stderr)
|
||||
assert b"not found in PATH" in (r.stdout + r.stderr)
|
||||
|
||||
|
||||
def test_close_black_tab_active_last():
|
||||
"""Alt+Delete on a black tab (its command has not connected yet) closes
|
||||
it outright: the surviving tab becomes active, the black slot is removed
|
||||
(closed tabs leave no hole), and closing the survivor exits cleanly."""
|
||||
if not os.environ.get("WAYLAND_DISPLAY"):
|
||||
pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)")
|
||||
# CLI #0 (RED) maps hidden; the active tab is the still-black `sleep`.
|
||||
s = _nested_session(f"{WL} '{RED}'", "sleep 999")
|
||||
try:
|
||||
s.wait_for(r"\[diag\] map app=0")
|
||||
s.chord(ALT, DELETE)
|
||||
s.wait_for(r"tabbed-wlroots: closing tab 1")
|
||||
# The black tab is gone; the RED survivor becomes active in its place.
|
||||
s.wait_for(r"active app=0")
|
||||
assert s.proc.poll() is None, \
|
||||
"compositor exited after closing the black tab"
|
||||
|
||||
# Closing the last live tab tears the session down cleanly.
|
||||
m = re.search(r"\[claim\] app=0 pid=(\d+)", s.read_log())
|
||||
assert m, "RED tab never claimed a slot"
|
||||
os.kill(int(m.group(1)), signal.SIGTERM)
|
||||
s.proc.wait(timeout=5)
|
||||
assert s.proc.returncode == 0, \
|
||||
f"compositor exited with {s.proc.returncode} after last tab closed"
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_command_never_connecting_drops_tab():
|
||||
"""A command whose whole process group exits without ever connecting
|
||||
(`/bin/false`) is swept away instead of leaving a permanent black tab;
|
||||
the surviving command's tab stays active."""
|
||||
if not os.environ.get("WAYLAND_DISPLAY"):
|
||||
pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)")
|
||||
s = _nested_session("/bin/false", f"{WL} '{BLUE}'")
|
||||
try:
|
||||
s.wait_for(r"dropping tab 0")
|
||||
# The surviving tab claims the vacated index and becomes active.
|
||||
s.wait_for(r"active app=0")
|
||||
assert s.proc.poll() is None, \
|
||||
"compositor exited after dropping the never-connecting tab"
|
||||
finally:
|
||||
s.close()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue