Repository navigation
Expand file tree
/
Copy pathdevice.py
More file actions
executable file
·508 lines (461 loc) · 23 KB
/
Copy pathdevice.py
File metadata and controls
executable file
·508 lines (461 loc) · 23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
#!/usr/bin/env python3
"""A mouse and a keyboard that are not there, through /dev/uinput.
wtype has keys but no pointer, and Hyprland can warp the pointer but not press
its buttons or turn its wheel, so what the wheel and a drag do — zooming about
the pointer, panning by hand — cannot be recorded without a device of our
own. This registers one with the kernel for as long as the command runs,
sends the events, and takes it away again. Nothing but the standard library:
the ioctls and the event record are spelled out here from linux/uinput.h and
linux/input.h.
It has keys as well, for the bindings wtype cannot reach. wtype types a
keysym under a keymap of its own, at whatever keycode it chose for it, and
a binding on a key's position — Shift+2 for 50%, matched by the key being
the second digit and not by what it says — never sees the key it wants.
A key from here is the real keycode, read under the real keymap.
device.py wheel N [S] N notches: positive is away from the hand, which zooms in;
spread evenly over S seconds rather than sent at once
device.py glide X Y the pointer moved, with no button held, until it is at X, Y,
which may be fractions of a logical pixel, and the window
told so
device.py place X Y the same; the name is for a script to say where the pixel
under the pointer is what matters
device.py drag DX DY the left button held while the pointer moves DX, DY
device.py drag_to X Y [placed]
the left button held while the pointer glides to X, Y —
and is placed there, if asked, before it is let go
device.py drag_along [S] X Y X Y ...
the same held through every mark in turn, so that the
hand draws a curve and the window sees one stroke;
over S seconds, rather than at the pointer's own pace
device.py along [S] X Y X Y ...
the pointer taken through every mark in turn with no
button held, as one motion; over S seconds
device.py click the left button pressed and released
device.py hold_along [S] X Y X Y ...
the right button held while the pointer is taken through
every mark as `drag_along` takes it, still for a moment
after the press and before the release; over S seconds
device.py key CHORD a key by its position, with modifiers: 2, shift+2, ctrl+shift+c
device.py rest S [T] nothing, for S seconds, or for somewhere between S and T:
a hand that pauses between two things
Several of these, separated by `--`, are done in turn by the one device:
`device.py glide 400 300 -- click -- key down -- key down` is a hand that
goes somewhere, clicks and presses a key twice, and the compositor meets
the device once rather than four times. After each, where the pointer is
is printed, a line of two numbers: what a drag moved is the difference
between two of them.
Motion is sent in units, and the compositor is told to make each unit of
this device exactly a quarter of a logical pixel, at any pace: a flat
acceleration profile, for this device alone, so that the pointer's place
is arithmetic — where it started plus the units sent, over four. It is
asked of the compositor once when the device is made, and once more at
the end, to check the sum. The real mouse keeps its own acceleration.
/dev/uinput has to be writable by the user; Omarchy grants that through an
ACL, and `getfacl /dev/uinput` says whether it has.
"""
import fcntl
import math
import os
import random
import struct
import subprocess
import sys
import time
# linux/input-event-codes.h
EV_SYN, EV_KEY, EV_REL = 0x00, 0x01, 0x02
SYN_REPORT = 0
REL_X, REL_Y, REL_WHEEL, REL_WHEEL_HI_RES = 0x00, 0x01, 0x08, 0x0B
BTN_LEFT, BTN_RIGHT = 0x110, 0x111
# The keys a chord can name, by their evdev codes.
KEYS = {
"esc": 1, "1": 2, "2": 3, "3": 4, "4": 5, "5": 6, "6": 7, "7": 8, "8": 9,
"9": 10, "0": 11, "minus": 12, "equal": 13, "backspace": 14, "tab": 15,
"q": 16, "w": 17, "e": 18, "r": 19, "t": 20, "y": 21, "u": 22, "i": 23,
"o": 24, "p": 25, "leftbrace": 26, "rightbrace": 27, "enter": 28,
"leftctrl": 29, "a": 30, "s": 31, "d": 32, "f": 33, "g": 34, "h": 35,
"j": 36, "k": 37, "l": 38, "semicolon": 39, "apostrophe": 40, "grave": 41,
"leftshift": 42, "backslash": 43, "z": 44, "x": 45, "c": 46, "v": 47,
"b": 48, "n": 49, "m": 50, "comma": 51, "dot": 52, "slash": 53,
"rightshift": 54, "leftalt": 56, "space": 57, "up": 103, "pageup": 104,
"left": 105, "right": 106, "end": 107, "down": 108, "pagedown": 109,
}
MODIFIERS = {"shift": "leftshift", "ctrl": "leftctrl", "alt": "leftalt"}
# linux/uinput.h, with the _IO macros already applied: 'U' is 0x55, and
# uinput_setup is an input_id (four u16), an 80-byte name and a u32.
UI_DEV_CREATE = 0x5501
UI_DEV_DESTROY = 0x5502
UI_DEV_SETUP = 0x405C5503
UI_SET_EVBIT = 0x40045564
UI_SET_KEYBIT = 0x40045565
UI_SET_RELBIT = 0x40045566
# libinput's notion of a notch, for REL_WHEEL_HI_RES.
NOTCH = 120
# How long the compositor takes to notice a device that has just appeared,
# and to drain what it sent before it goes.
SETTLE = 0.2
# Hyprland's rule for this device, by the name it lists it under: a flat
# profile, so that a unit of motion carries the same distance whatever the
# pace, and a sensitivity that makes the distance a quarter of a logical
# pixel — at 0 it is one whole pixel. Set through the Lua API before the
# device is made, and for this device alone.
PROFILE = 'hl.device({ name = "gamut-screenshots-1", accel_profile = "flat", sensitivity = -0.75 })'
UNIT = 0.25
# Logical pixels a step of a move, at a step a frame, taken over the
# move: a drag on screen that reads as a hand's, not so slow as to try
# the patience of a film. A hand sets off and stops gently, so the steps
# are not even: this much of the move follows an S-curve and the rest is
# spread evenly, which makes the first and last steps about a third of
# PACE and the middle ones a third more. At 0 every step would be PACE;
# at 1 the first and last would be nothing.
# POINTER_PACE in the environment raises or lowers it for a run: a film of
# the whole desk crosses a 1400-pixel window rather than a 1000-pixel one,
# and a hand does not take twice as long to do it.
PACE = int(os.environ.get("POINTER_PACE", 16))
EASE = 0.7
# How long a step of a motion waits before the next, about a frame at 60 and
# so an event or two in every frame the window draws. A motion given a time
# to take keeps this rate and shortens its steps, rather than keeping PACE
# and waiting longer between them: what a pan is filmed to show is a picture
# that moves in every frame.
STEP = 0.012
# The least a key press waits before the next, and the most: a hand does
# not press a key ten times at a metronome's beat.
KEY_GAP = (0.0, 0.02)
# The units of the step that ends a glide, two pixels: the window hears of
# the pointer only as it crosses from one whole pixel into the next, and
# then where that event landed, so a glide's last step goes far enough
# along its way to be sure of crossing an edge, and lands on the mark.
LAST = 8
# How long the window takes to have drawn a frame with the pointer where
# it is, two frames at the film's 60. A glide waits this long before
# anything is done where it ended, since a click on a handle is a click
# on whichever handle the window last drew the pointer over, and a drag's
# press is at wherever it last drew it; and the button is up this long
# before the pointer moves on, since a toolkit counts a click only if the
# pointer is still on the widget in the frame the button came up, and
# takes motion in the frame of a drag's release as part of the drag.
HEARD = 0.035
# How many steps of two units a drag creeps from its press before it sets
# off: half a pixel a step, seven and a half pixels an axis, past the six
# the toolkit takes for a click.
CREEP = 15
# Lua for Hyprland that raises the pointer's position as its error.
CURSOR_POSITION = 'local p = hl.get_cursor_pos(); error(string.format("%.4f %.4f", p.x, p.y))'
class Device:
def __init__(self):
subprocess.run(["hyprctl", "eval", PROFILE], capture_output=True, check=True)
self.fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK)
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY)
fcntl.ioctl(self.fd, UI_SET_KEYBIT, BTN_LEFT)
fcntl.ioctl(self.fd, UI_SET_KEYBIT, BTN_RIGHT)
for code in KEYS.values():
fcntl.ioctl(self.fd, UI_SET_KEYBIT, code)
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_REL)
for code in (REL_X, REL_Y, REL_WHEEL, REL_WHEEL_HI_RES):
fcntl.ioctl(self.fd, UI_SET_RELBIT, code)
setup = struct.pack("HHHH80sI", 0x03, 0x1234, 0x5678, 1, b"gamut screenshots", 0)
fcntl.ioctl(self.fd, UI_DEV_SETUP, setup)
fcntl.ioctl(self.fd, UI_DEV_CREATE)
time.sleep(SETTLE)
# Where the pointer is, asked once; every move from here is added
# to it, since each unit is known to carry UNIT.
self.at = list(self.resting())
def close(self):
time.sleep(SETTLE)
fcntl.ioctl(self.fd, UI_DEV_DESTROY)
os.close(self.fd)
def emit(self, kind, code, value):
# struct input_event: a timeval the kernel fills in, then type, code, value.
os.write(self.fd, struct.pack("llHHi", 0, 0, kind, code, value))
def sync(self):
self.emit(EV_SYN, SYN_REPORT, 0)
def wheel(self, notches, seconds=None):
# As fast as a flick unless told how long to take: a slow turn, for
# a scroll that is meant to be read on the way down, is the same
# notches with the whole time parted out between them.
step = 1 if notches > 0 else -1
pause = 0.05 if seconds is None else seconds / max(1, abs(notches))
for _ in range(abs(notches)):
self.emit(EV_REL, REL_WHEEL, step)
self.emit(EV_REL, REL_WHEEL_HI_RES, step * NOTCH)
self.sync()
time.sleep(pause)
def move(self, ux, uy):
# UX, UY units of motion, in steps, so that it is a drag rather than
# a jump: a pointer that arrives in one event is one motion event,
# and a drag threshold or an easing that watches the pointer's path
# sees nothing of it. PACE pixels a step on average, about a step a
# frame at the film's 60, eased in and out by EASE.
if ux == 0 and uy == 0:
return
steps = max(1, int(max(abs(ux), abs(uy)) * UNIT / PACE))
gone = [0, 0]
for i in range(1, steps + 1):
t = i / steps
f = (1 - EASE) * t + EASE * t * t * (3 - 2 * t)
to = [ux, uy] if i == steps else [round(ux * f), round(uy * f)]
self.emit(EV_REL, REL_X, to[0] - gone[0])
self.emit(EV_REL, REL_Y, to[1] - gone[1])
self.sync()
gone = to
time.sleep(STEP)
self.at[0] += ux * UNIT
self.at[1] += uy * UNIT
def units(self, x, y):
# The units that take the pointer from where it is to X, Y: to the
# nearest quarter of a pixel, an eighth off the mark at worst.
return round((x - self.at[0]) / UNIT), round((y - self.at[1]) / UNIT)
def glide(self, x, y):
# To the mark at `move`'s pace, all but the last step, which is
# LAST units along the way, sent as one event. The window hears of
# the pointer only as it crosses from one whole pixel into the next,
# and then where that event landed, so after a last move of less
# than a pixel the window has the pointer up to a pixel from where
# it is; the last step here is long enough to cross an edge on
# whichever axis it mostly goes along, so the window hears it and
# has the pointer where it stopped. A mark within the last step's
# length is backed away from by the difference first, and a mark
# under the pointer already is stepped away from and back.
ux, uy = self.units(x, y)
length = math.hypot(ux, uy)
if length == 0:
lx = ly = round(LAST / math.sqrt(2))
else:
lx, ly = round(ux * LAST / length), round(uy * LAST / length)
self.move(ux - lx, uy - ly)
self.emit(EV_REL, REL_X, lx)
self.emit(EV_REL, REL_Y, ly)
self.sync()
self.at[0] += lx * UNIT
self.at[1] += ly * UNIT
time.sleep(HEARD)
def place(self, x, y):
# The same motion: the name is for the scripts, which say `place`
# where the pixel under the pointer is what matters, as at the two
# corners of `region`'s box.
self.glide(x, y)
def resting(self):
last = None
while True:
at = self.position()
if at == last:
return at
last = at
time.sleep(0.03)
def position(self):
# `hyprctl cursorpos` cuts the fraction off. The Lua API has it
# whole, but `hyprctl eval` prints nothing a script returns — only
# what it raises, so the position is raised.
said = subprocess.run(
["hyprctl", "eval", CURSOR_POSITION], capture_output=True, text=True
).stdout
x, y = said.split()[-2:]
return float(x), float(y)
def button(self, down, code=BTN_LEFT):
self.emit(EV_KEY, code, 1 if down else 0)
self.sync()
if not down:
time.sleep(HEARD)
def drag(self, dx, dy):
self.button(True)
time.sleep(0.05)
self.move(round(dx / UNIT), round(dy / UNIT))
time.sleep(0.05)
self.button(False)
def drag_to(self, x, y, placed=False):
# A glide with the button down, to a point in the layout rather
# than by a distance. The glide ends with the frames the window
# takes to draw the pointer at its mark, and the button comes up
# after them: a toolkit takes the drag's end from the last frame it
# was dragging on, and a motion that arrives in the same frame as
# the release is not part of it. A drag that is to end on a given
# pixel of the picture, which a pan could miss the last step of and
# be none the worse, holds a while longer to be sure. The press is
# a frame before the creep for the same reason, the other way about.
self.button(True)
time.sleep(0.02)
self.creep(x, y)
self.glide(x, y)
if placed:
time.sleep(0.1)
self.button(False)
def along(self, marks, seconds=None):
# The pointer through every mark in turn as one motion: one pace and
# one easing over the whole path, rather than a glide to each mark.
#
# A glide is a motion of its own — it eases in, eases out, and waits
# to be heard at the end — so a path walked glide by glide is as many
# little starts and stops as it has marks, which is a stutter in the
# one beat filmed to show that panning does not stutter. Here the
# marks are only the shape of the path: the distance along it is what
# is parted into steps, and the easing is applied to that distance, so
# the pointer sets off once and stops once however many marks it is
# given, and a hundred marks are as smooth as two.
#
# SECONDS spends that many seconds on the path, at the same step rate
# and so in shorter steps; without it the steps are PACE long and the
# path takes as long as it takes.
path = [(self.at[0], self.at[1])] + [(float(x), float(y)) for x, y in marks]
legs = [math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(path, path[1:])]
total = sum(legs)
if total == 0:
return
steps = max(1, round(seconds / STEP)) if seconds else max(1, int(total / PACE))
start = path[0]
gone = [0, 0]
leg = 0
behind = 0.0
for i in range(1, steps + 1):
t = i / steps
f = (1 - EASE) * t + EASE * t * t * (3 - 2 * t)
want = f * total
# The leg this far along the path falls in, the legs before it
# counted off as they are passed: the marks are walked once
# between them all, not searched for at every step.
while leg < len(legs) - 1 and want > behind + legs[leg]:
behind += legs[leg]
leg += 1
a, b = path[leg], path[leg + 1]
part = 0.0 if legs[leg] == 0 else min(1.0, (want - behind) / legs[leg])
to = (a[0] + (b[0] - a[0]) * part, a[1] + (b[1] - a[1]) * part)
ux = round((to[0] - start[0]) / UNIT)
uy = round((to[1] - start[1]) / UNIT)
if ux != gone[0] or uy != gone[1]:
self.emit(EV_REL, REL_X, ux - gone[0])
self.emit(EV_REL, REL_Y, uy - gone[1])
self.sync()
gone = [ux, uy]
time.sleep(STEP)
self.at[0] = start[0] + gone[0] * UNIT
self.at[1] = start[1] + gone[1] * UNIT
def drag_along(self, marks, seconds=None):
# A drag through several points rather than to one, the button down
# once and up once, so that what the window is given is a single
# stroke along whatever path the marks describe — an arc, where a
# straight `drag_to` would be its chord. The press, the creep into
# the path and the hold before the release are `drag_to`'s, and are
# there for its reasons.
self.button(True)
time.sleep(0.02)
self.creep(*marks[0])
self.along(marks, seconds)
time.sleep(0.1)
self.button(False)
def hold_along(self, marks, seconds=None):
# The right button held through a path rather than the left: what
# is held up while it is down, as gamut's loupe is, is seen standing
# before the pointer sets off and after it stops, so there is a
# moment still at each end. Not a drag, so there is no creep.
self.button(True, BTN_RIGHT)
time.sleep(0.5)
self.along(marks, seconds)
time.sleep(0.5)
self.button(False, BTN_RIGHT)
def creep(self, x, y):
# A toolkit takes a press for a click until the pointer has gone
# some way from it — six logical pixels, for egui — and only then
# for a drag, and the motion of the frame that decides it is not
# part of the drag. A stroke that sets off at speed loses its first
# eight pixels or so that way, and the picture ends up that much
# short of where the pointer took it. So the first pixels are
# covered two units at a time, half a pixel each, until the pointer
# is well past the toolkit's distance, and what the deciding frame
# loses is a fraction of a pixel. An axis with less than that to go
# creeps only as far as its mark.
ux, uy = self.units(x, y)
sx = max(-2, min(2, ux))
sy = max(-2, min(2, uy))
for _ in range(CREEP):
if sx == 0 and sy == 0:
return
self.emit(EV_REL, REL_X, sx)
self.emit(EV_REL, REL_Y, sy)
self.sync()
self.at[0] += sx * UNIT
self.at[1] += sy * UNIT
ux -= sx
uy -= sy
sx = max(-2, min(2, ux))
sy = max(-2, min(2, uy))
time.sleep(0.008)
def click(self):
self.button(True)
time.sleep(0.05)
self.button(False)
def key(self, chord):
*modifiers, key = chord.lower().split("+")
codes = [KEYS[MODIFIERS[m]] for m in modifiers] + [KEYS[key]]
for code in codes:
self.emit(EV_KEY, code, 1)
self.sync()
time.sleep(0.02)
for code in reversed(codes):
self.emit(EV_KEY, code, 0)
self.sync()
time.sleep(0.02)
time.sleep(random.uniform(*KEY_GAP))
def pairs(marks):
"""A flat list of numbers as the points they are, two by two."""
return [(float(marks[i]), float(marks[i + 1])) for i in range(0, len(marks), 2)]
def main(argv):
if len(argv) < 2:
sys.exit(__doc__)
actions = [[]]
for word in argv[1:]:
if word == "--":
actions.append([])
else:
actions[-1].append(word)
device = Device()
try:
for action in actions:
match action:
case ["wheel", notches]:
device.wheel(int(notches))
case ["wheel", notches, seconds]:
device.wheel(int(notches), float(seconds))
case ["glide", x, y]:
device.glide(float(x), float(y))
case ["place", x, y]:
device.place(float(x), float(y))
case ["drag", dx, dy]:
device.drag(int(dx), int(dy))
case ["drag_to", x, y]:
device.drag_to(float(x), float(y))
case ["drag_to", x, y, "placed"]:
device.drag_to(float(x), float(y), placed=True)
case ["drag_along", *marks] if len(marks) >= 2 and len(marks) % 2 == 0:
device.drag_along(pairs(marks))
case ["drag_along", seconds, *marks] if len(marks) >= 2 and len(marks) % 2 == 0:
device.drag_along(pairs(marks), float(seconds))
case ["along", *marks] if len(marks) >= 2 and len(marks) % 2 == 0:
device.along(pairs(marks))
case ["along", seconds, *marks] if len(marks) >= 2 and len(marks) % 2 == 0:
device.along(pairs(marks), float(seconds))
case ["hold_along", *marks] if len(marks) >= 2 and len(marks) % 2 == 0:
device.hold_along(pairs(marks))
case ["hold_along", seconds, *marks] if len(marks) >= 2 and len(marks) % 2 == 0:
device.hold_along(pairs(marks), float(seconds))
case ["click"]:
device.click()
case ["key", chord]:
device.key(chord)
case ["rest", seconds]:
time.sleep(float(seconds))
case ["rest", least, most]:
time.sleep(random.uniform(float(least), float(most)))
case _:
sys.exit(__doc__)
print("%.2f %.2f" % tuple(device.at))
# Where the compositor says the pointer is, which the sum should
# agree with; a difference means the profile was not applied, or
# the pointer met the edge of a screen. Asked once at the end:
# asked after each action it would be a pause between them.
at = device.resting()
if abs(at[0] - device.at[0]) > 0.05 or abs(at[1] - device.at[1]) > 0.05:
print("the pointer is at %.2f %.2f, not %.2f %.2f as the units sent add up to"
% (*at, *device.at), file=sys.stderr)
finally:
device.close()
if __name__ == "__main__":
main(sys.argv)