Expose active window title through swmctl events

b56dddf6bf4d21206f36747e9b0ad4cfee429fb2
Alexis Sellier committed ago 1 parent abb61208
Makefile +22 -4
67 67
	@echo "ld   $^ => $@"
68 68
	@$(CC) $(LDFLAGS) $^ $(LDLIBS) -o $@
69 69
	@echo "ok   $@"
70 70
71 71
swmctl: swmctl.o ext-foreign-toplevel-list-v1-protocol.o \
72 +
		wlr-foreign-toplevel-management-unstable-v1-protocol.o \
72 73
		swm-toplevel-v1-protocol.o swm-workspace-v1-protocol.o
73 74
	@echo "ld   $^ => $@"
74 75
	@$(CC) $(LDFLAGS) $^ $(CTL_LDLIBS) -o $@
75 76
	@echo "ok   $@"
76 77
77 78
swmctl.o: swmctl.c ext-foreign-toplevel-list-v1-client-protocol.h \
79 +
		wlr-foreign-toplevel-management-unstable-v1-client-protocol.h \
78 80
		swm-toplevel-v1-client-protocol.h swm-workspace-v1-client-protocol.h \
79 81
		Makefile config.mk
80 82
	@echo "cc   $< => $@"
81 83
	@$(CC) $(CTL_CPPFLAGS) $(CFLAGS) -c $< -o $@
82 84
104 106
	@$(WL_SCANNER) client-header $< $@
105 107
ext-foreign-toplevel-list-v1-protocol.c: \
106 108
		$(WL_PROTO)/staging/ext-foreign-toplevel-list/ext-foreign-toplevel-list-v1.xml
107 109
	@echo "src  $@"
108 110
	@$(WL_SCANNER) private-code $< $@
111 +
112 +
wlr-foreign-toplevel-management-unstable-v1-client-protocol.h: \
113 +
		protocols/wlr-foreign-toplevel-management-unstable-v1.xml
114 +
	@echo "hdr  $@"
115 +
	@$(WL_SCANNER) client-header $< $@
116 +
117 +
wlr-foreign-toplevel-management-unstable-v1-protocol.c: \
118 +
		protocols/wlr-foreign-toplevel-management-unstable-v1.xml
119 +
	@echo "src  $@"
120 +
	@$(WL_SCANNER) private-code $< $@
109 121
swm-toplevel-v1-protocol.h: protocols/swm-toplevel-v1.xml
110 122
	@echo "hdr  $@"
111 123
	@$(WL_SCANNER) server-header $< $@
112 124
swm-toplevel-v1-client-protocol.h: protocols/swm-toplevel-v1.xml
113 125
	@echo "hdr  $@"
197 209
		-Wl,--wrap=wlr_cursor_set_xcursor \
198 210
		-Wl,--wrap=wlr_output_layout_output_at $(LDLIBS) -o $@
199 211
200 212
$(TEST_BIN)/ctl-unit: test/ctl_unit.c swmctl.c \
201 213
		ext-foreign-toplevel-list-v1-client-protocol.h \
214 +
		wlr-foreign-toplevel-management-unstable-v1-client-protocol.h \
202 215
		swm-toplevel-v1-client-protocol.h swm-workspace-v1-client-protocol.h \
203 -
		ext-foreign-toplevel-list-v1-protocol.c swm-toplevel-v1-protocol.c \
216 +
		ext-foreign-toplevel-list-v1-protocol.c \
217 +
		wlr-foreign-toplevel-management-unstable-v1-protocol.c swm-toplevel-v1-protocol.c \
204 218
		swm-workspace-v1-protocol.c | $(TEST_BIN)
205 219
	@echo "cc   test/ctl_unit.c => $@"
206 220
	@$(CC) -I. $(CTL_CPPFLAGS) $(TEST_CFLAGS) test/ctl_unit.c \
207 -
		ext-foreign-toplevel-list-v1-protocol.c swm-toplevel-v1-protocol.c \
