local-control 0.1.2__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.
@@ -0,0 +1,1043 @@
1
+ """
2
+ Cross-platform input and power control utilities without external GUI dependencies.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import platform
8
+ import subprocess
9
+ from typing import Dict, Optional, Sequence
10
+
11
+ import ctypes
12
+
13
+
14
+ class _BaseBackend:
15
+ def move_cursor(self, dx: float, dy: float) -> None: # pragma: no cover - OS specific
16
+ raise NotImplementedError
17
+
18
+ def click(self, button: str = "left", double: bool = False) -> None: # pragma: no cover
19
+ raise NotImplementedError
20
+
21
+ def button_action(self, button: str, action: str) -> None: # pragma: no cover
22
+ raise NotImplementedError
23
+
24
+ def scroll(self, vertical: float = 0.0, horizontal: float = 0.0) -> None: # pragma: no cover
25
+ raise NotImplementedError
26
+
27
+ def type_text(self, text: str) -> None: # pragma: no cover
28
+ raise NotImplementedError
29
+
30
+ def key_action(self, key: str, action: str) -> None: # pragma: no cover
31
+ raise NotImplementedError
32
+
33
+ def state(self) -> Dict[str, float]:
34
+ raise NotImplementedError
35
+
36
+
37
+ class _WindowsBackend(_BaseBackend):
38
+ INPUT_MOUSE = 0
39
+ INPUT_KEYBOARD = 1
40
+
41
+ MOUSEEVENTF_LEFTDOWN = 0x0002
42
+ MOUSEEVENTF_LEFTUP = 0x0004
43
+ MOUSEEVENTF_RIGHTDOWN = 0x0008
44
+ MOUSEEVENTF_RIGHTUP = 0x0010
45
+ MOUSEEVENTF_MIDDLEDOWN = 0x0020
46
+ MOUSEEVENTF_MIDDLEUP = 0x0040
47
+ MOUSEEVENTF_WHEEL = 0x0800
48
+ MOUSEEVENTF_HWHEEL = 0x01000
49
+
50
+ KEYEVENTF_KEYUP = 0x0002
51
+ KEYEVENTF_UNICODE = 0x0004
52
+
53
+ _KEY_MAP: Dict[str, int] = {
54
+ "enter": 0x0D,
55
+ "esc": 0x1B,
56
+ "backspace": 0x08,
57
+ "tab": 0x09,
58
+ "up": 0x26,
59
+ "down": 0x28,
60
+ "left": 0x25,
61
+ "right": 0x27,
62
+ "delete": 0x2E,
63
+ "home": 0x24,
64
+ "end": 0x23,
65
+ "pageup": 0x21,
66
+ "pagedown": 0x22,
67
+ "shift": 0xA0,
68
+ "ctrl": 0xA2,
69
+ "alt": 0xA4,
70
+ "command": 0x5B, # Windows / Command key
71
+ "space": 0x20,
72
+ "minus": 0xBD,
73
+ "equals": 0xBB,
74
+ "leftbracket": 0xDB,
75
+ "rightbracket": 0xDD,
76
+ "backslash": 0xDC,
77
+ "semicolon": 0xBA,
78
+ "quote": 0xDE,
79
+ "comma": 0xBC,
80
+ "period": 0xBE,
81
+ "slash": 0xBF,
82
+ "grave": 0xC0,
83
+ }
84
+
85
+ _BUTTON_FLAGS = {
86
+ "left": (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
87
+ "middle": (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
88
+ "right": (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP),
89
+ }
90
+
91
+ def __init__(self) -> None:
92
+ from ctypes import wintypes
93
+
94
+ self._user32 = ctypes.WinDLL("user32", use_last_error=True)
95
+
96
+ self._user32.VkKeyScanW.argtypes = [wintypes.WCHAR]
97
+ self._user32.VkKeyScanW.restype = wintypes.SHORT
98
+
99
+ ULONG_PTR = wintypes.ULONG_PTR
100
+
101
+ class MOUSEINPUT(ctypes.Structure):
102
+ _fields_ = [
103
+ ("dx", wintypes.LONG),
104
+ ("dy", wintypes.LONG),
105
+ ("mouseData", wintypes.DWORD),
106
+ ("dwFlags", wintypes.DWORD),
107
+ ("time", wintypes.DWORD),
108
+ ("dwExtraInfo", ULONG_PTR),
109
+ ]
110
+
111
+ class KEYBDINPUT(ctypes.Structure):
112
+ _fields_ = [
113
+ ("wVk", wintypes.WORD),
114
+ ("wScan", wintypes.WORD),
115
+ ("dwFlags", wintypes.DWORD),
116
+ ("time", wintypes.DWORD),
117
+ ("dwExtraInfo", ULONG_PTR),
118
+ ]
119
+
120
+ class HARDWAREINPUT(ctypes.Structure):
121
+ _fields_ = [
122
+ ("uMsg", wintypes.DWORD),
123
+ ("wParamL", wintypes.WORD),
124
+ ("wParamH", wintypes.WORD),
125
+ ]
126
+
127
+ class _INPUT_UNION(ctypes.Union):
128
+ _fields_ = [
129
+ ("mi", MOUSEINPUT),
130
+ ("ki", KEYBDINPUT),
131
+ ("hi", HARDWAREINPUT),
132
+ ]
133
+
134
+ class INPUT(ctypes.Structure):
135
+ _anonymous_ = ("value",)
136
+ _fields_ = [("type", wintypes.DWORD), ("value", _INPUT_UNION)]
137
+
138
+ self._wintypes = wintypes
139
+ self._MOUSEINPUT = MOUSEINPUT
140
+ self._KEYBDINPUT = KEYBDINPUT
141
+ self._INPUT = INPUT
142
+
143
+ def _send_inputs(self, inputs: Sequence[ctypes.Structure]) -> None:
144
+ if not inputs:
145
+ return
146
+ array_type = self._INPUT * len(inputs)
147
+ array = array_type(*inputs)
148
+ sent = self._user32.SendInput(
149
+ len(array),
150
+ ctypes.byref(array),
151
+ ctypes.sizeof(self._INPUT),
152
+ )
153
+ if sent != len(array):
154
+ raise OSError(ctypes.get_last_error())
155
+
156
+ def move_cursor(self, dx: float, dy: float) -> None:
157
+ point = self._wintypes.POINT()
158
+ if not self._user32.GetCursorPos(ctypes.byref(point)):
159
+ raise OSError(ctypes.get_last_error())
160
+ target_x = int(round(point.x + dx))
161
+ target_y = int(round(point.y + dy))
162
+ if not self._user32.SetCursorPos(target_x, target_y):
163
+ raise OSError(ctypes.get_last_error())
164
+
165
+ def click(self, button: str = "left", double: bool = False) -> None:
166
+ if button not in {"left", "middle", "right"}:
167
+ raise ValueError(f"Unsupported button: {button}")
168
+ down_flag, up_flag = self._BUTTON_FLAGS[button]
169
+
170
+ events = []
171
+ for _ in range(2 if double else 1):
172
+ down = self._INPUT()
173
+ down.type = self.INPUT_MOUSE
174
+ down.value.mi = self._MOUSEINPUT(
175
+ dx=0,
176
+ dy=0,
177
+ mouseData=0,
178
+ dwFlags=down_flag,
179
+ time=0,
180
+ dwExtraInfo=0,
181
+ )
182
+ up = self._INPUT()
183
+ up.type = self.INPUT_MOUSE
184
+ up.value.mi = self._MOUSEINPUT(
185
+ dx=0,
186
+ dy=0,
187
+ mouseData=0,
188
+ dwFlags=up_flag,
189
+ time=0,
190
+ dwExtraInfo=0,
191
+ )
192
+ events.extend([down, up])
193
+ self._send_inputs(events)
194
+
195
+ def button_action(self, button: str, action: str) -> None:
196
+ if button not in self._BUTTON_FLAGS:
197
+ raise ValueError(f"Unsupported button: {button}")
198
+ if action not in {"down", "up"}:
199
+ raise ValueError(f"Unsupported button action: {action}")
200
+ down_flag, up_flag = self._BUTTON_FLAGS[button]
201
+ flag = down_flag if action == "down" else up_flag
202
+ event = self._INPUT()
203
+ event.type = self.INPUT_MOUSE
204
+ event.value.mi = self._MOUSEINPUT(
205
+ dx=0,
206
+ dy=0,
207
+ mouseData=0,
208
+ dwFlags=flag,
209
+ time=0,
210
+ dwExtraInfo=0,
211
+ )
212
+ self._send_inputs([event])
213
+
214
+ def scroll(self, vertical: float = 0.0, horizontal: float = 0.0) -> None:
215
+ if vertical:
216
+ value = -vertical
217
+ amount = int(round(value * 120))
218
+ if amount == 0:
219
+ amount = -120 if vertical > 0 else 120
220
+ event = self._INPUT()
221
+ event.type = self.INPUT_MOUSE
222
+ event.value.mi = self._MOUSEINPUT(
223
+ dx=0,
224
+ dy=0,
225
+ mouseData=amount,
226
+ dwFlags=self.MOUSEEVENTF_WHEEL,
227
+ time=0,
228
+ dwExtraInfo=0,
229
+ )
230
+ self._send_inputs([event])
231
+ if horizontal:
232
+ amount = int(round(horizontal * 120))
233
+ if amount == 0:
234
+ amount = 120 if horizontal > 0 else -120
235
+ event = self._INPUT()
236
+ event.type = self.INPUT_MOUSE
237
+ event.value.mi = self._MOUSEINPUT(
238
+ dx=0,
239
+ dy=0,
240
+ mouseData=amount,
241
+ dwFlags=self.MOUSEEVENTF_HWHEEL,
242
+ time=0,
243
+ dwExtraInfo=0,
244
+ )
245
+ self._send_inputs([event])
246
+
247
+ def type_text(self, text: str) -> None:
248
+ if not text:
249
+ return
250
+ events = []
251
+ for char in text:
252
+ code = ord(char)
253
+ down = self._INPUT()
254
+ down.type = self.INPUT_KEYBOARD
255
+ down.value.ki = self._KEYBDINPUT(
256
+ wVk=0,
257
+ wScan=code,
258
+ dwFlags=self.KEYEVENTF_UNICODE,
259
+ time=0,
260
+ dwExtraInfo=0,
261
+ )
262
+ up = self._INPUT()
263
+ up.type = self.INPUT_KEYBOARD
264
+ up.value.ki = self._KEYBDINPUT(
265
+ wVk=0,
266
+ wScan=code,
267
+ dwFlags=self.KEYEVENTF_UNICODE | self.KEYEVENTF_KEYUP,
268
+ time=0,
269
+ dwExtraInfo=0,
270
+ )
271
+ events.extend([down, up])
272
+ self._send_inputs(events)
273
+
274
+ def key_action(self, key: str, action: str) -> None:
275
+ vk = self._KEY_MAP.get(key)
276
+ if vk is None and len(key) == 1:
277
+ char = key
278
+ result = self._user32.VkKeyScanW(ctypes.c_wchar(char))
279
+ if result != -1:
280
+ vk = result & 0xFF
281
+ if vk is None:
282
+ raise ValueError(f"Unsupported key: {key}")
283
+ if action not in {"press", "down", "up"}:
284
+ raise ValueError(f"Unsupported action: {action}")
285
+
286
+ event_down = self._INPUT()
287
+ event_down.type = self.INPUT_KEYBOARD
288
+ event_down.value.ki = self._KEYBDINPUT(
289
+ wVk=vk,
290
+ wScan=0,
291
+ dwFlags=0,
292
+ time=0,
293
+ dwExtraInfo=0,
294
+ )
295
+ event_up = self._INPUT()
296
+ event_up.type = self.INPUT_KEYBOARD
297
+ event_up.value.ki = self._KEYBDINPUT(
298
+ wVk=vk,
299
+ wScan=0,
300
+ dwFlags=self.KEYEVENTF_KEYUP,
301
+ time=0,
302
+ dwExtraInfo=0,
303
+ )
304
+
305
+ if action == "press":
306
+ self._send_inputs([event_down, event_up])
307
+ elif action == "down":
308
+ self._send_inputs([event_down])
309
+ else:
310
+ self._send_inputs([event_up])
311
+
312
+ def state(self) -> Dict[str, float]:
313
+ point = self._wintypes.POINT()
314
+ if not self._user32.GetCursorPos(ctypes.byref(point)):
315
+ raise OSError(ctypes.get_last_error())
316
+ width = self._user32.GetSystemMetrics(0)
317
+ height = self._user32.GetSystemMetrics(1)
318
+ return {
319
+ "x": float(point.x),
320
+ "y": float(point.y),
321
+ "width": float(width),
322
+ "height": float(height),
323
+ }
324
+
325
+
326
+ class _DarwinBackend(_BaseBackend):
327
+ kCGHIDEventTap = 0
328
+
329
+ kCGEventLeftMouseDown = 1
330
+ kCGEventLeftMouseUp = 2
331
+ kCGEventRightMouseDown = 3
332
+ kCGEventRightMouseUp = 4
333
+ kCGEventOtherMouseDown = 25
334
+ kCGEventOtherMouseUp = 26
335
+ kCGEventMouseMoved = 5
336
+ kCGEventScrollWheel = 22
337
+
338
+ kCGMouseButtonLeft = 0
339
+ kCGMouseButtonRight = 1
340
+ kCGMouseButtonCenter = 2
341
+
342
+ kCGScrollEventUnitPixel = 0
343
+ kCGScrollEventUnitLine = 1
344
+
345
+ _KEY_MAP: Dict[str, int] = {
346
+ "enter": 36,
347
+ "esc": 53,
348
+ "backspace": 51,
349
+ "tab": 48,
350
+ "up": 126,
351
+ "down": 125,
352
+ "left": 123,
353
+ "right": 124,
354
+ "delete": 117,
355
+ "home": 115,
356
+ "end": 119,
357
+ "pageup": 116,
358
+ "pagedown": 121,
359
+ "shift": 56,
360
+ "ctrl": 59,
361
+ "alt": 58,
362
+ "command": 55,
363
+ "space": 49,
364
+ "grave": 50,
365
+ "minus": 27,
366
+ "equals": 24,
367
+ "leftbracket": 33,
368
+ "rightbracket": 30,
369
+ "backslash": 42,
370
+ "semicolon": 41,
371
+ "quote": 39,
372
+ "comma": 43,
373
+ "period": 47,
374
+ "slash": 44,
375
+ "0": 29,
376
+ "1": 18,
377
+ "2": 19,
378
+ "3": 20,
379
+ "4": 21,
380
+ "5": 23,
381
+ "6": 22,
382
+ "7": 26,
383
+ "8": 28,
384
+ "9": 25,
385
+ "a": 0,
386
+ "b": 11,
387
+ "c": 8,
388
+ "d": 2,
389
+ "e": 14,
390
+ "f": 3,
391
+ "g": 5,
392
+ "h": 4,
393
+ "i": 34,
394
+ "j": 38,
395
+ "k": 40,
396
+ "l": 37,
397
+ "m": 46,
398
+ "n": 45,
399
+ "o": 31,
400
+ "p": 35,
401
+ "q": 12,
402
+ "r": 15,
403
+ "s": 1,
404
+ "t": 17,
405
+ "u": 32,
406
+ "v": 9,
407
+ "w": 13,
408
+ "x": 7,
409
+ "y": 16,
410
+ "z": 6,
411
+ }
412
+
413
+ def __init__(self) -> None:
414
+ from ctypes import (
415
+ c_bool,
416
+ c_double,
417
+ c_int32,
418
+ c_size_t,
419
+ c_uint16,
420
+ c_uint32,
421
+ c_uint64,
422
+ c_void_p,
423
+ )
424
+
425
+ self._c_bool = c_bool
426
+ self._c_double = c_double
427
+ self._c_int32 = c_int32
428
+ self._c_uint16 = c_uint16
429
+ self._c_uint32 = c_uint32
430
+ self._c_uint64 = c_uint64
431
+ self._c_void_p = c_void_p
432
+ self._c_size_t = c_size_t
433
+
434
+ self._quartz = ctypes.CDLL(
435
+ "/System/Library/Frameworks/ApplicationServices.framework/Versions/A/ApplicationServices"
436
+ )
437
+
438
+ class CGPoint(ctypes.Structure):
439
+ _fields_ = [("x", c_double), ("y", c_double)]
440
+
441
+ self.CGPoint = CGPoint
442
+
443
+ self._quartz.CGEventCreateMouseEvent.restype = c_void_p
444
+ self._quartz.CGEventCreateMouseEvent.argtypes = [
445
+ c_void_p,
446
+ c_uint32,
447
+ CGPoint,
448
+ c_uint32,
449
+ ]
450
+
451
+ self._quartz.CGEventCreateScrollWheelEvent.restype = c_void_p
452
+ self._quartz.CGEventCreateScrollWheelEvent.argtypes = [
453
+ c_void_p,
454
+ c_uint32,
455
+ c_uint32,
456
+ c_int32,
457
+ c_int32,
458
+ ]
459
+
460
+ self._quartz.CGEventCreateKeyboardEvent.restype = c_void_p
461
+ self._quartz.CGEventCreateKeyboardEvent.argtypes = [
462
+ c_void_p,
463
+ c_uint16,
464
+ c_bool,
465
+ ]
466
+
467
+ self._quartz.CGEventKeyboardSetUnicodeString.argtypes = [
468
+ c_void_p,
469
+ ctypes.c_long,
470
+ ctypes.POINTER(c_uint16),
471
+ ]
472
+
473
+ self._quartz.CGEventPost.argtypes = [c_uint32, c_void_p]
474
+ self._quartz.CFRelease.argtypes = [c_void_p]
475
+
476
+ self._quartz.CGEventCreate.restype = c_void_p
477
+ self._quartz.CGEventCreate.argtypes = [c_void_p]
478
+ self._quartz.CGEventGetLocation.argtypes = [c_void_p]
479
+ self._quartz.CGEventGetLocation.restype = CGPoint
480
+ self._quartz.CGMainDisplayID.restype = c_uint32
481
+ self._quartz.CGDisplayPixelsWide.argtypes = [c_uint32]
482
+ self._quartz.CGDisplayPixelsWide.restype = c_size_t
483
+ self._quartz.CGDisplayPixelsHigh.argtypes = [c_uint32]
484
+ self._quartz.CGDisplayPixelsHigh.restype = c_size_t
485
+ self._quartz.CGEventSetFlags.argtypes = [c_void_p, c_uint64]
486
+ self._quartz.CGEventSetFlags.restype = None
487
+
488
+ def _prepare_event(self, event: ctypes.c_void_p, flags: int = 0) -> ctypes.c_void_p:
489
+ if event:
490
+ self._quartz.CGEventSetFlags(event, self._c_uint64(flags))
491
+ return event
492
+
493
+ def _current_position(self) -> "CGPoint":
494
+ event = self._quartz.CGEventCreate(self._c_void_p())
495
+ if not event:
496
+ raise RuntimeError("Failed to create CGEvent for cursor location.")
497
+ try:
498
+ return self._quartz.CGEventGetLocation(event)
499
+ finally:
500
+ self._quartz.CFRelease(event)
501
+
502
+ def _post_event(self, event: ctypes.c_void_p) -> None:
503
+ if not event:
504
+ raise RuntimeError("Unable to create Quartz event.")
505
+ try:
506
+ self._quartz.CGEventPost(self.kCGHIDEventTap, event)
507
+ finally:
508
+ self._quartz.CFRelease(event)
509
+
510
+ def move_cursor(self, dx: float, dy: float) -> None:
511
+ current = self._current_position()
512
+ target = self.CGPoint(current.x + dx, current.y + dy)
513
+ event = self._prepare_event(
514
+ self._quartz.CGEventCreateMouseEvent(
515
+ None,
516
+ self.kCGEventMouseMoved,
517
+ target,
518
+ self.kCGMouseButtonLeft,
519
+ )
520
+ )
521
+ self._post_event(event)
522
+
523
+ def click(self, button: str = "left", double: bool = False) -> None:
524
+ if button not in {"left", "middle", "right"}:
525
+ raise ValueError(f"Unsupported button: {button}")
526
+ if button == "left":
527
+ down_type = self.kCGEventLeftMouseDown
528
+ up_type = self.kCGEventLeftMouseUp
529
+ cg_button = self.kCGMouseButtonLeft
530
+ elif button == "right":
531
+ down_type = self.kCGEventRightMouseDown
532
+ up_type = self.kCGEventRightMouseUp
533
+ cg_button = self.kCGMouseButtonRight
534
+ else:
535
+ down_type = self.kCGEventOtherMouseDown
536
+ up_type = self.kCGEventOtherMouseUp
537
+ cg_button = self.kCGMouseButtonCenter
538
+
539
+ current = self._current_position()
540
+ point = self.CGPoint(current.x, current.y)
541
+
542
+ for _ in range(2 if double else 1):
543
+ down_event = self._prepare_event(
544
+ self._quartz.CGEventCreateMouseEvent(None, down_type, point, cg_button)
545
+ )
546
+ up_event = self._prepare_event(
547
+ self._quartz.CGEventCreateMouseEvent(None, up_type, point, cg_button)
548
+ )
549
+ self._post_event(down_event)
550
+ self._post_event(up_event)
551
+
552
+ def button_action(self, button: str, action: str) -> None:
553
+ if button not in {"left", "middle", "right"}:
554
+ raise ValueError(f"Unsupported button: {button}")
555
+ if action not in {"down", "up"}:
556
+ raise ValueError(f"Unsupported button action: {action}")
557
+ if button == "left":
558
+ down_type = self.kCGEventLeftMouseDown
559
+ up_type = self.kCGEventLeftMouseUp
560
+ cg_button = self.kCGMouseButtonLeft
561
+ elif button == "right":
562
+ down_type = self.kCGEventRightMouseDown
563
+ up_type = self.kCGEventRightMouseUp
564
+ cg_button = self.kCGMouseButtonRight
565
+ else:
566
+ down_type = self.kCGEventOtherMouseDown
567
+ up_type = self.kCGEventOtherMouseUp
568
+ cg_button = self.kCGMouseButtonCenter
569
+ event_type = down_type if action == "down" else up_type
570
+ current = self._current_position()
571
+ point = self.CGPoint(current.x, current.y)
572
+ event = self._prepare_event(
573
+ self._quartz.CGEventCreateMouseEvent(None, event_type, point, cg_button)
574
+ )
575
+ self._post_event(event)
576
+
577
+ def scroll(self, vertical: float = 0.0, horizontal: float = 0.0) -> None:
578
+ if not vertical and not horizontal:
579
+ return
580
+
581
+ def _normalize(value: float, invert: bool = False) -> int:
582
+ """
583
+ Convert browser wheel deltas (typically ~120 per detent) into smaller
584
+ macOS-friendly increments. The divisor keeps physical scroll wheels from
585
+ jumping entire pages while still allowing smooth trackpad gestures.
586
+ """
587
+ if value == 0.0:
588
+ return 0
589
+ divisor = 40.0
590
+ scaled = value / divisor
591
+ if invert:
592
+ scaled = -scaled
593
+ rounded = int(round(scaled))
594
+ if rounded == 0:
595
+ rounded = -1 if scaled < 0 else 1
596
+ return rounded
597
+
598
+ vert = _normalize(vertical, invert=True)
599
+ horiz = _normalize(horizontal)
600
+ event = self._prepare_event(
601
+ self._quartz.CGEventCreateScrollWheelEvent(
602
+ None,
603
+ self.kCGScrollEventUnitLine,
604
+ 2,
605
+ vert,
606
+ horiz,
607
+ )
608
+ )
609
+ self._post_event(event)
610
+
611
+ def type_text(self, text: str) -> None:
612
+ if not text:
613
+ return
614
+ utf16 = text.encode("utf-16-le")
615
+ units = [int.from_bytes(utf16[i : i + 2], "little") for i in range(0, len(utf16), 2)]
616
+ buffer_type = self._c_uint16 * len(units)
617
+ buf = buffer_type(*units)
618
+ down = self._prepare_event(self._quartz.CGEventCreateKeyboardEvent(None, 0, True))
619
+ self._quartz.CGEventKeyboardSetUnicodeString(down, len(units), buf)
620
+ up = self._prepare_event(self._quartz.CGEventCreateKeyboardEvent(None, 0, False))
621
+ self._quartz.CGEventKeyboardSetUnicodeString(up, len(units), buf)
622
+ self._post_event(down)
623
+ self._post_event(up)
624
+
625
+ def key_action(self, key: str, action: str) -> None:
626
+ keycode = self._KEY_MAP.get(key)
627
+ if keycode is None:
628
+ raise ValueError(f"Unsupported key: {key}")
629
+ if action not in {"press", "down", "up"}:
630
+ raise ValueError(f"Unsupported action: {action}")
631
+ if action in {"press", "down"}:
632
+ down = self._prepare_event(self._quartz.CGEventCreateKeyboardEvent(None, keycode, True))
633
+ self._post_event(down)
634
+ if action in {"press", "up"}:
635
+ up = self._prepare_event(self._quartz.CGEventCreateKeyboardEvent(None, keycode, False))
636
+ self._post_event(up)
637
+
638
+ def state(self) -> Dict[str, float]:
639
+ current = self._current_position()
640
+ display = self._quartz.CGMainDisplayID()
641
+ width = float(self._quartz.CGDisplayPixelsWide(display))
642
+ height = float(self._quartz.CGDisplayPixelsHigh(display))
643
+ return {
644
+ "x": float(current.x),
645
+ "y": float(current.y),
646
+ "width": width,
647
+ "height": height,
648
+ }
649
+
650
+
651
+ class _X11Backend(_BaseBackend):
652
+ _SHIFT_REQUIRED = set("~!@#$%^&*()_+{}|:\"<>?ABCDEFGHIJKLMNOPQRSTUVWXYZ")
653
+
654
+ _KEY_MAP: Dict[str, str] = {
655
+ "enter": "Return",
656
+ "esc": "Escape",
657
+ "backspace": "BackSpace",
658
+ "tab": "Tab",
659
+ "up": "Up",
660
+ "down": "Down",
661
+ "left": "Left",
662
+ "right": "Right",
663
+ "delete": "Delete",
664
+ "home": "Home",
665
+ "end": "End",
666
+ "pageup": "Page_Up",
667
+ "pagedown": "Page_Down",
668
+ "shift": "Shift_L",
669
+ "ctrl": "Control_L",
670
+ "alt": "Alt_L",
671
+ "command": "Super_L",
672
+ "space": "space",
673
+ "minus": "minus",
674
+ "equals": "equal",
675
+ "leftbracket": "bracketleft",
676
+ "rightbracket": "bracketright",
677
+ "backslash": "backslash",
678
+ "semicolon": "semicolon",
679
+ "quote": "apostrophe",
680
+ "comma": "comma",
681
+ "period": "period",
682
+ "slash": "slash",
683
+ "grave": "grave",
684
+ }
685
+
686
+ _BUTTON_MAP: Dict[str, int] = {
687
+ "left": 1,
688
+ "middle": 2,
689
+ "right": 3,
690
+ }
691
+
692
+ def __init__(self) -> None:
693
+ self._x11 = ctypes.cdll.LoadLibrary("libX11.so.6")
694
+ self._xtst = ctypes.cdll.LoadLibrary("libXtst.so.6")
695
+
696
+ self._x11.XOpenDisplay.restype = ctypes.c_void_p
697
+ self._display = self._x11.XOpenDisplay(None)
698
+ if not self._display:
699
+ raise RuntimeError("Unable to open X11 display. Ensure DISPLAY is set.")
700
+
701
+ self._x11.XKeysymToKeycode.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
702
+ self._x11.XKeysymToKeycode.restype = ctypes.c_uint
703
+ self._x11.XStringToKeysym.argtypes = [ctypes.c_char_p]
704
+ self._x11.XStringToKeysym.restype = ctypes.c_ulong
705
+ self._xtst.XTestFakeRelativeMotionEvent.argtypes = [
706
+ ctypes.c_void_p,
707
+ ctypes.c_int,
708
+ ctypes.c_int,
709
+ ctypes.c_ulong,
710
+ ]
711
+ self._xtst.XTestFakeButtonEvent.argtypes = [
712
+ ctypes.c_void_p,
713
+ ctypes.c_uint,
714
+ ctypes.c_int,
715
+ ctypes.c_ulong,
716
+ ]
717
+ self._xtst.XTestFakeKeyEvent.argtypes = [
718
+ ctypes.c_void_p,
719
+ ctypes.c_uint,
720
+ ctypes.c_int,
721
+ ctypes.c_ulong,
722
+ ]
723
+ self._x11.XFlush.argtypes = [ctypes.c_void_p]
724
+ self._x11.XDefaultScreen.argtypes = [ctypes.c_void_p]
725
+ self._x11.XDefaultScreen.restype = ctypes.c_int
726
+ self._x11.XDisplayWidth.argtypes = [ctypes.c_void_p, ctypes.c_int]
727
+ self._x11.XDisplayWidth.restype = ctypes.c_int
728
+ self._x11.XDisplayHeight.argtypes = [ctypes.c_void_p, ctypes.c_int]
729
+ self._x11.XDisplayHeight.restype = ctypes.c_int
730
+ self._x11.XRootWindow.argtypes = [ctypes.c_void_p, ctypes.c_int]
731
+ self._x11.XRootWindow.restype = ctypes.c_ulong
732
+ self._x11.XQueryPointer.argtypes = [
733
+ ctypes.c_void_p,
734
+ ctypes.c_ulong,
735
+ ctypes.POINTER(ctypes.c_ulong),
736
+ ctypes.POINTER(ctypes.c_ulong),
737
+ ctypes.POINTER(ctypes.c_int),
738
+ ctypes.POINTER(ctypes.c_int),
739
+ ctypes.POINTER(ctypes.c_int),
740
+ ctypes.POINTER(ctypes.c_int),
741
+ ctypes.POINTER(ctypes.c_uint),
742
+ ]
743
+ self._x11.XQueryPointer.restype = ctypes.c_bool
744
+
745
+ self._shift_keycode = self._resolve_keycode("Shift_L")
746
+
747
+ def _resolve_keycode(self, keysym_name: str) -> Optional[int]:
748
+ keysym = self._x11.XStringToKeysym(keysym_name.encode("ascii"))
749
+ if keysym == 0:
750
+ return None
751
+ keycode = self._x11.XKeysymToKeycode(self._display, keysym)
752
+ return int(keycode) if keycode else None
753
+
754
+ def _flush(self) -> None:
755
+ self._x11.XFlush(self._display)
756
+
757
+ def move_cursor(self, dx: float, dy: float) -> None:
758
+ self._xtst.XTestFakeRelativeMotionEvent(
759
+ self._display, int(round(dx)), int(round(dy)), 0
760
+ )
761
+ self._flush()
762
+
763
+ def click(self, button: str = "left", double: bool = False) -> None:
764
+ mapping = {"left": 1, "middle": 2, "right": 3}
765
+ if button not in mapping:
766
+ raise ValueError(f"Unsupported button: {button}")
767
+ button_code = mapping[button]
768
+ repeats = 2 if double else 1
769
+ for _ in range(repeats):
770
+ self._xtst.XTestFakeButtonEvent(self._display, button_code, True, 0)
771
+ self._xtst.XTestFakeButtonEvent(self._display, button_code, False, 0)
772
+ self._flush()
773
+
774
+ def button_action(self, button: str, action: str) -> None:
775
+ button_code = self._BUTTON_MAP.get(button)
776
+ if button_code is None:
777
+ raise ValueError(f"Unsupported button: {button}")
778
+ if action not in {"down", "up"}:
779
+ raise ValueError(f"Unsupported button action: {action}")
780
+ press = 1 if action == "down" else 0
781
+ self._xtst.XTestFakeButtonEvent(self._display, button_code, press, 0)
782
+ self._flush()
783
+
784
+ def scroll(self, vertical: float = 0.0, horizontal: float = 0.0) -> None:
785
+ def _emit(button: int, count: int) -> None:
786
+ for _ in range(abs(count)):
787
+ self._xtst.XTestFakeButtonEvent(self._display, button, True, 0)
788
+ self._xtst.XTestFakeButtonEvent(self._display, button, False, 0)
789
+
790
+ if vertical:
791
+ count = int(round(vertical))
792
+ if count == 0:
793
+ count = 1 if vertical > 0 else -1
794
+ button = 4 if count > 0 else 5
795
+ _emit(button, count)
796
+ if horizontal:
797
+ count = int(round(horizontal))
798
+ if count == 0:
799
+ count = 1 if horizontal > 0 else -1
800
+ button = 7 if count > 0 else 6
801
+ _emit(button, count)
802
+ self._flush()
803
+
804
+ def _press_keycode(self, keycode: int, shift: bool = False) -> None:
805
+ if shift and self._shift_keycode is not None:
806
+ self._xtst.XTestFakeKeyEvent(self._display, self._shift_keycode, True, 0)
807
+ self._xtst.XTestFakeKeyEvent(self._display, keycode, True, 0)
808
+ self._xtst.XTestFakeKeyEvent(self._display, keycode, False, 0)
809
+ if shift and self._shift_keycode is not None:
810
+ self._xtst.XTestFakeKeyEvent(self._display, self._shift_keycode, False, 0)
811
+ self._flush()
812
+
813
+ def type_text(self, text: str) -> None:
814
+ for char in text:
815
+ if char == "\n":
816
+ self.key_action("enter", "press")
817
+ continue
818
+ if char == "\r":
819
+ continue
820
+ if char == "\t":
821
+ self.key_action("tab", "press")
822
+ continue
823
+ if char == " ":
824
+ keysym = self._x11.XStringToKeysym(b"space")
825
+ else:
826
+ keysym = self._x11.XStringToKeysym(char.encode("utf-8"))
827
+ if keysym == 0:
828
+ continue
829
+ keycode = self._x11.XKeysymToKeycode(self._display, keysym)
830
+ if keycode == 0:
831
+ continue
832
+ need_shift = char in self._SHIFT_REQUIRED
833
+ self._press_keycode(keycode, shift=need_shift)
834
+
835
+ def key_action(self, key: str, action: str) -> None:
836
+ keysym_name = self._KEY_MAP.get(key)
837
+ if not keysym_name and len(key) == 1:
838
+ keysym_name = key
839
+ if not keysym_name:
840
+ raise ValueError(f"Unsupported key: {key}")
841
+ keycode = self._resolve_keycode(keysym_name)
842
+ if keycode is None:
843
+ raise RuntimeError(f"Could not resolve keycode for {keysym_name}")
844
+ if action == "press":
845
+ self._press_keycode(keycode)
846
+ return
847
+ if action == "down":
848
+ self._xtst.XTestFakeKeyEvent(self._display, keycode, True, 0)
849
+ elif action == "up":
850
+ self._xtst.XTestFakeKeyEvent(self._display, keycode, False, 0)
851
+ else:
852
+ raise ValueError(f"Unsupported action: {action}")
853
+ self._flush()
854
+
855
+ def state(self) -> Dict[str, float]:
856
+ screen = self._x11.XDefaultScreen(self._display)
857
+ width = float(self._x11.XDisplayWidth(self._display, screen))
858
+ height = float(self._x11.XDisplayHeight(self._display, screen))
859
+ root = self._x11.XRootWindow(self._display, screen)
860
+ root_return = ctypes.c_ulong()
861
+ child_return = ctypes.c_ulong()
862
+ root_x = ctypes.c_int()
863
+ root_y = ctypes.c_int()
864
+ win_x = ctypes.c_int()
865
+ win_y = ctypes.c_int()
866
+ mask = ctypes.c_uint()
867
+ success = self._x11.XQueryPointer(
868
+ self._display,
869
+ root,
870
+ ctypes.byref(root_return),
871
+ ctypes.byref(child_return),
872
+ ctypes.byref(root_x),
873
+ ctypes.byref(root_y),
874
+ ctypes.byref(win_x),
875
+ ctypes.byref(win_y),
876
+ ctypes.byref(mask),
877
+ )
878
+ if not success:
879
+ return {"x": 0.0, "y": 0.0, "width": width, "height": height}
880
+ return {
881
+ "x": float(root_x.value),
882
+ "y": float(root_y.value),
883
+ "width": width,
884
+ "height": height,
885
+ }
886
+
887
+
888
+ def _create_backend() -> _BaseBackend:
889
+ system = platform.system()
890
+ if system == "Windows":
891
+ return _WindowsBackend()
892
+ if system == "Darwin":
893
+ return _DarwinBackend()
894
+ return _X11Backend()
895
+
896
+
897
+ _BACKEND: Optional[_BaseBackend] = None
898
+
899
+
900
+ def _backend() -> _BaseBackend:
901
+ global _BACKEND
902
+ if _BACKEND is None:
903
+ _BACKEND = _create_backend()
904
+ return _BACKEND
905
+
906
+
907
+ def move_cursor(dx: float, dy: float) -> Dict[str, float]:
908
+ backend = _backend()
909
+ if dx != 0 or dy != 0:
910
+ backend.move_cursor(dx, dy)
911
+ return backend.state()
912
+
913
+
914
+ def click(button: str = "left", double: bool = False) -> None:
915
+ _backend().click(button=button, double=double)
916
+
917
+
918
+ def button_action(button: str, action: str) -> None:
919
+ _backend().button_action(button=button, action=action)
920
+
921
+
922
+ def scroll(vertical: float = 0.0, horizontal: float = 0.0) -> None:
923
+ _backend().scroll(vertical=vertical, horizontal=horizontal)
924
+
925
+
926
+ def type_text(text: str) -> None:
927
+ _backend().type_text(text)
928
+
929
+
930
+ def key_action(key: str, action: str) -> None:
931
+ _backend().key_action(key, action)
932
+
933
+
934
+ def cursor_state() -> Dict[str, float]:
935
+ return _backend().state()
936
+
937
+
938
+ def lock_screen() -> None:
939
+ system = platform.system()
940
+ if system == "Windows":
941
+ subprocess.run(
942
+ ["rundll32.exe", "user32.dll,LockWorkStation"],
943
+ check=False,
944
+ )
945
+ return
946
+
947
+ if system == "Darwin":
948
+ mac_commands = [
949
+ [
950
+ "/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession",
951
+ "-suspend",
952
+ ],
953
+ [
954
+ "osascript",
955
+ "-e",
956
+ 'tell application "System Events" to keystroke "q" using {control down, command down}',
957
+ ],
958
+ [
959
+ "pmset",
960
+ "displaysleepnow",
961
+ ],
962
+ ]
963
+ _run_first_success(
964
+ mac_commands,
965
+ error_message="Lock command unavailable on this system.",
966
+ )
967
+ return
968
+
969
+ _run_first_success(
970
+ [
971
+ ["loginctl", "lock-session"],
972
+ ["gnome-screensaver-command", "-l"],
973
+ ["xdg-screensaver", "lock"],
974
+ ],
975
+ error_message="Lock command unavailable on this system.",
976
+ )
977
+
978
+
979
+ def shutdown_system() -> None:
980
+ system = platform.system()
981
+ if system == "Windows":
982
+ subprocess.run(["shutdown", "/s", "/t", "0"], check=False)
983
+ return
984
+
985
+ if system == "Darwin":
986
+ subprocess.run(
987
+ ["osascript", "-e", 'tell application "System Events" to shut down'],
988
+ check=False,
989
+ )
990
+ return
991
+
992
+ _run_first_success(
993
+ [
994
+ ["systemctl", "poweroff"],
995
+ ["shutdown", "-h", "now"],
996
+ ],
997
+ error_message="Shutdown command unavailable on this system.",
998
+ )
999
+
1000
+
1001
+ def _run_first_success(commands: Sequence[Sequence[str]], error_message: str) -> None:
1002
+ for command in commands:
1003
+ try:
1004
+ result = subprocess.run(command, check=False)
1005
+ except FileNotFoundError:
1006
+ continue
1007
+ if result.returncode == 0:
1008
+ return
1009
+ raise RuntimeError(error_message)
1010
+
1011
+
1012
+ def unlock_screen() -> None:
1013
+ system = platform.system()
1014
+ if system == "Windows":
1015
+ try:
1016
+ user32 = ctypes.WinDLL("user32", use_last_error=True)
1017
+ VK_SHIFT = 0x10
1018
+ KEYEVENTF_KEYUP = 0x0002
1019
+ user32.keybd_event(VK_SHIFT, 0, 0, 0)
1020
+ user32.keybd_event(VK_SHIFT, 0, KEYEVENTF_KEYUP, 0)
1021
+ except Exception:
1022
+ pass
1023
+ return
1024
+
1025
+ if system == "Darwin":
1026
+ mac_commands = [
1027
+ ["osascript", "-e", 'tell application "System Events" to key code 49'],
1028
+ ["caffeinate", "-u", "-t", "2"],
1029
+ ]
1030
+ _run_first_success(
1031
+ mac_commands,
1032
+ error_message="Unlock command unavailable on this system.",
1033
+ )
1034
+ return
1035
+
1036
+ _run_first_success(
1037
+ [
1038
+ ["loginctl", "unlock-session"],
1039
+ ["gnome-screensaver-command", "-d"],
1040
+ ["xdg-screensaver", "reset"],
1041
+ ],
1042
+ error_message="Unlock command unavailable on this system.",
1043
+ )