test/clients.py 36.0 KiB raw
1
#!/usr/bin/env python3
2
3
"""Protocol clients used by the Python integration runner."""
4
5
from __future__ import annotations
6
7
import ctypes
8
import mmap
9
import os
10
import struct
11
import subprocess
12
import sys
13
import tempfile
14
import time
15
from pathlib import Path
16
17
from pywayland.client import Display
18
19
from protocols.ext_session_lock_v1 import (
20
    ExtSessionLockManagerV1,
21
)
22
from protocols.ext_workspace_v1 import ExtWorkspaceManagerV1
23
from protocols.idle_inhibit_unstable_v1 import ZwpIdleInhibitManagerV1
24
from protocols.input_method_unstable_v2 import ZwpInputMethodManagerV2
25
from protocols.keyboard_shortcuts_inhibit_unstable_v1 import (
26
    ZwpKeyboardShortcutsInhibitManagerV1,
27
)
28
from protocols.pointer_constraints_unstable_v1 import ZwpPointerConstraintsV1
29
from protocols.relative_pointer_unstable_v1 import ZwpRelativePointerManagerV1
30
from protocols.text_input_unstable_v3 import ZwpTextInputManagerV3
31
from protocols.virtual_keyboard_unstable_v1 import ZwpVirtualKeyboardManagerV1
32
from protocols.wayland import (
33
    WlCompositor,
34
    WlKeyboard,
35
    WlOutput,
36
    WlSeat,
37
    WlShm,
38
)
39
from protocols.wlr_foreign_toplevel_management_unstable_v1 import (
40
    ZwlrForeignToplevelManagerV1,
41
)
42
from protocols.wlr_layer_shell_unstable_v1 import (
43
    ZwlrLayerShellV1,
44
    ZwlrLayerSurfaceV1,
45
)
46
from protocols.wlr_output_power_management_unstable_v1 import (
47
    ZwlrOutputPowerManagerV1,
48
)
49
from protocols.wlr_virtual_pointer_unstable_v1 import ZwlrVirtualPointerManagerV1
50
from protocols.xdg_shell import XdgPositioner, XdgToplevel, XdgWmBase
51
52
53
class Failure(RuntimeError):
54
    """Report a protocol-client failure."""
55
56
57
class Connection:
58
    """Bind requested globals on one Wayland connection."""
59
60
    def __init__(self, *interfaces):
61
        self.display = Display()
62
        self.display.connect()
63
        self.registry = self.display.get_registry()
64
        self.interfaces = {interface.name: interface for interface in interfaces}
65
        self.globals: dict[str, object] = {}
66
        self._versions: dict[str, int] = {}
67
        self.registry.dispatcher["global"] = self._global
68
        self.display.roundtrip()
69
70
    def _global(self, registry, name: int, interface: str, version: int) -> None:
71
        wanted = self.interfaces.get(interface)
72
        if wanted is not None and interface not in self.globals:
73
            self._versions[interface] = version
74
            self.globals[interface] = registry.bind(name, wanted, min(version, wanted.version))
75
76
    def get(self, interface):
77
        """Return a required bound global."""
78
79
        try:
80
            return self.globals[interface.name]
81
        except KeyError as error:
82
            raise Failure(f"compositor does not advertise {interface.name}") from error
83
84
    def require_version(self, interface, version: int) -> None:
85
        """Require a minimum advertised version for one global."""
86
87
        advertised = self._versions.get(interface.name, 0)
88
        if advertised < version:
89
            raise Failure(
90
                f"compositor advertises {interface.name} version {advertised}, "
91
                f"but version {version} is required"
92
            )
93
94
    def roundtrips(self, count: int = 1) -> None:
95
        """Complete several request/event exchanges."""
96
97
        for _ in range(count):
98
            if self.display.roundtrip() < 0:
99
                raise Failure("Wayland roundtrip failed")
100
101
    def close(self) -> None:
102
        """Disconnect from the compositor."""
103
104
        self.display.disconnect()
105
106
107
def create_buffer(shm, width: int, height: int, color: int):
108
    """Create a solid-color shared-memory buffer."""
109
110
    size = width * height * 4
111
    with tempfile.TemporaryFile(dir=os.environ.get("SWM_TEST_DIR")) as stream:
112
        stream.truncate(size)
113
        pixels = mmap.mmap(stream.fileno(), size)
114
        pixels[:] = struct.pack("=I", color) * (width * height)
115
        pixels.flush()
116
        pool = shm.create_pool(stream.fileno(), size)
117
        buffer = pool.create_buffer(0, width, height, width * 4, WlShm.format.argb8888)
118
        pool.destroy()
119
    buffer.dispatcher["release"] = lambda proxy: proxy.destroy()
120
    return buffer
121
122
123
class Window:
124
    """Own one mapped XDG toplevel and report configure state."""
125
126
    def __init__(
127
        self,
128
        connection: Connection,
129
        title: str,
130
        color: int,
131
        parent=None,
132
        fullscreen: bool = False,
133
        report: Path | None = None,
134
        fixed_size: bool = False,
135
    ):
136
        self.connection = connection
137
        self.compositor = connection.get(WlCompositor)
138
        self.shm = connection.get(WlShm)
139
        self.wm_base = connection.get(XdgWmBase)
140
        self.title = title
141
        self.color = color
142
        self.width = 320
143
        self.height = 200
144
        self.fullscreen = False
145
        self.running = True