221 +
		ext-foreign-toplevel-list-v1-protocol.c \
222 +
		wlr-foreign-toplevel-management-unstable-v1-protocol.c swm-toplevel-v1-protocol.c \
208 223
		swm-workspace-v1-protocol.c $(CTL_LDLIBS) -o $@
209 224
210 225
$(TEST_BIN)/swm: $(TEST_COMMON) | $(TEST_BIN)
211 226
	@echo "cc   swm.c => $@"
212 227
	@$(CC) $(TEST_CPPFLAGS) $(TEST_CFLAGS) $(TEST_SRC)/swm.c util.c \
213 228
		ext-workspace-v1-protocol.c swm-toplevel-v1-protocol.c \
214 229
		swm-workspace-v1-protocol.c $(LDLIBS) -o $@
215 230
216 231
$(TEST_BIN)/swmctl: swmctl.c ext-foreign-toplevel-list-v1-client-protocol.h \
232 +
		wlr-foreign-toplevel-management-unstable-v1-client-protocol.h \
217 233
		swm-toplevel-v1-client-protocol.h swm-workspace-v1-client-protocol.h \
218 -
		ext-foreign-toplevel-list-v1-protocol.c swm-toplevel-v1-protocol.c \
234 +
		ext-foreign-toplevel-list-v1-protocol.c \
235 +
		wlr-foreign-toplevel-management-unstable-v1-protocol.c swm-toplevel-v1-protocol.c \
219 236
		swm-workspace-v1-protocol.c | $(TEST_BIN)
220 237
	@echo "cc   swmctl.c => $@"
221 238
	@$(CC) $(CTL_CPPFLAGS) $(TEST_CFLAGS) swmctl.c \
222 -
		ext-foreign-toplevel-list-v1-protocol.c swm-toplevel-v1-protocol.c \
239 +
		ext-foreign-toplevel-list-v1-protocol.c \
240 +
		wlr-foreign-toplevel-management-unstable-v1-protocol.c swm-toplevel-v1-protocol.c \
223 241
		swm-workspace-v1-protocol.c $(CTL_LDLIBS) -o $@
224 242
225 243
test-build: $(TEST_BIN)/unit $(TEST_BIN)/ctl-unit $(TEST_BIN)/swm $(TEST_BIN)/swmctl
226 244
227 245
test: test-build
swmctl.1.adoc +17 -1
3 3
:manmanual: SWM Manual
4 4
:mansource: swm 1.0.0
5 5
6 6
== NAME
7 7
8 -
swmctl - inspect and change swm workspace metadata
8 +
swmctl - inspect swm state and change workspace metadata
9 9
10 10
== SYNOPSIS
11 11
12 12
*swmctl workspace list*
13 13
17 17
18 18
*swmctl workspace clear* _N_ (*title*|*color*)
19 19
20 20
*swmctl workspace subscribe* [*--format=waybar*]
21 21
22 +
*swmctl events --filter=active-window*
23 +
22 24
== DESCRIPTION
23 25
24 26
*swmctl* connects to the running *swm* Wayland compositor and reads or changes
25 27
the title and color associated with a workspace. This metadata is ephemeral: it
26 28
is stored only in compositor memory and is discarded when *swm* exits.
43 45
for the workspace on swm's selected output. Its additional title becomes the
44 46
module text and its color becomes the text foreground. An unset title produces
45 47
empty text. Configure the module with `return-type` set to `json` and `escape`
46 48
set to `false`.
47 49
50 +
*events --filter=active-window* writes the current active window title, then
51 +
writes a new line when the active window or its title changes. It writes an
52 +
empty line when no window is active. Control characters in titles become spaces.
53 +
The command stays connected until it is stopped or the compositor exits.
54 +
48 55
== WAYBAR
49 56
50 57
The `ext/workspaces` module continues to display the numeric workspace names
51 58
`1` through `10`. A custom module can display the additional title and color
52 59
independently:
61 68
62 69
63 70
The custom module follows the workspace on swm's selected output and uses its
64 71
color for the title text.
65 72
73 +
To show the active window title in Waybar, use:
74 +
75 +
    "modules-center": ["custom/swm-window"],
