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/__init__.py +31 -0
- libei/_capi/__init__.py +6 -0
- libei/_capi/libei.py +247 -0
- libei/_capi/libeis.py +285 -0
- libei/_capi/liboeffis.py +29 -0
- libei/_capi/loader.py +112 -0
- libei/_cobject.py +252 -0
- libei/ei.py +1244 -0
- libei/eis.py +1234 -0
- libei/oeffis.py +238 -0
- libei/py.typed +0 -0
- python_libei-0.1.0.dist-info/METADATA +770 -0
- python_libei-0.1.0.dist-info/RECORD +16 -0
- python_libei-0.1.0.dist-info/WHEEL +5 -0
- python_libei-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_libei-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
]
|