prkit 0.1.0__tar.gz

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-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.3
2
+ Name: prkit
3
+ Version: 0.1.0
4
+ Summary: A cross-platform system-tray presentation toolkit
5
+ Author: meebox
6
+ Author-email: meebox <meebox@gmail.com>
7
+ Requires-Dist: pynput>=1.8.2
8
+ Requires-Dist: pyobjc-framework-cocoa>=12.2.1 ; sys_platform == 'darwin'
9
+ Requires-Dist: pyside6>=6.11.1
10
+ Requires-Dist: python-xlib>=0.33 ; sys_platform == 'linux'
11
+ Requires-Python: >=3.12
12
+ Project-URL: Homepage, https://github.com/codemee/prtools
13
+ Project-URL: Repository, https://github.com/codemee/prtools
14
+ Project-URL: Issues, https://github.com/codemee/prtools/issues
15
+ Description-Content-Type: text/markdown
16
+
17
+ # PRKit:簡報瑞士刀
18
+
19
+ [繁體中文](README.md) | [English](README.en.md)
20
+
21
+ 跨平台的系統匣簡報輔助工具。程式不會開啟主視窗,所有設定都在系統匣圖示的右鍵選單中完成。
22
+
23
+ ## 功能
24
+
25
+ - 聚光燈游標:可調整顏色、透明度與 48–320 px 圓圈大小;Windows 使用真正的系統游標,macOS/Linux 使用不攔截點擊的透明覆蓋層。
26
+ - 按鍵顯示:在游標所在螢幕中央下方顯示單鍵或組合鍵,不會攔截原本的鍵盤輸入。
27
+ - 自動保存:記住兩項功能的啟用狀態與外觀設定。
28
+ - 多螢幕與高 DPI:使用 Qt 邏輯像素定位與繪製。
29
+
30
+ ## 從 PyPI 安裝
31
+
32
+ 需要先安裝 [uv](https://docs.astral.sh/uv/),再執行:
33
+
34
+ ```powershell
35
+ uv tool install prkit
36
+ prkit
37
+ ```
38
+
39
+ 也可以從已下載的專案目錄安裝:
40
+
41
+ ```powershell
42
+ uv tool install .
43
+ prkit
44
+ ```
45
+
46
+ 或直接從 GitHub 安裝最新正式版;`latest` 標籤會在每次正式發布時更新:
47
+
48
+ ```powershell
49
+ uv tool install git+https://github.com/codemee/prtools.git@latest
50
+ prkit
51
+ ```
52
+
53
+ 若要固定特定版本,將 `latest` 改成版本標籤,例如 `v0.1.0`。
54
+
55
+ 若安裝後找不到 `prkit` 指令,執行 `uv tool update-shell`,再重新開啟終端機。移除工具可使用 `uv tool uninstall prkit`。
56
+
57
+ ## 使用 uvx 免安裝執行
58
+
59
+ 直接從 PyPI 執行:
60
+
61
+ ```powershell
62
+ uvx prkit
63
+ ```
64
+
65
+ 也可以在已下載的專案目錄執行,或直接從 GitHub 執行最新正式版:
66
+
67
+ ```powershell
68
+ uvx --from . prkit
69
+ uvx --from git+https://github.com/codemee/prtools.git@latest prkit
70
+ ```
71
+
72
+ `uvx` 會在隔離環境中準備並執行程式,不會把 `prkit` 持久安裝到工具目錄。
73
+
74
+ ## 開發
75
+
76
+ Python 3.12、虛擬環境及相依套件均由 uv 管理:
77
+
78
+ ```powershell
79
+ uv sync
80
+ uv run prkit
81
+ ```
82
+
83
+ 品質檢查:
84
+
85
+ ```powershell
86
+ uv run ruff check .
87
+ uv run ruff format --check .
88
+ uv run pytest -q
89
+ ```
90
+
91
+ ## 平台注意事項
92
+
93
+ - Windows:不需要額外權限。啟用聚光燈時會暫時替換系統游標,停用或結束時自動恢復;隨工具一併安裝的獨立 watchdog 會在主程式異常終止時復原游標。
94
+ - macOS:按鍵顯示使用 HID 層的唯讀事件監看,需要在「系統設定 → 隱私權與安全性 → 輸入監控」允許啟動本程式的應用程式(例如 ChatGPT、Terminal 或其他啟動 `prkit` 的終端程式)。監看器不會修改或攔截事件;即使組合鍵被其他快捷鍵工具處理,仍可先顯示按鍵。
95
+ - Linux:完整支援目標為 X11,桌面環境必須提供 StatusNotifierItem 或 XEmbed 系統匣。GNOME 可能需要 AppIndicator 類型的擴充套件。
96
+ - Wayland:受全域輸入與覆蓋層協定限制,按鍵顯示和點擊穿透不保證可用;程式會在選單中顯示警告。
97
+
98
+ 按鍵內容只用於即時畫面顯示,不會寫入設定、檔案或日誌。
99
+
100
+ 實作架構、macOS 原生視窗層級、權限模型與鍵盤事件流程請參閱[技術設計](docs/TECHNICAL.md)。
prkit-0.1.0/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # PRKit:簡報瑞士刀
2
+
3
+ [繁體中文](README.md) | [English](README.en.md)
4
+
5
+ 跨平台的系統匣簡報輔助工具。程式不會開啟主視窗,所有設定都在系統匣圖示的右鍵選單中完成。
6
+
7
+ ## 功能
8
+
9
+ - 聚光燈游標:可調整顏色、透明度與 48–320 px 圓圈大小;Windows 使用真正的系統游標,macOS/Linux 使用不攔截點擊的透明覆蓋層。
10
+ - 按鍵顯示:在游標所在螢幕中央下方顯示單鍵或組合鍵,不會攔截原本的鍵盤輸入。
11
+ - 自動保存:記住兩項功能的啟用狀態與外觀設定。
12
+ - 多螢幕與高 DPI:使用 Qt 邏輯像素定位與繪製。
13
+
14
+ ## 從 PyPI 安裝
15
+
16
+ 需要先安裝 [uv](https://docs.astral.sh/uv/),再執行:
17
+
18
+ ```powershell
19
+ uv tool install prkit
20
+ prkit
21
+ ```
22
+
23
+ 也可以從已下載的專案目錄安裝:
24
+
25
+ ```powershell
26
+ uv tool install .
27
+ prkit
28
+ ```
29
+
30
+ 或直接從 GitHub 安裝最新正式版;`latest` 標籤會在每次正式發布時更新:
31
+
32
+ ```powershell
33
+ uv tool install git+https://github.com/codemee/prtools.git@latest
34
+ prkit
35
+ ```
36
+
37
+ 若要固定特定版本,將 `latest` 改成版本標籤,例如 `v0.1.0`。
38
+
39
+ 若安裝後找不到 `prkit` 指令,執行 `uv tool update-shell`,再重新開啟終端機。移除工具可使用 `uv tool uninstall prkit`。
40
+
41
+ ## 使用 uvx 免安裝執行
42
+
43
+ 直接從 PyPI 執行:
44
+
45
+ ```powershell
46
+ uvx prkit
47
+ ```
48
+
49
+ 也可以在已下載的專案目錄執行,或直接從 GitHub 執行最新正式版:
50
+
51
+ ```powershell
52
+ uvx --from . prkit
53
+ uvx --from git+https://github.com/codemee/prtools.git@latest prkit
54
+ ```
55
+
56
+ `uvx` 會在隔離環境中準備並執行程式,不會把 `prkit` 持久安裝到工具目錄。
57
+
58
+ ## 開發
59
+
60
+ Python 3.12、虛擬環境及相依套件均由 uv 管理:
61
+
62
+ ```powershell
63
+ uv sync
64
+ uv run prkit
65
+ ```
66
+
67
+ 品質檢查:
68
+
69
+ ```powershell
70
+ uv run ruff check .
71
+ uv run ruff format --check .
72
+ uv run pytest -q
73
+ ```
74
+
75
+ ## 平台注意事項
76
+
77
+ - Windows:不需要額外權限。啟用聚光燈時會暫時替換系統游標,停用或結束時自動恢復;隨工具一併安裝的獨立 watchdog 會在主程式異常終止時復原游標。
78
+ - macOS:按鍵顯示使用 HID 層的唯讀事件監看,需要在「系統設定 → 隱私權與安全性 → 輸入監控」允許啟動本程式的應用程式(例如 ChatGPT、Terminal 或其他啟動 `prkit` 的終端程式)。監看器不會修改或攔截事件;即使組合鍵被其他快捷鍵工具處理,仍可先顯示按鍵。
79
+ - Linux:完整支援目標為 X11,桌面環境必須提供 StatusNotifierItem 或 XEmbed 系統匣。GNOME 可能需要 AppIndicator 類型的擴充套件。
80
+ - Wayland:受全域輸入與覆蓋層協定限制,按鍵顯示和點擊穿透不保證可用;程式會在選單中顯示警告。
81
+
82
+ 按鍵內容只用於即時畫面顯示,不會寫入設定、檔案或日誌。
83
+
84
+ 實作架構、macOS 原生視窗層級、權限模型與鍵盤事件流程請參閱[技術設計](docs/TECHNICAL.md)。
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "prkit"
3
+ version = "0.1.0"
4
+ description = "A cross-platform system-tray presentation toolkit"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "pynput>=1.8.2",
9
+ "pyobjc-framework-cocoa>=12.2.1 ; sys_platform == 'darwin'",
10
+ "pyside6>=6.11.1",
11
+ "python-xlib>=0.33 ; sys_platform == 'linux'",
12
+ ]
13
+
14
+ [[project.authors]]
15
+ name = "meebox"
16
+ email = "meebox@gmail.com"
17
+
18
+ [project.scripts]
19
+ prkit = "prkit.app:main"
20
+ prkit-cursor-watchdog = "prkit.cursor_watchdog:main"
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/codemee/prtools"
24
+ Repository = "https://github.com/codemee/prtools"
25
+ Issues = "https://github.com/codemee/prtools/issues"
26
+
27
+ [tool.pytest.ini_options]
28
+ addopts = "-q"
29
+ testpaths = ["tests"]
30
+
31
+ [tool.ruff]
32
+ line-length = 100
33
+ target-version = "py312"
34
+
35
+ [tool.ruff.lint]
36
+ select = [
37
+ "E",
38
+ "F",
39
+ "I",
40
+ "UP",
41
+ "B",
42
+ "SIM",
43
+ ]
44
+
45
+ [build-system]
46
+ requires = ["uv_build>=0.11.28,<0.12.0"]
47
+ build-backend = "uv_build"
48
+
49
+ [dependency-groups]
50
+ dev = [
51
+ "pytest>=9.1.1",
52
+ "pytest-qt>=4.5.0",
53
+ "ruff>=0.15.21",
54
+ ]
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "prkit"
3
+ version = "0.1.0"
4
+ description = "A cross-platform system-tray presentation toolkit"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "meebox", email = "meebox@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "pynput>=1.8.2",
12
+ "pyobjc-framework-cocoa>=12.2.1 ; sys_platform == 'darwin'",
13
+ "pyside6>=6.11.1",
14
+ "python-xlib>=0.33 ; sys_platform == 'linux'",
15
+ ]
16
+
17
+ [project.scripts]
18
+ prkit = "prkit.app:main"
19
+ prkit-cursor-watchdog = "prkit.cursor_watchdog:main"
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/codemee/prtools"
23
+ Repository = "https://github.com/codemee/prtools"
24
+ Issues = "https://github.com/codemee/prtools/issues"
25
+
26
+ [tool.pytest.ini_options]
27
+ addopts = "-q"
28
+ testpaths = ["tests"]
29
+
30
+ [tool.ruff]
31
+ line-length = 100
32
+ target-version = "py312"
33
+
34
+ [tool.ruff.lint]
35
+ select = ["E", "F", "I", "UP", "B", "SIM"]
36
+
37
+ [build-system]
38
+ requires = ["uv_build>=0.11.28,<0.12.0"]
39
+ build-backend = "uv_build"
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "pytest>=9.1.1",
44
+ "pytest-qt>=4.5.0",
45
+ "ruff>=0.15.21",
46
+ ]
@@ -0,0 +1,3 @@
1
+ """Presentation tools for the system tray."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from prkit.app import main
2
+
3
+ raise SystemExit(main())
@@ -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()
@@ -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())
@@ -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
@@ -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))