prkit 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.
- prkit/__init__.py +3 -0
- prkit/__main__.py +3 -0
- prkit/app.py +183 -0
- prkit/cursor_watchdog.py +48 -0
- prkit/icon.py +25 -0
- prkit/keyboard.py +68 -0
- prkit/keys.py +111 -0
- prkit/mac_keyboard.py +240 -0
- prkit/overlays.py +170 -0
- prkit/platform.py +152 -0
- prkit/py.typed +0 -0
- prkit/settings.py +102 -0
- prkit/spotlight.py +26 -0
- prkit/tray.py +242 -0
- prkit/windows_cursor.py +467 -0
- prkit-0.1.0.dist-info/METADATA +100 -0
- prkit-0.1.0.dist-info/RECORD +19 -0
- prkit-0.1.0.dist-info/WHEEL +4 -0
- prkit-0.1.0.dist-info/entry_points.txt +4 -0
prkit/__init__.py
ADDED
prkit/__main__.py
ADDED
prkit/app.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import QObject, QSignalBlocker, QTimer
|
|
8
|
+
from PySide6.QtWidgets import QApplication, QMessageBox
|
|
9
|
+
|
|
10
|
+
from prkit.keyboard import KeyboardMonitor
|
|
11
|
+
from prkit.overlays import KeystrokeOverlay
|
|
12
|
+
from prkit.settings import SettingsStore, SettingsStoreProtocol
|
|
13
|
+
from prkit.spotlight import SpotlightBackend, create_spotlight
|
|
14
|
+
from prkit.tray import TrayController
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AppController(QObject):
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
app: QApplication,
|
|
21
|
+
store: SettingsStoreProtocol | None = None,
|
|
22
|
+
monitor: KeyboardMonitor | None = None,
|
|
23
|
+
spotlight: SpotlightBackend | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__()
|
|
26
|
+
self._app = app
|
|
27
|
+
self._store = store or SettingsStore()
|
|
28
|
+
self.settings = self._store.load()
|
|
29
|
+
self.spotlight = spotlight or create_spotlight(self.settings.spotlight)
|
|
30
|
+
self.keystroke = KeystrokeOverlay(self.settings.keystroke)
|
|
31
|
+
self.monitor = monitor or KeyboardMonitor(self)
|
|
32
|
+
self.tray = TrayController(self.settings, self)
|
|
33
|
+
self._shutting_down = False
|
|
34
|
+
self._keystroke_start_pending = False
|
|
35
|
+
|
|
36
|
+
self.tray.connect_settings(
|
|
37
|
+
spotlight_enabled=self.set_spotlight_enabled,
|
|
38
|
+
spotlight_color=self.set_spotlight_color,
|
|
39
|
+
spotlight_opacity=self.set_spotlight_opacity,
|
|
40
|
+
spotlight_size=self.set_spotlight_size,
|
|
41
|
+
keystroke_enabled=self.set_keystroke_enabled,
|
|
42
|
+
keystroke_color=self.set_keystroke_color,
|
|
43
|
+
keystroke_opacity=self.set_keystroke_opacity,
|
|
44
|
+
)
|
|
45
|
+
self.tray.exit_requested.connect(self.shutdown)
|
|
46
|
+
self.tray.menu.aboutToShow.connect(self.spotlight.sync_position)
|
|
47
|
+
self.tray.menu.aboutToHide.connect(self._start_pending_keyboard_monitor)
|
|
48
|
+
self.monitor.chord_pressed.connect(self.keystroke.show_chord)
|
|
49
|
+
self.monitor.all_released.connect(self.keystroke.keys_released)
|
|
50
|
+
self.monitor.failed.connect(self._keyboard_failed)
|
|
51
|
+
self._app.aboutToQuit.connect(self.cleanup)
|
|
52
|
+
|
|
53
|
+
def start(self) -> None:
|
|
54
|
+
self.tray.show()
|
|
55
|
+
self.spotlight.set_enabled(self.settings.spotlight.enabled)
|
|
56
|
+
if self.settings.keystroke.enabled:
|
|
57
|
+
self.set_keystroke_enabled(True)
|
|
58
|
+
|
|
59
|
+
def set_spotlight_enabled(self, enabled: bool) -> None:
|
|
60
|
+
try:
|
|
61
|
+
self.spotlight.set_enabled(enabled)
|
|
62
|
+
except Exception as error:
|
|
63
|
+
self._disable_spotlight_ui()
|
|
64
|
+
self.tray.notify_warning(f"無法啟用聚光燈游標:{error}")
|
|
65
|
+
return
|
|
66
|
+
self.settings.spotlight.enabled = enabled
|
|
67
|
+
self._save()
|
|
68
|
+
|
|
69
|
+
def set_spotlight_color(self, color: str) -> None:
|
|
70
|
+
self.settings.spotlight.color = color
|
|
71
|
+
self._apply_spotlight_appearance()
|
|
72
|
+
|
|
73
|
+
def set_spotlight_opacity(self, opacity: int) -> None:
|
|
74
|
+
self.settings.spotlight.opacity = opacity
|
|
75
|
+
self._apply_spotlight_appearance()
|
|
76
|
+
|
|
77
|
+
def set_spotlight_size(self, size: int) -> None:
|
|
78
|
+
self.settings.spotlight.size = size
|
|
79
|
+
self._apply_spotlight_appearance()
|
|
80
|
+
|
|
81
|
+
def _apply_spotlight_appearance(self) -> None:
|
|
82
|
+
value = self.settings.spotlight
|
|
83
|
+
try:
|
|
84
|
+
self.spotlight.set_appearance(value.color, value.opacity, value.size)
|
|
85
|
+
except Exception as error:
|
|
86
|
+
self._disable_spotlight_ui()
|
|
87
|
+
self.tray.notify_warning(f"無法更新聚光燈游標:{error}")
|
|
88
|
+
return
|
|
89
|
+
self._save()
|
|
90
|
+
|
|
91
|
+
def _disable_spotlight_ui(self) -> None:
|
|
92
|
+
with suppress(Exception):
|
|
93
|
+
self.spotlight.set_enabled(False)
|
|
94
|
+
self.settings.spotlight.enabled = False
|
|
95
|
+
with QSignalBlocker(self.tray.panel.spotlight_enabled):
|
|
96
|
+
self.tray.panel.set_spotlight_enabled(False)
|
|
97
|
+
self._save()
|
|
98
|
+
|
|
99
|
+
def set_keystroke_enabled(self, enabled: bool) -> None:
|
|
100
|
+
if enabled:
|
|
101
|
+
self._keystroke_start_pending = True
|
|
102
|
+
if not self.tray.menu.isVisible():
|
|
103
|
+
QTimer.singleShot(0, self._start_pending_keyboard_monitor)
|
|
104
|
+
return
|
|
105
|
+
else:
|
|
106
|
+
self._keystroke_start_pending = False
|
|
107
|
+
self.monitor.stop()
|
|
108
|
+
self.keystroke.hide_now()
|
|
109
|
+
self.settings.keystroke.enabled = enabled
|
|
110
|
+
self._save()
|
|
111
|
+
|
|
112
|
+
def _start_pending_keyboard_monitor(self) -> None:
|
|
113
|
+
if not self._keystroke_start_pending:
|
|
114
|
+
return
|
|
115
|
+
self._keystroke_start_pending = False
|
|
116
|
+
success, error = self.monitor.start()
|
|
117
|
+
if not success:
|
|
118
|
+
self._disable_keystroke_ui()
|
|
119
|
+
self.tray.notify_warning(f"無法啟用按鍵顯示:{error or '未知錯誤'}")
|
|
120
|
+
return
|
|
121
|
+
self.settings.keystroke.enabled = True
|
|
122
|
+
self._save()
|
|
123
|
+
|
|
124
|
+
def set_keystroke_color(self, color: str) -> None:
|
|
125
|
+
self.settings.keystroke.color = color
|
|
126
|
+
self._apply_keystroke_appearance()
|
|
127
|
+
|
|
128
|
+
def set_keystroke_opacity(self, opacity: int) -> None:
|
|
129
|
+
self.settings.keystroke.opacity = opacity
|
|
130
|
+
self._apply_keystroke_appearance()
|
|
131
|
+
|
|
132
|
+
def _apply_keystroke_appearance(self) -> None:
|
|
133
|
+
value = self.settings.keystroke
|
|
134
|
+
self.keystroke.set_appearance(value.color, value.opacity)
|
|
135
|
+
self._save()
|
|
136
|
+
|
|
137
|
+
def _keyboard_failed(self, message: str) -> None:
|
|
138
|
+
self._disable_keystroke_ui()
|
|
139
|
+
self.tray.notify_warning(f"按鍵監聽已停止:{message}")
|
|
140
|
+
|
|
141
|
+
def _disable_keystroke_ui(self) -> None:
|
|
142
|
+
self._keystroke_start_pending = False
|
|
143
|
+
self.monitor.stop()
|
|
144
|
+
self.keystroke.hide_now()
|
|
145
|
+
self.settings.keystroke.enabled = False
|
|
146
|
+
with QSignalBlocker(self.tray.panel.keystroke_enabled):
|
|
147
|
+
self.tray.panel.set_keystroke_enabled(False)
|
|
148
|
+
self._save()
|
|
149
|
+
|
|
150
|
+
def _save(self) -> None:
|
|
151
|
+
self._store.save(self.settings)
|
|
152
|
+
|
|
153
|
+
def shutdown(self) -> None:
|
|
154
|
+
if self._shutting_down:
|
|
155
|
+
return
|
|
156
|
+
self._shutting_down = True
|
|
157
|
+
self.cleanup()
|
|
158
|
+
self._app.quit()
|
|
159
|
+
|
|
160
|
+
def cleanup(self) -> None:
|
|
161
|
+
self._keystroke_start_pending = False
|
|
162
|
+
self.monitor.stop()
|
|
163
|
+
self.spotlight.set_enabled(False)
|
|
164
|
+
self.keystroke.hide_now()
|
|
165
|
+
self.tray.hide()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def create_application(arguments: Sequence[str] | None = None) -> QApplication:
|
|
169
|
+
app = QApplication(list(arguments) if arguments is not None else sys.argv)
|
|
170
|
+
app.setApplicationName("簡報瑞士刀")
|
|
171
|
+
app.setOrganizationName("prkit")
|
|
172
|
+
app.setQuitOnLastWindowClosed(False)
|
|
173
|
+
return app
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main() -> int:
|
|
177
|
+
app = create_application()
|
|
178
|
+
if not TrayController.is_available():
|
|
179
|
+
QMessageBox.critical(None, "簡報瑞士刀", "此桌面環境沒有可用的系統匣。")
|
|
180
|
+
return 1
|
|
181
|
+
controller = AppController(app)
|
|
182
|
+
controller.start()
|
|
183
|
+
return app.exec()
|
prkit/cursor_watchdog.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ctypes
|
|
4
|
+
import sys
|
|
5
|
+
from ctypes import wintypes
|
|
6
|
+
|
|
7
|
+
SPI_SETCURSORS = 0x0057
|
|
8
|
+
SYNCHRONIZE = 0x00100000
|
|
9
|
+
WAIT_OBJECT_0 = 0
|
|
10
|
+
INFINITE = 0xFFFFFFFF
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def restore_system_cursors() -> None:
|
|
14
|
+
ctypes.windll.user32.SystemParametersInfoW(SPI_SETCURSORS, 0, None, 0)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def watch(parent_pid: int, stop_event_name: str) -> int:
|
|
18
|
+
kernel32 = ctypes.windll.kernel32
|
|
19
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
20
|
+
kernel32.OpenEventW.restype = wintypes.HANDLE
|
|
21
|
+
parent = kernel32.OpenProcess(SYNCHRONIZE, False, parent_pid)
|
|
22
|
+
stop_event = kernel32.OpenEventW(SYNCHRONIZE, False, stop_event_name)
|
|
23
|
+
if not parent or not stop_event:
|
|
24
|
+
restore_system_cursors()
|
|
25
|
+
if parent:
|
|
26
|
+
kernel32.CloseHandle(parent)
|
|
27
|
+
if stop_event:
|
|
28
|
+
kernel32.CloseHandle(stop_event)
|
|
29
|
+
return 1
|
|
30
|
+
try:
|
|
31
|
+
handles = (wintypes.HANDLE * 2)(parent, stop_event)
|
|
32
|
+
result = kernel32.WaitForMultipleObjects(2, handles, False, INFINITE)
|
|
33
|
+
if result == WAIT_OBJECT_0:
|
|
34
|
+
restore_system_cursors()
|
|
35
|
+
finally:
|
|
36
|
+
kernel32.CloseHandle(parent)
|
|
37
|
+
kernel32.CloseHandle(stop_event)
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main() -> int:
|
|
42
|
+
if sys.platform != "win32" or len(sys.argv) != 3:
|
|
43
|
+
return 2
|
|
44
|
+
return watch(int(sys.argv[1]), sys.argv[2])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
raise SystemExit(main())
|
prkit/icon.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from PySide6.QtCore import QByteArray
|
|
4
|
+
from PySide6.QtGui import QIcon, QPainter, QPixmap
|
|
5
|
+
from PySide6.QtSvg import QSvgRenderer
|
|
6
|
+
|
|
7
|
+
TRAY_ICON_SVG = b"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
8
|
+
<defs><radialGradient id="g"><stop offset="0" stop-color="#fff"/>
|
|
9
|
+
<stop offset=".18" stop-color="#ffb3b3"/><stop offset=".48" stop-color="#f44336"/>
|
|
10
|
+
<stop offset="1" stop-color="#8b0000" stop-opacity=".25"/></radialGradient></defs>
|
|
11
|
+
<circle cx="32" cy="32" r="27" fill="url(#g)"/><circle cx="32" cy="32" r="8" fill="#fff"/>
|
|
12
|
+
</svg>"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def tray_icon() -> QIcon:
|
|
16
|
+
renderer = QSvgRenderer(QByteArray(TRAY_ICON_SVG))
|
|
17
|
+
icon = QIcon()
|
|
18
|
+
for size in (16, 20, 24, 32, 48, 64):
|
|
19
|
+
pixmap = QPixmap(size, size)
|
|
20
|
+
pixmap.fill("transparent")
|
|
21
|
+
painter = QPainter(pixmap)
|
|
22
|
+
renderer.render(painter)
|
|
23
|
+
painter.end()
|
|
24
|
+
icon.addPixmap(pixmap)
|
|
25
|
+
return icon
|
prkit/keyboard.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from PySide6.QtCore import QObject, Signal
|
|
7
|
+
|
|
8
|
+
from prkit.keys import KeyChordTracker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KeyboardMonitor(QObject):
|
|
12
|
+
chord_pressed = Signal(str)
|
|
13
|
+
all_released = Signal()
|
|
14
|
+
failed = Signal(str)
|
|
15
|
+
|
|
16
|
+
def __init__(self, parent: QObject | None = None) -> None:
|
|
17
|
+
super().__init__(parent)
|
|
18
|
+
self._listener: Any | None = None
|
|
19
|
+
self._tracker = KeyChordTracker()
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def running(self) -> bool:
|
|
23
|
+
return self._listener is not None
|
|
24
|
+
|
|
25
|
+
def start(self) -> tuple[bool, str | None]:
|
|
26
|
+
if self._listener is not None:
|
|
27
|
+
return True, None
|
|
28
|
+
try:
|
|
29
|
+
if sys.platform == "darwin":
|
|
30
|
+
from prkit.mac_keyboard import MacKeyboardListener
|
|
31
|
+
|
|
32
|
+
listener = MacKeyboardListener(self._on_press, self._on_release)
|
|
33
|
+
success, error = listener.start()
|
|
34
|
+
if not success:
|
|
35
|
+
return False, error
|
|
36
|
+
else:
|
|
37
|
+
from pynput import keyboard
|
|
38
|
+
|
|
39
|
+
listener = keyboard.Listener(on_press=self._on_press, on_release=self._on_release)
|
|
40
|
+
listener.start()
|
|
41
|
+
listener.wait()
|
|
42
|
+
self._listener = listener
|
|
43
|
+
except Exception as error: # pynput exposes backend-specific exceptions
|
|
44
|
+
self._listener = None
|
|
45
|
+
self._tracker.clear()
|
|
46
|
+
return False, str(error)
|
|
47
|
+
return True, None
|
|
48
|
+
|
|
49
|
+
def stop(self) -> None:
|
|
50
|
+
listener, self._listener = self._listener, None
|
|
51
|
+
self._tracker.clear()
|
|
52
|
+
if listener is not None:
|
|
53
|
+
listener.stop()
|
|
54
|
+
|
|
55
|
+
def _on_press(self, key: object) -> None:
|
|
56
|
+
try:
|
|
57
|
+
label = self._tracker.press(key)
|
|
58
|
+
if label:
|
|
59
|
+
self.chord_pressed.emit(label)
|
|
60
|
+
except Exception as error:
|
|
61
|
+
self.failed.emit(str(error))
|
|
62
|
+
|
|
63
|
+
def _on_release(self, key: object) -> None:
|
|
64
|
+
try:
|
|
65
|
+
if self._tracker.release(key):
|
|
66
|
+
self.all_released.emit()
|
|
67
|
+
except Exception as error:
|
|
68
|
+
self.failed.emit(str(error))
|
prkit/keys.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Hashable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
MODIFIER_ORDER = ("Ctrl", "⌘", "Alt", "⌥", "Shift", "Meta")
|
|
8
|
+
|
|
9
|
+
SPECIAL_KEYS = {
|
|
10
|
+
"alt": "⌥" if sys.platform == "darwin" else "Alt",
|
|
11
|
+
"alt_l": "⌥" if sys.platform == "darwin" else "Alt",
|
|
12
|
+
"alt_r": "⌥" if sys.platform == "darwin" else "Alt",
|
|
13
|
+
"alt_gr": "AltGr",
|
|
14
|
+
"backspace": "Backspace",
|
|
15
|
+
"caps_lock": "Caps Lock",
|
|
16
|
+
"cmd": "⌘" if sys.platform == "darwin" else "Meta",
|
|
17
|
+
"cmd_l": "⌘" if sys.platform == "darwin" else "Meta",
|
|
18
|
+
"cmd_r": "⌘" if sys.platform == "darwin" else "Meta",
|
|
19
|
+
"ctrl": "Ctrl",
|
|
20
|
+
"ctrl_l": "Ctrl",
|
|
21
|
+
"ctrl_r": "Ctrl",
|
|
22
|
+
"delete": "Delete",
|
|
23
|
+
"down": "↓",
|
|
24
|
+
"end": "End",
|
|
25
|
+
"enter": "Enter",
|
|
26
|
+
"esc": "Esc",
|
|
27
|
+
"f1": "F1",
|
|
28
|
+
"f2": "F2",
|
|
29
|
+
"f3": "F3",
|
|
30
|
+
"f4": "F4",
|
|
31
|
+
"f5": "F5",
|
|
32
|
+
"f6": "F6",
|
|
33
|
+
"f7": "F7",
|
|
34
|
+
"f8": "F8",
|
|
35
|
+
"f9": "F9",
|
|
36
|
+
"f10": "F10",
|
|
37
|
+
"f11": "F11",
|
|
38
|
+
"f12": "F12",
|
|
39
|
+
"home": "Home",
|
|
40
|
+
"insert": "Insert",
|
|
41
|
+
"left": "←",
|
|
42
|
+
"media_next": "Next Track",
|
|
43
|
+
"media_play_pause": "Play/Pause",
|
|
44
|
+
"media_previous": "Previous Track",
|
|
45
|
+
"page_down": "Page Down",
|
|
46
|
+
"page_up": "Page Up",
|
|
47
|
+
"pause": "Pause",
|
|
48
|
+
"print_screen": "Print Screen",
|
|
49
|
+
"right": "→",
|
|
50
|
+
"scroll_lock": "Scroll Lock",
|
|
51
|
+
"shift": "Shift",
|
|
52
|
+
"shift_l": "Shift",
|
|
53
|
+
"shift_r": "Shift",
|
|
54
|
+
"space": "Space",
|
|
55
|
+
"tab": "Tab",
|
|
56
|
+
"up": "↑",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def key_identity(key: object) -> Hashable:
|
|
61
|
+
name = getattr(key, "name", None)
|
|
62
|
+
if name:
|
|
63
|
+
return ("name", name)
|
|
64
|
+
vk = getattr(key, "vk", None)
|
|
65
|
+
if vk is not None:
|
|
66
|
+
return ("vk", vk)
|
|
67
|
+
char = getattr(key, "char", None)
|
|
68
|
+
if char is not None:
|
|
69
|
+
return ("char", char)
|
|
70
|
+
return ("object", str(key))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def key_label(key: object) -> str:
|
|
74
|
+
name = getattr(key, "name", None)
|
|
75
|
+
if name:
|
|
76
|
+
return SPECIAL_KEYS.get(name, name.replace("_", " ").title())
|
|
77
|
+
char = getattr(key, "char", None)
|
|
78
|
+
if char is not None:
|
|
79
|
+
if char == " ":
|
|
80
|
+
return "Space"
|
|
81
|
+
return char.upper() if len(char) == 1 and char.isalpha() else char
|
|
82
|
+
text = str(key)
|
|
83
|
+
if text.startswith("Key."):
|
|
84
|
+
text = text[4:]
|
|
85
|
+
return SPECIAL_KEYS.get(text, text.replace("_", " ").title())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(slots=True)
|
|
89
|
+
class KeyChordTracker:
|
|
90
|
+
_pressed: dict[Hashable, str] = field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
def press(self, key: object) -> str | None:
|
|
93
|
+
identity = key_identity(key)
|
|
94
|
+
if identity in self._pressed:
|
|
95
|
+
return None
|
|
96
|
+
self._pressed[identity] = key_label(key)
|
|
97
|
+
return self.label
|
|
98
|
+
|
|
99
|
+
def release(self, key: object) -> bool:
|
|
100
|
+
self._pressed.pop(key_identity(key), None)
|
|
101
|
+
return not self._pressed
|
|
102
|
+
|
|
103
|
+
def clear(self) -> None:
|
|
104
|
+
self._pressed.clear()
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def label(self) -> str:
|
|
108
|
+
labels = list(dict.fromkeys(self._pressed.values()))
|
|
109
|
+
modifiers = [label for label in MODIFIER_ORDER if label in labels]
|
|
110
|
+
ordinary = [label for label in labels if label not in MODIFIER_ORDER]
|
|
111
|
+
return " + ".join([*modifiers, *ordinary])
|
prkit/mac_keyboard.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from CoreFoundation import (
|
|
8
|
+
CFMachPortCreateRunLoopSource,
|
|
9
|
+
CFRunLoopAddSource,
|
|
10
|
+
CFRunLoopGetCurrent,
|
|
11
|
+
CFRunLoopRun,
|
|
12
|
+
CFRunLoopStop,
|
|
13
|
+
kCFRunLoopDefaultMode,
|
|
14
|
+
)
|
|
15
|
+
from Quartz import (
|
|
16
|
+
CGEventGetFlags,
|
|
17
|
+
CGEventGetIntegerValueField,
|
|
18
|
+
CGEventMaskBit,
|
|
19
|
+
CGEventTapCreate,
|
|
20
|
+
CGEventTapEnable,
|
|
21
|
+
CGPreflightListenEventAccess,
|
|
22
|
+
kCGEventFlagMaskAlternate,
|
|
23
|
+
kCGEventFlagMaskCommand,
|
|
24
|
+
kCGEventFlagMaskControl,
|
|
25
|
+
kCGEventFlagMaskShift,
|
|
26
|
+
kCGEventFlagsChanged,
|
|
27
|
+
kCGEventKeyDown,
|
|
28
|
+
kCGEventKeyUp,
|
|
29
|
+
kCGEventTapOptionListenOnly,
|
|
30
|
+
kCGHeadInsertEventTap,
|
|
31
|
+
kCGHIDEventTap,
|
|
32
|
+
kCGKeyboardEventKeycode,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class MacKey:
|
|
38
|
+
name: str | None = None
|
|
39
|
+
char: str | None = None
|
|
40
|
+
vk: int | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
SPECIAL_KEYCODES = {
|
|
44
|
+
36: "enter",
|
|
45
|
+
48: "tab",
|
|
46
|
+
49: "space",
|
|
47
|
+
51: "backspace",
|
|
48
|
+
53: "esc",
|
|
49
|
+
54: "cmd_r",
|
|
50
|
+
55: "cmd_l",
|
|
51
|
+
56: "shift_l",
|
|
52
|
+
57: "caps_lock",
|
|
53
|
+
58: "alt_l",
|
|
54
|
+
59: "ctrl_l",
|
|
55
|
+
60: "shift_r",
|
|
56
|
+
61: "alt_r",
|
|
57
|
+
62: "ctrl_r",
|
|
58
|
+
117: "delete",
|
|
59
|
+
123: "left",
|
|
60
|
+
124: "right",
|
|
61
|
+
125: "down",
|
|
62
|
+
126: "up",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
CHARACTER_KEYCODES = {
|
|
66
|
+
0: "a",
|
|
67
|
+
1: "s",
|
|
68
|
+
2: "d",
|
|
69
|
+
3: "f",
|
|
70
|
+
4: "h",
|
|
71
|
+
5: "g",
|
|
72
|
+
6: "z",
|
|
73
|
+
7: "x",
|
|
74
|
+
8: "c",
|
|
75
|
+
9: "v",
|
|
76
|
+
11: "b",
|
|
77
|
+
12: "q",
|
|
78
|
+
13: "w",
|
|
79
|
+
14: "e",
|
|
80
|
+
15: "r",
|
|
81
|
+
16: "y",
|
|
82
|
+
17: "t",
|
|
83
|
+
18: "1",
|
|
84
|
+
19: "2",
|
|
85
|
+
20: "3",
|
|
86
|
+
21: "4",
|
|
87
|
+
22: "6",
|
|
88
|
+
23: "5",
|
|
89
|
+
25: "9",
|
|
90
|
+
26: "7",
|
|
91
|
+
28: "8",
|
|
92
|
+
29: "0",
|
|
93
|
+
31: "o",
|
|
94
|
+
32: "u",
|
|
95
|
+
34: "i",
|
|
96
|
+
35: "p",
|
|
97
|
+
37: "l",
|
|
98
|
+
38: "j",
|
|
99
|
+
40: "k",
|
|
100
|
+
24: "=",
|
|
101
|
+
27: "-",
|
|
102
|
+
30: "]",
|
|
103
|
+
33: "[",
|
|
104
|
+
39: "'",
|
|
105
|
+
41: ";",
|
|
106
|
+
42: "\\",
|
|
107
|
+
43: ",",
|
|
108
|
+
44: "/",
|
|
109
|
+
45: "n",
|
|
110
|
+
46: "m",
|
|
111
|
+
47: ".",
|
|
112
|
+
50: "`",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
MODIFIER_FLAGS = {
|
|
116
|
+
55: kCGEventFlagMaskCommand,
|
|
117
|
+
56: kCGEventFlagMaskShift,
|
|
118
|
+
58: kCGEventFlagMaskAlternate,
|
|
119
|
+
59: kCGEventFlagMaskControl,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class MacKeyboardListener:
|
|
124
|
+
"""Passive macOS event monitor that cannot consume or alter keystrokes."""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
on_press: Callable[[object], None],
|
|
129
|
+
on_release: Callable[[object], None],
|
|
130
|
+
) -> None:
|
|
131
|
+
self._on_press = on_press
|
|
132
|
+
self._on_release = on_release
|
|
133
|
+
self._thread: threading.Thread | None = None
|
|
134
|
+
self._loop: object | None = None
|
|
135
|
+
self._ready = threading.Event()
|
|
136
|
+
self._error: str | None = None
|
|
137
|
+
self._pressed_modifiers: set[int] = set()
|
|
138
|
+
self._pressed_keycodes: set[int] = set()
|
|
139
|
+
|
|
140
|
+
def start(self) -> tuple[bool, str | None]:
|
|
141
|
+
if not CGPreflightListenEventAccess():
|
|
142
|
+
return False, "請在系統設定的「輸入監控」中允許 ChatGPT"
|
|
143
|
+
self._ready.clear()
|
|
144
|
+
self._error = None
|
|
145
|
+
self._thread = threading.Thread(target=self._run, name="prkit-mac-keyboard", daemon=True)
|
|
146
|
+
self._thread.start()
|
|
147
|
+
if not self._ready.wait(timeout=2):
|
|
148
|
+
return False, "鍵盤事件監聽啟動逾時"
|
|
149
|
+
return self._error is None, self._error
|
|
150
|
+
|
|
151
|
+
def stop(self) -> None:
|
|
152
|
+
loop, self._loop = self._loop, None
|
|
153
|
+
if loop is not None:
|
|
154
|
+
CFRunLoopStop(loop)
|
|
155
|
+
self._thread = None
|
|
156
|
+
self._pressed_modifiers.clear()
|
|
157
|
+
self._pressed_keycodes.clear()
|
|
158
|
+
|
|
159
|
+
def _run(self) -> None:
|
|
160
|
+
mask = (
|
|
161
|
+
CGEventMaskBit(kCGEventKeyDown)
|
|
162
|
+
| CGEventMaskBit(kCGEventKeyUp)
|
|
163
|
+
| CGEventMaskBit(kCGEventFlagsChanged)
|
|
164
|
+
)
|
|
165
|
+
tap = CGEventTapCreate(
|
|
166
|
+
kCGHIDEventTap,
|
|
167
|
+
kCGHeadInsertEventTap,
|
|
168
|
+
kCGEventTapOptionListenOnly,
|
|
169
|
+
mask,
|
|
170
|
+
self._handle_event,
|
|
171
|
+
None,
|
|
172
|
+
)
|
|
173
|
+
if tap is None:
|
|
174
|
+
self._error = "無法建立 macOS 唯讀鍵盤事件監看器"
|
|
175
|
+
self._ready.set()
|
|
176
|
+
return
|
|
177
|
+
source = CFMachPortCreateRunLoopSource(None, tap, 0)
|
|
178
|
+
self._loop = CFRunLoopGetCurrent()
|
|
179
|
+
CFRunLoopAddSource(self._loop, source, kCFRunLoopDefaultMode)
|
|
180
|
+
CGEventTapEnable(tap, True)
|
|
181
|
+
self._ready.set()
|
|
182
|
+
CFRunLoopRun()
|
|
183
|
+
|
|
184
|
+
def _handle_event(
|
|
185
|
+
self, proxy: object, event_type: int, event: object, refcon: object
|
|
186
|
+
) -> object:
|
|
187
|
+
del proxy, refcon
|
|
188
|
+
keycode = int(CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode))
|
|
189
|
+
if event_type == kCGEventFlagsChanged:
|
|
190
|
+
self._update_modifiers(int(CGEventGetFlags(event)))
|
|
191
|
+
if keycode == 57:
|
|
192
|
+
self._press_transient_key(keycode)
|
|
193
|
+
return event
|
|
194
|
+
key = self._key(keycode)
|
|
195
|
+
if key is None:
|
|
196
|
+
return event
|
|
197
|
+
if event_type == kCGEventKeyDown:
|
|
198
|
+
self._pressed_keycodes.add(keycode)
|
|
199
|
+
self._on_press(key)
|
|
200
|
+
elif event_type == kCGEventKeyUp and keycode in self._pressed_keycodes:
|
|
201
|
+
self._release_key(keycode)
|
|
202
|
+
return event
|
|
203
|
+
|
|
204
|
+
def _update_modifiers(self, flags: int) -> None:
|
|
205
|
+
for keycode, flag in MODIFIER_FLAGS.items():
|
|
206
|
+
is_pressed = bool(flags & flag)
|
|
207
|
+
was_pressed = keycode in self._pressed_modifiers
|
|
208
|
+
if is_pressed and not was_pressed:
|
|
209
|
+
self._pressed_modifiers.add(keycode)
|
|
210
|
+
self._pressed_keycodes.add(keycode)
|
|
211
|
+
self._on_press(self._key(keycode))
|
|
212
|
+
elif not is_pressed and was_pressed:
|
|
213
|
+
self._pressed_modifiers.remove(keycode)
|
|
214
|
+
self._pressed_keycodes.discard(keycode)
|
|
215
|
+
self._on_release(self._key(keycode))
|
|
216
|
+
|
|
217
|
+
def _press_transient_key(self, keycode: int) -> None:
|
|
218
|
+
key = self._key(keycode)
|
|
219
|
+
if key is None:
|
|
220
|
+
return
|
|
221
|
+
self._pressed_keycodes.add(keycode)
|
|
222
|
+
self._on_press(key)
|
|
223
|
+
self._release_key(keycode)
|
|
224
|
+
|
|
225
|
+
def _release_key(self, keycode: int) -> None:
|
|
226
|
+
if keycode not in self._pressed_keycodes:
|
|
227
|
+
return
|
|
228
|
+
self._pressed_keycodes.remove(keycode)
|
|
229
|
+
self._on_release(self._key(keycode))
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _key(keycode: int) -> MacKey | None:
|
|
233
|
+
if keycode == 255:
|
|
234
|
+
return None
|
|
235
|
+
name = SPECIAL_KEYCODES.get(keycode)
|
|
236
|
+
return (
|
|
237
|
+
MacKey(name=name, vk=keycode)
|
|
238
|
+
if name
|
|
239
|
+
else MacKey(char=CHARACTER_KEYCODES.get(keycode, f"Key {keycode}"), vk=keycode)
|
|
240
|
+
)
|