prkit 0.1.1__tar.gz → 0.2.1__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.2.1/PKG-INFO ADDED
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.3
2
+ Name: prkit
3
+ Version: 0.2.1
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: pyobjc-framework-quartz>=12.2.1 ; sys_platform == 'darwin'
10
+ Requires-Dist: pyside6>=6.11.1
11
+ Requires-Dist: python-xlib>=0.33 ; sys_platform == 'linux'
12
+ Requires-Python: >=3.12
13
+ Project-URL: Homepage, https://github.com/codemee/prkit
14
+ Project-URL: Repository, https://github.com/codemee/prkit
15
+ Project-URL: Issues, https://github.com/codemee/prkit/issues
16
+ Description-Content-Type: text/markdown
17
+
18
+ # PRKit:簡報瑞士刀
19
+
20
+ [繁體中文](README.md) | [English](README.en.md)
21
+
22
+ 跨平台的系統匣簡報輔助工具。程式不會開啟主視窗,所有設定都在系統匣圖示的右鍵選單中完成。
23
+
24
+ ## 功能
25
+
26
+ - 聚光燈游標:可調整顏色、透明度與 48–320 px 圓圈大小;Windows 使用真正的系統游標,macOS/Linux 使用不攔截點擊的透明覆蓋層。
27
+ - 按鍵顯示:在游標所在螢幕中央下方顯示單鍵或組合鍵,不會攔截原本的鍵盤輸入。
28
+ - Caps Lock 狀態:以可拖曳的小圓角矩形顯示 ON/OFF,可設定顯示時間、背景顏色與透明度。
29
+ - macOS 輸入法狀態:可切換為顯示目前的中文/英文輸入來源,與 Caps Lock 燈號分開追蹤。
30
+ - 自動保存:記住各項功能的啟用狀態、外觀設定與 Caps Lock 提示位置。
31
+ - 多螢幕與高 DPI:使用 Qt 邏輯像素定位與繪製。
32
+
33
+ ## 下載桌面版
34
+
35
+ 從 [GitHub Releases](https://github.com/codemee/prkit/releases/latest) 下載,不需另外安裝 Python 或 uv:
36
+
37
+ | 平台 | 下載檔案 | 啟動方式 |
38
+ | --- | --- | --- |
39
+ | Windows x64 | `PRKit-0.2.1-windows-x64.exe` | 直接開啟執行 |
40
+ | macOS Apple Silicon | `PRKit-0.2.1-macos-arm64.dmg` | 開啟 DMG,將 `PRKit.app` 拖到「Applications」捷徑後啟動 |
41
+
42
+ Windows 為單一 EXE,已包含執行環境與游標復原 watchdog,不需額外下載檔案。macOS 目前僅提供 Apple Silicon 版。程式啟動後顯示在系統匣/選單列,不會開啟主視窗。
43
+
44
+ 桌面版尚未使用 Windows 發行者憑證或 Apple Developer ID 簽署/公證;macOS 僅使用 ad-hoc 簽章。若系統阻擋你確認來源的下載檔案,請依系統安全提示允許開啟。macOS 按鍵顯示仍需在「輸入監控」允許 `PRKit`;輸入法狀態模式不需此權限。每個下載檔另提供 `.sha256` 校驗檔。
45
+
46
+ 版本標籤會自動觸發 GitHub 雲端打包與成品測試;手動建置可在 Actions 的 **Build desktop apps** 執行,成品在該次執行的 Artifacts。詳細操作見[發佈指南](docs/RELEASING.md)。
47
+
48
+ ## 從 PyPI 安裝
49
+
50
+ 需要先安裝 [uv](https://docs.astral.sh/uv/),再執行:
51
+
52
+ ```powershell
53
+ uv tool install prkit
54
+ prkit
55
+ ```
56
+
57
+ 也可以從已下載的專案目錄安裝:
58
+
59
+ ```powershell
60
+ uv tool install .
61
+ prkit
62
+ ```
63
+
64
+ 或直接從 GitHub 安裝最新正式版;`latest` 標籤會在每次正式發布時更新:
65
+
66
+ ```powershell
67
+ uv tool install git+https://github.com/codemee/prkit.git@latest
68
+ prkit
69
+ ```
70
+
71
+ 若要固定特定版本,將 `latest` 改成版本標籤,例如 `v0.2.1`。
72
+
73
+ 若安裝後找不到 `prkit` 指令,執行 `uv tool update-shell`,再重新開啟終端機。移除工具可使用 `uv tool uninstall prkit`。
74
+
75
+ ## 使用 uvx 免安裝執行
76
+
77
+ 直接從 PyPI 執行:
78
+
79
+ ```powershell
80
+ uvx prkit
81
+ ```
82
+
83
+ 也可以在已下載的專案目錄執行,或直接從 GitHub 執行最新正式版:
84
+
85
+ ```powershell
86
+ uvx --from . prkit
87
+ uvx --from git+https://github.com/codemee/prkit.git@latest prkit
88
+ ```
89
+
90
+ `uvx` 會在隔離環境中準備並執行程式,不會把 `prkit` 持久安裝到工具目錄。
91
+
92
+ ## Caps Lock/輸入法狀態顯示
93
+
94
+ 啟動後,右鍵點擊系統匣圖示,在「Caps Lock/輸入法狀態」勾選「啟用狀態顯示」。此功能可獨立於按鍵顯示使用,啟用時會先顯示目前狀態。
95
+
96
+ macOS 可在「顯示內容」選擇「Caps Lock 指示燈」或「目前輸入法(中文/英文)」。輸入法模式直接追蹤系統選中的輸入來源:內建注音顯示「中文(注音)」,ABC/美式鍵盤顯示「英文」,其他輸入來源顯示其名稱;使用 Caps Lock、快捷鍵或選單切換都會更新。此模式不需要輸入監控權限,也不會把切換中英文時短暫出現的 Caps Lock 狀態當成大寫鎖定。
97
+
98
+ - **顯示時間**:設定狀態變化後顯示 1–60 秒;設為 0(「一直顯示」)便會持續顯示目前狀態。
99
+ - **背景顏色與透明度**:透過色塊與滑桿調整,修改時會顯示預覽。
100
+ - **移動位置**:直接用滑鼠左鍵拖曳提示框;拖曳期間暫停隱藏倒數,放開後重新計時並保存位置。下次啟動沿用,若原螢幕已移除則回到可見螢幕。
101
+
102
+ | 平台 | Caps Lock ON/OFF | 目前輸入法 |
103
+ | --- | --- | --- |
104
+ | Windows | 支援 | 未支援,不顯示選項 |
105
+ | macOS | 支援 | 支援 |
106
+ | Linux X11 | 支援 | 未支援,不顯示選項 |
107
+ | Wayland | 未支援 | 未支援 |
108
+
109
+ macOS 啟用 Caps Lock 切換輸入方式時,短按切換中英文,按住直到大寫鎖定符號出現才啟用持續大寫。若要跟隨中英文,請選擇輸入法模式;Caps Lock 模式顯示系統鎖定狀態。輸入法模式不推測第三方輸入法在同一來源內的私有中英文狀態。Wayland 啟用時會提示並自動停用。
110
+
111
+ ## 開發
112
+
113
+ Python 3.12、虛擬環境及相依套件均由 uv 管理:
114
+
115
+ ```powershell
116
+ uv sync
117
+ uv run prkit
118
+ ```
119
+
120
+ 品質檢查:
121
+
122
+ ```powershell
123
+ uv run ruff check .
124
+ uv run ruff format --check .
125
+ uv run pytest -q
126
+ ```
127
+
128
+ ## 平台注意事項
129
+
130
+ - Windows:不需要額外權限。啟用聚光燈時會暫時替換系統游標,停用或結束時自動恢復;隨工具一併安裝的獨立 watchdog 會在主程式異常終止時復原游標。
131
+ - macOS:按鍵顯示使用 HID 層的唯讀事件監看,需要在「系統設定 → 隱私權與安全性 → 輸入監控」允許啟動本程式的應用程式(例如 ChatGPT、Terminal 或其他啟動 `prkit` 的終端程式)。監看器不會修改或攔截事件;即使組合鍵被其他快捷鍵工具處理,仍可先顯示按鍵。
132
+ - Linux:完整支援目標為 X11,桌面環境必須提供 StatusNotifierItem 或 XEmbed 系統匣。GNOME 可能需要 AppIndicator 類型的擴充套件。
133
+ - Wayland:受全域輸入與覆蓋層協定限制,按鍵顯示和點擊穿透不保證可用;程式會在選單中顯示警告。
134
+
135
+ 按鍵內容只用於即時畫面顯示,不會寫入設定、檔案或日誌。
136
+
137
+ 實作架構、macOS 原生視窗層級、權限模型與鍵盤事件流程請參閱[技術設計](docs/TECHNICAL.md)。
138
+
139
+ 各版本變更見[版本紀錄](CHANGELOG.md),雲端打包與 PyPI 發佈程序見[發佈指南](docs/RELEASING.md)。
prkit-0.2.1/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # PRKit:簡報瑞士刀
2
+
3
+ [繁體中文](README.md) | [English](README.en.md)
4
+
5
+ 跨平台的系統匣簡報輔助工具。程式不會開啟主視窗,所有設定都在系統匣圖示的右鍵選單中完成。
6
+
7
+ ## 功能
8
+
9
+ - 聚光燈游標:可調整顏色、透明度與 48–320 px 圓圈大小;Windows 使用真正的系統游標,macOS/Linux 使用不攔截點擊的透明覆蓋層。
10
+ - 按鍵顯示:在游標所在螢幕中央下方顯示單鍵或組合鍵,不會攔截原本的鍵盤輸入。
11
+ - Caps Lock 狀態:以可拖曳的小圓角矩形顯示 ON/OFF,可設定顯示時間、背景顏色與透明度。
12
+ - macOS 輸入法狀態:可切換為顯示目前的中文/英文輸入來源,與 Caps Lock 燈號分開追蹤。
13
+ - 自動保存:記住各項功能的啟用狀態、外觀設定與 Caps Lock 提示位置。
14
+ - 多螢幕與高 DPI:使用 Qt 邏輯像素定位與繪製。
15
+
16
+ ## 下載桌面版
17
+
18
+ 從 [GitHub Releases](https://github.com/codemee/prkit/releases/latest) 下載,不需另外安裝 Python 或 uv:
19
+
20
+ | 平台 | 下載檔案 | 啟動方式 |
21
+ | --- | --- | --- |
22
+ | Windows x64 | `PRKit-0.2.1-windows-x64.exe` | 直接開啟執行 |
23
+ | macOS Apple Silicon | `PRKit-0.2.1-macos-arm64.dmg` | 開啟 DMG,將 `PRKit.app` 拖到「Applications」捷徑後啟動 |
24
+
25
+ Windows 為單一 EXE,已包含執行環境與游標復原 watchdog,不需額外下載檔案。macOS 目前僅提供 Apple Silicon 版。程式啟動後顯示在系統匣/選單列,不會開啟主視窗。
26
+
27
+ 桌面版尚未使用 Windows 發行者憑證或 Apple Developer ID 簽署/公證;macOS 僅使用 ad-hoc 簽章。若系統阻擋你確認來源的下載檔案,請依系統安全提示允許開啟。macOS 按鍵顯示仍需在「輸入監控」允許 `PRKit`;輸入法狀態模式不需此權限。每個下載檔另提供 `.sha256` 校驗檔。
28
+
29
+ 版本標籤會自動觸發 GitHub 雲端打包與成品測試;手動建置可在 Actions 的 **Build desktop apps** 執行,成品在該次執行的 Artifacts。詳細操作見[發佈指南](docs/RELEASING.md)。
30
+
31
+ ## 從 PyPI 安裝
32
+
33
+ 需要先安裝 [uv](https://docs.astral.sh/uv/),再執行:
34
+
35
+ ```powershell
36
+ uv tool install prkit
37
+ prkit
38
+ ```
39
+
40
+ 也可以從已下載的專案目錄安裝:
41
+
42
+ ```powershell
43
+ uv tool install .
44
+ prkit
45
+ ```
46
+
47
+ 或直接從 GitHub 安裝最新正式版;`latest` 標籤會在每次正式發布時更新:
48
+
49
+ ```powershell
50
+ uv tool install git+https://github.com/codemee/prkit.git@latest
51
+ prkit
52
+ ```
53
+
54
+ 若要固定特定版本,將 `latest` 改成版本標籤,例如 `v0.2.1`。
55
+
56
+ 若安裝後找不到 `prkit` 指令,執行 `uv tool update-shell`,再重新開啟終端機。移除工具可使用 `uv tool uninstall prkit`。
57
+
58
+ ## 使用 uvx 免安裝執行
59
+
60
+ 直接從 PyPI 執行:
61
+
62
+ ```powershell
63
+ uvx prkit
64
+ ```
65
+
66
+ 也可以在已下載的專案目錄執行,或直接從 GitHub 執行最新正式版:
67
+
68
+ ```powershell
69
+ uvx --from . prkit
70
+ uvx --from git+https://github.com/codemee/prkit.git@latest prkit
71
+ ```
72
+
73
+ `uvx` 會在隔離環境中準備並執行程式,不會把 `prkit` 持久安裝到工具目錄。
74
+
75
+ ## Caps Lock/輸入法狀態顯示
76
+
77
+ 啟動後,右鍵點擊系統匣圖示,在「Caps Lock/輸入法狀態」勾選「啟用狀態顯示」。此功能可獨立於按鍵顯示使用,啟用時會先顯示目前狀態。
78
+
79
+ macOS 可在「顯示內容」選擇「Caps Lock 指示燈」或「目前輸入法(中文/英文)」。輸入法模式直接追蹤系統選中的輸入來源:內建注音顯示「中文(注音)」,ABC/美式鍵盤顯示「英文」,其他輸入來源顯示其名稱;使用 Caps Lock、快捷鍵或選單切換都會更新。此模式不需要輸入監控權限,也不會把切換中英文時短暫出現的 Caps Lock 狀態當成大寫鎖定。
80
+
81
+ - **顯示時間**:設定狀態變化後顯示 1–60 秒;設為 0(「一直顯示」)便會持續顯示目前狀態。
82
+ - **背景顏色與透明度**:透過色塊與滑桿調整,修改時會顯示預覽。
83
+ - **移動位置**:直接用滑鼠左鍵拖曳提示框;拖曳期間暫停隱藏倒數,放開後重新計時並保存位置。下次啟動沿用,若原螢幕已移除則回到可見螢幕。
84
+
85
+ | 平台 | Caps Lock ON/OFF | 目前輸入法 |
86
+ | --- | --- | --- |
87
+ | Windows | 支援 | 未支援,不顯示選項 |
88
+ | macOS | 支援 | 支援 |
89
+ | Linux X11 | 支援 | 未支援,不顯示選項 |
90
+ | Wayland | 未支援 | 未支援 |
91
+
92
+ macOS 啟用 Caps Lock 切換輸入方式時,短按切換中英文,按住直到大寫鎖定符號出現才啟用持續大寫。若要跟隨中英文,請選擇輸入法模式;Caps Lock 模式顯示系統鎖定狀態。輸入法模式不推測第三方輸入法在同一來源內的私有中英文狀態。Wayland 啟用時會提示並自動停用。
93
+
94
+ ## 開發
95
+
96
+ Python 3.12、虛擬環境及相依套件均由 uv 管理:
97
+
98
+ ```powershell
99
+ uv sync
100
+ uv run prkit
101
+ ```
102
+
103
+ 品質檢查:
104
+
105
+ ```powershell
106
+ uv run ruff check .
107
+ uv run ruff format --check .
108
+ uv run pytest -q
109
+ ```
110
+
111
+ ## 平台注意事項
112
+
113
+ - Windows:不需要額外權限。啟用聚光燈時會暫時替換系統游標,停用或結束時自動恢復;隨工具一併安裝的獨立 watchdog 會在主程式異常終止時復原游標。
114
+ - macOS:按鍵顯示使用 HID 層的唯讀事件監看,需要在「系統設定 → 隱私權與安全性 → 輸入監控」允許啟動本程式的應用程式(例如 ChatGPT、Terminal 或其他啟動 `prkit` 的終端程式)。監看器不會修改或攔截事件;即使組合鍵被其他快捷鍵工具處理,仍可先顯示按鍵。
115
+ - Linux:完整支援目標為 X11,桌面環境必須提供 StatusNotifierItem 或 XEmbed 系統匣。GNOME 可能需要 AppIndicator 類型的擴充套件。
116
+ - Wayland:受全域輸入與覆蓋層協定限制,按鍵顯示和點擊穿透不保證可用;程式會在選單中顯示警告。
117
+
118
+ 按鍵內容只用於即時畫面顯示,不會寫入設定、檔案或日誌。
119
+
120
+ 實作架構、macOS 原生視窗層級、權限模型與鍵盤事件流程請參閱[技術設計](docs/TECHNICAL.md)。
121
+
122
+ 各版本變更見[版本紀錄](CHANGELOG.md),雲端打包與 PyPI 發佈程序見[發佈指南](docs/RELEASING.md)。
@@ -1,12 +1,13 @@
1
1
  [project]
2
2
  name = "prkit"
3
- version = "0.1.1"
3
+ version = "0.2.1"
4
4
  description = "A cross-platform system-tray presentation toolkit"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
7
7
  dependencies = [
8
8
  "pynput>=1.8.2",
9
9
  "pyobjc-framework-cocoa>=12.2.1 ; sys_platform == 'darwin'",
10
+ "pyobjc-framework-quartz>=12.2.1 ; sys_platform == 'darwin'",
10
11
  "pyside6>=6.11.1",
11
12
  "python-xlib>=0.33 ; sys_platform == 'linux'",
12
13
  ]
@@ -47,6 +48,7 @@ requires = ["uv_build>=0.11.28,<0.12.0"]
47
48
  build-backend = "uv_build"
48
49
 
49
50
  [dependency-groups]
51
+ build = ["pyinstaller>=6.16,<7"]
50
52
  dev = [
51
53
  "pytest>=9.1.1",
52
54
  "pytest-qt>=4.5.0",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prkit"
3
- version = "0.1.1"
3
+ version = "0.2.1"
4
4
  description = "A cross-platform system-tray presentation toolkit"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -10,6 +10,7 @@ requires-python = ">=3.12"
10
10
  dependencies = [
11
11
  "pynput>=1.8.2",
12
12
  "pyobjc-framework-cocoa>=12.2.1 ; sys_platform == 'darwin'",
13
+ "pyobjc-framework-quartz>=12.2.1 ; sys_platform == 'darwin'",
13
14
  "pyside6>=6.11.1",
14
15
  "python-xlib>=0.33 ; sys_platform == 'linux'",
15
16
  ]
@@ -39,6 +40,9 @@ requires = ["uv_build>=0.11.28,<0.12.0"]
39
40
  build-backend = "uv_build"
40
41
 
41
42
  [dependency-groups]
43
+ build = [
44
+ "pyinstaller>=6.16,<7",
45
+ ]
42
46
  dev = [
43
47
  "pytest>=9.1.1",
44
48
  "pytest-qt>=4.5.0",
@@ -1,3 +1,3 @@
1
1
  """Presentation tools for the system tray."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.2.1"
@@ -7,8 +7,10 @@ from contextlib import suppress
7
7
  from PySide6.QtCore import QObject, QSignalBlocker, QTimer
8
8
  from PySide6.QtWidgets import QApplication, QMessageBox
9
9
 
10
+ from prkit.capslock import CapsLockMonitor
11
+ from prkit.input_source import InputSourceMonitor
10
12
  from prkit.keyboard import KeyboardMonitor
11
- from prkit.overlays import KeystrokeOverlay
13
+ from prkit.overlays import CapsLockOverlay, KeystrokeOverlay
12
14
  from prkit.settings import SettingsStore, SettingsStoreProtocol
13
15
  from prkit.spotlight import SpotlightBackend, create_spotlight
14
16
  from prkit.tray import TrayController
@@ -21,6 +23,8 @@ class AppController(QObject):
21
23
  store: SettingsStoreProtocol | None = None,
22
24
  monitor: KeyboardMonitor | None = None,
23
25
  spotlight: SpotlightBackend | None = None,
26
+ capslock_monitor: CapsLockMonitor | None = None,
27
+ input_source_monitor: InputSourceMonitor | None = None,
24
28
  ) -> None:
25
29
  super().__init__()
26
30
  self._app = app
@@ -28,6 +32,11 @@ class AppController(QObject):
28
32
  self.settings = self._store.load()
29
33
  self.spotlight = spotlight or create_spotlight(self.settings.spotlight)
30
34
  self.keystroke = KeystrokeOverlay(self.settings.keystroke)
35
+ self.capslock = CapsLockOverlay(self.settings.capslock)
36
+ self.capslock_monitor = capslock_monitor or CapsLockMonitor(self)
37
+ self.input_source_monitor = input_source_monitor or InputSourceMonitor(self)
38
+ if sys.platform != "darwin":
39
+ self.settings.capslock.mode = "capslock"
31
40
  self.monitor = monitor or KeyboardMonitor(self)
32
41
  self.tray = TrayController(self.settings, self)
33
42
  self._shutting_down = False
@@ -43,6 +52,16 @@ class AppController(QObject):
43
52
  keystroke_opacity=self.set_keystroke_opacity,
44
53
  )
45
54
  self.tray.exit_requested.connect(self.shutdown)
55
+ self.tray.panel.capslock_enabled_changed.connect(self.set_capslock_enabled)
56
+ self.tray.panel.capslock_color_changed.connect(self.set_capslock_color)
57
+ self.tray.panel.capslock_opacity_changed.connect(self.set_capslock_opacity)
58
+ self.tray.panel.capslock_duration_changed.connect(self.set_capslock_duration)
59
+ self.tray.panel.capslock_mode_changed.connect(self.set_capslock_mode)
60
+ self.capslock_monitor.changed.connect(self._show_capslock_state)
61
+ self.input_source_monitor.changed.connect(self._show_input_source)
62
+ self.input_source_monitor.failed.connect(self._capslock_failed)
63
+ self.capslock_monitor.failed.connect(self._capslock_failed)
64
+ self.capslock.position_changed.connect(self._save_capslock_position)
46
65
  self.tray.menu.aboutToShow.connect(self.spotlight.sync_position)
47
66
  self.tray.menu.aboutToHide.connect(self._start_pending_keyboard_monitor)
48
67
  self.monitor.chord_pressed.connect(self.keystroke.show_chord)
@@ -55,6 +74,65 @@ class AppController(QObject):
55
74
  self.spotlight.set_enabled(self.settings.spotlight.enabled)
56
75
  if self.settings.keystroke.enabled:
57
76
  self.set_keystroke_enabled(True)
77
+ if self.settings.capslock.enabled:
78
+ self.set_capslock_enabled(True)
79
+
80
+ def set_capslock_enabled(self, enabled: bool) -> None:
81
+ self.settings.capslock.enabled = enabled
82
+ self.capslock_monitor.stop()
83
+ self.input_source_monitor.stop()
84
+ self.capslock.hide_now()
85
+ if enabled:
86
+ if self.settings.capslock.mode == "input_source":
87
+ self.input_source_monitor.start()
88
+ else:
89
+ self.capslock_monitor.start()
90
+ self._save()
91
+
92
+ def set_capslock_mode(self, mode: str) -> None:
93
+ if mode not in {"capslock", "input_source"}:
94
+ return
95
+ if mode == "input_source" and sys.platform != "darwin":
96
+ return
97
+ self.settings.capslock.mode = mode
98
+ combo = self.tray.panel.capslock_mode
99
+ with QSignalBlocker(combo):
100
+ combo.setCurrentIndex(combo.findData(mode))
101
+ self.set_capslock_enabled(self.settings.capslock.enabled)
102
+
103
+ def _show_capslock_state(self, state: bool) -> None:
104
+ if self.settings.capslock.mode == "capslock":
105
+ self.capslock.show_state(state)
106
+
107
+ def _show_input_source(self, name: str) -> None:
108
+ if self.settings.capslock.mode == "input_source":
109
+ self.capslock.show_input_source(name)
110
+
111
+ def set_capslock_color(self, color: str) -> None:
112
+ self.settings.capslock.color = color
113
+ self._apply_capslock_settings()
114
+
115
+ def set_capslock_opacity(self, opacity: int) -> None:
116
+ self.settings.capslock.opacity = opacity
117
+ self._apply_capslock_settings()
118
+
119
+ def set_capslock_duration(self, duration: int) -> None:
120
+ self.settings.capslock.duration = duration
121
+ self._apply_capslock_settings()
122
+
123
+ def _apply_capslock_settings(self) -> None:
124
+ self.capslock.apply_settings()
125
+ self._save()
126
+
127
+ def _save_capslock_position(self, x: int, y: int) -> None:
128
+ self.settings.capslock.x, self.settings.capslock.y = x, y
129
+ self._save()
130
+
131
+ def _capslock_failed(self, message: str) -> None:
132
+ self.set_capslock_enabled(False)
133
+ with QSignalBlocker(self.tray.panel.capslock_enabled):
134
+ self.tray.panel.capslock_enabled.setChecked(False)
135
+ self.tray.notify_warning(f"無法顯示鍵盤狀態:{message}")
58
136
 
59
137
  def set_spotlight_enabled(self, enabled: bool) -> None:
60
138
  try:
@@ -159,6 +237,9 @@ class AppController(QObject):
159
237
 
160
238
  def cleanup(self) -> None:
161
239
  self._keystroke_start_pending = False
240
+ self.capslock_monitor.stop()
241
+ self.input_source_monitor.stop()
242
+ self.capslock.hide_now()
162
243
  self.monitor.stop()
163
244
  self.spotlight.set_enabled(False)
164
245
  self.keystroke.hide_now()
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ from PySide6.QtCore import QObject, QTimer, Signal, Slot
8
+
9
+ from prkit.platform import is_wayland
10
+
11
+
12
+ class CapsLockMonitor(QObject):
13
+ """Read the actual lock state independently of the keystroke display."""
14
+
15
+ changed = Signal(bool)
16
+ failed = Signal(str)
17
+ _refresh = Signal()
18
+
19
+ def __init__(self, parent: QObject | None = None) -> None:
20
+ super().__init__(parent)
21
+ self._read: Callable[[], bool] | None = None
22
+ self._connection: Any = None
23
+ self._listener: Any = None
24
+ self._state: bool | None = None
25
+ self._timer = QTimer(self)
26
+ self._timer.setInterval(100)
27
+ self._timer.timeout.connect(self._poll)
28
+ self._refresh.connect(self._poll)
29
+
30
+ def start(self) -> None:
31
+ self.stop()
32
+ try:
33
+ if sys.platform == "win32":
34
+ import ctypes
35
+
36
+ self._read = lambda: bool(ctypes.windll.user32.GetKeyState(0x14) & 1)
37
+ elif sys.platform == "darwin":
38
+ from prkit.mac_capslock import MacCapsLockState
39
+
40
+ self._connection = MacCapsLockState()
41
+ self._read = self._connection.read
42
+ elif sys.platform.startswith("linux") and not is_wayland():
43
+ from Xlib import X, display
44
+
45
+ self._connection = display.Display()
46
+ root = self._connection.screen().root
47
+ self._read = lambda: bool(root.query_pointer().mask & X.LockMask)
48
+ else:
49
+ raise RuntimeError("此平台不支援 Caps Lock 狀態偵測(Linux 需要 X11)。")
50
+ self._poll()
51
+ if self._read is not None:
52
+ if sys.platform == "darwin":
53
+ self._start_macos_listener()
54
+ self._timer.start()
55
+ except Exception as error:
56
+ self.stop()
57
+ self.failed.emit(str(error))
58
+
59
+ def stop(self) -> None:
60
+ self._timer.stop()
61
+ if self._listener is not None:
62
+ self._listener.stop()
63
+ self._listener = None
64
+ self._read = None
65
+ self._state = None
66
+ if self._connection is not None:
67
+ self._connection.close()
68
+ self._connection = None
69
+
70
+ def _start_macos_listener(self) -> None:
71
+ from prkit.mac_keyboard import MacKeyboardListener
72
+
73
+ # Event flags can be transient or disagree with the lock indicator.
74
+ # Events only wake the Qt thread to query the same source as polling.
75
+ listener = MacKeyboardListener(
76
+ on_press=lambda key: None,
77
+ on_release=lambda key: None,
78
+ on_capslock=lambda state: self._refresh.emit(),
79
+ )
80
+ success, _ = listener.start()
81
+ if success:
82
+ self._listener = listener
83
+ else:
84
+ listener.stop()
85
+
86
+ @Slot(bool)
87
+ def _set_state(self, state: bool) -> None:
88
+ if self._read is not None and state != self._state:
89
+ self._state = state
90
+ self.changed.emit(state)
91
+
92
+ @Slot()
93
+ def _poll(self) -> None:
94
+ try:
95
+ if self._read is None:
96
+ return
97
+ self._set_state(self._read())
98
+ except Exception as error:
99
+ self.stop()
100
+ self.failed.emit(str(error))
@@ -6,6 +6,7 @@ from ctypes import wintypes
6
6
 
7
7
  SPI_SETCURSORS = 0x0057
8
8
  SYNCHRONIZE = 0x00100000
9
+ EVENT_MODIFY_STATE = 0x0002
9
10
  WAIT_OBJECT_0 = 0
10
11
  INFINITE = 0xFFFFFFFF
11
12
 
@@ -14,10 +15,20 @@ def restore_system_cursors() -> None:
14
15
  ctypes.windll.user32.SystemParametersInfoW(SPI_SETCURSORS, 0, None, 0)
15
16
 
16
17
 
17
- def watch(parent_pid: int, stop_event_name: str) -> int:
18
+ def watch(parent_pid: int, stop_event_name: str, ready_event_name: str | None = None) -> int:
18
19
  kernel32 = ctypes.windll.kernel32
19
20
  kernel32.OpenProcess.restype = wintypes.HANDLE
21
+ kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
20
22
  kernel32.OpenEventW.restype = wintypes.HANDLE
23
+ kernel32.OpenEventW.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.LPCWSTR]
24
+ kernel32.SetEvent.argtypes = [wintypes.HANDLE]
25
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
26
+ kernel32.WaitForMultipleObjects.argtypes = [
27
+ wintypes.DWORD,
28
+ ctypes.POINTER(wintypes.HANDLE),
29
+ wintypes.BOOL,
30
+ wintypes.DWORD,
31
+ ]
21
32
  parent = kernel32.OpenProcess(SYNCHRONIZE, False, parent_pid)
22
33
  stop_event = kernel32.OpenEventW(SYNCHRONIZE, False, stop_event_name)
23
34
  if not parent or not stop_event:
@@ -28,6 +39,15 @@ def watch(parent_pid: int, stop_event_name: str) -> int:
28
39
  kernel32.CloseHandle(stop_event)
29
40
  return 1
30
41
  try:
42
+ if ready_event_name is not None:
43
+ ready = kernel32.OpenEventW(EVENT_MODIFY_STATE, False, ready_event_name)
44
+ if not ready:
45
+ return 1
46
+ try:
47
+ if not kernel32.SetEvent(ready):
48
+ return 1
49
+ finally:
50
+ kernel32.CloseHandle(ready)
31
51
  handles = (wintypes.HANDLE * 2)(parent, stop_event)
32
52
  result = kernel32.WaitForMultipleObjects(2, handles, False, INFINITE)
33
53
  if result == WAIT_OBJECT_0:
@@ -39,9 +59,9 @@ def watch(parent_pid: int, stop_event_name: str) -> int:
39
59
 
40
60
 
41
61
  def main() -> int:
42
- if sys.platform != "win32" or len(sys.argv) != 3:
62
+ if sys.platform != "win32" or len(sys.argv) not in {3, 4}:
43
63
  return 2
44
- return watch(int(sys.argv[1]), sys.argv[2])
64
+ return watch(int(sys.argv[1]), sys.argv[2], sys.argv[3] if len(sys.argv) == 4 else None)
45
65
 
46
66
 
47
67
  if __name__ == "__main__":