146
        self.report = report
147
        self.wm_base.dispatcher["ping"] = lambda proxy, serial: proxy.pong(serial)
148
        self.surface = self.compositor.create_surface()
149
        self.xdg_surface = self.wm_base.get_xdg_surface(self.surface)
150
        self.xdg_surface.dispatcher["configure"] = self._surface_configure
151
        self.toplevel = self.xdg_surface.get_toplevel()
152
        self.toplevel.dispatcher["configure"] = self._toplevel_configure
153
        self.toplevel.dispatcher["close"] = lambda proxy: setattr(self, "running", False)
154
        self.toplevel.set_title(title)
155
        self.toplevel.set_app_id("swm-test")
156
        if parent is not None:
157
            self.toplevel.set_parent(parent)
158
        if fullscreen:
159
            self.toplevel.set_fullscreen(None)
160
        if fixed_size:
161
            self.toplevel.set_min_size(self.width, self.height)
162
            self.toplevel.set_max_size(self.width, self.height)
163
        self.surface.commit()
164
165
    def _toplevel_configure(self, proxy, width: int, height: int, states) -> None:
166
        if width > 0:
167
            self.width = width
168
        if height > 0:
169
            self.height = height
170
        self.fullscreen = int(XdgToplevel.state.fullscreen) in list(states)
171
        self._write_report()
172
173
    def _surface_configure(self, proxy, serial: int) -> None:
174
        proxy.ack_configure(serial)
175
        self.draw()
176
177
    def _write_report(self) -> None:
178
        if self.report:
179
            self.report.write_text(f"{self.width} {self.height} {int(self.fullscreen)}\n")
180
181
    def draw(self) -> None:
182
        """Attach a fresh buffer and commit the surface."""
183
184
        buffer = create_buffer(self.shm, self.width, self.height, self.color)
185
        self.surface.attach(buffer, 0, 0)
186
        self.surface.damage_buffer(0, 0, self.width, self.height)
187
        self.surface.commit()
188
        self._write_report()
189
190
    def unmap(self) -> None:
191
        """Detach the surface buffer."""
192
193
        self.surface.attach(None, 0, 0)
194
        self.surface.commit()
195
196
    def destroy(self) -> None:
197
        """Destroy the complete toplevel role."""
198
199
        self.toplevel.destroy()
200
        self.xdg_surface.destroy()
201
        self.surface.destroy()
202
203
204
def xdg_client(arguments: list[str]) -> None:
205
    """Run one persistent XDG window."""
206
207
    color = int(arguments[0], 16) if arguments else 0xFF336699
208
    title = arguments[1] if len(arguments) > 1 else "swm-test"
209
    report = Path(os.environ["SWM_CLIENT_REPORT"]) if os.environ.get("SWM_CLIENT_REPORT") else None
210
    connection = Connection(WlCompositor, WlShm, XdgWmBase)
211
    window = Window(
212
        connection,
213
        title,
214
        color,
215
        fullscreen="fullscreen" in arguments[2:],
216
        report=report,
217
    )
218
    while window.running:
219
        connection.display.dispatch(block=True)
220
    window.destroy()
221
    connection.close()
222
223
224
def xdg_resize(arguments: list[str]) -> None:
225
    """Commit a client-selected size after an XDG window maps.
226
227
    The window maps with fixed size limits so the compositor cannot choose its
228
    initial size, then drops those limits and commits each requested size on
229
    its own once the runner triggers it.
230
    """
231
232
    if len(arguments) != 4:
233
        raise Failure("xdg-resize requires WIDTH HEIGHT WIDTH HEIGHT")
234
    trigger = Path(os.environ["SWM_RESIZE_TRIGGER"])
235
    committed = Path(os.environ["SWM_RESIZE_COMMITTED"])
236
    connection = Connection(WlCompositor, WlShm, XdgWmBase)
237
    window = Window(
238
        connection,
239
        "client-resize",
240
        0xFF336699,
241
        fixed_size=True,
242
        report=Path(os.environ["SWM_CLIENT_REPORT"]),
243
    )
244
    connection.roundtrips(3)
245
    # The window mapped at a size the compositor could not override. Unlock the
246
    # size limits so later commits choose their own size the way an application
247
    # that resizes itself does.
248
    window.toplevel.set_min_size(0, 0)
249
    window.toplevel.set_max_size(0, 0)
250
    window.surface.commit()
251
    connection.roundtrips(2)
252
    for index in range(2):
253
        deadline = time.monotonic() + 5
254
        while not trigger.exists() or trigger.read_text().strip() != str(index):
255
            if time.monotonic() >= deadline:
256
                raise Failure(f"timed out waiting for resize trigger {index}")
257
            time.sleep(0.01)
258
        window.width = int(arguments[index * 2])
259
        window.height = int(arguments[index * 2 + 1])
260
        window.xdg_surface.set_window_geometry(0, 0, window.width, window.height)
261
        window.draw()
262
        connection.display.flush()
263
        committed.write_text(f"{index}\n")
264
    while window.running:
265
        connection.display.dispatch(block=True)
266
    window.destroy()
267
    connection.close()
268
269
270
def xdg_lifecycle(arguments: list[str]) -> None:
271
    """Repeatedly map and unmap a focused XDG toplevel."""
272
273
    count = int(arguments[0]) if arguments else 8
274
    connection = Connection(WlCompositor, WlShm, XdgWmBase)