76 +
    "custom/swm-window": {
77 +
        "exec": "swmctl events --filter=active-window",
78 +
        "max-length": 80,
79 +
        "tooltip": false
80 +
    }
81 +
66 82
== EXAMPLES
67 83
68 84
Set both fields for workspace 3:
69 85
70 86
    swmctl workspace set 3 title "Prototyping new window manager"
swmctl.c +166 -1
12 12
#include <wayland-client.h>
13 13
14 14
#include "ext-foreign-toplevel-list-v1-client-protocol.h"
15 15
#include "swm-toplevel-v1-client-protocol.h"
16 16
#include "swm-workspace-v1-client-protocol.h"
17 +
#include "wlr-foreign-toplevel-management-unstable-v1-client-protocol.h"
17 18
18 19
#define MAX_WORKSPACES      32
19 20
#define MAX_WORKSPACE_TITLE 256
20 21
21 22
typedef struct {
28 29
/* Wayland globals required by one command family. */
29 30
enum command_kind {
30 31
    COMMAND_WORKSPACE,
31 32
    COMMAND_WINDOW_LIST,
32 33
    COMMAND_WINDOW_ACTIVATE,
34 +
    COMMAND_EVENTS,
33 35
};
34 36
35 37
/* Cached state for one ext-foreign-toplevel handle. */
36 38
typedef struct {
37 39
    struct wl_list                         link;
40 42
    char                                  *app_id;
41 43
    char                                   identifier[33];
42 44
    bool                                   closed;
43 45
} window_t;
44 46
47 +
/* Cached title and activation state for one event-stream toplevel. */
48 +
typedef struct {
49 +
    struct wl_list link;
50 +
    char          *title;
51 +
    bool           active;
52 +
    bool           ready;
53 +
} event_window_t;
54 +
45 55
static struct wl_display                   *display;
46 56
static struct swm_workspace_manager_v1     *manager;
47 57
static struct ext_foreign_toplevel_list_v1 *toplevel_list;
48 58
static struct swm_toplevel_manager_v1      *toplevel_control;
49 59
static struct wl_list                       windows;
51 61
static bool                                 subscribing;
52 62
static bool                                 waybar;
53 63
static bool                                 toplevel_list_finished;
54 64
static enum command_kind                    requested_kind;
55 65
66 +
static struct zwlr_foreign_toplevel_manager_v1 *event_manager;
67 +
static struct wl_list                           event_windows;
68 +
static event_window_t                          *previous_event_window;
69 +
static char                                    *previous_event_title;
70 +
static bool                                     event_published;
71 +
56 72
[[noreturn]] static void usage(void) {
57 73
    fprintf(
58 74
        stderr,
59 75
        "usage:\n"
60 76
        "  swmctl workspace list\n"
62 78
        "  swmctl workspace set N (title TITLE | color COLOR)\n"
63 79
        "  swmctl workspace clear N (title | color)\n"
64 80
        "  swmctl workspace subscribe [--format=waybar]\n"
65 81
        "  swmctl window list\n"
66 82
        "  swmctl window activate IDENTIFIER\n"
83 +
        "  swmctl events --filter=active-window\n"
67 84
    );
68 85
    exit(EXIT_FAILURE);
69 86
}
70 87
71 88
/* Print a color in its shortest lossless notation. */
326 343
static const struct ext_foreign_toplevel_list_v1_listener window_list_listener = {
327 344
    .toplevel = window_announced,
328 345
    .finished = window_list_finished,
329 346
};
330 347
348 +
/* Cache a toplevel title for the active-window event stream. */
349 +
static void event_title(
350 +
    void *data, struct zwlr_foreign_toplevel_handle_v1 *handle, const char *title
351 +
) {
352 +
    event_window_t *window = data;
353 +
354 +
    replace_string(&window->title, title);
355 +
    window->ready = false;
356 +
}
357 +
358 +
static void event_app_id(
359 +
    void *data, struct zwlr_foreign_toplevel_handle_v1 *handle, const char *app_id
360 +
) {}
361 +
362 +
static void event_output(
363 +
    void *data, struct zwlr_foreign_toplevel_handle_v1 *handle, struct wl_output *output
364 +
) {}
365 +
366 +
static void event_parent(
367 +
    void                                   *data,
368 +
    struct zwlr_foreign_toplevel_handle_v1 *handle,
369 +
    struct zwlr_foreign_toplevel_handle_v1 *parent
370 +
) {}
371 +
372 +
/* Cache whether a toplevel is active. */
373 +
static void event_state(
374 +
    void *data, struct zwlr_foreign_toplevel_handle_v1 *handle, struct wl_array *state
375 +
) {
376 +
    event_window_t *window = data;
377 +
    uint32_t       *value;
378 +
379 +
    window->active = false;
380 +
    window->ready  = false;
381 +
    wl_array_for_each(value, state) {
382 +
        if (*value == ZWLR_FOREIGN_TOPLEVEL_HANDLE_V1_STATE_ACTIVATED)
383 +
            window->active = true;
384 +
    }
385 +
}
386 +
387 +
/* Mark a complete toplevel update ready for publication. */
388 +
static void event_done(void *data, struct zwlr_foreign_toplevel_handle_v1 *handle) {
389 +
    event_window_t *window = data;
390 +
391 +
    window->ready = true;
392 +
}
393 +
394 +
/* Release a closed toplevel and invalidate its last published identity. */
395 +
static void event_closed(void *data, struct zwlr_foreign_toplevel_handle_v1 *handle) {
396 +
    event_window_t *window = data;
397 +
398 +
    zwlr_foreign_toplevel_handle_v1_destroy(handle);
399 +
    if (window == previous_event_window) {
400 +
        previous_event_window = nullptr;
401 +
        event_published       = false;
402 +
    }
403 +
    wl_list_remove(&window->link);
404 +
    free(window->title);
405 +
    free(window);
406 +
}
407 +
408 +
static const struct zwlr_foreign_toplevel_handle_v1_listener event_window_listener = {
409 +
    .title        = event_title,
410 +
    .app_id       = event_app_id,
411 +
    .output_enter = event_output,
412 +
    .output_leave = event_output,
413 +
    .state        = event_state,
414 +
    .done         = event_done,
415 +
    .closed       = event_closed,
416 +
    .parent       = event_parent,
417 +
};
418 +
419 +
/* Track a new toplevel for active-window updates. */
420 +
static void event_toplevel(
421 +
    void                                    *data,
422 +
    struct zwlr_foreign_toplevel_manager_v1 *object,
423 +
    struct zwlr_foreign_toplevel_handle_v1  *handle
424 +
) {
425 +
    event_window_t *window = calloc(1, sizeof(*window));
426 +
427 +
    if (!window) {
428 +
        fprintf(stderr, "swmctl: out of memory\n");
429 +
        exit(EXIT_FAILURE);
430 +
    }
431 +
    wl_list_insert(event_windows.prev, &window->link);
432 +
    zwlr_foreign_toplevel_handle_v1_add_listener(handle, &event_window_listener, window);
433 +
}
434 +
435 +
static void event_finished(void *data, struct zwlr_foreign_toplevel_manager_v1 *object) {
436 +
    event_manager = nullptr;
437 +
}
438 +
439 +
static const struct zwlr_foreign_toplevel_manager_v1_listener event_manager_listener = {
440 +
    .toplevel = event_toplevel,
441 +
    .finished = event_finished,
442 +
};
443 +
444 +
/* Write one title as a single line suitable for a Waybar custom module. */
445 +
static void print_event_title(const char *title) {
446 +
    const unsigned char *p;
447 +
448 +
    for (p = (const unsigned char *)title; *p; p++)
449 +
        putchar(*p < ' ' || *p == 0x7f ? ' ' : *p);
450 +
    putchar('\n');
451 +
    fflush(stdout);
452 +
}
453 +
454 +
/* Publish the active title after a complete batch of Wayland events. */
455 +
static void publish_active_window(void) {
456 +
    event_window_t *window, *active = nullptr;
457 +
    const char     *title;
458 +
459 +
    wl_list_for_each(window, &event_windows, link) {
460 +
        if (!window->ready)
461 +
            return;
462 +
        if (window->active)
463 +
            active = window;
464 +
    }
465 +
    title = active && active->title ? active->title : "";
466 +
    if (event_published && active == previous_event_window && !strcmp(title, previous_event_title))
467 +
        return;
468 +
    replace_string(&previous_event_title, title);
469 +
    previous_event_window = active;
470 +
    event_published       = true;
471 +
    print_event_title(title);
472 +
}
473 +
331 474
/* Bind swm's workspace and toplevel control globals. */
332 475
static void global(
333 476
    void *data, struct wl_registry *registry, uint32_t name, const char *interface, uint32_t version
334 477
) {
335 478
    if (requested_kind == COMMAND_WORKSPACE &&
346 489
    } else if (
347 490
        requested_kind == COMMAND_WINDOW_ACTIVATE &&
348 491
        !strcmp(interface, swm_toplevel_manager_v1_interface.name)
349 492
    ) {
350 493
        toplevel_control = wl_registry_bind(registry, name, &swm_toplevel_manager_v1_interface, 1);
494 +
    } else if (
495 +
        requested_kind == COMMAND_EVENTS &&
496 +
        !strcmp(interface, zwlr_foreign_toplevel_manager_v1_interface.name)
497 +
    ) {
498 +
        event_manager =
499 +
            wl_registry_bind(registry, name, &zwlr_foreign_toplevel_manager_v1_interface, 1);
500 +
        zwlr_foreign_toplevel_manager_v1_add_listener(
501 +
            event_manager, &event_manager_listener, nullptr
502 +
        );
351 503
    }
352 504
}
353 505
354 506
static void global_remove(void *data, struct wl_registry *registry, uint32_t name) {}
355 507
481 633
    if (!(display = wl_display_connect(nullptr))) {
482 634
        fprintf(stderr, "swmctl: cannot connect to Wayland display\n");
483 635
        exit(EXIT_FAILURE);
484 636
    }
485 637
    wl_list_init(&windows);
638 +
    wl_list_init(&event_windows);
486 639
    requested_kind = kind;
487 640
    registry       = wl_display_get_registry(display);
488 641
    wl_registry_add_listener(registry, &registry_listener, nullptr);
489 642
490 643
    if (wl_display_roundtrip(display) < 0 || (kind == COMMAND_WORKSPACE && !manager) ||
491 644
        (kind == COMMAND_WINDOW_LIST && !toplevel_list) ||
492 -
        (kind == COMMAND_WINDOW_ACTIVATE && !toplevel_control)) {
645 +
        (kind == COMMAND_WINDOW_ACTIVATE && !toplevel_control) ||
646 +
        (kind == COMMAND_EVENTS && !event_manager)) {
493 647
        fprintf(
494 648
            stderr,
495 649
            kind == COMMAND_WORKSPACE ? "swmctl: compositor does not support swm-workspace-v1\n"
496 650
            : kind == COMMAND_WINDOW_LIST
497 651
                ? "swmctl: compositor does not support ext-foreign-toplevel-list-v1\n"
652 +
            : kind == COMMAND_EVENTS
653 +
                ? "swmctl: compositor does not support wlr-foreign-toplevel-management-v1\n"
498 654
                : "swmctl: compositor does not support swm-toplevel-v1\n"
499 655
        );
500 656
        exit(EXIT_FAILURE);
501 657
    }
502 658
    if (kind != COMMAND_WINDOW_ACTIVATE && wl_display_roundtrip(display) < 0) {
518 674
    const char *value     = nullptr;
519 675
    uint32_t    workspace = 0;
520 676
521 677
    if (argc < 3)
522 678
        usage();
679 +
    if (!strcmp(argv[1], "events")) {
680 +
        if (argc != 3 || strcmp(argv[2], "--filter=active-window"))
681 +
            usage();
682 +
        connect_manager(COMMAND_EVENTS);
683 +
        publish_active_window();
684 +
        while (wl_display_dispatch(display) >= 0)
685 +
            publish_active_window();
686 +
        return EXIT_FAILURE;
687 +
    }
523 688
    window_command = !strcmp(argv[1], "window");
524 689
    command        = argv[2];
525 690
    if (window_command) {
526 691
        if (!strcmp(command, "list")) {
527 692
            if (argc != 3)
test/clients.py +24 -0
262 262
        connection.display.dispatch(block=True)
263 263
    window.destroy()
264 264
    connection.close()
265 265
266 266
267 +
def xdg_title_change(arguments: list[str]) -> None:
268 +
    """Change a mapped window title when the runner requests it."""
269 +
270 +
    if len(arguments) != 2:
271 +
        raise Failure("xdg-title-change requires INITIAL CHANGED")
272 +
    trigger = Path(os.environ["SWM_TITLE_TRIGGER"])
273 +
    connection = Connection(WlCompositor, WlShm, XdgWmBase)
274 +
    window = Window(connection, arguments[0], 0xFF336699)
275 +
    connection.roundtrips(3)
276 +
    deadline = time.monotonic() + 5
277 +
    while not trigger.exists():
278 +
        if time.monotonic() >= deadline:
279 +
            raise Failure("timed out waiting for title trigger")
280 +
        time.sleep(0.01)
281 +
    window.toplevel.set_title(arguments[1])
282 +
    window.surface.commit()
283 +
    connection.display.flush()
284 +
    while window.running:
285 +
        connection.display.dispatch(block=True)
286 +
    window.destroy()
287 +
    connection.close()
288 +
289 +
267 290
def xdg_resize(arguments: list[str]) -> None:
268 291
    """Commit a client-selected size after an XDG window maps.
269 292
270 293
    The window maps with fixed size limits so the compositor cannot choose its
271 294
    initial size, then drops those limits and commits each requested size on
1476 1499
    connection.disconnect()
1477 1500
1478 1501
1479 1502
ROLES = {
1480 1503
    "xdg": xdg_client,
1504 +
    "xdg-title-change": xdg_title_change,
1481 1505
    "xdg-deferred-configure": xdg_deferred_configure,
1482 1506
    "xdg-resize": xdg_resize,
1483 1507
    "xdg-lifecycle": xdg_lifecycle,
1484 1508
    "premap-fullscreen": premap_fullscreen,
1485 1509
    "transient": transient,
test/ctl_unit.c +40 -0
1 +
#define _POSIX_C_SOURCE 200809L
1 2
#include <assert.h>
2 3
#include <fcntl.h>
3 4
#include <sys/wait.h>
4 5
#include <unistd.h>
5 6
24 25
    assert(waitpid(child, &status, 0) == child);
25 26
    assert(WIFEXITED(status));
26 27
    assert(WEXITSTATUS(status) == EXIT_FAILURE);
27 28
}
28 29
30 +
/* Verify active-window updates and single-line title output. */
31 +
static void test_active_window_events(void) {
32 +
    event_window_t first       = { .title = "one", .active = true, .ready = true };
33 +
    event_window_t second      = { .title = "two", .ready = true };
34 +
    char           output[128] = { 0 };
35 +
    FILE          *capture     = tmpfile();
36 +
    int            saved;
37 +
38 +
    assert(capture && fflush(stdout) == 0);
39 +
    saved = dup(STDOUT_FILENO);
40 +
    assert(saved >= 0 && dup2(fileno(capture), STDOUT_FILENO) >= 0);
41 +
    wl_list_init(&event_windows);
42 +
    wl_list_insert(event_windows.prev, &first.link);
43 +
    wl_list_insert(event_windows.prev, &second.link);
44 +
45 +
    publish_active_window();
46 +
    publish_active_window();
47 +
    second.title = "renamed";
48 +
    publish_active_window();
49 +
    first.title = "one\nchanged";
50 +
    first.ready = false;
51 +
    publish_active_window();
52 +
    first.ready = true;
53 +
    publish_active_window();
54 +
    first.active  = false;
55 +
    second.active = true;
56 +
    publish_active_window();
57 +
    second.active = false;
58 +
    publish_active_window();
59 +
60 +
    assert(dup2(saved, STDOUT_FILENO) >= 0);
61 +
    close(saved);
62 +
    rewind(capture);
63 +
    assert(fread(output, 1, sizeof(output) - 1, capture) > 0);
64 +
    assert(!strcmp(output, "one\none changed\nrenamed\n\n"));
65 +
    fclose(capture);
66 +
}
67 +
29 68
int main(void) {
30 69
    char  escaped[64] = {};
31 70
    FILE *stream      = tmpfile();
32 71
33 72
    assert(stream);
38 77
    assert(fclose(stream) == 0);
39 78
    assert(parse_color("#012345") == 0x012345ff);
40 79
    assert(parse_color("#01234567") == 0x01234567);
41 80
    expect_invalid_color("# 12345");
42 81
    expect_invalid_color("#+12345");
82 +
    test_active_window_events();
43 83
    return EXIT_SUCCESS;
44 84
}
test/run.py +35 -0
363 363
        virtual_keyboard(36)
364 364
        wait_title("two" if before == "one" else "one")
365 365
366 366
    test("window publication and focus", publication)
367 367
368 +
    def active_window_events() -> None:
369 +
        title = current_title()
370 +
        log = client_log("active-window-events")
371 +
        subscriber = spawn(
372 +
            log,
373 +
            BIN / "swmctl",
374 +
            "events",
375 +
            "--filter=active-window",
376 +
            env=os.environ.copy(),
377 +
        )
378 +
        eventually("initial active-window event", lambda: log.read_text().splitlines() == [title])
379 +
        virtual_keyboard(36)
380 +
        next_title = "two" if title == "one" else "one"
381 +
        wait_title(next_title)
382 +
        eventually("changed active-window event", lambda: log.read_text().splitlines()[-1] == next_title)
383 +
        trigger = Path(os.environ["SWM_TEST_DIR"]) / "active-window-title.trigger"
384 +
        env = os.environ.copy()
385 +
        env["SWM_TITLE_TRIGGER"] = str(trigger)
386 +
        window = spawn(
387 +
            client_log("active-window-title"),
388 +
            *client_command("xdg-title-change", "initial-title", "changed-title"),
389 +
            env=env,
390 +
        )
391 +
        wait_title("initial-title")
392 +
        eventually("new active-window event", lambda: log.read_text().splitlines()[-1] == "initial-title")
393 +
        trigger.write_text("change\n")
394 +
        wait_title("changed-title")
395 +
        eventually("active title change", lambda: log.read_text().splitlines()[-1] == "changed-title")
396 +
        terminate(window)
397 +
        eventually("focus after title client closes", lambda: current_title() in {"one", "two"})
398 +
        eventually("active window after close", lambda: log.read_text().splitlines()[-1] == current_title())
399 +
        terminate(subscriber)
400 +
401 +
    test("active-window events", active_window_events)
402 +
368 403
    def deferred_configure() -> None:
369 404
        report = Path(os.environ["SWM_TEST_DIR"]) / "deferred-configure.geometry"
370 405
        env = os.environ.copy()
371 406
        env["SWM_CLIENT_REPORT"] = str(report)
372 407
        client = spawn(