fshot 0.0.8__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.
fshot-0.0.8/PKG-INFO ADDED
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.3
2
+ Name: fshot
3
+ Version: 0.0.8
4
+ Summary: Keyboard-first cross-platform screenshot utility
5
+ Requires-Dist: comtypes>=1.4.16 ; sys_platform == 'win32'
6
+ Requires-Dist: keyboard>=0.13.5 ; sys_platform != 'darwin'
7
+ Requires-Dist: mss>=10.1.0
8
+ Requires-Dist: pillow>=11.3.0
9
+ Requires-Dist: pyobjc-framework-applicationservices>=12.0 ; sys_platform == 'darwin'
10
+ Requires-Dist: pyobjc-framework-cocoa>=12.0 ; sys_platform == 'darwin'
11
+ Requires-Dist: pyobjc-framework-quartz>=12.0 ; sys_platform == 'darwin'
12
+ Requires-Dist: pyside6>=6.10.0
13
+ Requires-Dist: pywin32>=311 ; sys_platform == 'win32'
14
+ Requires-Python: >=3.11
15
+ Project-URL: Homepage, https://github.com/codemee/fshot
16
+ Project-URL: Repository, https://github.com/codemee/fshot
17
+ Project-URL: Issues, https://github.com/codemee/fshot/issues
18
+ Description-Content-Type: text/markdown
19
+
20
+ # FShot
21
+
22
+ [繁體中文](https://github.com/codemee/fshot/blob/main/README.md) | [English](https://github.com/codemee/fshot/blob/main/README.en.md)
23
+
24
+ FShot 是以 Python 實作的桌面截圖工具,主打全域快捷鍵、系統匣常駐、多頁籤編輯與快速複製/存檔。目前支援 Windows 與 macOS;平台權限與驗收方式請閱讀 [Cross-Platform Notes](https://github.com/codemee/fshot/blob/main/docs/cross-platform.md)。
25
+
26
+ ## Current Status
27
+
28
+ - Windows: 主要功能已實作並經手動測試調整。
29
+ - macOS: 已實作 Quartz 全域快捷鍵、螢幕擷取、焦點視窗、視窗/控制項選取與游標擷取。
30
+ - Linux: 僅保留基本架構與部分通用能力,尚未作為主要目標平台。
31
+
32
+ ## Quick Start
33
+
34
+ FShot 可從 [PyPI](https://pypi.org/project/fshot/) 安裝。不需安裝即可使用 `uvx` 執行最新正式版本:
35
+
36
+ ```powershell
37
+ uvx fshot
38
+ ```
39
+
40
+ 第一次執行會從 PyPI 下載套件並建立 uv 快取環境,後續會重用快取。臨時試用建議使用 `uvx`,若要長期使用則安裝為 uv tool。
41
+
42
+ 使用 uv 從 PyPI 安裝:
43
+
44
+ ```powershell
45
+ uv tool install fshot
46
+ fshot
47
+ ```
48
+
49
+ 安裝後可直接執行 `fshot`;更新至 PyPI 最新版本:
50
+
51
+ ```powershell
52
+ uv tool upgrade fshot
53
+ ```
54
+
55
+ 若要直接從 GitHub 測試 `main` 的最新開發成果:
56
+
57
+ ```powershell
58
+ uv tool install --force "fshot @ git+https://github.com/codemee/fshot.git@main"
59
+ ```
60
+
61
+ 從原始碼啟動開發環境:
62
+
63
+ ```powershell
64
+ uv sync
65
+ uv run fshot
66
+ ```
67
+
68
+ 程式啟動後會隱藏主視窗並留在系統匣。雙擊系統匣圖示可顯示編輯視窗,右鍵選單可退出。
69
+ 工具列的主題按鈕可單擊循環切換「跟隨系統 → 淺色 → 深色」;預設跟隨作業系統配色,選擇會保留至下次啟動。太陽代表淺色、月亮代表深色,分隔排列的太陽與月亮代表跟隨系統。
70
+ 語言按鈕可單擊循環切換「跟隨系統 → 繁體中文 → English」,並保留使用者選擇;圖示分別為 `文/A`、`中` 與 `En`。
71
+ 工具列按鈕的提示文字會依目前語言顯示,具備快捷鍵的動作也會在提示中列出對應按鍵。
72
+ 工具列的鍵盤圖示可設定四種截圖方式及「重複前一次擷取」的全域快捷鍵。每組可選 Ctrl、Shift、Alt 與 A–Z 字母;Shift 必須搭配 Ctrl 或 Alt。「使用預設」會將面板中的五組欄位恢復為預設值;按 OK 且系統註冊成功後才會套用並保留設定,Cancel 不會變更目前快捷鍵。
73
+
74
+ 圖片也可透過拖放或剪貼簿加入編輯器:拖放圖片會以完整檔名建立頁籤、保留來源路徑並在修改後寫回原檔;直接貼上影像或從檔案管理器複製圖片後貼上,會以截圖時間格式建立新的未存檔頁籤。成功存檔後會記住檔案所在資料夾,其他尚未存檔的頁籤會從該資料夾開啟儲存交談窗。Windows/Linux 使用 `Ctrl+V`,macOS 使用 `Command+V`。
75
+
76
+ ## Windows Shortcuts
77
+
78
+ - `Ctrl+Shift+Q`: 重複前一次的截圖方式(矩形區域與選取的視窗/控制項會沿用前次目標,可在設定面板自訂)
79
+ - `Ctrl+Shift+A`: 擷取目前焦點視窗
80
+ - `Ctrl+Shift+R`: 擷取矩形區域
81
+ - `Ctrl+Shift+F`: 擷取全螢幕
82
+ - `Ctrl+Shift+W`: 選取視窗或控制項後擷取
83
+
84
+ macOS 使用相同的 `Ctrl+Shift` 字母組合。首次執行時,請依系統提示授予「螢幕錄製」與「輔助使用」權限。目前 FShot 是透過 `uv`/`uvx` 執行,而非封裝成獨立的 macOS App,因此系統設定中的授權對象通常是啟動指令所在的 App,例如「終端機」、iTerm2 或 IDE。授權後請完全結束並重新開啟該 App,再重新執行 FShot。
85
+
86
+ 編輯工具快捷鍵:
87
+
88
+ - `Alt+P`: 自由畫筆
89
+ - `Alt+L`: 線條
90
+ - `Alt+A`: 箭頭
91
+ - `Alt+R`: 矩形
92
+ - `Alt+T`: 文字
93
+ - `Alt+M`: 馬賽克
94
+ - `Alt+C`: 線條粗細與顏色
95
+ - `Ctrl++` / `Ctrl+=`: 放大
96
+ - `Ctrl+-`: 縮小
97
+ - `Ctrl+0`: 重設縮放
98
+ - `F2`: 直接在目前標籤頁重新命名已存檔的檔案(macOS 使用 `Return`)
99
+
100
+ 已存檔的標籤頁也可直接雙擊名稱進入重新命名;副檔名會保留不變。
101
+
102
+ ## Project Docs
103
+
104
+ - [PRD](https://github.com/codemee/fshot/blob/main/PRD.md): 原始產品需求。
105
+ - [Architecture](https://github.com/codemee/fshot/blob/main/docs/architecture.md): 專案結構、主要模組與資料流。
106
+ - [Cross-Platform Notes](https://github.com/codemee/fshot/blob/main/docs/cross-platform.md): Windows/macOS/Linux 差異與 macOS 後續實作重點。
107
+
108
+ ## Development
109
+
110
+ ```shell
111
+ uv sync
112
+ uv run pytest -q
113
+ uv run python -m compileall src tests
114
+ ```
115
+
116
+ 若 FShot 正在執行,`compileall` 有時會因 `__pycache__` 或 executable 被鎖而失敗;先關閉或停止 `fshot.exe` 後再重跑。
fshot-0.0.8/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # FShot
2
+
3
+ [繁體中文](https://github.com/codemee/fshot/blob/main/README.md) | [English](https://github.com/codemee/fshot/blob/main/README.en.md)
4
+
5
+ FShot 是以 Python 實作的桌面截圖工具,主打全域快捷鍵、系統匣常駐、多頁籤編輯與快速複製/存檔。目前支援 Windows 與 macOS;平台權限與驗收方式請閱讀 [Cross-Platform Notes](https://github.com/codemee/fshot/blob/main/docs/cross-platform.md)。
6
+
7
+ ## Current Status
8
+
9
+ - Windows: 主要功能已實作並經手動測試調整。
10
+ - macOS: 已實作 Quartz 全域快捷鍵、螢幕擷取、焦點視窗、視窗/控制項選取與游標擷取。
11
+ - Linux: 僅保留基本架構與部分通用能力,尚未作為主要目標平台。
12
+
13
+ ## Quick Start
14
+
15
+ FShot 可從 [PyPI](https://pypi.org/project/fshot/) 安裝。不需安裝即可使用 `uvx` 執行最新正式版本:
16
+
17
+ ```powershell
18
+ uvx fshot
19
+ ```
20
+
21
+ 第一次執行會從 PyPI 下載套件並建立 uv 快取環境,後續會重用快取。臨時試用建議使用 `uvx`,若要長期使用則安裝為 uv tool。
22
+
23
+ 使用 uv 從 PyPI 安裝:
24
+
25
+ ```powershell
26
+ uv tool install fshot
27
+ fshot
28
+ ```
29
+
30
+ 安裝後可直接執行 `fshot`;更新至 PyPI 最新版本:
31
+
32
+ ```powershell
33
+ uv tool upgrade fshot
34
+ ```
35
+
36
+ 若要直接從 GitHub 測試 `main` 的最新開發成果:
37
+
38
+ ```powershell
39
+ uv tool install --force "fshot @ git+https://github.com/codemee/fshot.git@main"
40
+ ```
41
+
42
+ 從原始碼啟動開發環境:
43
+
44
+ ```powershell
45
+ uv sync
46
+ uv run fshot
47
+ ```
48
+
49
+ 程式啟動後會隱藏主視窗並留在系統匣。雙擊系統匣圖示可顯示編輯視窗,右鍵選單可退出。
50
+ 工具列的主題按鈕可單擊循環切換「跟隨系統 → 淺色 → 深色」;預設跟隨作業系統配色,選擇會保留至下次啟動。太陽代表淺色、月亮代表深色,分隔排列的太陽與月亮代表跟隨系統。
51
+ 語言按鈕可單擊循環切換「跟隨系統 → 繁體中文 → English」,並保留使用者選擇;圖示分別為 `文/A`、`中` 與 `En`。
52
+ 工具列按鈕的提示文字會依目前語言顯示,具備快捷鍵的動作也會在提示中列出對應按鍵。
53
+ 工具列的鍵盤圖示可設定四種截圖方式及「重複前一次擷取」的全域快捷鍵。每組可選 Ctrl、Shift、Alt 與 A–Z 字母;Shift 必須搭配 Ctrl 或 Alt。「使用預設」會將面板中的五組欄位恢復為預設值;按 OK 且系統註冊成功後才會套用並保留設定,Cancel 不會變更目前快捷鍵。
54
+
55
+ 圖片也可透過拖放或剪貼簿加入編輯器:拖放圖片會以完整檔名建立頁籤、保留來源路徑並在修改後寫回原檔;直接貼上影像或從檔案管理器複製圖片後貼上,會以截圖時間格式建立新的未存檔頁籤。成功存檔後會記住檔案所在資料夾,其他尚未存檔的頁籤會從該資料夾開啟儲存交談窗。Windows/Linux 使用 `Ctrl+V`,macOS 使用 `Command+V`。
56
+
57
+ ## Windows Shortcuts
58
+
59
+ - `Ctrl+Shift+Q`: 重複前一次的截圖方式(矩形區域與選取的視窗/控制項會沿用前次目標,可在設定面板自訂)
60
+ - `Ctrl+Shift+A`: 擷取目前焦點視窗
61
+ - `Ctrl+Shift+R`: 擷取矩形區域
62
+ - `Ctrl+Shift+F`: 擷取全螢幕
63
+ - `Ctrl+Shift+W`: 選取視窗或控制項後擷取
64
+
65
+ macOS 使用相同的 `Ctrl+Shift` 字母組合。首次執行時,請依系統提示授予「螢幕錄製」與「輔助使用」權限。目前 FShot 是透過 `uv`/`uvx` 執行,而非封裝成獨立的 macOS App,因此系統設定中的授權對象通常是啟動指令所在的 App,例如「終端機」、iTerm2 或 IDE。授權後請完全結束並重新開啟該 App,再重新執行 FShot。
66
+
67
+ 編輯工具快捷鍵:
68
+
69
+ - `Alt+P`: 自由畫筆
70
+ - `Alt+L`: 線條
71
+ - `Alt+A`: 箭頭
72
+ - `Alt+R`: 矩形
73
+ - `Alt+T`: 文字
74
+ - `Alt+M`: 馬賽克
75
+ - `Alt+C`: 線條粗細與顏色
76
+ - `Ctrl++` / `Ctrl+=`: 放大
77
+ - `Ctrl+-`: 縮小
78
+ - `Ctrl+0`: 重設縮放
79
+ - `F2`: 直接在目前標籤頁重新命名已存檔的檔案(macOS 使用 `Return`)
80
+
81
+ 已存檔的標籤頁也可直接雙擊名稱進入重新命名;副檔名會保留不變。
82
+
83
+ ## Project Docs
84
+
85
+ - [PRD](https://github.com/codemee/fshot/blob/main/PRD.md): 原始產品需求。
86
+ - [Architecture](https://github.com/codemee/fshot/blob/main/docs/architecture.md): 專案結構、主要模組與資料流。
87
+ - [Cross-Platform Notes](https://github.com/codemee/fshot/blob/main/docs/cross-platform.md): Windows/macOS/Linux 差異與 macOS 後續實作重點。
88
+
89
+ ## Development
90
+
91
+ ```shell
92
+ uv sync
93
+ uv run pytest -q
94
+ uv run python -m compileall src tests
95
+ ```
96
+
97
+ 若 FShot 正在執行,`compileall` 有時會因 `__pycache__` 或 executable 被鎖而失敗;先關閉或停止 `fshot.exe` 後再重跑。
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "fshot"
3
+ version = "0.0.8"
4
+ description = "Keyboard-first cross-platform screenshot utility"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "comtypes>=1.4.16; sys_platform == 'win32'",
9
+ "keyboard>=0.13.5; sys_platform != 'darwin'",
10
+ "mss>=10.1.0",
11
+ "pillow>=11.3.0",
12
+ "pyobjc-framework-applicationservices>=12.0; sys_platform == 'darwin'",
13
+ "pyobjc-framework-cocoa>=12.0; sys_platform == 'darwin'",
14
+ "pyobjc-framework-quartz>=12.0; sys_platform == 'darwin'",
15
+ "pyside6>=6.10.0",
16
+ "pywin32>=311; sys_platform == 'win32'",
17
+ ]
18
+
19
+ [project.scripts]
20
+ fshot = "fshot.app:main"
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/codemee/fshot"
24
+ Repository = "https://github.com/codemee/fshot"
25
+ Issues = "https://github.com/codemee/fshot/issues"
26
+
27
+ [dependency-groups]
28
+ dev = ["pytest>=8.4.1"]
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.11.25,<0.12.0"]
32
+ build-backend = "uv_build"
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "fshot"
3
+ version = "0.0.8"
4
+ description = "Keyboard-first cross-platform screenshot utility"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "comtypes>=1.4.16; sys_platform == 'win32'",
9
+ "keyboard>=0.13.5; sys_platform != 'darwin'",
10
+ "mss>=10.1.0",
11
+ "pillow>=11.3.0",
12
+ "pyobjc-framework-applicationservices>=12.0; sys_platform == 'darwin'",
13
+ "pyobjc-framework-cocoa>=12.0; sys_platform == 'darwin'",
14
+ "pyobjc-framework-quartz>=12.0; sys_platform == 'darwin'",
15
+ "pyside6>=6.10.0",
16
+ "pywin32>=311; sys_platform == 'win32'",
17
+ ]
18
+
19
+ [project.scripts]
20
+ fshot = "fshot.app:main"
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/codemee/fshot"
24
+ Repository = "https://github.com/codemee/fshot"
25
+ Issues = "https://github.com/codemee/fshot/issues"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "pytest>=8.4.1",
30
+ ]
31
+
32
+ [build-system]
33
+ requires = ["uv_build>=0.11.25,<0.12.0"]
34
+ build-backend = "uv_build"
@@ -0,0 +1,5 @@
1
+ """FShot screenshot utility."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.0.8"
@@ -0,0 +1,4 @@
1
+ from fshot.app import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,372 @@
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+ import sys
5
+ import traceback
6
+ from ctypes import wintypes
7
+
8
+ from PySide6.QtCore import QAbstractNativeEventFilter, QObject, QTimer, Signal
9
+ from PySide6.QtGui import QAction
10
+ from PySide6.QtWidgets import QApplication, QMenu, QMessageBox, QSystemTrayIcon
11
+
12
+ from fshot.capture import CaptureService
13
+ from fshot.icons import tray_icon
14
+ from fshot.hotkeys import HotkeyAction, HotkeyCombination, HotkeyStore, validate_hotkeys
15
+ from fshot.i18n import LanguageManager
16
+ from fshot.main_window import EditorWindow
17
+ from fshot.settings import CaptureMode
18
+ from fshot.theme import ThemeManager
19
+
20
+ if sys.platform == "win32":
21
+ import win32con
22
+ import win32gui
23
+ else: # pragma: no cover - platform branch
24
+ win32con = None
25
+ win32gui = None
26
+
27
+
28
+ class MSG(ctypes.Structure):
29
+ _fields_ = [
30
+ ("hwnd", wintypes.HWND),
31
+ ("message", wintypes.UINT),
32
+ ("wParam", wintypes.WPARAM),
33
+ ("lParam", wintypes.LPARAM),
34
+ ("time", wintypes.DWORD),
35
+ ("pt", wintypes.POINT),
36
+ ]
37
+
38
+
39
+ class HotkeyBridge(QObject):
40
+ captureRequested = Signal(CaptureMode)
41
+ repeatRequested = Signal()
42
+
43
+
44
+ def _activate_window(window) -> None:
45
+ window.showNormal()
46
+ window.raise_()
47
+ window.activateWindow()
48
+ QApplication.processEvents()
49
+ if sys.platform == "win32":
50
+ hwnd = int(window.winId())
51
+ win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
52
+ win32gui.BringWindowToTop(hwnd)
53
+ win32gui.SetForegroundWindow(hwnd)
54
+ if win32gui.GetForegroundWindow() == hwnd:
55
+ return
56
+ flags = win32con.SWP_NOMOVE | win32con.SWP_NOSIZE | win32con.SWP_SHOWWINDOW
57
+ win32gui.SetWindowPos(hwnd, win32con.HWND_TOPMOST, 0, 0, 0, 0, flags)
58
+ win32gui.SetWindowPos(hwnd, win32con.HWND_NOTOPMOST, 0, 0, 0, 0, flags)
59
+ win32gui.SetForegroundWindow(hwnd)
60
+
61
+
62
+ class WindowsHotkeyFilter(QAbstractNativeEventFilter):
63
+ WM_HOTKEY = 0x0312
64
+ MOD_ALT = 0x0001
65
+ MOD_CONTROL = 0x0002
66
+ MOD_SHIFT = 0x0004
67
+ MOD_NOREPEAT = 0x4000
68
+
69
+ def __init__(self, hwnd: int | None, bridge: HotkeyBridge, bindings) -> None:
70
+ super().__init__()
71
+ self.hwnd = hwnd
72
+ self.bridge = bridge
73
+ self.bindings = {
74
+ 1001 + index: (combination, mode)
75
+ for index, (mode, combination) in enumerate(bindings.items())
76
+ }
77
+ self.registered_ids: list[int] = []
78
+
79
+ def register(self) -> None:
80
+ ctypes.windll.user32.RegisterHotKey.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_uint, ctypes.c_uint]
81
+ ctypes.windll.user32.UnregisterHotKey.argtypes = [ctypes.c_void_p, ctypes.c_int]
82
+ for hotkey_id, (combination, _mode) in self.bindings.items():
83
+ modifiers = self.modifiers(combination) | self.MOD_NOREPEAT
84
+ ok = ctypes.windll.user32.RegisterHotKey(
85
+ ctypes.c_void_p(self.hwnd or 0),
86
+ hotkey_id,
87
+ modifiers,
88
+ ord(combination.letter),
89
+ )
90
+ if not ok:
91
+ self.unregister()
92
+ raise ctypes.WinError()
93
+ self.registered_ids.append(hotkey_id)
94
+
95
+ def unregister(self) -> None:
96
+ for hotkey_id in self.registered_ids:
97
+ ctypes.windll.user32.UnregisterHotKey(ctypes.c_void_p(self.hwnd or 0), hotkey_id)
98
+ self.registered_ids.clear()
99
+
100
+ def nativeEventFilter(self, event_type, message):
101
+ event_name = bytes(event_type).decode(errors="ignore") if not isinstance(event_type, str) else event_type
102
+ if event_name not in {"windows_generic_MSG", "windows_dispatcher_MSG"}:
103
+ return False, 0
104
+ msg = MSG.from_address(int(message))
105
+ if msg.message != self.WM_HOTKEY:
106
+ return False, 0
107
+ binding = self.bindings.get(int(msg.wParam))
108
+ if binding is None:
109
+ return False, 0
110
+ _combination, mode = binding
111
+ if mode == HotkeyAction.REPEAT:
112
+ self.bridge.repeatRequested.emit()
113
+ else:
114
+ self.bridge.captureRequested.emit(mode)
115
+ return True, 0
116
+
117
+ @classmethod
118
+ def modifiers(cls, combination: HotkeyCombination) -> int:
119
+ return (
120
+ (cls.MOD_CONTROL if combination.ctrl else 0)
121
+ | (cls.MOD_SHIFT if combination.shift else 0)
122
+ | (cls.MOD_ALT if combination.alt else 0)
123
+ )
124
+
125
+
126
+ class FShotApplication(QObject):
127
+ def __init__(self, app: QApplication) -> None:
128
+ super().__init__()
129
+ self.app = app
130
+ self.app.setApplicationName("FShot")
131
+ self.app.setOrganizationName("FShot")
132
+ self.app.setQuitOnLastWindowClosed(False)
133
+ self.theme_manager = ThemeManager(self.app)
134
+ self.language_manager = LanguageManager()
135
+ self.capture = CaptureService()
136
+ self.window = EditorWindow(self.theme_manager, self.language_manager)
137
+ self.hotkey_store = HotkeyStore()
138
+ self.hotkeys = self.hotkey_store.load()
139
+ self.bridge = HotkeyBridge()
140
+ self.bridge.captureRequested.connect(self.capture_mode)
141
+ self.bridge.repeatRequested.connect(self.repeat_capture)
142
+ self.tray = self._build_tray()
143
+ self.language_manager.changed.connect(self._language_changed)
144
+ self._hotkey_handles: list[object] = []
145
+ self._native_hotkey_filter: WindowsHotkeyFilter | None = None
146
+ self._mac_hotkey_listener = None
147
+ self._capture_in_progress = False
148
+ self.window.configure_hotkeys(self.hotkeys, self._validate_hotkeys, self._apply_hotkeys)
149
+ self._register_hotkeys()
150
+
151
+ def run(self) -> int:
152
+ self.tray.show()
153
+ self.window.hide()
154
+ return self.app.exec()
155
+
156
+ def show_window(self) -> None:
157
+ _activate_window(self.window)
158
+
159
+ def quit(self) -> None:
160
+ self._unregister_hotkeys()
161
+ self.tray.hide()
162
+ self.app.quit()
163
+
164
+ def capture_mode(self, mode: CaptureMode) -> None:
165
+ settings = self.window.capture_settings
166
+ frozen_selection = None
167
+
168
+ def freeze_before_hotkey_returns() -> None:
169
+ nonlocal frozen_selection
170
+ frozen_selection = self.capture.prepare_frozen_selection(mode, settings)
171
+
172
+ self._start_capture(
173
+ lambda: self.capture.capture(mode, settings, frozen_selection),
174
+ before_event_flush=freeze_before_hotkey_returns,
175
+ )
176
+
177
+ def repeat_capture(self) -> None:
178
+ self._start_capture(lambda: self.capture.repeat(self.window.capture_settings))
179
+
180
+ def _start_capture(self, capture, before_event_flush=None) -> None:
181
+ if self._capture_in_progress:
182
+ return
183
+ self._capture_in_progress = True
184
+ self.window.hide()
185
+ if before_event_flush is not None:
186
+ try:
187
+ before_event_flush()
188
+ except Exception as exc: # pragma: no cover - UI guard
189
+ traceback.print_exc()
190
+ QMessageBox.warning(
191
+ self.window,
192
+ "FShot",
193
+ self.language_manager.text("capture_failed", error=exc),
194
+ )
195
+ self._capture_in_progress = False
196
+ return
197
+ QApplication.processEvents()
198
+
199
+ def do_capture() -> None:
200
+ try:
201
+ image = capture()
202
+ except Exception as exc: # pragma: no cover - UI guard
203
+ traceback.print_exc()
204
+ QMessageBox.warning(
205
+ self.window,
206
+ "FShot",
207
+ self.language_manager.text("capture_failed", error=exc),
208
+ )
209
+ self._capture_in_progress = False
210
+ return
211
+ if image is None:
212
+ self._capture_in_progress = False
213
+ return
214
+ self.window.add_shot(image)
215
+ self.show_window()
216
+ # Selection completion can post a delayed activation restore after
217
+ # SetForegroundWindow initially succeeds. Reassert the editor once
218
+ # that input transaction has fully settled.
219
+ QTimer.singleShot(200, self.show_window)
220
+ self.window.copy_current()
221
+ self._capture_in_progress = False
222
+
223
+ QTimer.singleShot(120, do_capture)
224
+
225
+ def _build_tray(self) -> QSystemTrayIcon:
226
+ tray = QSystemTrayIcon(tray_icon(macos=sys.platform == "darwin"), self.app)
227
+ tray.setToolTip("FShot")
228
+ menu = QMenu()
229
+ self.exit_action = QAction(self.language_manager.text("exit"), menu)
230
+ self.exit_action.triggered.connect(self.quit)
231
+ menu.addAction(self.exit_action)
232
+ tray.setContextMenu(menu)
233
+ tray.activated.connect(self._tray_activated)
234
+ return tray
235
+
236
+ def _language_changed(self, _mode, _effective) -> None:
237
+ self.exit_action.setText(self.language_manager.text("exit"))
238
+
239
+ def _tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
240
+ if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
241
+ self.show_window()
242
+
243
+ def _register_hotkeys(self, bindings=None, show_warning: bool = True) -> bool:
244
+ bindings = bindings or self.hotkeys
245
+ if sys.platform == "win32":
246
+ try:
247
+ self._native_hotkey_filter = WindowsHotkeyFilter(None, self.bridge, bindings)
248
+ self._native_hotkey_filter.register()
249
+ self.app.installNativeEventFilter(self._native_hotkey_filter)
250
+ return True
251
+ except Exception as exc:
252
+ self._native_hotkey_filter = None
253
+ if show_warning:
254
+ QMessageBox.warning(self.window, "FShot", f"Native global hotkeys unavailable: {exc}")
255
+ return False
256
+
257
+ if sys.platform == "darwin":
258
+ try:
259
+ from fshot.platforms.macos import MacHotkeyListener, accessibility_allowed
260
+
261
+ accessibility_allowed(request=True)
262
+ actions = {combination: action for action, combination in bindings.items()}
263
+
264
+ def dispatch(combination) -> None:
265
+ action = actions[combination]
266
+ if action == HotkeyAction.REPEAT:
267
+ self.bridge.repeatRequested.emit()
268
+ else:
269
+ self.bridge.captureRequested.emit(action)
270
+
271
+ self._mac_hotkey_listener = MacHotkeyListener(
272
+ dispatch,
273
+ tuple(actions),
274
+ )
275
+ self._mac_hotkey_listener.start()
276
+ return True
277
+ except Exception as exc:
278
+ if show_warning:
279
+ QMessageBox.warning(self.window, "FShot", f"macOS global hotkeys unavailable: {exc}")
280
+ return False
281
+
282
+ try:
283
+ import keyboard
284
+ except Exception as exc: # pragma: no cover - optional dependency guard
285
+ if show_warning:
286
+ QMessageBox.warning(self.window, "FShot", f"Global hotkeys unavailable: {exc}")
287
+ return False
288
+
289
+ for action, combination in bindings.items():
290
+ shortcut = combination.display().lower()
291
+ try:
292
+ if action == HotkeyAction.REPEAT:
293
+ callback = lambda: self.bridge.repeatRequested.emit()
294
+ else:
295
+ callback = lambda capture_mode=action: self.bridge.captureRequested.emit(capture_mode)
296
+ handle = keyboard.add_hotkey(shortcut, callback, suppress=True)
297
+ self._hotkey_handles.append(handle)
298
+ except Exception as exc:
299
+ self._unregister_hotkeys()
300
+ if show_warning:
301
+ QMessageBox.warning(self.window, "FShot", f"Could not register {shortcut}: {exc}")
302
+ return False
303
+ return True
304
+
305
+ def _validate_hotkeys(self, bindings) -> tuple[bool, str]:
306
+ problem = validate_hotkeys(bindings)
307
+ if problem:
308
+ return False, self.language_manager.text(f"hotkey_{problem}")
309
+ if sys.platform != "win32" or self._native_hotkey_filter is None:
310
+ return True, ""
311
+ owned = {combination for combination, _mode in self._native_hotkey_filter.bindings.values()}
312
+ registered: list[int] = []
313
+ try:
314
+ for index, combination in enumerate(bindings.values()):
315
+ if combination in owned:
316
+ continue
317
+ hotkey_id = 2101 + index
318
+ ok = ctypes.windll.user32.RegisterHotKey(
319
+ ctypes.c_void_p(0),
320
+ hotkey_id,
321
+ WindowsHotkeyFilter.modifiers(combination)
322
+ | WindowsHotkeyFilter.MOD_NOREPEAT,
323
+ ord(combination.letter),
324
+ )
325
+ if not ok:
326
+ return False, self.language_manager.text(
327
+ "hotkey_conflict", shortcut=combination.display()
328
+ )
329
+ registered.append(hotkey_id)
330
+ finally:
331
+ for hotkey_id in registered:
332
+ ctypes.windll.user32.UnregisterHotKey(ctypes.c_void_p(0), hotkey_id)
333
+ return True, ""
334
+
335
+ def _apply_hotkeys(self, bindings) -> tuple[bool, str]:
336
+ valid, message = self._validate_hotkeys(bindings)
337
+ if not valid:
338
+ return valid, message
339
+ previous = self.hotkeys
340
+ self._unregister_hotkeys()
341
+ if not self._register_hotkeys(bindings, show_warning=False):
342
+ self._register_hotkeys(previous, show_warning=False)
343
+ return False, self.language_manager.text("hotkey_registration_failed")
344
+ self.hotkeys = dict(bindings)
345
+ self.hotkey_store.save(self.hotkeys)
346
+ self.window.configure_hotkeys(self.hotkeys, self._validate_hotkeys, self._apply_hotkeys)
347
+ return True, ""
348
+
349
+ def _unregister_hotkeys(self) -> None:
350
+ if self._mac_hotkey_listener is not None:
351
+ self._mac_hotkey_listener.stop()
352
+ self._mac_hotkey_listener = None
353
+ if self._native_hotkey_filter is not None:
354
+ self.app.removeNativeEventFilter(self._native_hotkey_filter)
355
+ self._native_hotkey_filter.unregister()
356
+ self._native_hotkey_filter = None
357
+ try:
358
+ import keyboard
359
+ except Exception:
360
+ return
361
+ for handle in self._hotkey_handles:
362
+ try:
363
+ keyboard.remove_hotkey(handle)
364
+ except Exception:
365
+ pass
366
+ self._hotkey_handles.clear()
367
+
368
+
369
+ def main() -> int:
370
+ app = QApplication(sys.argv)
371
+ controller = FShotApplication(app)
372
+ return controller.run()