275
    fallback = Window(connection, "swm-xdg-fallback", 0xFF446622)
276
    window = Window(connection, "swm-xdg-lifecycle", 0xFF224466)
277
    connection.roundtrips(3)
278
    for index in range(count):
279
        window.unmap()
280
        connection.roundtrips(2)
281
        window.color += 0x00050311
282
        window.surface.commit()
283
        connection.roundtrips(2)
284
    window.destroy()
285
    fallback.destroy()
286
    connection.close()
287
288
289
def transient(arguments: list[str]) -> None:
290
    """Exercise popup, parented, and fullscreen XDG surfaces."""
291
292
    count = int(arguments[0]) if arguments else 4
293
    connection = Connection(WlCompositor, WlShm, XdgWmBase)
294
    parent = Window(connection, "swm-child-parent", 0xFF335577)
295
    fullscreen = Window(connection, "swm-fullscreen", 0xFF224466, fullscreen=True)
296
    connection.roundtrips(4)
297
    if not fullscreen.fullscreen:
298
        raise Failure("fullscreen window was not configured fullscreen")
299
300
    positioner = connection.get(XdgWmBase).create_positioner()
301
    positioner.set_size(120, 60)
302
    positioner.set_anchor_rect(30, 20, 10, 10)
303
    positioner.set_anchor(XdgPositioner.anchor.bottom_right)
304
    positioner.set_gravity(XdgPositioner.gravity.bottom_right)
305
    positioner.set_constraint_adjustment(
306
        XdgPositioner.constraint_adjustment.slide_x
307
        | XdgPositioner.constraint_adjustment.slide_y
308
    )
309
    popup_surface = connection.get(WlCompositor).create_surface()
310
    popup_xdg = connection.get(XdgWmBase).get_xdg_surface(popup_surface)
311
    popup_configured = []
312
313
    def configure(proxy, serial) -> None:
314
        proxy.ack_configure(serial)
315
        buffer = create_buffer(connection.get(WlShm), 120, 60, 0xFF557733)
316
        popup_surface.attach(buffer, 0, 0)
317
        popup_surface.damage_buffer(0, 0, 120, 60)
318
        popup_surface.commit()
319
        popup_configured.append(True)
320
321
    popup_xdg.dispatcher["configure"] = configure
322
    popup = popup_xdg.get_popup(parent.xdg_surface, positioner)
323
    popup.dispatcher["configure"] = lambda proxy, x, y, width, height: None
324
    popup.dispatcher["popup_done"] = lambda proxy: None
325
    popup.dispatcher["repositioned"] = lambda proxy, token: None
326
    positioner.destroy()
327
    popup_surface.commit()
328
    connection.roundtrips(4)
329
    if not popup_configured:
330
        raise Failure("transient popup was not configured")
331
    popup.destroy()
332
    popup_xdg.destroy()
333
    popup_surface.destroy()
334
    connection.roundtrips()
335
336
    for index in range(count):
337
        child = Window(
338
            connection,
339
            f"swm-child-{index}",
340
            0xFF773355 + index * 0x0003070D,
341
            parent=parent.toplevel,
342
        )
343
        connection.roundtrips(3)
344
        if not fullscreen.fullscreen:
345
            raise Failure("transient child stole fullscreen")
346
        child.destroy()
347
        connection.roundtrips()
348
    fullscreen.destroy()
349
    parent.destroy()
350
    connection.close()
351
352
353
def keymap_text() -> tuple[bytes, dict[str, int]]:
354
    """Build a default XKB keymap and return modifier indices."""
355
356
    xkb = ctypes.CDLL("libxkbcommon.so.0")
357
    xkb.xkb_context_new.argtypes = [ctypes.c_int]
358
    xkb.xkb_context_new.restype = ctypes.c_void_p
359
    xkb.xkb_keymap_new_from_names.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int]
360
    xkb.xkb_keymap_new_from_names.restype = ctypes.c_void_p
361
    xkb.xkb_keymap_get_as_string.argtypes = [ctypes.c_void_p, ctypes.c_int]
362
    xkb.xkb_keymap_get_as_string.restype = ctypes.c_void_p
363
    xkb.xkb_keymap_mod_get_index.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
364
    xkb.xkb_keymap_mod_get_index.restype = ctypes.c_uint
365
    xkb.xkb_keymap_unref.argtypes = [ctypes.c_void_p]
366
    xkb.xkb_context_unref.argtypes = [ctypes.c_void_p]
367
    context = xkb.xkb_context_new(0)
368
    keymap = xkb.xkb_keymap_new_from_names(context, None, 0)
369
    pointer = xkb.xkb_keymap_get_as_string(keymap, 1)
370
    text = ctypes.string_at(pointer)
371
    ctypes.CDLL(None).free(ctypes.c_void_p(pointer))
372
    indices = {
373
        "shift": xkb.xkb_keymap_mod_get_index(keymap, b"Shift"),
374
        "ctrl": xkb.xkb_keymap_mod_get_index(keymap, b"Control"),
375
        "alt": xkb.xkb_keymap_mod_get_index(keymap, b"Mod1"),
376
        "logo": xkb.xkb_keymap_mod_get_index(keymap, b"Mod4"),
377
    }
378
    xkb.xkb_keymap_unref(keymap)
379
    xkb.xkb_context_unref(context)
380
    return text, indices
381
382
383
def keyboard(arguments: list[str]) -> None:
384
    """Send a key and modifiers through virtual-keyboard-v1."""
