Launch CLI tabs serially at the connection gate; crown the last command
Commands fork one at a time, each gated on the previous one's client connecting, so tab order always mirrors the command-line order (no pid matching, no /proc ancestry). The window stays black until the last command's tab maps, which is then crowned active. A new tab never steals focus on its own: only Ctrl+Shift+T terminals, sub-windows of the tab currently shown, and the startup crown activate. Sub-windows (a second toplevel from an already-connected client) get their own slot glued right after their parent tab. A command that never connects stalls the chain and the crown, leaving the window black (accepted). Tests: wait_any_active waits for the single deterministic crown; an externally-spawned client is now born hidden (cycle to it). Add wltest --extra (same-client second toplevel) plus tests for sub-window placement/focus and Ctrl+Shift+T focus-on-map. Update README.
This commit is contained in:
parent
0e57ced16c
commit
13154c78b5
4 changed files with 397 additions and 88 deletions
27
README.md
27
README.md
|
|
@ -92,6 +92,29 @@ Notes:
|
|||
only closes their tab; closing the host tabbed-wlroots window, or sending
|
||||
SIGINT/SIGTERM, shuts the whole session down and kills all client children.
|
||||
|
||||
### 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 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.
|
||||
|
||||
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 (`Ctrl+Shift+T` still works) until you close it.
|
||||
|
||||
Later tab openings follow the same no-steal rule: a brand-new tab never yanks
|
||||
the active surface on its own. `Ctrl+Shift+T` still focuses its fresh
|
||||
terminal, and a sub-window of the tab you are currently looking at becomes
|
||||
active; every other new window or tab just appears in the list, hidden, until
|
||||
you cycle to it.
|
||||
|
||||
## Key bindings
|
||||
|
||||
| Key | Action |
|
||||
|
|
@ -127,8 +150,8 @@ librsvg); tabs without a resolvable icon get a generic placeholder.
|
|||
|
||||
`Ctrl+Shift+T` launches a fresh terminal (`$TERMINAL`, default
|
||||
`alacritty`) as a new tab placed immediately to the right of the current tab,
|
||||
and it becomes the active tab once it maps — exactly as if its command had been
|
||||
given on the command line.
|
||||
and it becomes the active tab once it maps (the one exception to the
|
||||
no-map-steal rule at startup, along with sub-windows of the shown tab).
|
||||
|
||||
The tab-cycling hotkey only emits a physical press for the first tab switch;
|
||||
repeats are synthesized, so holding `Alt+Up/Down` keeps stepping through
|
||||
|
|
|
|||
230
tabbed-wlroots.c
230
tabbed-wlroots.c
|
|
@ -99,10 +99,11 @@
|
|||
struct tabbed_app {
|
||||
char *cmd;
|
||||
pid_t pid;
|
||||
/* Process group the *connected* client lives in. Slots are claimed in
|
||||
connection order (see handle_new_toplevel), which is not the launch
|
||||
order used for app->pid, so a close must signal the group recorded at
|
||||
claim time rather than the launcher's 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. */
|
||||
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
|
||||
|
|
@ -134,6 +135,22 @@ struct tabbed_app {
|
|||
"right of the current tab" position. */
|
||||
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 a Ctrl+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;
|
||||
|
||||
/* The user closed this tab (Alt+Delete). While set, cycle_tab skips the
|
||||
slot and the SIGKILL escalation timer (close_timer) may still be armed;
|
||||
cleared once the toplevel is destroyed or the slot is reused. */
|
||||
|
|
@ -172,6 +189,11 @@ struct tabbed_server {
|
|||
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;
|
||||
struct tabbed_app **apps;
|
||||
int num_apps;
|
||||
int apps_cap;
|
||||
|
|
@ -384,6 +406,9 @@ static struct tabbed_app *app_for_toplevel(struct wlr_xdg_toplevel *toplevel);
|
|||
static int app_index(struct tabbed_app *app);
|
||||
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);
|
||||
|
||||
/* Logging levels: quiet by default so a plain launch writes nothing to the
|
||||
launching terminal except fatal errors. TABBED_LOG=1 shows status lines,
|
||||
|
|
@ -437,8 +462,8 @@ static void tabbed_log_level_init(void) {
|
|||
}
|
||||
|
||||
/* The process group to signal for an app: the group its claimed client lives
|
||||
in (set at claim time; slots are claimed in connection order, not launch
|
||||
order), falling back to the launcher pid for slots with no client yet. */
|
||||
in (set at claim time), falling back to the launcher pid for slots with no
|
||||
client yet. */
|
||||
static pid_t app_signal_group(struct tabbed_app *app) {
|
||||
return (app && app->pgid > 0) ? app->pgid : (app ? app->pid : -1);
|
||||
}
|
||||
|
|
@ -2146,14 +2171,50 @@ static void handle_toplevel_commit(struct wl_listener *listener, void *data) {
|
|||
}
|
||||
}
|
||||
|
||||
/* Insert `app` in the slot order immediately after `anchor`, shifting the
|
||||
slots after it right; append at the far right if anchor is NULL or no
|
||||
longer present. */
|
||||
static void insert_app_after(struct tabbed_app *app, struct tabbed_app *anchor) {
|
||||
int at = server.num_apps;
|
||||
if (anchor)
|
||||
for (int i = 0; i < server.num_apps; i++)
|
||||
if (server.apps[i] == anchor) {
|
||||
at = i + 1;
|
||||
break;
|
||||
}
|
||||
if (at < server.num_apps)
|
||||
memmove(&server.apps[at + 1], &server.apps[at],
|
||||
(server.num_apps - at) * sizeof(*server.apps));
|
||||
server.apps[at] = app;
|
||||
server.num_apps++;
|
||||
}
|
||||
|
||||
static void handle_toplevel_map(struct wl_listener *listener, void *data) {
|
||||
(void)data;
|
||||
struct tabbed_app *app = wl_container_of(listener, app, map);
|
||||
struct wlr_xdg_toplevel *toplevel = app->toplevel;
|
||||
if (tabbed_log_level() >= TABBED_LOG_DEBUG)
|
||||
tabbed_log(TABBED_LOG_DEBUG, "[diag] map app=%d\n", app_index(app));
|
||||
wlr_xdg_toplevel_set_activated(toplevel, true);
|
||||
set_active_surface(toplevel);
|
||||
|
||||
/* A new tab never steals focus on its own. The exceptions: a
|
||||
Ctrl+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. */
|
||||
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)
|
||||
activate = true;
|
||||
else if (app == server.crown_app && !server.active)
|
||||
activate = true;
|
||||
|
||||
if (activate) {
|
||||
wlr_xdg_toplevel_set_activated(toplevel, true);
|
||||
set_active_surface(toplevel);
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_new_toplevel(struct wl_listener *listener, void *data) {
|
||||
|
|
@ -2172,19 +2233,38 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) {
|
|||
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 command-line startup tab belongs to the slot whose launch pid matches
|
||||
the connecting client, so the tab order mirrors the command-line order
|
||||
even though the clients start concurrently and connect out of sequence.
|
||||
(add_tab()'s tracked pid can also match its own slot, so the
|
||||
pending_claim pass above always wins for those.) */
|
||||
if (!app && cpid > 0)
|
||||
/* 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. */
|
||||
if (!app && cpid > 0) {
|
||||
struct tabbed_app *owner = NULL;
|
||||
for (int i = 0; i < server.num_apps; i++)
|
||||
if (!server.apps[i]->toplevel && server.apps[i]->pid == cpid) {
|
||||
app = server.apps[i];
|
||||
if (server.apps[i]->toplevel &&
|
||||
server.apps[i]->client_pid == cpid) {
|
||||
owner = server.apps[i];
|
||||
break;
|
||||
}
|
||||
/* Otherwise claim the first free slot: a client opened by a running app,
|
||||
or a wrapped command whose exec pid differs from the fork pid. */
|
||||
if (owner) {
|
||||
if (apps_ensure_cap(server.num_apps + 1) != 0) return;
|
||||
struct tabbed_app *sub = calloc(1, sizeof(*sub));
|
||||
if (!sub) return;
|
||||
sub->pid = -1;
|
||||
sub->cmd = NULL;
|
||||
sub->origin = ORIGIN_SUB;
|
||||
sub->parent_app = owner;
|
||||
struct tabbed_app *pa = toplevel->parent
|
||||
? app_for_toplevel(toplevel->parent) : NULL;
|
||||
insert_app_after(sub, pa ? pa : owner);
|
||||
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. */
|
||||
if (!app)
|
||||
for (int i = 0; i < server.num_apps; i++)
|
||||
if (!server.apps[i]->toplevel) {
|
||||
|
|
@ -2192,38 +2272,22 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) {
|
|||
break;
|
||||
}
|
||||
if (!app) {
|
||||
/* No slot for this toplevel (e.g. a dialog/second window opened by
|
||||
an already-running client). Give it its own tab so it appears on
|
||||
top and stays reachable via tab cycling. Place the new tab right
|
||||
after the tab that created it (protocol parent, else the active
|
||||
tab); fall back to the far right if neither can be identified. */
|
||||
/* No slot for this toplevel (an external client with every slot
|
||||
taken). Give it its own tab so it appears on top and stays
|
||||
reachable via tab cycling, right after the tab that created it
|
||||
(protocol parent, else the active tab); far right if unidentified. */
|
||||
if (apps_ensure_cap(server.num_apps + 1) != 0) return;
|
||||
app = calloc(1, sizeof(*app));
|
||||
if (!app) return;
|
||||
app->pid = -1;
|
||||
app->cmd = NULL;
|
||||
|
||||
int anchor = -1;
|
||||
struct tabbed_app *extra = calloc(1, sizeof(*extra));
|
||||
if (!extra) return;
|
||||
extra->pid = -1;
|
||||
extra->cmd = NULL;
|
||||
extra->origin = ORIGIN_EXTERNAL;
|
||||
struct tabbed_app *pa = toplevel->parent
|
||||
? app_for_toplevel(toplevel->parent) : NULL;
|
||||
if (!pa)
|
||||
pa = app_for_toplevel(server.active);
|
||||
if (pa)
|
||||
for (int i = 0; i < server.num_apps; i++)
|
||||
if (server.apps[i] == pa) {
|
||||
anchor = i;
|
||||
break;
|
||||
}
|
||||
if (anchor < 0) {
|
||||
server.apps[server.num_apps] = app;
|
||||
} else {
|
||||
int at = anchor + 1;
|
||||
if (at < server.num_apps)
|
||||
memmove(&server.apps[at + 1], &server.apps[at],
|
||||
(server.num_apps - at) * sizeof(*server.apps));
|
||||
server.apps[at] = app;
|
||||
}
|
||||
server.num_apps++;
|
||||
insert_app_after(extra, pa);
|
||||
app = extra;
|
||||
}
|
||||
|
||||
app->toplevel = toplevel;
|
||||
|
|
@ -2235,8 +2299,9 @@ 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
|
||||
pid match (CLI tabs keep command-line order), by add_tab()'s pending
|
||||
claim, or by the first-free-slot fallback. */
|
||||
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
|
||||
|
|
@ -2269,6 +2334,10 @@ 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 ===================== */
|
||||
|
|
@ -4846,7 +4915,7 @@ static int cmd_in_path(const char *cmd) {
|
|||
return 0;
|
||||
}
|
||||
|
||||
static void launch_client(int idx) {
|
||||
static void launch_client(struct tabbed_app *app) {
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
/* Own process group so we can kill the whole client tree on exit. */
|
||||
|
|
@ -4868,16 +4937,46 @@ static void launch_client(int idx) {
|
|||
if (nullfd > STDERR_FILENO) close(nullfd);
|
||||
}
|
||||
}
|
||||
execlp("/bin/sh", "sh", "-c", server.apps[idx]->cmd, NULL);
|
||||
execlp("/bin/sh", "sh", "-c", app->cmd, NULL);
|
||||
_exit(127);
|
||||
}
|
||||
if (pid > 0) {
|
||||
server.apps[idx]->pid = pid;
|
||||
app->pid = pid;
|
||||
app->launched = true;
|
||||
tabbed_log(TABBED_LOG_INFO, "[launch] app=%d pid=%d cmd=%s\n",
|
||||
idx, pid, server.apps[idx]->cmd);
|
||||
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;
|
||||
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 (!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;
|
||||
}
|
||||
}
|
||||
if (!prev_connected) return;
|
||||
}
|
||||
launch_client(next);
|
||||
}
|
||||
|
||||
/* Add a new tab launching `cmd` and start it, exactly as if `cmd` had been
|
||||
given as a command-line argument. The tab is inserted in the slot order
|
||||
right after the currently active tab (falling back to the far right if
|
||||
|
|
@ -4890,23 +4989,11 @@ static int add_tab(const char *cmd) {
|
|||
app->cmd = strdup(cmd);
|
||||
app->pid = -1;
|
||||
app->pending_claim = true;
|
||||
app->origin = ORIGIN_ADD_TAB;
|
||||
|
||||
int at = server.num_apps;
|
||||
struct tabbed_app *active_app = app_for_toplevel(server.active);
|
||||
if (active_app)
|
||||
for (int i = 0; i < server.num_apps; i++)
|
||||
if (server.apps[i] == active_app) {
|
||||
at = i + 1;
|
||||
break;
|
||||
}
|
||||
if (at < server.num_apps)
|
||||
memmove(&server.apps[at + 1], &server.apps[at],
|
||||
(server.num_apps - at) * sizeof(*server.apps));
|
||||
server.apps[at] = app;
|
||||
|
||||
int idx = at;
|
||||
server.num_apps++;
|
||||
launch_client(idx);
|
||||
insert_app_after(app, app_for_toplevel(server.active));
|
||||
int idx = app_index(app);
|
||||
launch_client(app);
|
||||
return idx;
|
||||
}
|
||||
|
||||
|
|
@ -5428,9 +5515,14 @@ 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;
|
||||
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] == '-') {
|
||||
|
|
@ -5449,8 +5541,10 @@ int main(int argc, char *argv[]) {
|
|||
server.apps[i]->cmd, server.apps[i]->cmd);
|
||||
}
|
||||
}
|
||||
launch_client(i);
|
||||
}
|
||||
/* Launch the command-line commands one at a time, gated on each one
|
||||
connecting (see maybe_launch_next). */
|
||||
maybe_launch_next();
|
||||
|
||||
const char *prefix = tab_prefix == PREFIX_SUPER ? "Super" : "Alt";
|
||||
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: running. "
|
||||
|
|
|
|||
|
|
@ -249,21 +249,22 @@ class Session:
|
|||
pytest.fail(f"active app never changed away from {away_from}")
|
||||
|
||||
def wait_any_active(self, timeout=10.0):
|
||||
"""Wait until the active tab has settled, return its index.
|
||||
"""Wait until the startup crown has activated a tab, return its index.
|
||||
|
||||
The two test clients map in nondeterministic order, so the first
|
||||
`active app=` line can name the first-mapped tab before the other
|
||||
maps. Only once *both* tabs have logged an active line is the
|
||||
last one the settled active tab.
|
||||
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.
|
||||
"""
|
||||
rx = re.compile(r"active app=(\d+)")
|
||||
since = time.monotonic()
|
||||
while time.monotonic() - since < timeout:
|
||||
seen = set(rx.findall(self.read_log()))
|
||||
if {"0", "1"}.issubset(seen):
|
||||
return self.active()
|
||||
m = rx.findall(self.read_log())
|
||||
if m:
|
||||
return int(m[-1])
|
||||
time.sleep(0.1)
|
||||
pytest.fail("both tabs never logged an active line")
|
||||
pytest.fail("no tab was ever activated (startup crown never fired)")
|
||||
|
||||
def wait_app_active(self, index, timeout=10.0):
|
||||
"""Wait until the single app `index` reports itself active (for
|
||||
|
|
@ -1251,8 +1252,12 @@ def test_tab_death_activates_left_neighbor(session):
|
|||
session.read_log()).group(1))
|
||||
pid0 = pids[0]
|
||||
|
||||
# Rightmost tab dies: fall to its left neighbor (index 1), not 0.
|
||||
# The externally-spawned client is born hidden (no map-autofocus
|
||||
# anymore), so cycle to it: active is 1 (the crown), +1 reaches 2.
|
||||
session.cycle(+1)
|
||||
session.wait_active(2)
|
||||
|
||||
# Rightmost tab dies: fall to its left neighbor (index 1), not 0.
|
||||
os.kill(pid2, signal.SIGTERM)
|
||||
assert session.wait_active_change(2) == 1, \
|
||||
"active did not fall to index 1 (left neighbor) after index 2 died"
|
||||
|
|
@ -1295,6 +1300,90 @@ def _nested_session(*tabs, extra_args=()):
|
|||
return Session(proc)
|
||||
|
||||
|
||||
def test_ctrl_shift_t_becomes_active(session):
|
||||
"""Ctrl+Shift+T's fresh terminal still becomes the active tab on map
|
||||
(the add_tab exception to the no-steal rule), while other new tabs never
|
||||
steal focus."""
|
||||
old_term = os.environ.get("TERMINAL")
|
||||
os.environ["TERMINAL"] = f"{WL} '#ffff00'"
|
||||
try:
|
||||
session.wait_any_active()
|
||||
before = session.active_count()
|
||||
session.chord(CTRL | SHIFT, 20)
|
||||
deadline = time.monotonic() + 15.0
|
||||
while session.active_count() <= before:
|
||||
assert session.proc.poll() is None, \
|
||||
"compositor exited after Ctrl+Shift+T"
|
||||
assert time.monotonic() < deadline, \
|
||||
"Ctrl+Shift+T's terminal never became active"
|
||||
time.sleep(0.1)
|
||||
assert session.active_count() == before + 1, \
|
||||
"Ctrl+Shift+T should produce exactly one focus transition"
|
||||
assert session.proc.poll() is None
|
||||
finally:
|
||||
if old_term is None:
|
||||
os.environ.pop("TERMINAL", None)
|
||||
else:
|
||||
os.environ["TERMINAL"] = old_term
|
||||
|
||||
|
||||
def test_sub_window_hidden_when_parent_not_active():
|
||||
"""A second toplevel from an already-running client is a sub-window of
|
||||
that tab: it gets its own slot glued right after the parent tab, and
|
||||
because the parent is not the tab being shown it must NOT steal focus
|
||||
(no churn -- the crowned tab stays active)."""
|
||||
if not os.environ.get("WAYLAND_DISPLAY"):
|
||||
pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)")
|
||||
# Tab 0 (RED) opens a green extra toplevel from the same client.
|
||||
s = _nested_session(f"{WL} --extra '{GREEN}' '{RED}'", f"{WL} '{BLUE}'")
|
||||
try:
|
||||
s.wait_any_active()
|
||||
p0 = s.launch_pids()[0]
|
||||
# The extra claims the slot right after tab 0 (app=1) with the same
|
||||
# client pid as RED. BLUE can also claim app=1 (before the extra
|
||||
# inserts and shifts it), so match app=1 by pid on the extra.
|
||||
deadline = time.monotonic() + 15.0
|
||||
extra_claimed = False
|
||||
while time.monotonic() < deadline:
|
||||
for a, p in re.findall(r"\[claim\] app=(\d+) pid=(\d+)",
|
||||
s.read_log()):
|
||||
if a == "1" and int(p) == p0:
|
||||
extra_claimed = True
|
||||
break
|
||||
if extra_claimed:
|
||||
break
|
||||
assert s.proc.poll() is None, "compositor exited while extra mapped"
|
||||
time.sleep(0.1)
|
||||
assert extra_claimed, \
|
||||
"sub-window never claimed a slot right of its parent"
|
||||
# The extra must have appeared hidden: only the crown's active line
|
||||
# may exist.
|
||||
time.sleep(0.5)
|
||||
assert len(ACTIVE_RE.findall(s.read_log())) == 1, \
|
||||
"sub-window stole focus from the crowned tab"
|
||||
assert s.proc.poll() is None
|
||||
finally:
|
||||
s.close()
|
||||
if s.proc.poll() is None:
|
||||
s.proc.terminate()
|
||||
|
||||
|
||||
def test_sub_window_activates_when_parent_active():
|
||||
"""A sub-window whose parent tab is the one being shown becomes the
|
||||
active tab on map (the second exception to the no-steal rule)."""
|
||||
if not os.environ.get("WAYLAND_DISPLAY"):
|
||||
pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)")
|
||||
s = _nested_session(f"{WL} --extra '{GREEN}' '{RED}'")
|
||||
try:
|
||||
s.wait_any_active() # the single tab is crowned, then ...
|
||||
s.wait_for(r"active app=1") # ... its sub-window activates on map.
|
||||
assert s.proc.poll() is None
|
||||
finally:
|
||||
s.close()
|
||||
if s.proc.poll() is None:
|
||||
s.proc.terminate()
|
||||
|
||||
|
||||
def test_alt_delete_activates_right_neighbor():
|
||||
"""Alt+Delete closes the active tab, making the tab to its right active
|
||||
(SIGTERM, escalated to SIGKILL only if the client lingers). With three
|
||||
|
|
|
|||
119
tests/wltest.c
119
tests/wltest.c
|
|
@ -33,7 +33,21 @@ static bool fullscreen;
|
|||
static int W = 1280, H = 800;
|
||||
static uint8_t PIXEL[4] = { 0xff, 0x00, 0x00, 0xff }; /* RGBA */
|
||||
|
||||
/* Optional second toplevel from the SAME client (--extra <hex>): lets the
|
||||
smoke tests exercise the compositor's sub-window handling (a second
|
||||
toplevel of an already-structured tab gets its own slot glued right after
|
||||
its parent). */
|
||||
static bool want_extra;
|
||||
static bool extra_done;
|
||||
static uint8_t EXTRA_PIXEL[4] = { 0x00, 0xff, 0x00, 0xff };
|
||||
static int EX_W = 640, EX_H = 400;
|
||||
static struct wl_surface *ex_surface;
|
||||
static struct xdg_surface *ex_xdg;
|
||||
static struct xdg_toplevel *ex_toplevel;
|
||||
static struct wl_buffer *ex_buffer;
|
||||
|
||||
static void create_buffer(void);
|
||||
static void extra_open(void);
|
||||
|
||||
static void keyboard_keymap(void *data, struct wl_keyboard *kb,
|
||||
uint32_t format, int32_t fd, uint32_t size) {
|
||||
|
|
@ -103,6 +117,10 @@ static void xdg_surface_configure(void *data, struct xdg_surface *xs, uint32_t s
|
|||
wl_surface_attach(surface, buffer, 0, 0);
|
||||
wl_surface_damage_buffer(surface, 0, 0, W, H);
|
||||
wl_surface_commit(surface);
|
||||
if (want_extra && !extra_done) {
|
||||
extra_done = true;
|
||||
extra_open();
|
||||
}
|
||||
}
|
||||
|
||||
static const struct xdg_surface_listener xdg_surface_listener = {
|
||||
|
|
@ -201,22 +219,107 @@ static void create_buffer(void) {
|
|||
wl_shm_pool_destroy(pool);
|
||||
}
|
||||
|
||||
/* ---- the extra (second, same-process) toplevel ---- */
|
||||
|
||||
static void extra_toplevel_configure(void *data, struct xdg_toplevel *xp,
|
||||
int32_t w, int32_t h, struct wl_array *states) {
|
||||
(void)data; (void)xp; (void)w; (void)h; (void)states;
|
||||
}
|
||||
|
||||
static const struct xdg_toplevel_listener extra_toplevel_listener = {
|
||||
.configure = extra_toplevel_configure,
|
||||
.close = xdg_toplevel_close,
|
||||
};
|
||||
|
||||
static void extra_create_buffer(void) {
|
||||
if (ex_buffer) return;
|
||||
size_t stride = (size_t)EX_W * 4;
|
||||
size_t size = stride * (size_t)EX_H;
|
||||
int fd;
|
||||
struct wl_shm_pool *pool = make_pool(size, &fd);
|
||||
if (!pool) {
|
||||
fprintf(stderr, "wltest: extra shm pool failed\n");
|
||||
exit(1);
|
||||
}
|
||||
uint8_t *data = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
if (data == MAP_FAILED) {
|
||||
fprintf(stderr, "wltest: extra mmap failed\n");
|
||||
exit(1);
|
||||
}
|
||||
for (int y = 0; y < EX_H; y++) {
|
||||
uint8_t *row = data + (size_t)y * stride;
|
||||
for (int x = 0; x < EX_W; x++) {
|
||||
row[x*4+0] = EXTRA_PIXEL[2]; /* B */
|
||||
row[x*4+1] = EXTRA_PIXEL[1]; /* G */
|
||||
row[x*4+2] = EXTRA_PIXEL[0]; /* R */
|
||||
row[x*4+3] = EXTRA_PIXEL[3]; /* A */
|
||||
}
|
||||
}
|
||||
munmap(data, size);
|
||||
close(fd);
|
||||
ex_buffer = wl_shm_pool_create_buffer(pool, 0, EX_W, EX_H,
|
||||
(int32_t)stride, WL_SHM_FORMAT_XRGB8888);
|
||||
wl_shm_pool_destroy(pool);
|
||||
}
|
||||
|
||||
static void extra_xdg_configure(void *data, struct xdg_surface *xs, uint32_t serial) {
|
||||
(void)data;
|
||||
xdg_surface_ack_configure(xs, serial);
|
||||
extra_create_buffer();
|
||||
wl_surface_attach(ex_surface, ex_buffer, 0, 0);
|
||||
wl_surface_damage_buffer(ex_surface, 0, 0, EX_W, EX_H);
|
||||
wl_surface_commit(ex_surface);
|
||||
}
|
||||
|
||||
static const struct xdg_surface_listener extra_xdg_listener = {
|
||||
.configure = extra_xdg_configure,
|
||||
};
|
||||
|
||||
static void extra_open(void) {
|
||||
ex_surface = wl_compositor_create_surface(compositor);
|
||||
ex_xdg = xdg_wm_base_get_xdg_surface(wm_base, ex_surface);
|
||||
ex_toplevel = xdg_surface_get_toplevel(ex_xdg);
|
||||
xdg_toplevel_set_title(ex_toplevel, "extra");
|
||||
xdg_surface_add_listener(ex_xdg, &extra_xdg_listener, NULL);
|
||||
xdg_toplevel_add_listener(ex_toplevel, &extra_toplevel_listener, NULL);
|
||||
wl_surface_commit(ex_surface);
|
||||
}
|
||||
|
||||
static void print_usage(const char *argv0) {
|
||||
fprintf(stderr, "usage: %s [--app-id <id>] #RRGGBB [WIDTHxHEIGHT]\n"
|
||||
" %s shows a solid-color xdg_toplevel, e.g. #ff0000\n",
|
||||
fprintf(stderr, "usage: %s [--app-id <id>] [--extra #RRGGBB] #RRGGBB [WIDTHxHEIGHT]\n"
|
||||
" %s shows a solid-color xdg_toplevel, e.g. #ff0000\n"
|
||||
" --extra also opens a second toplevel from the same client\n",
|
||||
argv0, argv0);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const char *app_id = NULL;
|
||||
int i = 1;
|
||||
while (i < argc && strcmp(argv[i], "--app-id") == 0) {
|
||||
if (i + 1 >= argc) {
|
||||
fprintf(stderr, "wltest: --app-id needs a value\n");
|
||||
return 1;
|
||||
while (i < argc) {
|
||||
if (strcmp(argv[i], "--app-id") == 0) {
|
||||
if (i + 1 >= argc) {
|
||||
fprintf(stderr, "wltest: --app-id needs a value\n");
|
||||
return 1;
|
||||
}
|
||||
app_id = argv[i + 1];
|
||||
i += 2;
|
||||
} else if (strcmp(argv[i], "--extra") == 0) {
|
||||
if (i + 1 >= argc) {
|
||||
fprintf(stderr, "wltest: --extra needs a value\n");
|
||||
return 1;
|
||||
}
|
||||
const char *x = argv[i + 1];
|
||||
if (*x == '#') x++;
|
||||
if (strlen(x) != 6 || sscanf(x, "%2hhx%2hhx%2hhx",
|
||||
&EXTRA_PIXEL[0], &EXTRA_PIXEL[1], &EXTRA_PIXEL[2]) != 3) {
|
||||
fprintf(stderr, "wltest: bad extra color '%s'\n", argv[i + 1]);
|
||||
return 1;
|
||||
}
|
||||
want_extra = true;
|
||||
i += 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
app_id = argv[i + 1];
|
||||
i += 2;
|
||||
}
|
||||
if (i >= argc || argv[i][0] != '#') {
|
||||
print_usage(argv[0]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue