python-libei 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
libei/ei.py ADDED
@@ -0,0 +1,1244 @@
1
+ """Pythonic wrapper around libei -- the EI *client* library.
2
+
3
+ An EI client is either a :class:`Sender` (injects input -- what a remote-
4
+ control or automation client wants) or a :class:`Receiver` (consumes input
5
+ -- what a compositor implementation wants). Both are :class:`Context`
6
+ subclasses.
7
+
8
+ Typical sender usage. Note the ``dispatch()`` call: :attr:`Context.events`
9
+ drains only what is already queued, so it yields nothing until
10
+ ``dispatch()`` has read from the connection::
11
+
12
+ ctx = Sender.create_for_fd(eis_fd, name="my-app")
13
+
14
+ device = None
15
+ while device is None:
16
+ ctx.dispatch()
17
+ for event in ctx.events:
18
+ if event.event_type is EventType.SEAT_ADDED:
19
+ event.seat.bind((DeviceCapability.POINTER,))
20
+ elif event.event_type is EventType.DEVICE_RESUMED:
21
+ device = event.device
22
+
23
+ device.start_emulating().pointer_motion(5, 0).frame().stop_emulating()
24
+
25
+ Wait for ``DEVICE_RESUMED``, not ``DEVICE_ADDED``: a device arrives paused,
26
+ and libei calls sending events before it resumes "a client bug".
27
+
28
+ A real client should ``select()`` on :attr:`Context.fd` rather than
29
+ spinning, and give up after a timeout; see the README for that form.
30
+
31
+ Keys are raw Linux evdev keycodes -- key *positions*, not characters, and
32
+ no character or keysym mapping happens here. To type text under the user's
33
+ actual layout, either read :attr:`Device.keymap` and resolve through it
34
+ (e.g. with ``xkbcommon``), or use :meth:`Device.text_utf8` where libei 1.6
35
+ is available and the device has :attr:`DeviceCapability.TEXT`.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import contextlib
41
+ import dataclasses
42
+ import enum
43
+ import itertools
44
+ import logging
45
+ import os
46
+ from collections.abc import Iterator
47
+ from ctypes import byref, c_double, c_int, c_void_p
48
+ from pathlib import Path
49
+ from typing import IO, TypeVar
50
+
51
+ from . import _capi
52
+ from ._capi.libei import log_handler_t
53
+ from ._capi.loader import LibraryNotFoundError
54
+ from ._cobject import CObject
55
+
56
+ logger = logging.getLogger("libei.ei")
57
+
58
+ # Process-wide rather than per-Device, deliberately. libei requires the
59
+ # emulation sequence to increase on every ei_device_start_emulating() call
60
+ # for a given device; a counter stored on the Python wrapper would restart
61
+ # at 1 whenever that wrapper was garbage-collected and later rebuilt from
62
+ # the same C pointer, repeating sequence numbers for a device that is very
63
+ # much still alive. Sharing one counter across all devices trivially
64
+ # satisfies the per-device requirement and has no such lifetime coupling.
65
+ # itertools.count().__next__ is atomic under CPython, so no lock is needed.
66
+ _emulating_sequence = itertools.count(1)
67
+
68
+
69
+ def _next_emulating_sequence() -> int:
70
+ # Masked into uint32 to match the C parameter. libei asks callers to
71
+ # keep wraparound detection "reasonable"; skipping 0 keeps the value
72
+ # away from anything that might read as unset.
73
+ return (next(_emulating_sequence) % 0xFFFFFFFF) + 1
74
+
75
+
76
+ def is_available() -> bool:
77
+ """Whether libei.so.1 can be loaded on this system."""
78
+ return _capi.libei.lib.is_available()
79
+
80
+
81
+ class Error(Exception):
82
+ """A libei call failed.
83
+
84
+ ``errno`` is the positive errno where libei reported one (its setup
85
+ functions return a negative errno rather than setting the global), and
86
+ ``None`` where the failure was a NULL return with no code attached.
87
+ """
88
+
89
+ def __init__(self, message: str, errno: int | None = None) -> None:
90
+ super().__init__(message)
91
+ self.message = message
92
+ self.errno = errno
93
+
94
+
95
+ class EventType(enum.IntEnum):
96
+ """Mirrors ``enum ei_event_type`` from libei.h.
97
+
98
+ libei's own docs say this enum "is not exhaustive, future versions of
99
+ this library may add new event types" and that unknown events must
100
+ still be released with ``ei_event_unref()``. :attr:`Event.event_type`
101
+ honors that: a value not listed here is returned as a plain ``int``
102
+ rather than raising.
103
+ """
104
+
105
+ CONNECT = 1
106
+ DISCONNECT = 2
107
+ SEAT_ADDED = 3
108
+ SEAT_REMOVED = 4
109
+ DEVICE_ADDED = 5
110
+ DEVICE_REMOVED = 6
111
+ DEVICE_PAUSED = 7
112
+ DEVICE_RESUMED = 8
113
+ KEYBOARD_MODIFIERS = 9
114
+ PONG = 90
115
+ SYNC = 91
116
+ FRAME = 100
117
+ DEVICE_START_EMULATING = 200
118
+ DEVICE_STOP_EMULATING = 201
119
+ POINTER_MOTION = 300
120
+ POINTER_MOTION_ABSOLUTE = 400
121
+ BUTTON_BUTTON = 500
122
+ SCROLL_DELTA = 600
123
+ SCROLL_STOP = 601
124
+ SCROLL_CANCEL = 602
125
+ SCROLL_DISCRETE = 603
126
+ KEYBOARD_KEY = 700
127
+ TOUCH_DOWN = 800
128
+ TOUCH_UP = 801
129
+ TOUCH_MOTION = 802
130
+ TEXT_KEYSYM = 900
131
+ TEXT_UTF8 = 901
132
+ # Everything below exists on libei's main branch but in NO released
133
+ # version -- 1.6.0's enum ei_event_type stops at EI_EVENT_TEXT_UTF8.
134
+ # The values match upstream main exactly, so they are ready for the
135
+ # release that adds them, but no shipping library can send these and
136
+ # this package binds none of main's gesture/stylus accessors (nothing
137
+ # released exports them to verify against). See docs/vs-snegg.md.
138
+ SWIPE_BEGIN = 1000
139
+ SWIPE_UPDATE = 1001
140
+ SWIPE_END = 1002
141
+ SWIPE_ABORTED = 1003
142
+ PINCH_BEGIN = 1010
143
+ PINCH_UPDATE = 1011
144
+ PINCH_END = 1012
145
+ PINCH_ABORTED = 1013
146
+ HOLD_BEGIN = 1020
147
+ HOLD_END = 1021
148
+ HOLD_ABORTED = 1022
149
+ STYLUS_PROXIMITY_IN = 1101
150
+ STYLUS_PROXIMITY_OUT = 1102
151
+ STYLUS_ERASE_START = 1103
152
+ STYLUS_ERASE_STOP = 1104
153
+ STYLUS_TIP_DOWN = 1105
154
+ STYLUS_TIP_UP = 1106
155
+ STYLUS_AXIS = 1107
156
+
157
+
158
+ class DeviceCapability(enum.IntFlag):
159
+ """Mirrors ``enum ei_device_capability`` from libei.h.
160
+
161
+ An :class:`enum.IntFlag` so callers can talk about sets of them, but
162
+ note that libei's own functions never take an OR'd mask -- see
163
+ :meth:`Seat.bind`, which passes one value per vararg.
164
+ """
165
+
166
+ POINTER = 1 << 0
167
+ POINTER_ABSOLUTE = 1 << 1
168
+ KEYBOARD = 1 << 2
169
+ TOUCH = 1 << 3
170
+ SCROLL = 1 << 4
171
+ BUTTON = 1 << 5
172
+ TEXT = 1 << 6
173
+ # On libei's main branch only: 1.6.0's enum ei_device_capability
174
+ # stops at TEXT. Binding one of these against a released library is a
175
+ # silent noop -- no error, no device.
176
+ GESTURES = 1 << 7
177
+ STYLUS = 1 << 8
178
+
179
+
180
+ class DeviceType(enum.IntEnum):
181
+ """Whether a device is synthesised or backed by real hardware."""
182
+
183
+ VIRTUAL = 1
184
+ PHYSICAL = 2
185
+
186
+
187
+ class KeymapType(enum.IntEnum):
188
+ """Keymap format. libei defines exactly one."""
189
+
190
+ XKB = 1
191
+
192
+
193
+ class _LogPriority(enum.IntEnum):
194
+ DEBUG = 10
195
+ INFO = 20
196
+ WARNING = 30
197
+ ERROR = 40
198
+
199
+
200
+ @dataclasses.dataclass(frozen=True, slots=True)
201
+ class XkbModifiersEvent:
202
+ depressed: int
203
+ latched: int
204
+ locked: int
205
+ group: int
206
+
207
+
208
+ @dataclasses.dataclass(frozen=True, slots=True)
209
+ class KeyEvent:
210
+ key: int
211
+ is_press: bool
212
+
213
+
214
+ @dataclasses.dataclass(frozen=True, slots=True)
215
+ class ButtonEvent:
216
+ button: int
217
+ is_press: bool
218
+
219
+
220
+ @dataclasses.dataclass(frozen=True, slots=True)
221
+ class PointerEvent:
222
+ dx: float
223
+ dy: float
224
+
225
+
226
+ @dataclasses.dataclass(frozen=True, slots=True)
227
+ class PointerAbsoluteEvent:
228
+ x: float
229
+ y: float
230
+
231
+
232
+ @dataclasses.dataclass(frozen=True, slots=True)
233
+ class ScrollEvent:
234
+ dx: float
235
+ dy: float
236
+
237
+
238
+ @dataclasses.dataclass(frozen=True, slots=True)
239
+ class ScrollDiscreteEvent:
240
+ dx: int
241
+ dy: int
242
+
243
+
244
+ @dataclasses.dataclass(frozen=True, slots=True)
245
+ class ScrollStopEvent:
246
+ stop_x: bool
247
+ stop_y: bool
248
+
249
+
250
+ @dataclasses.dataclass(frozen=True, slots=True)
251
+ class TouchEvent:
252
+ touchid: int
253
+ x: float
254
+ y: float
255
+
256
+
257
+ @dataclasses.dataclass(frozen=True, slots=True)
258
+ class TouchUpEvent:
259
+ touchid: int
260
+ is_cancel: bool
261
+
262
+
263
+ @dataclasses.dataclass(frozen=True, slots=True)
264
+ class TextUtf8Event:
265
+ text: str
266
+
267
+
268
+ @dataclasses.dataclass(frozen=True, slots=True)
269
+ class TextKeysymEvent:
270
+ keysym: int
271
+ is_press: bool
272
+
273
+
274
+ class Region(CObject):
275
+ """A rectangular area of the desktop an absolute device maps onto.
276
+
277
+ A device with :attr:`DeviceCapability.POINTER_ABSOLUTE` or ``TOUCH``
278
+ covers one or more regions, and the coordinates passed to
279
+ :meth:`Device.pointer_motion_absolute` are in the desktop-wide logical
280
+ pixel space those regions sit in -- not relative to any one of them.
281
+ Use :meth:`convert_point` to go the other way.
282
+ """
283
+
284
+ _ref_func = staticmethod(_capi.libei.region_ref)
285
+ _unref_func = staticmethod(_capi.libei.region_unref)
286
+
287
+ def __repr__(self) -> str:
288
+ w, h = self.dimension
289
+ x, y = self.position
290
+ return f"<Region {w}x{h}+{x}+{y}>"
291
+
292
+ @property
293
+ def position(self) -> tuple[int, int]:
294
+ """Top-left corner of the region, in logical pixels."""
295
+ return (
296
+ _capi.libei.region_get_x(self),
297
+ _capi.libei.region_get_y(self),
298
+ )
299
+
300
+ @property
301
+ def dimension(self) -> tuple[int, int]:
302
+ """Width and height of the region, in logical pixels."""
303
+ return (
304
+ _capi.libei.region_get_width(self),
305
+ _capi.libei.region_get_height(self),
306
+ )
307
+
308
+ @property
309
+ def physical_scale(self) -> float:
310
+ """Scale between logical pixels and this region's physical size."""
311
+ return _capi.libei.region_get_physical_scale(self)
312
+
313
+ @property
314
+ def mapping_id(self) -> str | None:
315
+ """Identifier shared by regions that map to the same thing.
316
+
317
+ ``None`` where the server set none. Requires libei 1.1.
318
+ """
319
+ raw = _capi.libei.region_get_mapping_id(self)
320
+ return None if raw is None else raw.decode("utf-8")
321
+
322
+ def convert_point(self, x: float, y: float) -> tuple[float, float] | None:
323
+ """Convert a desktop-wide point to one relative to this region.
324
+
325
+ Returns the point with the region's offset subtracted, or ``None``
326
+ if it falls outside the region -- which is also how you test
327
+ membership without a second :meth:`contains` call. Requires
328
+ libei 1.1.
329
+ """
330
+ # x/y are in-out parameters: libei overwrites them only when the
331
+ # point is inside, so the return value has to gate reading them.
332
+ cx, cy = c_double(x), c_double(y)
333
+ if not _capi.libei.region_convert_point(self, byref(cx), byref(cy)):
334
+ return None
335
+ return (cx.value, cy.value)
336
+
337
+ def contains(self, x: float, y: float) -> bool:
338
+ """Whether the given logical-pixel point falls inside this region."""
339
+ return bool(_capi.libei.region_contains(self, x, y))
340
+
341
+
342
+ class Keymap(CObject):
343
+ """The XKB keymap the server has assigned to a keyboard device.
344
+
345
+ Read :attr:`fd` and :attr:`size` to feed it to ``xkbcommon`` and work
346
+ out which keycode produces a given character -- see the module
347
+ docstring on why keycodes are positions rather than characters.
348
+ """
349
+
350
+ _ref_func = staticmethod(_capi.libei.keymap_ref)
351
+ _unref_func = staticmethod(_capi.libei.keymap_unref)
352
+
353
+ @property
354
+ def keymap_type(self) -> KeymapType:
355
+ """Keymap format; currently always XKB."""
356
+ return KeymapType(_capi.libei.keymap_get_type(self))
357
+
358
+ @property
359
+ def size(self) -> int:
360
+ """Size of the keymap data, in bytes."""
361
+ return _capi.libei.keymap_get_size(self)
362
+
363
+ @property
364
+ def fd(self) -> IO[bytes]:
365
+ """Memmap-able file descriptor holding the keymap data.
366
+
367
+ A fresh duplicate on each read, which the caller owns and should
368
+ close; the keymap keeps its own. Rewound to position 0 where the
369
+ fd allows it, so the data is simply readable.
370
+ """
371
+ # ei_keymap_get_fd() is a plain field read; the keymap still owns
372
+ # that fd. os.fdopen() would make the returned file object close
373
+ # it, so duplicate it and hand out the copy.
374
+ raw_fd = _capi.libei.keymap_get_fd(self)
375
+ if raw_fd < 0:
376
+ # Without this, os.dup(-1) surfaces as a bare EBADF that says
377
+ # nothing about which object failed.
378
+ raise Error("ei_keymap_get_fd() reported no usable file descriptor")
379
+ duplicate = os.dup(raw_fd)
380
+ # dup(2) shares the file offset with the original, and libei's own
381
+ # fd is normally sitting at EOF -- reading straight from the copy
382
+ # returned zero bytes and no error, which looks exactly like an
383
+ # empty keymap. Rewind the copy; the offset is shared, so this
384
+ # also rewinds libei's, which is harmless for a memfd it only ever
385
+ # mmaps. A keymap fd is documented as memmap-able and so always
386
+ # seekable, but tolerate one that isn't rather than turning a
387
+ # readable fd into an exception.
388
+ with contextlib.suppress(OSError):
389
+ os.lseek(duplicate, 0, os.SEEK_SET)
390
+ return os.fdopen(duplicate, "rb")
391
+
392
+ @property
393
+ def device(self) -> Device:
394
+ """The device this keymap belongs to."""
395
+ device = Device.wrap(_capi.libei.keymap_get_device(self))
396
+ # wrap() is typed `T | None` because the C API's getters may
397
+ # return NULL in general; this one is documented never to. The
398
+ # assert is here to narrow the type for mypy, not to enforce an
399
+ # invariant -- under `python -O` it vanishes and a surprise NULL
400
+ # surfaces as an AttributeError on None at the caller, which is
401
+ # survivable. Contrast _cobject.py's cross-class pointer check,
402
+ # which guards memory safety and so is a real `raise`. Every
403
+ # other `assert ... is not None` in this module is the same
404
+ # narrowing idiom.
405
+ assert device is not None
406
+ return device
407
+
408
+
409
+ class Touch(CObject):
410
+ """One touch point, from :meth:`Device.touch_new` to up or cancel.
411
+
412
+ Created per touch rather than per device, so several can be in flight
413
+ at once for a multi-touch gesture. Like the :class:`Device` methods,
414
+ the calls here only queue -- :meth:`Device.frame` commits them.
415
+ """
416
+
417
+ _unref_func = staticmethod(_capi.libei.touch_unref)
418
+
419
+ @property
420
+ def device(self) -> Device:
421
+ """The device this touch belongs to."""
422
+ device = Device.wrap(_capi.libei.touch_get_device(self))
423
+ assert device is not None
424
+ return device
425
+
426
+ def down(self, x: float, y: float) -> Touch:
427
+ """Begin the touch at the given point."""
428
+ _capi.libei.touch_down(self, x, y)
429
+ return self
430
+
431
+ def motion(self, x: float, y: float) -> Touch:
432
+ """Move the in-progress touch to the given point."""
433
+ _capi.libei.touch_motion(self, x, y)
434
+ return self
435
+
436
+ def up(self) -> Touch:
437
+ """End the touch."""
438
+ _capi.libei.touch_up(self)
439
+ return self
440
+
441
+ def cancel(self) -> Touch:
442
+ """End the touch as cancelled rather than logically released.
443
+
444
+ Requires libei 1.4. It also needs version 2 or later of the
445
+ ``ei_touchscreen`` interface on both sides -- against an older EIS
446
+ implementation the call succeeds but arrives as a plain release.
447
+ """
448
+ _capi.libei.touch_cancel(self)
449
+ return self
450
+
451
+
452
+ class Device(CObject):
453
+ """An input device the server has handed this client.
454
+
455
+ Never constructed directly: ask for capabilities with
456
+ :meth:`Seat.bind`, then take the device off the DEVICE_ADDED event and
457
+ wait for DEVICE_RESUMED before sending anything.
458
+
459
+ The sending methods queue an event and return ``self``, so a whole
460
+ input sequence chains: :meth:`start_emulating`, the events themselves,
461
+ :meth:`frame` to commit them as one logical hardware event, then
462
+ :meth:`stop_emulating`.
463
+ """
464
+
465
+ _ref_func = staticmethod(_capi.libei.device_ref)
466
+ _unref_func = staticmethod(_capi.libei.device_unref)
467
+
468
+ def __repr__(self) -> str:
469
+ caps = "|".join(c.name or str(c.value) for c in self.capabilities)
470
+ return f"<Device {self.name!r} {self.device_type.name} {caps}>"
471
+
472
+ @property
473
+ def device_type(self) -> DeviceType:
474
+ """Whether the device is virtual or represents real hardware."""
475
+ return DeviceType(_capi.libei.device_get_type(self))
476
+
477
+ @property
478
+ def name(self) -> str:
479
+ """The device name assigned by the server."""
480
+ return _capi.libei.device_get_name(self).decode("utf-8")
481
+
482
+ @property
483
+ def width(self) -> int:
484
+ """Device width in logical pixels; 0 if unsized."""
485
+ return _capi.libei.device_get_width(self)
486
+
487
+ @property
488
+ def height(self) -> int:
489
+ """Device height in logical pixels; 0 if unsized."""
490
+ return _capi.libei.device_get_height(self)
491
+
492
+ @property
493
+ def capabilities(self) -> tuple[DeviceCapability, ...]:
494
+ """The capabilities this object actually has."""
495
+ return tuple(
496
+ c for c in DeviceCapability if _capi.libei.device_has_capability(self, c)
497
+ )
498
+
499
+ @property
500
+ def regions(self) -> tuple[Region, ...]:
501
+ """The device's regions, in index order."""
502
+ regions = []
503
+ index = 0
504
+ while True:
505
+ pointer = _capi.libei.device_get_region(self, index)
506
+ if not pointer:
507
+ break
508
+ region = Region.wrap(pointer)
509
+ assert region is not None
510
+ regions.append(region)
511
+ index += 1
512
+ return tuple(regions)
513
+
514
+ @property
515
+ def seat(self) -> Seat:
516
+ """The seat this device belongs to."""
517
+ seat = Seat.wrap(_capi.libei.device_get_seat(self))
518
+ assert seat is not None
519
+ return seat
520
+
521
+ @property
522
+ def keymap(self) -> Keymap | None:
523
+ """The device's keymap, or None if it has no keyboard capability."""
524
+ return Keymap.wrap(_capi.libei.device_keyboard_get_keymap(self))
525
+
526
+ def close(self) -> None:
527
+ """Ask the server to remove this device."""
528
+ _capi.libei.device_close(self)
529
+
530
+ def start_emulating(self, sequence: int | None = None) -> Device:
531
+ """Begin an emulation transaction; pair with :meth:`stop_emulating`.
532
+
533
+ ``sequence`` identifies the transaction and, per libei, "must go up
534
+ by at least 1 on each call". The default draws from a process-wide
535
+ counter that satisfies that for every device.
536
+ """
537
+ if sequence is None:
538
+ sequence = _next_emulating_sequence()
539
+ _capi.libei.device_start_emulating(self, sequence)
540
+ return self
541
+
542
+ def stop_emulating(self) -> Device:
543
+ """End the transaction opened by :meth:`start_emulating`."""
544
+ _capi.libei.device_stop_emulating(self)
545
+ return self
546
+
547
+ def frame(self, timestamp: int | None = None) -> Device:
548
+ """Commit the events queued since the last frame as one logical
549
+ hardware event. ``timestamp`` defaults to the context's current
550
+ time."""
551
+ if timestamp is None:
552
+ timestamp = _capi.libei.now(_capi.libei.device_get_context(self))
553
+ _capi.libei.device_frame(self, timestamp)
554
+ return self
555
+
556
+ def pointer_motion(self, dx: float, dy: float) -> Device:
557
+ """Queue a relative pointer motion, in logical pixels."""
558
+ _capi.libei.device_pointer_motion(self, dx, dy)
559
+ return self
560
+
561
+ def pointer_motion_absolute(self, x: float, y: float) -> Device:
562
+ """Queue an absolute pointer motion, in the device's region."""
563
+ _capi.libei.device_pointer_motion_absolute(self, x, y)
564
+ return self
565
+
566
+ def button(self, button: int, is_press: bool) -> Device:
567
+ """Queue a button press or release. ``button`` is a Linux
568
+ ``BTN_*`` code (e.g. ``0x110`` for ``BTN_LEFT``)."""
569
+ _capi.libei.device_button_button(self, button, is_press)
570
+ return self
571
+
572
+ def keyboard_key(self, key: int, is_press: bool) -> Device:
573
+ """Queue a key press or release, by Linux ``KEY_*`` keycode."""
574
+ _capi.libei.device_keyboard_key(self, key, is_press)
575
+ return self
576
+
577
+ def scroll_delta(self, dx: float, dy: float) -> Device:
578
+ """Queue a smooth scroll, in logical pixels."""
579
+ _capi.libei.device_scroll_delta(self, dx, dy)
580
+ return self
581
+
582
+ def scroll_discrete(self, dx: int, dy: int) -> Device:
583
+ """Queue a discrete (detent) scroll; one detent is 120."""
584
+ _capi.libei.device_scroll_discrete(self, dx, dy)
585
+ return self
586
+
587
+ def scroll_stop(self, stop_x: bool, stop_y: bool) -> Device:
588
+ """Signal that scrolling has stopped on the given axes."""
589
+ _capi.libei.device_scroll_stop(self, stop_x, stop_y)
590
+ return self
591
+
592
+ def scroll_cancel(self, cancel_x: bool, cancel_y: bool) -> Device:
593
+ """Signal that scroll kinetics are cancelled on the given axes."""
594
+ _capi.libei.device_scroll_cancel(self, cancel_x, cancel_y)
595
+ return self
596
+
597
+ def region_at(self, x: float, y: float) -> Region | None:
598
+ """The region containing this desktop-wide point, or None.
599
+
600
+ Requires libei 1.1.
601
+ """
602
+ return Region.wrap(_capi.libei.device_get_region_at(self, x, y))
603
+
604
+ def text_utf8(self, text: str) -> Device:
605
+ """Send text directly, for a device with the TEXT capability.
606
+
607
+ The compositor turns this into whatever key events its own layout
608
+ needs -- the one path here that types *characters* rather than key
609
+ positions. Requires libei 1.6 on both sides, and is silently
610
+ ignored by a device without :attr:`DeviceCapability.TEXT`.
611
+ """
612
+ # Encoded here and passed with an explicit length: the plain
613
+ # ei_device_text_utf8() takes a NUL-terminated string, which would
614
+ # silently truncate a str containing a NUL.
615
+ data = text.encode("utf-8")
616
+ _capi.libei.device_text_utf8_with_length(self, data, len(data))
617
+ return self
618
+
619
+ def text_keysym(self, keysym: int, is_press: bool) -> Device:
620
+ """Send an XKB keysym, for a device with the TEXT capability.
621
+
622
+ Requires libei 1.6 on both sides.
623
+ """
624
+ _capi.libei.device_text_keysym(self, keysym, is_press)
625
+ return self
626
+
627
+ def touch_new(self) -> Touch:
628
+ """Start a new touch on a device with the TOUCH capability."""
629
+ pointer = _capi.libei.device_touch_new(self)
630
+ if not pointer:
631
+ raise Error("ei_device_touch_new() returned NULL")
632
+ touch = Touch.adopt(pointer)
633
+ assert touch is not None
634
+ return touch
635
+
636
+
637
+ class Seat(CObject):
638
+ """A group of devices the server offers, arriving as SEAT_ADDED.
639
+
640
+ A seat advertises capabilities; :meth:`bind` asks for the ones you
641
+ want, and the server answers with devices.
642
+ """
643
+
644
+ _ref_func = staticmethod(_capi.libei.seat_ref)
645
+ _unref_func = staticmethod(_capi.libei.seat_unref)
646
+
647
+ def __repr__(self) -> str:
648
+ caps = "|".join(c.name or str(c.value) for c in self.capabilities)
649
+ return f"<Seat {self.name!r} {caps}>"
650
+
651
+ @property
652
+ def name(self) -> str:
653
+ """The seat name assigned by the server."""
654
+ return _capi.libei.seat_get_name(self).decode("utf-8")
655
+
656
+ @property
657
+ def capabilities(self) -> tuple[DeviceCapability, ...]:
658
+ """The capabilities this object actually has."""
659
+ return tuple(
660
+ c for c in DeviceCapability if _capi.libei.seat_has_capability(self, c)
661
+ )
662
+
663
+ def bind(self, capabilities: tuple[DeviceCapability, ...]) -> None:
664
+ """Request these capabilities from the seat.
665
+
666
+ The server responds by adding matching devices, surfacing as
667
+ DEVICE_ADDED events. Raises :class:`ValueError` if given no
668
+ capabilities: that would send nothing, and the caller would wait
669
+ for devices that are never coming.
670
+ """
671
+ if not capabilities:
672
+ raise ValueError(
673
+ "bind() needs at least one capability; binding an empty set "
674
+ "sends nothing and no DEVICE_ADDED event will ever arrive"
675
+ )
676
+ # ei_seat_bind_capabilities is variadic, one *individual* capability
677
+ # value per vararg, sentinel-terminated -- the C side reads them
678
+ # with va_arg and switches on each exact value. Passing a single
679
+ # OR'd mask (e.g. POINTER|KEYBOARD) matches no case, silently binds
680
+ # nothing, and the caller hangs waiting for DEVICE_ADDED.
681
+ _capi.libei.seat_bind_capabilities(
682
+ self, *(c_int(c) for c in capabilities), c_int(0)
683
+ )
684
+
685
+ def request_device(self, capabilities: tuple[DeviceCapability, ...]) -> None:
686
+ """Ask for another device with (a subset of) these capabilities.
687
+
688
+ For when the devices you have are no longer enough -- after
689
+ :meth:`Device.close`, say. The capabilities must be a subset of
690
+ what :meth:`bind` asked for, the server may answer with a device
691
+ whose capabilities differ, and it may not answer at all. Any
692
+ device it does create arrives as a DEVICE_ADDED event.
693
+
694
+ Requires libei 1.6. Raises :class:`ValueError` if given no
695
+ capabilities, for the same reason as :meth:`bind`.
696
+ """
697
+ if not capabilities:
698
+ raise ValueError("request_device() needs at least one capability")
699
+ # Variadic and sentinel-terminated, exactly like bind() above --
700
+ # one capability per vararg, never an OR'd mask.
701
+ _capi.libei.seat_request_device_with_capabilities(
702
+ self, *(c_int(c) for c in capabilities), c_int(0)
703
+ )
704
+
705
+ def unbind(self, capabilities: tuple[DeviceCapability, ...]) -> None:
706
+ """Release previously bound capabilities on this seat.
707
+
708
+ Raises :class:`ValueError` if given no capabilities, for the same
709
+ reason as :meth:`bind`.
710
+ """
711
+ if not capabilities:
712
+ raise ValueError("unbind() needs at least one capability")
713
+ _capi.libei.seat_unbind_capabilities(
714
+ self, *(c_int(c) for c in capabilities), c_int(0)
715
+ )
716
+
717
+
718
+ class Ping(CObject):
719
+ """A round trip to the EIS implementation, answered by a PONG event.
720
+
721
+ Create one with :meth:`Context.new_ping`, call :meth:`send`, then watch
722
+ for :attr:`EventType.PONG` and compare :attr:`Event.pong` against this
723
+ object (or its :attr:`id`). Requires libei 1.4.
724
+ """
725
+
726
+ _ref_func = staticmethod(_capi.libei.ping_ref)
727
+ _unref_func = staticmethod(_capi.libei.ping_unref)
728
+
729
+ def __repr__(self) -> str:
730
+ return f"<Ping {self.id}>"
731
+
732
+ @property
733
+ def id(self) -> int:
734
+ """The identifier libei assigned to this round trip."""
735
+ return _capi.libei.ping_get_id(self)
736
+
737
+ def send(self) -> Ping:
738
+ """Start the round trip. The reply arrives as a PONG event."""
739
+ _capi.libei.ping(self)
740
+ return self
741
+
742
+
743
+ class Event(CObject):
744
+ """One event from :attr:`Context.events`.
745
+
746
+ :attr:`event_type` says which of the typed accessors below is valid;
747
+ reading the wrong one raises :class:`TypeError` rather than returning
748
+ the zeroes libei would hand back (see :meth:`_require`).
749
+
750
+ Valid only for the loop iteration that yielded it --
751
+ :attr:`Context.events` releases each event as it resumes.
752
+ """
753
+
754
+ _unref_func = staticmethod(_capi.libei.event_unref)
755
+
756
+ def __repr__(self) -> str:
757
+ event_type = self.event_type
758
+ label = event_type.name if isinstance(event_type, EventType) else event_type
759
+ return f"<Event {label}>"
760
+
761
+ @property
762
+ def event_type(self) -> EventType | int:
763
+ """The event's type, or a raw int for a value newer than this
764
+ package's :class:`EventType` table -- see its docstring."""
765
+ raw = _capi.libei.event_get_type(self)
766
+ try:
767
+ return EventType(raw)
768
+ except ValueError:
769
+ return raw
770
+
771
+ @property
772
+ def time(self) -> int:
773
+ """Event timestamp in microseconds, in the context's clock domain."""
774
+ return _capi.libei.event_get_time(self)
775
+
776
+ @property
777
+ def device(self) -> Device | None:
778
+ """The device this event concerns, or None if it has none."""
779
+ return Device.wrap(_capi.libei.event_get_device(self))
780
+
781
+ @property
782
+ def seat(self) -> Seat | None:
783
+ """The seat this event concerns, or None if it has none.
784
+
785
+ Connect/disconnect events carry no seat.
786
+ """
787
+ return Seat.wrap(_capi.libei.event_get_seat(self))
788
+
789
+ def _require(self, getter: str, *valid: EventType) -> None:
790
+ """Raise unless this event is one of ``valid``.
791
+
792
+ libei's accessors do not report a type mismatch to the caller:
793
+ reading ``key_event`` off a POINTER_MOTION event returns
794
+ ``KeyEvent(key=0, is_press=False)``, logging an internal "Bug:"
795
+ line for some accessors and nothing at all for others. Checking
796
+ first turns a plausible-looking zero into an immediate error.
797
+ """
798
+ actual = self.event_type
799
+ if actual in valid:
800
+ return
801
+ wanted = " or ".join(v.name for v in valid)
802
+ seen = actual.name if isinstance(actual, EventType) else str(actual)
803
+ raise TypeError(f"Event.{getter} is only valid for {wanted} events, not {seen}")
804
+
805
+ @property
806
+ def emulating_sequence(self) -> int:
807
+ """Sequence number of the start_emulating transaction."""
808
+ self._require("emulating_sequence", EventType.DEVICE_START_EMULATING)
809
+ return _capi.libei.event_emulating_get_sequence(self)
810
+
811
+ @property
812
+ def keyboard_xkb_modifiers(self) -> XkbModifiersEvent:
813
+ """XKB modifier state for a KEYBOARD_MODIFIERS event."""
814
+ self._require("keyboard_xkb_modifiers", EventType.KEYBOARD_MODIFIERS)
815
+ return XkbModifiersEvent(
816
+ depressed=_capi.libei.event_keyboard_get_xkb_mods_depressed(self),
817
+ latched=_capi.libei.event_keyboard_get_xkb_mods_latched(self),
818
+ locked=_capi.libei.event_keyboard_get_xkb_mods_locked(self),
819
+ group=_capi.libei.event_keyboard_get_xkb_group(self),
820
+ )
821
+
822
+ @property
823
+ def key_event(self) -> KeyEvent:
824
+ """Key code and press/release state for a KEYBOARD_KEY event."""
825
+ self._require("key_event", EventType.KEYBOARD_KEY)
826
+ return KeyEvent(
827
+ key=_capi.libei.event_keyboard_get_key(self),
828
+ is_press=bool(_capi.libei.event_keyboard_get_key_is_press(self)),
829
+ )
830
+
831
+ @property
832
+ def button_event(self) -> ButtonEvent:
833
+ """Button code and press/release state for a BUTTON_BUTTON event."""
834
+ self._require("button_event", EventType.BUTTON_BUTTON)
835
+ return ButtonEvent(
836
+ button=_capi.libei.event_button_get_button(self),
837
+ is_press=bool(_capi.libei.event_button_get_is_press(self)),
838
+ )
839
+
840
+ @property
841
+ def pointer_event(self) -> PointerEvent:
842
+ """Relative motion deltas for a POINTER_MOTION event."""
843
+ self._require("pointer_event", EventType.POINTER_MOTION)
844
+ return PointerEvent(
845
+ dx=_capi.libei.event_pointer_get_dx(self),
846
+ dy=_capi.libei.event_pointer_get_dy(self),
847
+ )
848
+
849
+ @property
850
+ def pointer_absolute_event(self) -> PointerAbsoluteEvent:
851
+ """Absolute position for a POINTER_MOTION_ABSOLUTE event."""
852
+ self._require("pointer_absolute_event", EventType.POINTER_MOTION_ABSOLUTE)
853
+ return PointerAbsoluteEvent(
854
+ x=_capi.libei.event_pointer_get_absolute_x(self),
855
+ y=_capi.libei.event_pointer_get_absolute_y(self),
856
+ )
857
+
858
+ @property
859
+ def scroll_event(self) -> ScrollEvent:
860
+ """Smooth scroll deltas for a SCROLL_DELTA event."""
861
+ self._require("scroll_event", EventType.SCROLL_DELTA)
862
+ return ScrollEvent(
863
+ dx=_capi.libei.event_scroll_get_dx(self),
864
+ dy=_capi.libei.event_scroll_get_dy(self),
865
+ )
866
+
867
+ @property
868
+ def scroll_discrete_event(self) -> ScrollDiscreteEvent:
869
+ """Detent deltas for a SCROLL_DISCRETE event (120 per detent)."""
870
+ self._require("scroll_discrete_event", EventType.SCROLL_DISCRETE)
871
+ return ScrollDiscreteEvent(
872
+ dx=_capi.libei.event_scroll_get_discrete_dx(self),
873
+ dy=_capi.libei.event_scroll_get_discrete_dy(self),
874
+ )
875
+
876
+ @property
877
+ def scroll_stop_event(self) -> ScrollStopEvent:
878
+ """Which axes stopped, for a SCROLL_STOP/SCROLL_CANCEL event.
879
+
880
+ libei's header documents these accessors for SCROLL_CANCEL only,
881
+ but both event types are accepted -- confirmed by round-tripping
882
+ each through a real libeis server, with no internal "Bug:" log.
883
+ """
884
+ self._require(
885
+ "scroll_stop_event", EventType.SCROLL_STOP, EventType.SCROLL_CANCEL
886
+ )
887
+ return ScrollStopEvent(
888
+ stop_x=bool(_capi.libei.event_scroll_get_stop_x(self)),
889
+ stop_y=bool(_capi.libei.event_scroll_get_stop_y(self)),
890
+ )
891
+
892
+ @property
893
+ def touch_event(self) -> TouchEvent:
894
+ """Touch id and position for a TOUCH_DOWN or TOUCH_MOTION event.
895
+
896
+ Not TOUCH_UP: that event carries no position, so it has its own
897
+ accessor, :attr:`touch_up_event`.
898
+ """
899
+ self._require("touch_event", EventType.TOUCH_DOWN, EventType.TOUCH_MOTION)
900
+ return TouchEvent(
901
+ touchid=_capi.libei.event_touch_get_id(self),
902
+ x=_capi.libei.event_touch_get_x(self),
903
+ y=_capi.libei.event_touch_get_y(self),
904
+ )
905
+
906
+ @property
907
+ def touch_up_event(self) -> TouchUpEvent:
908
+ """Touch id and cancellation flag for a TOUCH_UP event.
909
+
910
+ ``is_cancel`` distinguishes a touch the compositor cancelled from
911
+ one the user logically released. It is False on libei older than
912
+ 1.4, which cannot express cancellation, and against an EIS
913
+ implementation older than ``ei_touchscreen`` version 2. The touch
914
+ id is available everywhere.
915
+ """
916
+ self._require("touch_up_event", EventType.TOUCH_UP)
917
+ try:
918
+ is_cancel = bool(_capi.libei.event_touch_get_is_cancel(self))
919
+ except LibraryNotFoundError:
920
+ # ei_event_touch_get_is_cancel() arrived in libei 1.4.
921
+ # An older library has no way to express cancellation, so every
922
+ # TOUCH_UP it reports really is a plain release: False is the
923
+ # accurate answer, not a failure. Without this the whole
924
+ # accessor would raise on a 1.0-1.3 install, taking the touch
925
+ # id -- which those versions do provide -- down with it.
926
+ is_cancel = False
927
+ return TouchUpEvent(
928
+ touchid=_capi.libei.event_touch_get_id(self),
929
+ is_cancel=is_cancel,
930
+ )
931
+
932
+ @property
933
+ def text_utf8_event(self) -> TextUtf8Event:
934
+ """The text carried by a TEXT_UTF8 event. Requires libei 1.6."""
935
+ self._require("text_utf8_event", EventType.TEXT_UTF8)
936
+ raw = _capi.libei.event_text_get_utf8(self)
937
+ return TextUtf8Event(text="" if raw is None else raw.decode("utf-8"))
938
+
939
+ @property
940
+ def text_keysym_event(self) -> TextKeysymEvent:
941
+ """Keysym and press state for a TEXT_KEYSYM event. Requires libei 1.6."""
942
+ self._require("text_keysym_event", EventType.TEXT_KEYSYM)
943
+ return TextKeysymEvent(
944
+ keysym=_capi.libei.event_text_get_keysym(self),
945
+ is_press=bool(_capi.libei.event_text_get_keysym_is_press(self)),
946
+ )
947
+
948
+ @property
949
+ def pong(self) -> Ping:
950
+ """The :class:`Ping` this PONG event answers. Requires libei 1.4."""
951
+ self._require("pong", EventType.PONG)
952
+ # Borrowed: the event owns this reference, so wrap() (which takes
953
+ # its own ref) rather than adopt().
954
+ ping = Ping.wrap(_capi.libei.event_pong_get_ping(self))
955
+ if ping is None:
956
+ raise Error("ei_event_pong_get_ping() returned NULL for a PONG event")
957
+ return ping
958
+
959
+
960
+ def _log_callback(_ei: int, priority: int, message: bytes, _context: int) -> None:
961
+ # Look up the raw int, not _LogPriority(priority): constructing the
962
+ # enum from an unrecognized value raises ValueError immediately, which
963
+ # would happen *before* .get()'s default ever gets a chance to apply
964
+ # -- and inside a ctypes callback, that exception is silently dropped
965
+ # (printed to stderr) rather than propagated, so the log line is just
966
+ # lost instead of falling back to DEBUG. Keyed by .value (plain int)
967
+ # rather than the enum members themselves so mypy accepts a plain-int
968
+ # lookup key too.
969
+ level = {
970
+ _LogPriority.DEBUG.value: logging.DEBUG,
971
+ _LogPriority.INFO.value: logging.INFO,
972
+ _LogPriority.WARNING.value: logging.WARNING,
973
+ _LogPriority.ERROR.value: logging.ERROR,
974
+ }.get(priority, logging.DEBUG)
975
+ logger.log(level, message.decode("utf-8", errors="replace"))
976
+
977
+
978
+ # Kept as a module-level reference: ctypes does not keep a CFUNCTYPE callback
979
+ # alive on the C side, so letting this get garbage-collected would leave
980
+ # libei holding a dangling function pointer.
981
+ _log_handler = log_handler_t(_log_callback)
982
+
983
+
984
+ # Lets the chained configuration methods below (set_name/set_fd/set_socket)
985
+ # say "returns whatever subclass it was called on" -- annotating `self` with
986
+ # a TypeVar is the pre-3.11 spelling of typing.Self, which this package
987
+ # can't use while it supports Python 3.10 and ships zero dependencies.
988
+ # Without it, Sender.create_for_fd()'s `cls(cls._new()).set_name(...)` chain
989
+ # would be typed as plain Context and need a cast at every return.
990
+ _ContextT = TypeVar("_ContextT", bound="Context")
991
+
992
+
993
+ class Context(CObject):
994
+ """One connection to an EIS implementation; base of Sender/Receiver.
995
+
996
+ Not instantiated directly -- use :meth:`Sender.create_for_fd` or the
997
+ :class:`Receiver` equivalents, which allocate the context, name it and
998
+ set up its transport in one call. :meth:`dispatch` reads from the
999
+ connection and :attr:`events` drains what that queued.
1000
+ """
1001
+
1002
+ _unref_func = staticmethod(_capi.libei.unref)
1003
+ # Only ever created fresh via _new() inside create_for_fd()/
1004
+ # create_for_socket(), never handed out as a sub-object -- so wrap()/
1005
+ # adopt() on this class (and Sender/Receiver below) have no legitimate
1006
+ # caller. Blocking them stops a garbage pointer from ever reaching
1007
+ # __init__'s log_set_handler()/log_set_priority() calls below, which
1008
+ # would otherwise dereference it as a real `struct ei *` and segfault.
1009
+ _wrappable = False
1010
+
1011
+ def __init__(self, pointer: int, *, _adopt: bool = False) -> None:
1012
+ # _adopt is accepted and forwarded for signature consistency with
1013
+ # CObject, but with _wrappable = False, _get_or_create() never
1014
+ # actually reaches this constructor -- Context (and Sender/
1015
+ # Receiver) are always built directly via cls(cls._new()) in
1016
+ # create_for_fd()/create_for_socket().
1017
+ super().__init__(pointer, _adopt=_adopt)
1018
+ self._name: str | None = None
1019
+ _capi.libei.log_set_handler(self, _log_handler)
1020
+ _capi.libei.log_set_priority(self, _LogPriority.DEBUG)
1021
+
1022
+ def set_name(self: _ContextT, name: str) -> _ContextT:
1023
+ """Set the client name announced to the server. Call before connecting."""
1024
+ self._name = name
1025
+ _capi.libei.configure_name(self, name.encode("utf-8"))
1026
+ return self
1027
+
1028
+ @property
1029
+ def name(self) -> str | None:
1030
+ """The client name set via :meth:`set_name`, if any."""
1031
+ return self._name
1032
+
1033
+ @property
1034
+ def fd(self) -> int:
1035
+ """File descriptor to poll; readable when :meth:`dispatch` has work.
1036
+
1037
+ Only valid once a backend is set up (which the ``create_for_*``
1038
+ constructors do before returning); before that libei reports -1.
1039
+ """
1040
+ # Deliberately not memoized. ei_get_fd() is a plain field read, and
1041
+ # caching it meant a read taken before set_fd()/set_socket() -- now
1042
+ # reachable, since Context can be obtained via wrap() -- would pin
1043
+ # the pre-setup -1 for the object's whole life.
1044
+ return _capi.libei.get_fd(self)
1045
+
1046
+ @property
1047
+ def events(self) -> Iterator[Event]:
1048
+ """Drain currently-queued events.
1049
+
1050
+ Each event is released (unref'd) as soon as this generator resumes
1051
+ after yielding it -- do not hold a reference past the loop
1052
+ iteration that receives it. This matters beyond just memory: a
1053
+ SYNC event's pong reply is sent by libei precisely when the event
1054
+ is unref'd, so leaving that to Python's own GC timing (which, for
1055
+ a bare ``for event in ctx.events:`` loop, may not happen until the
1056
+ loop variable is next reassigned -- possibly never, if that event
1057
+ turns out to be the last one in a batch) can silently stall a
1058
+ caller waiting on that reply.
1059
+ """
1060
+ while True:
1061
+ pointer = _capi.libei.get_event(self)
1062
+ if not pointer:
1063
+ break
1064
+ event = Event.wrap(pointer)
1065
+ assert event is not None
1066
+ # try/finally, not a bare call after yield: breaking out of a
1067
+ # `for event in ctx.events:` loop (or an exception propagating
1068
+ # through it) throws GeneratorExit in at the yield and unwinds
1069
+ # this frame immediately -- release() right after wouldn't run.
1070
+ try:
1071
+ yield event
1072
+ finally:
1073
+ event.release()
1074
+
1075
+ @property
1076
+ def now(self) -> int:
1077
+ """The context's current time, in microseconds."""
1078
+ return _capi.libei.now(self)
1079
+
1080
+ def set_fd(self: _ContextT, fd: IO[bytes] | int) -> _ContextT:
1081
+ """Use an already-connected socket as the transport.
1082
+
1083
+ libei takes ownership of a raw int fd and closes it itself; a file
1084
+ object is duplicated first, so the caller's own object stays valid."""
1085
+ # ei_setup_backend_fd() takes ownership of the fd and will close it
1086
+ # itself. A raw int is assumed to already be one the caller is
1087
+ # handing off (matching what eis.Eis.add_client()/oeffis.eis_fd
1088
+ # return); a file object still thinks it owns its own fd and would
1089
+ # close it again later -- possibly a *different*, since-reused fd
1090
+ # number by then -- so duplicate it rather than handing over the
1091
+ # original.
1092
+ raw_fd = fd if isinstance(fd, int) else os.dup(fd.fileno())
1093
+ err = _capi.libei.setup_backend_fd(self, raw_fd)
1094
+ if err < 0:
1095
+ raise Error(os.strerror(-err), -err)
1096
+ return self
1097
+
1098
+ def set_socket(self: _ContextT, path: Path | None) -> _ContextT:
1099
+ """Connect to an EIS socket by path.
1100
+
1101
+ ``None`` uses ``$LIBEI_SOCKET``; a relative path is resolved
1102
+ against ``$XDG_RUNTIME_DIR``."""
1103
+ encoded = os.fspath(path).encode("utf-8") if path else None
1104
+ err = _capi.libei.setup_backend_socket(self, encoded)
1105
+ if err < 0:
1106
+ raise Error(os.strerror(-err), -err)
1107
+ return self
1108
+
1109
+ @property
1110
+ def is_sender(self) -> bool:
1111
+ """Whether this context injects input rather than consuming it."""
1112
+ return bool(_capi.libei.is_sender(self))
1113
+
1114
+ def peek_event_type(self) -> EventType | int | None:
1115
+ """Type of the next queued event, without consuming it.
1116
+
1117
+ ``None`` when the queue is empty. Only the type is returned, never
1118
+ the event itself: libei documents calling ``ei_get_event()`` while
1119
+ holding a reference from ``ei_peek_event()`` as undefined
1120
+ behavior, so that reference is dropped before this returns rather
1121
+ than handed out for a caller to trip over.
1122
+
1123
+ Like :attr:`Event.event_type`, a value newer than this package's
1124
+ table comes back as a plain ``int``.
1125
+ """
1126
+ pointer = _capi.libei.peek_event(self)
1127
+ if not pointer:
1128
+ return None
1129
+ # Deliberately not wrapped in an Event: that would put it in the
1130
+ # identity cache and give it a finalizer, i.e. exactly the held
1131
+ # reference the C API says must not outlive this call.
1132
+ try:
1133
+ raw = _capi.libei.event_get_type(pointer)
1134
+ finally:
1135
+ _capi.libei.event_unref(pointer)
1136
+ try:
1137
+ return EventType(raw)
1138
+ except ValueError:
1139
+ return raw
1140
+
1141
+ def new_ping(self) -> Ping:
1142
+ """Create a round trip to the server. Requires libei 1.4.
1143
+
1144
+ Call :meth:`Ping.send` to start it; the reply is a PONG event.
1145
+ """
1146
+ ping = Ping.adopt(_capi.libei.new_ping(self))
1147
+ if ping is None:
1148
+ raise Error("ei_new_ping() returned NULL")
1149
+ return ping
1150
+
1151
+ def disconnect(self) -> None:
1152
+ """Disconnect from the EIS implementation. Requires libei 1.4.
1153
+
1154
+ Teardown runs through the event queue rather than immediately:
1155
+ seats and devices are removed as though the server had done it,
1156
+ and DISCONNECT is the last event you will get. The context is
1157
+ inert afterwards, but still needs releasing like any other.
1158
+ """
1159
+ _capi.libei.disconnect(self)
1160
+
1161
+ def dispatch(self) -> None:
1162
+ """Read from the connection and queue any events that arrive.
1163
+
1164
+ Call this before iterating :attr:`events`, which only drains what
1165
+ is already queued."""
1166
+ _capi.libei.dispatch(self)
1167
+
1168
+
1169
+ class Sender(Context):
1170
+ """An EI client that injects input -- e.g. remote-control automation."""
1171
+
1172
+ @classmethod
1173
+ def _new(cls) -> int:
1174
+ pointer = _capi.libei.new_sender(c_void_p(None))
1175
+ if not pointer:
1176
+ raise Error("ei_new_sender() returned NULL")
1177
+ return pointer
1178
+
1179
+ @classmethod
1180
+ def create_for_fd(cls, fd: IO[bytes] | int, name: str | None = None) -> Sender:
1181
+ """Create a context speaking EI over an already-connected fd."""
1182
+ return cls(cls._new()).set_name(name or "unnamed").set_fd(fd)
1183
+
1184
+ @classmethod
1185
+ def create_for_socket(
1186
+ cls, path: Path | None = None, name: str | None = None
1187
+ ) -> Sender:
1188
+ """Create a context connecting to an EIS socket by path."""
1189
+ return cls(cls._new()).set_name(name or "unnamed").set_socket(path)
1190
+
1191
+
1192
+ class Receiver(Context):
1193
+ """An EI client that consumes input -- e.g. a compositor-side test."""
1194
+
1195
+ @classmethod
1196
+ def _new(cls) -> int:
1197
+ pointer = _capi.libei.new_receiver(c_void_p(None))
1198
+ if not pointer:
1199
+ raise Error("ei_new_receiver() returned NULL")
1200
+ return pointer
1201
+
1202
+ @classmethod
1203
+ def create_for_fd(cls, fd: IO[bytes] | int, name: str | None = None) -> Receiver:
1204
+ """Create a context speaking EI over an already-connected fd."""
1205
+ return cls(cls._new()).set_name(name or "unnamed").set_fd(fd)
1206
+
1207
+ @classmethod
1208
+ def create_for_socket(
1209
+ cls, path: Path | None = None, name: str | None = None
1210
+ ) -> Receiver:
1211
+ """Create a context connecting to an EIS socket by path."""
1212
+ return cls(cls._new()).set_name(name or "unnamed").set_socket(path)
1213
+
1214
+
1215
+ __all__ = [
1216
+ "ButtonEvent",
1217
+ "Context",
1218
+ "Device",
1219
+ "DeviceCapability",
1220
+ "DeviceType",
1221
+ "Error",
1222
+ "Event",
1223
+ "EventType",
1224
+ "KeyEvent",
1225
+ "Keymap",
1226
+ "KeymapType",
1227
+ "Ping",
1228
+ "PointerAbsoluteEvent",
1229
+ "PointerEvent",
1230
+ "Receiver",
1231
+ "Region",
1232
+ "ScrollDiscreteEvent",
1233
+ "ScrollEvent",
1234
+ "ScrollStopEvent",
1235
+ "Seat",
1236
+ "Sender",
1237
+ "TextKeysymEvent",
1238
+ "TextUtf8Event",
1239
+ "Touch",
1240
+ "TouchEvent",
1241
+ "TouchUpEvent",
1242
+ "XkbModifiersEvent",
1243
+ "is_available",
1244
+ ]