385
386
    if len(arguments) != 2:
387
        raise Failure("keyboard requires KEYCODE|none MODIFIERS")
388
    key = None if arguments[0] == "none" else int(arguments[0], 0)
389
    requested = int(arguments[1], 0)
390
    connection = Connection(WlSeat, ZwpVirtualKeyboardManagerV1)
391
    seat = connection.get(WlSeat)
392
    manager = connection.get(ZwpVirtualKeyboardManagerV1)
393
    virtual = manager.create_virtual_keyboard(seat)
394
    text, indices = keymap_text()
395
    with tempfile.TemporaryFile(dir=os.environ.get("SWM_TEST_DIR")) as stream:
396
        stream.write(text + b"\0")
397
        stream.flush()
398
        virtual.keymap(WlKeyboard.keymap_format.xkb_v1, stream.fileno(), len(text) + 1)
399
    modifiers = 0
400
    modifier_keys = (
401
        (1, "shift", 42),
402
        (4, "ctrl", 29),
403
        (8, "alt", 56),
404
        (64, "logo", 125),
405
    )
406
    for flag, name, code in modifier_keys:
407
        if requested & flag:
408
            modifiers |= 1 << indices[name]
409
            virtual.key(0, code, WlKeyboard.key_state.pressed)
410
    virtual.modifiers(modifiers, 0, 0, 0)
411
    if key is not None:
412
        virtual.key(1, key, WlKeyboard.key_state.pressed)
413
        virtual.key(2, key, WlKeyboard.key_state.released)
414
    connection.roundtrips()
415
    ready = os.environ.get("SWM_KEYBOARD_READY")
416
    if ready:
417
        Path(ready).write_text("ready\n")
418
    release = os.environ.get("SWM_KEYBOARD_RELEASE")
419
    if release:
420
        deadline = time.monotonic() + 2
421
        while not Path(release).exists():
422
            if time.monotonic() >= deadline:
423
                raise Failure("timed out waiting to release virtual keyboard")
424
            time.sleep(0.002)
425
    for flag, _, code in modifier_keys:
426
        if requested & flag:
427
            virtual.key(3, code, WlKeyboard.key_state.released)
428
    virtual.modifiers(0, 0, 0, 0)
429
    virtual.destroy()
430
    connection.roundtrips()
431
    connection.close()
432
433
434
def pointer(arguments: list[str]) -> None:
435
    """Move and click through virtual-pointer-v1."""
436
437
    x = int(arguments[0])
438
    button = 0x111 if len(arguments) > 1 and arguments[1] == "right" else 0x110
439
    connection = Connection(WlSeat, ZwlrVirtualPointerManagerV1)
440
    manager = connection.get(ZwlrVirtualPointerManagerV1)
441
    virtual = manager.create_virtual_pointer(connection.get(WlSeat))
442
    virtual.motion_absolute(0, x, 500, 1000, 1000)
443
    virtual.frame()
444
    virtual.button(1, button, 1)
445
    virtual.motion(2, 25.0, 20.0)
446
    virtual.frame()
447
    virtual.button(3, button, 0)
448
    virtual.axis_source(0)
449
    virtual.axis_discrete(3, 0, 1.0, 1)
450
    virtual.axis_stop(4, 0)
451
    virtual.frame()
452
    connection.roundtrips()
453
    virtual.destroy()
454
    connection.close()
455
456
457
def foreign(arguments: list[str]) -> None:
458
    """Apply one foreign-toplevel action to a named window."""
459
460
    title, action = arguments
461
    connection = Connection(WlSeat, ZwlrForeignToplevelManagerV1)
462
    seat = connection.get(WlSeat)
463
    manager = connection.get(ZwlrForeignToplevelManagerV1)
464
    acted = False
465
    observed = False
466
467
    def toplevel(proxy, handle):
468
        nonlocal acted, observed
469
        state = {"title": "", "fullscreen": False}
470
        fullscreen_state = int(handle.interface.state.fullscreen)
471
        handle.dispatcher["title"] = lambda p, value: state.update(title=value)
472
473
        def states(p, values):
474
            nonlocal acted, observed
475
            state["fullscreen"] = fullscreen_state in list(values)
476
            if state["title"] != title:
477
                return
478
            if not acted:
479
                if action == "activate":
480
                    handle.activate(seat)
481
                elif action == "fullscreen":
482
                    handle.set_fullscreen(None)
483
                elif action == "unfullscreen":
484
                    handle.unset_fullscreen()
485
                elif action == "close":
486
                    handle.close()
487
                else:
488
                    raise Failure(f"unknown foreign action: {action}")
489
                acted = True
490
            observed = (
491
                action == "close"
492
                or action == "fullscreen" and state["fullscreen"]
493
                or action in {"activate", "unfullscreen"} and not state["fullscreen"]
494
            )
495
496
        handle.dispatcher["state"] = states
497
        handle.dispatcher["done"] = lambda p: states(
498
            p, [fullscreen_state] if state["fullscreen"] else []
499
        )
500
        handle.dispatcher["closed"] = lambda p: None
501
502
    manager.dispatcher["toplevel"] = toplevel
503
    for _ in range(20):
504
        connection.roundtrips()
505
        if acted and observed:
506
            break
507
    if not acted or not observed:
508
        raise Failure(f"foreign-toplevel action {action} was not observed")
509
    if action == "close":
