fshot 0.0.8__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.
fshot/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """FShot screenshot utility."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.0.8"
fshot/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from fshot.app import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
fshot/app.py ADDED
@@ -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()