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 +5 -0
- fshot/__main__.py +4 -0
- fshot/app.py +372 -0
- fshot/canvas.py +374 -0
- fshot/capture.py +1168 -0
- fshot/document.py +48 -0
- fshot/hotkeys.py +99 -0
- fshot/i18n.py +221 -0
- fshot/icons.py +292 -0
- fshot/main_window.py +1264 -0
- fshot/platforms/__init__.py +1 -0
- fshot/platforms/macos.py +381 -0
- fshot/qt_image.py +37 -0
- fshot/settings.py +40 -0
- fshot/theme.py +279 -0
- fshot-0.0.8.dist-info/METADATA +116 -0
- fshot-0.0.8.dist-info/RECORD +19 -0
- fshot-0.0.8.dist-info/WHEEL +4 -0
- fshot-0.0.8.dist-info/entry_points.txt +3 -0
fshot/__init__.py
ADDED
fshot/__main__.py
ADDED
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()
|