510
        connection.roundtrips(2)
511
    connection.close()
512
513
514
def workspace(arguments: list[str]) -> None:
515
    """Activate a named workspace through ext-workspace-v1."""
516
517
    if len(arguments) != 1:
518
        raise Failure("workspace requires a workspace name")
519
    target = arguments[0]
520
    connection = Connection(ExtWorkspaceManagerV1)
521
    manager = connection.get(ExtWorkspaceManagerV1)
522
    workspaces: dict[str, object] = {}
523
524
    def announced(proxy, handle):
525
        handle.dispatcher["name"] = lambda p, name: workspaces.update({name: handle})
526
        handle.dispatcher["removed"] = lambda p: None
527
528
    manager.dispatcher["workspace_group"] = lambda proxy, group: None
529
    manager.dispatcher["workspace"] = announced
530
    manager.dispatcher["done"] = lambda proxy: None
531
    manager.dispatcher["finished"] = lambda proxy: None
532
    connection.roundtrips(2)
533
    if target not in workspaces:
534
        raise Failure(f"workspace {target!r} was not advertised")
535
    workspaces[target].activate()
536
    manager.commit()
537
    connection.roundtrips(2)
538
    connection.close()
539
540
541
def output_power(arguments: list[str]) -> None:
542
    """Turn one output off and back on through output-power-v1."""
543
544
    connection = Connection(WlOutput, ZwlrOutputPowerManagerV1)
545
    power = connection.get(ZwlrOutputPowerManagerV1).get_output_power(connection.get(WlOutput))
546
    modes: list[int] = []
547
    failed: list[bool] = []
548
    power.dispatcher["mode"] = lambda proxy, mode: modes.append(mode)
549
    power.dispatcher["failed"] = lambda proxy: failed.append(True)
550
    connection.roundtrips()
551
    if not modes or modes[-1] != 1:
552
        raise Failure("output did not begin powered on")
553
    power.set_mode(0)
554
    connection.roundtrips(2)
555
    if modes[-1] != 0:
556
        raise Failure("output did not power off")
557
    power.set_mode(1)
558
    connection.roundtrips(2)
559
    if modes[-1] != 1 or failed:
560
        raise Failure("output did not power back on")
561
    power.destroy()
562
    connection.close()
563
564
565
def output_management(arguments: list[str]) -> None:
566
    """Exercise output-management through the installed reference client."""
567
568
    command = ["wlr-randr"]
569
    if arguments and arguments[0] in {"disable-first", "disable-second"}:
570
        output = "HEADLESS-1" if arguments[0] == "disable-first" else "HEADLESS-2"
571
        command += ["--output", output, "--off"]
572
    else:
573
        command += ["--output", "HEADLESS-1", "--on"]
574
        subprocess.run([*command, "--dryrun"], check=True)
575
    subprocess.run(command, check=True)
576
577
578
def layer(arguments: list[str]) -> None:
579
    """Create, configure, map, and destroy layer-shell surfaces."""
580
581
    count = int(arguments[0]) if arguments else 2
582
    connection = Connection(
583
        WlCompositor,
584
        WlSeat,
585
        WlShm,
586
        ZwpVirtualKeyboardManagerV1,
587
        ZwlrLayerShellV1,
588
        ZwlrVirtualPointerManagerV1,
589
    )
590
    compositor = connection.get(WlCompositor)
591
    seat = connection.get(WlSeat)
592
    shm = connection.get(WlShm)
593
    shell = connection.get(ZwlrLayerShellV1)
594
    connection.require_version(ZwlrLayerShellV1, 4)
595
    virtual_keyboard = connection.get(ZwpVirtualKeyboardManagerV1).create_virtual_keyboard(
596
        seat
597
    )
598
    keymap, _ = keymap_text()
599
    keymap_stream = tempfile.TemporaryFile(dir=os.environ.get("SWM_TEST_DIR"))
600
    keymap_stream.write(keymap + b"\0")
601
    keymap_stream.flush()
602
    virtual_keyboard.keymap(
603
        WlKeyboard.keymap_format.xkb_v1,
604
        keymap_stream.fileno(),
605
        len(keymap) + 1,
606
    )
607
    connection.roundtrips()
608
    keyboard = seat.get_keyboard()
609
    keyboard_enters = []
610
    keyboard.dispatcher["enter"] = lambda proxy, serial, surface, keys: (
611
        keyboard_enters.append(surface)
612
    )
613
    keyboard.dispatcher["leave"] = lambda proxy, serial, surface: None
614
    layers = []
615
    for index in range(count):
616
        surface = compositor.create_surface()
617
        role = shell.get_layer_surface(
618
            surface,
619
            None,
620
            ZwlrLayerShellV1.layer.top,
621
            f"swm-layer-{index}",
622
        )
623
        role.set_keyboard_interactivity(
624
            ZwlrLayerSurfaceV1.keyboard_interactivity.on_demand
625
        )
626
        role.set_anchor(
627
            ZwlrLayerSurfaceV1.anchor.top
628
            | (
629
                ZwlrLayerSurfaceV1.anchor.left
630
                if index == 0
631
                else ZwlrLayerSurfaceV1.anchor.right
632
            )
633
        )
634
        configured = []
635
636
        def configure(
637
            proxy,
638
            serial,
639
            width,
640
            height,
641
            surface=surface,
642
            configured=configured,
643
            index=index,
644
        ):
645
            proxy.ack_configure(serial)
646
            width = width or 80
647
            height = height or 40
648
            surface.attach(create_buffer(shm, width, height, 0xCC112233 + index), 0, 0)
649
            surface.damage_buffer(0, 0, width, height)
650
            surface.commit()
651
            configured.append(True)
652
653
        role.dispatcher["configure"] = configure
654
        role.dispatcher["closed"] = lambda proxy: None
655
        role.set_size(80 + index, 40 + index)
656
        surface.commit()
657
        layers.append((surface, role, configured))
658
    connection.roundtrips(3)
659
    if not all(configured for _, _, configured in layers):
660
        raise Failure("layer surface was not configured")
661
    if keyboard_enters:
662
        raise Failure("on-demand layer surface received keyboard focus when mapped")
663
664
    pointer = connection.get(ZwlrVirtualPointerManagerV1).create_virtual_pointer(seat)
665
    pointer.motion_absolute(0, 20, 20, 1000, 1000)
666
    pointer.frame()
667
    pointer.button(1, 0x110, 1)
668
    pointer.frame()
669
    pointer.button(2, 0x110, 0)
670
    pointer.frame()
671
    connection.roundtrips(2)
672
    if not keyboard_enters:
673
        raise Failure("on-demand layer surface did not receive keyboard focus when pressed")
674
    pointer.destroy()
675
    keyboard.release()
676
    virtual_keyboard.destroy()
677
    keymap_stream.close()
678
    for surface, role, _ in reversed(layers):
679
        role.destroy()
680
        surface.destroy()
681
    connection.roundtrips()
682
    connection.close()
683
684
685
def session_lock(arguments: list[str]) -> None:
686
    """Create and release session locks."""
687
688
    count = int(arguments[0]) if arguments else 2
689
    connection = Connection(WlCompositor, WlShm, WlOutput, ExtSessionLockManagerV1)
690
    compositor = connection.get(WlCompositor)
691
    shm = connection.get(WlShm)
692
    output = connection.get(WlOutput)
693
    manager = connection.get(ExtSessionLockManagerV1)
694
    for index in range(count):
695
        lock = manager.lock()
696
        locked: list[bool] = []
697
        finished: list[bool] = []
698
        lock.dispatcher["locked"] = lambda proxy: locked.append(True)
699
        lock.dispatcher["finished"] = lambda proxy: finished.append(True)
700
        surface = compositor.create_surface()
701
        role = lock.get_lock_surface(surface, output)
702
        configured: list[bool] = []
703
704
        def configure(proxy, serial, width, height):
705
            proxy.ack_configure(serial)
706
            surface.attach(create_buffer(shm, width, height, 0xFF101010 + index), 0, 0)
707
            surface.damage_buffer(0, 0, width, height)
708
            surface.commit()
709
            configured.append(True)
710
711
        role.dispatcher["configure"] = configure
712
        connection.roundtrips(3)
713
        if not finished and (not locked or not configured):
714
            raise Failure("session lock was not configured")
715
        role.destroy()
716
        surface.destroy()
717
        if locked:
718
            lock.unlock_and_destroy()
719
        else:
720
            lock.destroy()
721
        connection.roundtrips()
722
    connection.close()
723
724
725
def text_input(arguments: list[str]) -> None:
726
    """Exercise text input, input methods, and input inhibitors together."""
727
728
    connection = Connection(
729
        WlCompositor,
730
        WlShm,
731
        WlSeat,
732
        XdgWmBase,
733
        ZwpTextInputManagerV3,
734
        ZwpInputMethodManagerV2,
735
        ZwpKeyboardShortcutsInhibitManagerV1,
736
        ZwpPointerConstraintsV1,
737
        ZwpRelativePointerManagerV1,
738
        ZwpIdleInhibitManagerV1,
739
        ZwlrVirtualPointerManagerV1,
740
    )
741
    seat = connection.get(WlSeat)
742
    pointer = seat.get_pointer()
743
    for event in ("enter", "leave", "motion", "button", "axis"):
744
        pointer.dispatcher[event] = lambda proxy, *values: None
745
746
    text = connection.get(ZwpTextInputManagerV3).get_text_input(seat)
747
    method = connection.get(ZwpInputMethodManagerV2).get_input_method(seat)
748
    state = {
749
        "activated": False,
750
        "surrounding": False,
751
        "cause": False,
752
        "content": False,
753
        "preedit": False,
754
        "commit": False,
755
        "delete": False,
756
        "done": False,
757
        "rectangle": False,
758
        "shortcuts": False,
759
        "locked": False,
760
        "relative": False,
761
        "serial": 0,
762
    }
763
764
    def text_enter(proxy, surface) -> None:
765
        if surface != window.surface:
766
            return
767
        text.enable()
768
        text.set_surrounding_text("before after", 6, 6)
769
        text.set_text_change_cause(1)
770
        text.set_content_type(1, 0)
771
        text.set_cursor_rectangle(7, 11, 3, 15)
772
        text.commit()
773
774
    def method_done(proxy) -> None:
775
        state["serial"] += 1
776
        if not all(state[name] for name in ("activated", "surrounding", "cause", "content")):
777
            return
778
        method.set_preedit_string("preedit", 2, 4)
779
        method.commit_string("committed")
780
        method.delete_surrounding_text(2, 3)
781
        method.commit(state["serial"])
782
783
    text.dispatcher["enter"] = text_enter
784
    text.dispatcher["leave"] = lambda proxy, surface: None
785
    text.dispatcher["preedit_string"] = lambda proxy, value, begin, end: state.update(
786
        preedit=value == "preedit" and begin == 2 and end == 4
787
    )
788
    text.dispatcher["commit_string"] = lambda proxy, value: state.update(
789
        commit=value == "committed"
790
    )
791
    text.dispatcher["delete_surrounding_text"] = lambda proxy, before, after: state.update(
792
        delete=before == 2 and after == 3
793
    )
794
    text.dispatcher["done"] = lambda proxy, serial: state.update(done=True)
795
796
    popup_surface = connection.get(WlCompositor).create_surface()
797
    popup = method.get_input_popup_surface(popup_surface)
798
    popup.dispatcher["text_input_rectangle"] = lambda proxy, x, y, width, height: state.update(
799
        rectangle=(x, y, width, height) == (0, -15, 3, 15)
800
    )
801
802
    def activate(proxy) -> None:
803
        state["activated"] = True
804
        buffer = create_buffer(connection.get(WlShm), 32, 16, 0xFF8855AA)
805
        popup_surface.attach(buffer, 0, 0)
806
        popup_surface.damage_buffer(0, 0, 32, 16)
807
        popup_surface.commit()
808
809
    method.dispatcher["activate"] = activate
810
    method.dispatcher["deactivate"] = lambda proxy: None
811
    method.dispatcher["surrounding_text"] = lambda proxy, value, cursor, anchor: state.update(
812
        surrounding=value == "before after" and cursor == 6 and anchor == 6
813
    )
814
    method.dispatcher["text_change_cause"] = lambda proxy, cause: state.update(cause=cause == 1)
815
    method.dispatcher["content_type"] = lambda proxy, hint, purpose: state.update(
816
        content=hint == 1 and purpose == 0
817
    )
818
    method.dispatcher["done"] = method_done
819
    method.dispatcher["unavailable"] = lambda proxy: (_ for _ in ()).throw(
820
        Failure("input method is unavailable")
821
    )
822
823
    window = Window(connection, "text-input", 0xFF556677)
824
    idle = connection.get(ZwpIdleInhibitManagerV1).create_inhibitor(window.surface)
825
    shortcuts = connection.get(ZwpKeyboardShortcutsInhibitManagerV1).inhibit_shortcuts(
826
        window.surface, seat
827
    )
828
    shortcuts.dispatcher["active"] = lambda proxy: state.update(shortcuts=True)
829
    shortcuts.dispatcher["inactive"] = lambda proxy: state.update(shortcuts=False)
830
    locked = connection.get(ZwpPointerConstraintsV1).lock_pointer(
831
        window.surface, pointer, None, 2
832
    )
833
    locked.dispatcher["locked"] = lambda proxy: state.update(locked=True)
834
    locked.dispatcher["unlocked"] = lambda proxy: state.update(locked=False)
835
    relative = connection.get(ZwpRelativePointerManagerV1).get_relative_pointer(pointer)
836
    relative.dispatcher["relative_motion"] = lambda proxy, hi, lo, dx, dy, ux, uy: state.update(
837
        relative=dx != 0
838
    )
839
    virtual = connection.get(ZwlrVirtualPointerManagerV1).create_virtual_pointer(seat)
840
841
    for index in range(100):
842
        virtual.motion_absolute(0, index % 10 * 100 + 50, index // 10 * 100 + 50, 1000, 1000)
843
        virtual.frame()
844
        connection.roundtrips()
845
        if state["locked"]:
846
            break
847
    virtual.motion(10, 5.0, 0.0)
848
    virtual.frame()
849
    connection.roundtrips(3)
850
851
    required = (
852
        "preedit",
853
        "commit",
854
        "delete",
855
        "done",
856
        "rectangle",
857
        "shortcuts",
858
        "locked",
859
        "relative",
860
    )
861
    missing = [name for name in required if not state[name]]
862
    if missing:
863
        raise Failure(f"text-input events not observed: {', '.join(missing)}")
864
865
    text.disable()
866
    text.commit()
867
    connection.roundtrips(2)
868
    window.destroy()
869
    virtual.destroy()
870
    relative.destroy()
871
    locked.destroy()
872
    shortcuts.destroy()
873
    idle.destroy()
874
    popup.destroy()
875
    popup_surface.destroy()
876
    text.destroy()
877
    method.destroy()
878
    connection.close()
879
880
881
def x11(arguments: list[str]) -> None:
882
    """Exercise managed, configured, fullscreen, and unmanaged X11 windows."""
883
884
    import xcffib
885
    import xcffib.xproto
886
887
    connection = xcffib.connect()
888
    setup = connection.get_setup()
889
    screen = setup.roots[connection.pref_screen]
890
    window = connection.generate_id()
891
    mask = xcffib.xproto.CW.BackPixel | xcffib.xproto.CW.EventMask
892
    values = [screen.white_pixel, xcffib.xproto.EventMask.StructureNotify]
893
    connection.core.CreateWindow(
894
        screen.root_depth,
895
        window,
896
        screen.root,
897
        30,
898
        30,
899
        220,
900
        140,
901
        0,
902
        xcffib.xproto.WindowClass.InputOutput,
903
        screen.root_visual,
904
        mask,
905
        values,
906
    )
907
    wm_name = connection.core.InternAtom(False, len("WM_NAME"), "WM_NAME").reply().atom
908
    string = connection.core.InternAtom(False, len("STRING"), "STRING").reply().atom
909
    title = b"swm-xwayland-test"
910
    connection.core.ChangeProperty(
911
        xcffib.xproto.PropMode.Replace,
912
        window,
913
        wm_name,
914
        string,
915
        8,
916
        len(title),
917
        title,
918
    )
919
    wm_class = connection.core.InternAtom(False, len("WM_CLASS"), "WM_CLASS").reply().atom
920
    class_name = b"x11-test\0X11-Test\0"
921
    connection.core.ChangeProperty(
922
        xcffib.xproto.PropMode.Replace,
923
        window,
924
        wm_class,
925
        string,
926
        8,
927
        len(class_name),
928
        class_name,
929
    )
930
    connection.core.MapWindow(window)
931
    configure = (
932
        xcffib.xproto.ConfigWindow.X
933
        | xcffib.xproto.ConfigWindow.Y
934
        | xcffib.xproto.ConfigWindow.Width
935
        | xcffib.xproto.ConfigWindow.Height
936
    )
937
    connection.core.ConfigureWindow(window, configure, [40, 50, 360, 240])
938
    connection.flush()
939
    for _ in range(100):
940
        event = connection.poll_for_event()
941
        if isinstance(event, xcffib.xproto.MapNotifyEvent):
942
            break
943
        time.sleep(0.01)
944
    else:
945
        raise Failure("XWayland window was not mapped")
946
947
    connection.core.ConfigureWindow(window, configure, [70, 80, 400, 260])
948
    connection.core.GetInputFocus().reply()
949
950
    unmanaged = connection.generate_id()
951
    unmanaged_mask = xcffib.xproto.CW.OverrideRedirect | xcffib.xproto.CW.EventMask
952
    connection.core.CreateWindow(
953
        screen.root_depth,
954
        unmanaged,
955
        screen.root,
956
        15,
957
        25,
958
        180,
959
        100,
960
        0,
961
        xcffib.xproto.WindowClass.InputOutput,
962
        screen.root_visual,
963
        unmanaged_mask,
964
        [1, xcffib.xproto.EventMask.StructureNotify],
965
    )
966
    unmanaged_title = b"x11-unmanaged"
967
    connection.core.ChangeProperty(
968
        xcffib.xproto.PropMode.Replace,
969
        unmanaged,
970
        wm_name,
971
        string,
972
        8,
973
        len(unmanaged_title),
974
        unmanaged_title,
975
    )
976
    connection.core.MapWindow(unmanaged)
977
    connection.flush()
978
    for _ in range(100):
979
        attributes = connection.core.GetWindowAttributes(unmanaged).reply()
980
        if attributes.map_state == xcffib.xproto.MapState.Viewable:
981
            break
982
        time.sleep(0.01)
983
    else:
984
        raise Failure("override-redirect XWayland window was not mapped")
985
    connection.core.ConfigureWindow(unmanaged, configure, [30, 40, 220, 130])
986
    connection.core.GetInputFocus().reply()
987
988
    net_state = connection.core.InternAtom(
989
        False, len("_NET_WM_STATE"), "_NET_WM_STATE"
990
    ).reply().atom
991
    net_fullscreen = connection.core.InternAtom(
992
        False, len("_NET_WM_STATE_FULLSCREEN"), "_NET_WM_STATE_FULLSCREEN"
993
    ).reply().atom
994
    message = xcffib.xproto.ClientMessageEvent.synthetic(
995
        32,
996
        window,
997
        net_state,
998
        ([1, net_fullscreen, 0, 1, 0], "=5I"),
999
    )
1000
    event_mask = (
1001
        xcffib.xproto.EventMask.SubstructureRedirect
1002
        | xcffib.xproto.EventMask.SubstructureNotify
1003
    )
1004
    connection.core.SendEvent(False, screen.root, event_mask, message.pack())
1005
    connection.flush()
1006
    connection.core.GetInputFocus().reply()
1007
1008
    connection.core.UnmapWindow(window)
1009
    connection.core.UnmapWindow(unmanaged)
1010
    connection.core.DestroyWindow(window)
1011
    connection.core.DestroyWindow(unmanaged)
1012
    connection.flush()
1013
    connection.disconnect()
1014
1015
1016
ROLES = {
1017
    "xdg": xdg_client,
1018
    "xdg-resize": xdg_resize,
1019
    "xdg-lifecycle": xdg_lifecycle,
1020
    "transient": transient,
1021
    "keyboard": keyboard,
1022
    "pointer": pointer,
1023
    "foreign": foreign,
1024
    "workspace": workspace,
1025
    "output-power": output_power,
1026
    "output-management": output_management,
1027
    "layer": layer,
1028
    "session-lock": session_lock,
1029
    "text-input": text_input,
1030
    "x11": x11,
1031
}
1032
1033
1034
def main() -> None:
1035
    """Run one selected protocol role."""
1036
1037
    if len(sys.argv) < 2 or sys.argv[1] not in ROLES:
1038
        raise Failure(f"usage: clients.py {'|'.join(ROLES)} [ARGS...]")
1039
    ROLES[sys.argv[1]](sys.argv[2:])
1040
1041
1042
if __name__ == "__main__":
1043
    try:
1044
        main()
1045
    except Exception as error:
1046
        print(error, file=sys.stderr)
1047
        raise SystemExit(1)