snapmockit 1.2.0__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.
Files changed (352) hide show
  1. snapmock/__init__.py +3 -0
  2. snapmock/__main__.py +6 -0
  3. snapmock/app.py +131 -0
  4. snapmock/capture/__init__.py +66 -0
  5. snapmock/capture/backend.py +315 -0
  6. snapmock/capture/cli.py +62 -0
  7. snapmock/capture/compositing.py +99 -0
  8. snapmock/capture/countdown.py +73 -0
  9. snapmock/capture/macos.py +69 -0
  10. snapmock/capture/manager.py +705 -0
  11. snapmock/capture/models.py +259 -0
  12. snapmock/capture/onboarding.py +197 -0
  13. snapmock/capture/overlay.py +672 -0
  14. snapmock/capture/single_instance.py +154 -0
  15. snapmock/capture/sound.py +32 -0
  16. snapmock/capture/tray.py +12 -0
  17. snapmock/capture/wayland_portal.py +217 -0
  18. snapmock/capture/window_state.py +103 -0
  19. snapmock/capture/windows.py +738 -0
  20. snapmock/capture/x11.py +651 -0
  21. snapmock/commands/__init__.py +5 -0
  22. snapmock/commands/add_item.py +37 -0
  23. snapmock/commands/arrange_commands.py +278 -0
  24. snapmock/commands/blur_commands.py +52 -0
  25. snapmock/commands/canvas_property_commands.py +63 -0
  26. snapmock/commands/canvas_transform_commands.py +113 -0
  27. snapmock/commands/eyedropper_commands.py +56 -0
  28. snapmock/commands/geometry_commands.py +138 -0
  29. snapmock/commands/group_commands.py +221 -0
  30. snapmock/commands/guide_commands.py +86 -0
  31. snapmock/commands/layer_commands.py +259 -0
  32. snapmock/commands/macro_command.py +23 -0
  33. snapmock/commands/marker_commands.py +136 -0
  34. snapmock/commands/merge_commands.py +163 -0
  35. snapmock/commands/modify_property.py +93 -0
  36. snapmock/commands/move_item_layer.py +51 -0
  37. snapmock/commands/move_items.py +45 -0
  38. snapmock/commands/move_tail_command.py +93 -0
  39. snapmock/commands/raster_commands.py +335 -0
  40. snapmock/commands/remove_item.py +36 -0
  41. snapmock/commands/scale_geometry_command.py +27 -0
  42. snapmock/commands/text_edit_command.py +109 -0
  43. snapmock/commands/text_format_command.py +85 -0
  44. snapmock/commands/transform_item.py +39 -0
  45. snapmock/config/__init__.py +1 -0
  46. snapmock/config/constants.py +487 -0
  47. snapmock/config/migration.py +294 -0
  48. snapmock/config/packaging.py +174 -0
  49. snapmock/config/settings.py +718 -0
  50. snapmock/config/shortcuts.py +112 -0
  51. snapmock/core/__init__.py +1 -0
  52. snapmock/core/clipboard_manager.py +131 -0
  53. snapmock/core/command_stack.py +206 -0
  54. snapmock/core/document.py +120 -0
  55. snapmock/core/document_manager.py +137 -0
  56. snapmock/core/emoji_data.py +258 -0
  57. snapmock/core/guides.py +81 -0
  58. snapmock/core/layer.py +137 -0
  59. snapmock/core/layer_manager.py +212 -0
  60. snapmock/core/path_utils.py +430 -0
  61. snapmock/core/process_memory.py +56 -0
  62. snapmock/core/render_engine.py +314 -0
  63. snapmock/core/rich_text_mixin.py +275 -0
  64. snapmock/core/scene.py +247 -0
  65. snapmock/core/selection_manager.py +90 -0
  66. snapmock/core/stamp_library.py +362 -0
  67. snapmock/core/theme_manager.py +430 -0
  68. snapmock/core/tool_themes.py +900 -0
  69. snapmock/core/update_check.py +246 -0
  70. snapmock/core/view.py +1451 -0
  71. snapmock/io/__init__.py +1 -0
  72. snapmock/io/exporter.py +467 -0
  73. snapmock/io/importer.py +53 -0
  74. snapmock/io/project_serializer.py +344 -0
  75. snapmock/io/rtf_utils.py +123 -0
  76. snapmock/io/snagit_reader.py +400 -0
  77. snapmock/io/snagit_writer.py +570 -0
  78. snapmock/items/__init__.py +1 -0
  79. snapmock/items/arc_item.py +338 -0
  80. snapmock/items/arrow_item.py +576 -0
  81. snapmock/items/base_item.py +310 -0
  82. snapmock/items/blur_item.py +671 -0
  83. snapmock/items/callout_item.py +786 -0
  84. snapmock/items/ellipse_item.py +80 -0
  85. snapmock/items/emoji_item.py +257 -0
  86. snapmock/items/freehand_item.py +578 -0
  87. snapmock/items/group_item.py +258 -0
  88. snapmock/items/highlight_item.py +205 -0
  89. snapmock/items/line_item.py +91 -0
  90. snapmock/items/mask_utils.py +178 -0
  91. snapmock/items/numbered_step_item.py +655 -0
  92. snapmock/items/polygon_item.py +317 -0
  93. snapmock/items/raster_region_item.py +105 -0
  94. snapmock/items/rectangle_item.py +245 -0
  95. snapmock/items/shadow.py +298 -0
  96. snapmock/items/stamp_item.py +351 -0
  97. snapmock/items/text_item.py +591 -0
  98. snapmock/items/vector_item.py +353 -0
  99. snapmock/library/__init__.py +1 -0
  100. snapmock/library/commands.py +129 -0
  101. snapmock/library/file_info.py +84 -0
  102. snapmock/library/manager.py +632 -0
  103. snapmock/library/model.py +574 -0
  104. snapmock/library/render.py +80 -0
  105. snapmock/main_window.py +4478 -0
  106. snapmock/resources/__init__.py +1 -0
  107. snapmock/resources/emoji/LICENSE +41 -0
  108. snapmock/resources/emoji/README.md +27 -0
  109. snapmock/resources/emoji/emoji.json +1 -0
  110. snapmock/resources/icons/snapmockit.svg +16 -0
  111. snapmock/resources/icons/tabler/LICENSE +21 -0
  112. snapmock/resources/icons/tabler/README.md +17 -0
  113. snapmock/resources/icons/tabler/app-window.svg +21 -0
  114. snapmock/resources/icons/tabler/arrow-back-up.svg +20 -0
  115. snapmock/resources/icons/tabler/arrow-bar-to-down.svg +22 -0
  116. snapmock/resources/icons/tabler/arrow-bar-to-up.svg +22 -0
  117. snapmock/resources/icons/tabler/arrow-down.svg +21 -0
  118. snapmock/resources/icons/tabler/arrow-forward-up.svg +20 -0
  119. snapmock/resources/icons/tabler/arrow-up-right.svg +20 -0
  120. snapmock/resources/icons/tabler/arrow-up.svg +21 -0
  121. snapmock/resources/icons/tabler/aspect-ratio.svg +21 -0
  122. snapmock/resources/icons/tabler/blur.svg +25 -0
  123. snapmock/resources/icons/tabler/book.svg +23 -0
  124. snapmock/resources/icons/tabler/border-inner.svg +32 -0
  125. snapmock/resources/icons/tabler/bug.svg +27 -0
  126. snapmock/resources/icons/tabler/camera.svg +20 -0
  127. snapmock/resources/icons/tabler/chevron-left.svg +19 -0
  128. snapmock/resources/icons/tabler/chevron-right.svg +19 -0
  129. snapmock/resources/icons/tabler/circle-number-1.svg +20 -0
  130. snapmock/resources/icons/tabler/circle.svg +19 -0
  131. snapmock/resources/icons/tabler/clipboard-check.svg +21 -0
  132. snapmock/resources/icons/tabler/clipboard.svg +20 -0
  133. snapmock/resources/icons/tabler/color-picker.svg +20 -0
  134. snapmock/resources/icons/tabler/color-swatch.svg +22 -0
  135. snapmock/resources/icons/tabler/components-off.svg +23 -0
  136. snapmock/resources/icons/tabler/components.svg +22 -0
  137. snapmock/resources/icons/tabler/copy-plus.svg +22 -0
  138. snapmock/resources/icons/tabler/copy.svg +20 -0
  139. snapmock/resources/icons/tabler/crop.svg +20 -0
  140. snapmock/resources/icons/tabler/crosshair.svg +24 -0
  141. snapmock/resources/icons/tabler/cut.svg +22 -0
  142. snapmock/resources/icons/tabler/deselect.svg +35 -0
  143. snapmock/resources/icons/tabler/device-desktop.svg +22 -0
  144. snapmock/resources/icons/tabler/device-floppy.svg +21 -0
  145. snapmock/resources/icons/tabler/download.svg +21 -0
  146. snapmock/resources/icons/tabler/external-link.svg +21 -0
  147. snapmock/resources/icons/tabler/eye-off.svg +21 -0
  148. snapmock/resources/icons/tabler/eye.svg +20 -0
  149. snapmock/resources/icons/tabler/file-export.svg +20 -0
  150. snapmock/resources/icons/tabler/file-plus.svg +22 -0
  151. snapmock/resources/icons/tabler/flip-horizontal.svg +21 -0
  152. snapmock/resources/icons/tabler/flip-vertical.svg +21 -0
  153. snapmock/resources/icons/tabler/focus-centered.svg +23 -0
  154. snapmock/resources/icons/tabler/folder-open.svg +19 -0
  155. snapmock/resources/icons/tabler/folder-plus.svg +21 -0
  156. snapmock/resources/icons/tabler/folder-symlink.svg +21 -0
  157. snapmock/resources/icons/tabler/folder.svg +19 -0
  158. snapmock/resources/icons/tabler/grid-dots.svg +27 -0
  159. snapmock/resources/icons/tabler/hand-move.svg +24 -0
  160. snapmock/resources/icons/tabler/highlight.svg +22 -0
  161. snapmock/resources/icons/tabler/history.svg +20 -0
  162. snapmock/resources/icons/tabler/home.svg +21 -0
  163. snapmock/resources/icons/tabler/info-circle.svg +21 -0
  164. snapmock/resources/icons/tabler/keyboard.svg +26 -0
  165. snapmock/resources/icons/tabler/lasso.svg +21 -0
  166. snapmock/resources/icons/tabler/layers-difference.svg +23 -0
  167. snapmock/resources/icons/tabler/layers-intersect.svg +20 -0
  168. snapmock/resources/icons/tabler/layers-union.svg +19 -0
  169. snapmock/resources/icons/tabler/layout-align-bottom.svg +20 -0
  170. snapmock/resources/icons/tabler/layout-align-center.svg +21 -0
  171. snapmock/resources/icons/tabler/layout-align-left.svg +20 -0
  172. snapmock/resources/icons/tabler/layout-align-middle.svg +21 -0
  173. snapmock/resources/icons/tabler/layout-align-right.svg +20 -0
  174. snapmock/resources/icons/tabler/layout-align-top.svg +20 -0
  175. snapmock/resources/icons/tabler/layout-bottombar.svg +20 -0
  176. snapmock/resources/icons/tabler/layout-dashboard.svg +22 -0
  177. snapmock/resources/icons/tabler/layout-distribute-horizontal.svg +21 -0
  178. snapmock/resources/icons/tabler/layout-distribute-vertical.svg +21 -0
  179. snapmock/resources/icons/tabler/line.svg +21 -0
  180. snapmock/resources/icons/tabler/link-off.svg +22 -0
  181. snapmock/resources/icons/tabler/link.svg +21 -0
  182. snapmock/resources/icons/tabler/lock-open.svg +21 -0
  183. snapmock/resources/icons/tabler/lock.svg +21 -0
  184. snapmock/resources/icons/tabler/magnet.svg +21 -0
  185. snapmock/resources/icons/tabler/marquee-2.svg +19 -0
  186. snapmock/resources/icons/tabler/message-2.svg +21 -0
  187. snapmock/resources/icons/tabler/minus.svg +19 -0
  188. snapmock/resources/icons/tabler/mood-smile.svg +22 -0
  189. snapmock/resources/icons/tabler/moon.svg +19 -0
  190. snapmock/resources/icons/tabler/new-section.svg +21 -0
  191. snapmock/resources/icons/tabler/palette.svg +22 -0
  192. snapmock/resources/icons/tabler/pencil.svg +20 -0
  193. snapmock/resources/icons/tabler/photo-plus.svg +24 -0
  194. snapmock/resources/icons/tabler/plus.svg +20 -0
  195. snapmock/resources/icons/tabler/pointer.svg +19 -0
  196. snapmock/resources/icons/tabler/polygon.svg +22 -0
  197. snapmock/resources/icons/tabler/power.svg +20 -0
  198. snapmock/resources/icons/tabler/printer.svg +21 -0
  199. snapmock/resources/icons/tabler/refresh.svg +20 -0
  200. snapmock/resources/icons/tabler/resize.svg +20 -0
  201. snapmock/resources/icons/tabler/rotate-clockwise.svg +19 -0
  202. snapmock/resources/icons/tabler/rotate.svg +19 -0
  203. snapmock/resources/icons/tabler/rubber-stamp.svg +20 -0
  204. snapmock/resources/icons/tabler/ruler.svg +25 -0
  205. snapmock/resources/icons/tabler/scissors.svg +22 -0
  206. snapmock/resources/icons/tabler/screenshot.svg +27 -0
  207. snapmock/resources/icons/tabler/select-all.svg +35 -0
  208. snapmock/resources/icons/tabler/settings.svg +20 -0
  209. snapmock/resources/icons/tabler/square.svg +19 -0
  210. snapmock/resources/icons/tabler/text-recognition.svg +24 -0
  211. snapmock/resources/icons/tabler/trash.svg +23 -0
  212. snapmock/resources/icons/tabler/typography.svg +23 -0
  213. snapmock/resources/icons/tabler/vector-bezier-arc.svg +21 -0
  214. snapmock/resources/icons/tabler/x.svg +20 -0
  215. snapmock/resources/icons/tabler/zoom-in-area.svg +27 -0
  216. snapmock/resources/icons/tabler/zoom-in.svg +22 -0
  217. snapmock/resources/icons/tabler/zoom-out.svg +21 -0
  218. snapmock/resources/icons/tabler/zoom-reset.svg +21 -0
  219. snapmock/resources/icons/tabler/zoom-scan.svg +24 -0
  220. snapmock/resources/sounds/shutter.wav +0 -0
  221. snapmock/resources/stamps/LICENSE +21 -0
  222. snapmock/resources/stamps/README.md +84 -0
  223. snapmock/resources/stamps/arrows/curved-arrow-ccw.svg +14 -0
  224. snapmock/resources/stamps/arrows/curved-arrow-cw.svg +14 -0
  225. snapmock/resources/stamps/arrows/double-arrow-horizontal.svg +16 -0
  226. snapmock/resources/stamps/arrows/double-arrow-vertical.svg +16 -0
  227. snapmock/resources/stamps/arrows/magnifying-glass.svg +17 -0
  228. snapmock/resources/stamps/arrows/pointer-hand.svg +17 -0
  229. snapmock/resources/stamps/arrows/target.svg +16 -0
  230. snapmock/resources/stamps/arrows/thick-arrow-down.svg +14 -0
  231. snapmock/resources/stamps/arrows/thick-arrow-left.svg +14 -0
  232. snapmock/resources/stamps/arrows/thick-arrow-right.svg +14 -0
  233. snapmock/resources/stamps/arrows/thick-arrow-up.svg +14 -0
  234. snapmock/resources/stamps/decorative/box-highlight.svg +2 -0
  235. snapmock/resources/stamps/decorative/bracket-left.svg +2 -0
  236. snapmock/resources/stamps/decorative/bracket-right.svg +2 -0
  237. snapmock/resources/stamps/decorative/brackets.svg +2 -0
  238. snapmock/resources/stamps/decorative/circle-highlight.svg +2 -0
  239. snapmock/resources/stamps/decorative/ribbon.svg +2 -0
  240. snapmock/resources/stamps/decorative/speech-bubble.svg +14 -0
  241. snapmock/resources/stamps/decorative/starburst.svg +2 -0
  242. snapmock/resources/stamps/decorative/swoosh.svg +2 -0
  243. snapmock/resources/stamps/decorative/thought-bubble.svg +2 -0
  244. snapmock/resources/stamps/shapes/bolt.svg +14 -0
  245. snapmock/resources/stamps/shapes/bookmark.svg +14 -0
  246. snapmock/resources/stamps/shapes/checkmark.svg +14 -0
  247. snapmock/resources/stamps/shapes/eye-off.svg +16 -0
  248. snapmock/resources/stamps/shapes/eye.svg +15 -0
  249. snapmock/resources/stamps/shapes/gear.svg +15 -0
  250. snapmock/resources/stamps/shapes/link.svg +16 -0
  251. snapmock/resources/stamps/shapes/lock.svg +16 -0
  252. snapmock/resources/stamps/shapes/minus.svg +14 -0
  253. snapmock/resources/stamps/shapes/pencil.svg +15 -0
  254. snapmock/resources/stamps/shapes/plus.svg +15 -0
  255. snapmock/resources/stamps/shapes/trash.svg +18 -0
  256. snapmock/resources/stamps/shapes/unlink.svg +17 -0
  257. snapmock/resources/stamps/shapes/unlock.svg +16 -0
  258. snapmock/resources/stamps/shapes/warning.svg +16 -0
  259. snapmock/resources/stamps/shapes/x-mark.svg +15 -0
  260. snapmock/resources/stamps/stamp_index.json +1193 -0
  261. snapmock/resources/stamps/status/approved.svg +15 -0
  262. snapmock/resources/stamps/status/attention.svg +16 -0
  263. snapmock/resources/stamps/status/draft.svg +2 -0
  264. snapmock/resources/stamps/status/heart.svg +14 -0
  265. snapmock/resources/stamps/status/info.svg +16 -0
  266. snapmock/resources/stamps/status/question.svg +16 -0
  267. snapmock/resources/stamps/status/rejected.svg +15 -0
  268. snapmock/resources/stamps/status/star.svg +14 -0
  269. snapmock/resources/stamps/status/thumbs-down.svg +14 -0
  270. snapmock/resources/stamps/status/thumbs-up.svg +14 -0
  271. snapmock/resources/stamps/ui/click.svg +2 -0
  272. snapmock/resources/stamps/ui/cursor-arrow.svg +14 -0
  273. snapmock/resources/stamps/ui/cursor-hand.svg +21 -0
  274. snapmock/resources/stamps/ui/double-click.svg +2 -0
  275. snapmock/resources/stamps/ui/drag.svg +19 -0
  276. snapmock/resources/stamps/ui/key.svg +21 -0
  277. snapmock/resources/stamps/ui/right-click.svg +2 -0
  278. snapmock/resources/stamps/ui/scroll.svg +15 -0
  279. snapmock/resources/stamps/ui/tap.svg +2 -0
  280. snapmock/resources/themes/dark.qss +359 -0
  281. snapmock/resources/themes/light.qss +359 -0
  282. snapmock/tools/__init__.py +1 -0
  283. snapmock/tools/arc_tool.py +347 -0
  284. snapmock/tools/arrow_tool.py +222 -0
  285. snapmock/tools/base_tool.py +342 -0
  286. snapmock/tools/blur_edit.py +161 -0
  287. snapmock/tools/blur_tool.py +659 -0
  288. snapmock/tools/callout_tool.py +347 -0
  289. snapmock/tools/crop_tool.py +305 -0
  290. snapmock/tools/ellipse_tool.py +147 -0
  291. snapmock/tools/emoji_tool.py +482 -0
  292. snapmock/tools/eyedropper_tool.py +400 -0
  293. snapmock/tools/freehand_tool.py +316 -0
  294. snapmock/tools/highlight_tool.py +387 -0
  295. snapmock/tools/lasso_select_tool.py +345 -0
  296. snapmock/tools/line_tool.py +123 -0
  297. snapmock/tools/numbered_step_tool.py +387 -0
  298. snapmock/tools/pan_tool.py +83 -0
  299. snapmock/tools/point_edit.py +687 -0
  300. snapmock/tools/polygon_tool.py +506 -0
  301. snapmock/tools/raster_select_tool.py +341 -0
  302. snapmock/tools/rectangle_tool.py +259 -0
  303. snapmock/tools/select_tool.py +1582 -0
  304. snapmock/tools/stamp_tool.py +468 -0
  305. snapmock/tools/text_tool.py +808 -0
  306. snapmock/tools/tool_manager.py +152 -0
  307. snapmock/tools/zoom_tool.py +165 -0
  308. snapmock/ui/__init__.py +1 -0
  309. snapmock/ui/about_dialog.py +119 -0
  310. snapmock/ui/accessibility.py +271 -0
  311. snapmock/ui/collapsible_section.py +76 -0
  312. snapmock/ui/color_picker.py +687 -0
  313. snapmock/ui/context_menus.py +319 -0
  314. snapmock/ui/crop_overlay.py +197 -0
  315. snapmock/ui/cursors.py +341 -0
  316. snapmock/ui/dimension_overlay.py +260 -0
  317. snapmock/ui/document_tabs.py +269 -0
  318. snapmock/ui/emoji_picker.py +395 -0
  319. snapmock/ui/export_dialog.py +565 -0
  320. snapmock/ui/find_replace_bar.py +328 -0
  321. snapmock/ui/find_replace_color_dialog.py +123 -0
  322. snapmock/ui/icons.py +239 -0
  323. snapmock/ui/item_properties_dialog.py +261 -0
  324. snapmock/ui/layer_panel.py +1117 -0
  325. snapmock/ui/layer_properties_dialog.py +117 -0
  326. snapmock/ui/library_panel.py +1132 -0
  327. snapmock/ui/loupe_overlay.py +311 -0
  328. snapmock/ui/manage_presets_dialog.py +286 -0
  329. snapmock/ui/panel_modes.py +50 -0
  330. snapmock/ui/preferences_dialog.py +748 -0
  331. snapmock/ui/property_panel.py +3156 -0
  332. snapmock/ui/resize_canvas_dialog.py +179 -0
  333. snapmock/ui/resize_image_dialog.py +161 -0
  334. snapmock/ui/ruler_widget.py +229 -0
  335. snapmock/ui/selection_overlay.py +188 -0
  336. snapmock/ui/shortcuts_dialog.py +214 -0
  337. snapmock/ui/stamp_library_panel.py +375 -0
  338. snapmock/ui/status_bar.py +203 -0
  339. snapmock/ui/step_inline_editor.py +169 -0
  340. snapmock/ui/toast.py +73 -0
  341. snapmock/ui/tool_options_bar.py +1514 -0
  342. snapmock/ui/tool_themes_dialog.py +362 -0
  343. snapmock/ui/toolbar.py +329 -0
  344. snapmock/ui/transform_handles.py +314 -0
  345. snapmock/ui/unmet_requirements.py +42 -0
  346. snapmock/ui/unsaved_changes_dialog.py +113 -0
  347. snapmock/ui/welcome_panel.py +391 -0
  348. snapmockit-1.2.0.dist-info/METADATA +179 -0
  349. snapmockit-1.2.0.dist-info/RECORD +352 -0
  350. snapmockit-1.2.0.dist-info/WHEEL +4 -0
  351. snapmockit-1.2.0.dist-info/entry_points.txt +2 -0
  352. snapmockit-1.2.0.dist-info/licenses/LICENSE +21 -0
snapmock/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Snapmockit — open-source multi-platform screenshot annotation & UI mockup tool."""
2
+
3
+ __version__ = "1.2.0"
snapmock/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m snapmock``."""
2
+
3
+ from snapmock.app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
snapmock/app.py ADDED
@@ -0,0 +1,131 @@
1
+ """QApplication bootstrap, the single-instance channel, and command-line capture (PRD 3.5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+ from collections.abc import Mapping
9
+ from pathlib import Path
10
+
11
+ from PyQt6.QtCore import QTimer
12
+ from PyQt6.QtWidgets import QApplication
13
+
14
+ from snapmock.capture.cli import CaptureCommand, parse_command_line
15
+ from snapmock.capture.single_instance import try_forward
16
+ from snapmock.config.constants import APP_NAME, APP_VERSION, DESKTOP_ENTRY_ID
17
+ from snapmock.config.migration import import_host_settings, migrate_storage
18
+ from snapmock.main_window import MainWindow
19
+ from snapmock.ui.icons import application_icon
20
+
21
+ log = logging.getLogger("snapmock")
22
+
23
+
24
+ def desktop_entry_installed(
25
+ entry_id: str = DESKTOP_ENTRY_ID, environ: Mapping[str, str] | None = None
26
+ ) -> bool:
27
+ """Whether ``<entry_id>.desktop`` is in an applications directory of the XDG data path.
28
+
29
+ ``$XDG_DATA_HOME`` (default ``~/.local/share``) and ``$XDG_DATA_DIRS``
30
+ (default ``/usr/local/share:/usr/share``), as the desktop and its portal
31
+ look the entry up. True from a package that installed the entry, or from an
32
+ AppImage integrated into the menu; false from source and from a bare AppImage.
33
+ """
34
+ env = os.environ if environ is None else environ
35
+ data_home = env.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
36
+ data_dirs = env.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share"
37
+ for base in [data_home, *data_dirs.split(":")]:
38
+ if base and (Path(base) / "applications" / f"{entry_id}.desktop").is_file():
39
+ return True
40
+ return False
41
+
42
+
43
+ def version_text() -> str:
44
+ """What ``--version`` prints: the product's name and version (packaging silence 6)."""
45
+ return f"{APP_NAME} {APP_VERSION}"
46
+
47
+
48
+ def main(argv: list[str] | None = None) -> None:
49
+ """Launch the application, or forward a capture request to the running instance.
50
+
51
+ ``--version`` prints :func:`version_text` and exits before any Qt object exists,
52
+ so a smoke test and a bug report can read it without a display.
53
+ """
54
+ argv = list(sys.argv if argv is None else argv)
55
+ if "--version" in argv[1:]:
56
+ print(version_text())
57
+ sys.exit(0)
58
+ command, files = parse_command_line(argv)
59
+ # The names are set before the application exists: Qt registers the desktop
60
+ # entry's id with the desktop portal when the first window shows, once, from
61
+ # the name it holds then; a name set afterwards registers a second time and
62
+ # the portal refuses it ("Connection already associated with an application
63
+ # ID", seen from the AppImage on 09-14-26). The id is given to Qt only where
64
+ # the desktop entry is installed, since the portal looks the entry up and
65
+ # answers "App info not found" for an AppImage that is not in the menu.
66
+ QApplication.setApplicationName(APP_NAME)
67
+ QApplication.setApplicationVersion(APP_VERSION)
68
+ if desktop_entry_installed():
69
+ QApplication.setDesktopFileName(DESKTOP_ENTRY_ID)
70
+ # The application is created before the forward because the write to the
71
+ # channel completes only once an event loop is available to pump it, which
72
+ # is how a Windows named pipe behaves (PRD 3.5).
73
+ app = QApplication(argv)
74
+ app.setWindowIcon(application_icon())
75
+ if command is not None and try_forward(argv):
76
+ sys.exit(0)
77
+
78
+ # Before anything reads the settings or the library (packaging decision 4).
79
+ migration = migrate_storage()
80
+ # Inside a Flatpak, the store is the sandbox's own; fill it once from the home
81
+ # directory's on the first start (Flatpak decision 4).
82
+ imported = import_host_settings()
83
+ window = MainWindow(restore_session=True)
84
+ manager = window.capture_manager
85
+ manager.listen_for_commands()
86
+ manager.register_hotkeys()
87
+
88
+ hide_until_done = command is not None and window.capture_manager.settings.capture_hide_window()
89
+ if not hide_until_done:
90
+ window.show()
91
+ window.report_hotkey_failures()
92
+ startup_messages = [text for text in (migration.message(), imported.message()) if text]
93
+ if startup_messages:
94
+ window.show_startup_message(" ".join(startup_messages))
95
+ if files:
96
+ window.open_paths([Path(name) for name in files])
97
+ if command is not None:
98
+ _capture_as_first_action(window, command, show_after=hide_until_done)
99
+ sys.exit(app.exec())
100
+
101
+
102
+ def _capture_as_first_action(
103
+ window: MainWindow, command: CaptureCommand, *, show_after: bool
104
+ ) -> None:
105
+ """Run ``--capture`` once the event loop starts; show the window when it is over."""
106
+ manager = window.capture_manager
107
+
108
+ def finish() -> None:
109
+ if show_after and not window.isVisible():
110
+ window.show()
111
+ window.report_hotkey_failures()
112
+ for signal in (
113
+ manager.capture_completed,
114
+ manager.capture_failed,
115
+ manager.capture_cancelled,
116
+ ):
117
+ try:
118
+ signal.disconnect(finish)
119
+ except TypeError:
120
+ pass
121
+
122
+ if show_after:
123
+ manager.capture_completed.connect(finish)
124
+ manager.capture_failed.connect(finish)
125
+ manager.capture_cancelled.connect(finish)
126
+
127
+ def start() -> None:
128
+ if not manager.start_from_command(command):
129
+ finish()
130
+
131
+ QTimer.singleShot(0, start)
@@ -0,0 +1,66 @@
1
+ """Screen capture: modes, entry points, the region overlay, and platform backends.
2
+
3
+ Nothing outside this package imports a platform module. The editor talks
4
+ to :class:`snapmock.capture.manager.CaptureManager` and nothing else.
5
+
6
+ Backend selection (PRD 6.2) happens here, once, from the platform name and
7
+ the session type. It is not user-configurable.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import logging
14
+ import os
15
+ import sys
16
+
17
+ from PyQt6.QtGui import QGuiApplication
18
+
19
+ from snapmock.capture.backend import (
20
+ CaptureBackend,
21
+ HotkeyBackend,
22
+ NullCaptureBackend,
23
+ NullHotkeyBackend,
24
+ )
25
+
26
+ log = logging.getLogger("snapmock.capture")
27
+
28
+
29
+ def is_wayland_session() -> bool:
30
+ """Whether Qt runs on Wayland or the session environment reports Wayland."""
31
+ platform = QGuiApplication.platformName().lower() if QGuiApplication.instance() else ""
32
+ if platform.startswith("wayland"):
33
+ return True
34
+ if platform in ("xcb", "offscreen", "minimal"):
35
+ return False
36
+ session = os.environ.get("XDG_SESSION_TYPE", "").lower()
37
+ return session == "wayland" or bool(os.environ.get("WAYLAND_DISPLAY"))
38
+
39
+
40
+ def select_backends() -> tuple[CaptureBackend, HotkeyBackend]:
41
+ """Pick the capture and hotkey backends for this platform and session.
42
+
43
+ Linux on Wayland selects the portal backend; Linux otherwise selects X11;
44
+ macOS and Windows select their backends. Anything else, and any platform
45
+ whose module cannot activate, gets the null backends.
46
+ """
47
+ capture: CaptureBackend = NullCaptureBackend()
48
+ hotkeys: HotkeyBackend = NullHotkeyBackend()
49
+ module_name: str | None = None
50
+ if sys.platform.startswith("linux"):
51
+ module_name = "wayland_portal" if is_wayland_session() else "x11"
52
+ elif sys.platform == "darwin":
53
+ module_name = "macos"
54
+ elif sys.platform.startswith("win"):
55
+ module_name = "windows"
56
+ if module_name is not None:
57
+ try:
58
+ module = importlib.import_module(f"snapmock.capture.{module_name}")
59
+ created = module.create_backends()
60
+ capture, hotkeys = created
61
+ except (ImportError, OSError, AttributeError) as e:
62
+ log.warning(
63
+ "Capture backend %s unavailable (%s); using the null backend", module_name, e
64
+ )
65
+ log.info("Selected capture backend %s, hotkey backend %s", capture.name, hotkeys.name)
66
+ return capture, hotkeys
@@ -0,0 +1,315 @@
1
+ """The capture backend abstraction (Screen Capture PRD Sections 6.1, 6.7).
2
+
3
+ The application sees :class:`CaptureBackend` and :class:`HotkeyBackend` and
4
+ nothing platform-specific. This module also holds the null backends used
5
+ when nothing is supported, a Qt-based grab shared by the X11, macOS and
6
+ Windows backends, and fake backends for the test suite.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from abc import ABC, abstractmethod
13
+ from datetime import datetime
14
+
15
+ from PyQt6.QtCore import QObject, QPoint, QRect, QSize, pyqtSignal
16
+ from PyQt6.QtGui import QColor, QCursor, QGuiApplication, QImage, QLinearGradient, QPainter
17
+
18
+ from snapmock.capture.models import (
19
+ BackendCapabilities,
20
+ HotkeyBinding,
21
+ MonitorInfo,
22
+ PermissionState,
23
+ ScreenGrab,
24
+ )
25
+
26
+ log = logging.getLogger("snapmock.capture")
27
+
28
+ UNSUPPORTED_PLATFORM_MESSAGE = "Screen capture is not supported on this platform."
29
+
30
+
31
+ class CaptureError(Exception):
32
+ """A capture failed; ``str(error)`` is the user-readable reason (PRD 7.4)."""
33
+
34
+
35
+ class CaptureCancelledError(CaptureError):
36
+ """The user cancelled inside a platform dialog (for example the portal's consent)."""
37
+
38
+
39
+ class CaptureBackend(ABC):
40
+ """Platform-specific pixel reading (PRD 6.1). One instance per process."""
41
+
42
+ #: ``x11``, ``wayland_portal``, ``macos``, ``windows``, ``null`` or ``fake``.
43
+ name: str = "null"
44
+
45
+ @abstractmethod
46
+ def capabilities(self) -> BackendCapabilities:
47
+ """Which modes and options this backend supports on this session."""
48
+
49
+ @abstractmethod
50
+ def monitors(self) -> list[MonitorInfo]:
51
+ """Every connected monitor."""
52
+
53
+ @abstractmethod
54
+ def grab_screens(self, include_cursor: bool) -> ScreenGrab:
55
+ """One physical-pixel image per monitor. Raises :class:`CaptureError`."""
56
+
57
+ def active_window_geometry(self) -> QRect | None:
58
+ """Logical geometry of the focused window, excluding shadow, or None."""
59
+ return None
60
+
61
+ def active_window_title(self) -> str | None:
62
+ return None
63
+
64
+ def request_permission(self) -> PermissionState:
65
+ return PermissionState.NOT_REQUIRED
66
+
67
+
68
+ class HotkeyBackend(QObject):
69
+ """Global hotkey registration (PRD 6.7).
70
+
71
+ Signals
72
+ -------
73
+ triggered(str)
74
+ A registered hotkey was pressed; carries the binding's action.
75
+ """
76
+
77
+ triggered = pyqtSignal(str)
78
+
79
+ name: str = "null"
80
+
81
+ @property
82
+ def supported(self) -> bool:
83
+ """Whether this backend can register hotkeys at all."""
84
+ return False
85
+
86
+ def register(self, binding: HotkeyBinding) -> bool:
87
+ """Register *binding*, updating its ``registered`` and ``failure_reason``."""
88
+ binding.registered = False
89
+ binding.failure_reason = "Global hotkeys are not available on this desktop."
90
+ return False
91
+
92
+ def unregister(self, binding: HotkeyBinding) -> None:
93
+ binding.registered = False
94
+
95
+ def unregister_all(self) -> None:
96
+ return None
97
+
98
+
99
+ # --- null backends (PRD 6.2: unrecognized platform) ---
100
+
101
+
102
+ class NullCaptureBackend(CaptureBackend):
103
+ """Reports nothing supported; every grab fails with the platform message."""
104
+
105
+ name = "null"
106
+
107
+ def capabilities(self) -> BackendCapabilities:
108
+ return BackendCapabilities()
109
+
110
+ def monitors(self) -> list[MonitorInfo]:
111
+ return qt_monitors()
112
+
113
+ def grab_screens(self, include_cursor: bool) -> ScreenGrab:
114
+ raise CaptureError(UNSUPPORTED_PLATFORM_MESSAGE)
115
+
116
+
117
+ class NullHotkeyBackend(HotkeyBackend):
118
+ """Reports every registration as unsupported (Wayland, macOS in version 1)."""
119
+
120
+ name = "null"
121
+
122
+
123
+ # --- Qt-based grab shared by the X11, macOS and Windows backends ---
124
+
125
+
126
+ def qt_monitors() -> list[MonitorInfo]:
127
+ """Build the monitor list from Qt's screens."""
128
+ result: list[MonitorInfo] = []
129
+ primary = QGuiApplication.primaryScreen()
130
+ for screen in QGuiApplication.screens():
131
+ ratio = float(screen.devicePixelRatio())
132
+ geo = screen.geometry()
133
+ result.append(
134
+ MonitorInfo(
135
+ name=screen.name() or f"screen{len(result)}",
136
+ logical_geometry=QRect(geo),
137
+ physical_size=QSize(round(geo.width() * ratio), round(geo.height() * ratio)),
138
+ device_pixel_ratio=ratio,
139
+ is_primary=screen is primary,
140
+ )
141
+ )
142
+ return result
143
+
144
+
145
+ def physical_to_logical(physical: QRect) -> QRect:
146
+ """Map a root-window (physical pixel) rectangle to Qt's logical virtual desktop."""
147
+ monitors = qt_monitors()
148
+ best = None
149
+ best_area = -1
150
+ for m in monitors:
151
+ area = m.physical_geometry.intersected(physical)
152
+ size = max(0, area.width()) * max(0, area.height())
153
+ if size > best_area:
154
+ best, best_area = m, size
155
+ if best is None:
156
+ return QRect(physical)
157
+ ratio = best.device_pixel_ratio
158
+ origin = best.physical_geometry.topLeft()
159
+ left = best.logical_geometry.x() + (physical.x() - origin.x()) / ratio
160
+ top = best.logical_geometry.y() + (physical.y() - origin.y()) / ratio
161
+ return QRect(
162
+ round(left), round(top), round(physical.width() / ratio), round(physical.height() / ratio)
163
+ )
164
+
165
+
166
+ class QtScreenGrabBackend(CaptureBackend):
167
+ """Grabs every screen through Qt's root-window grab (PRD 6.3, 6.5, 6.6).
168
+
169
+ Subclasses add the cursor image and the active window through platform
170
+ calls; when ``_cursor_image`` returns None the cursor is left out.
171
+ """
172
+
173
+ def monitors(self) -> list[MonitorInfo]:
174
+ return qt_monitors()
175
+
176
+ def grab_screens(self, include_cursor: bool) -> ScreenGrab:
177
+ monitors = self.monitors()
178
+ images: dict[str, QImage] = {}
179
+ for screen, info in zip(QGuiApplication.screens(), monitors, strict=False):
180
+ pixmap = screen.grabWindow()
181
+ if pixmap.isNull():
182
+ raise CaptureError("The screen could not be read.")
183
+ image = pixmap.toImage()
184
+ if image.size() != info.physical_size and not info.physical_size.isEmpty():
185
+ image = image.scaled(info.physical_size)
186
+ image.setDevicePixelRatio(1.0)
187
+ images[info.name] = image
188
+ if not images:
189
+ raise CaptureError("No monitor was found to capture.")
190
+ grab = ScreenGrab(images=images, monitors=monitors, taken_at=datetime.now())
191
+ grab.cursor_position = QCursor.pos()
192
+ if include_cursor:
193
+ cursor = self._cursor_image()
194
+ if cursor is not None:
195
+ grab.cursor_image, grab.cursor_hotspot = cursor
196
+ return grab
197
+
198
+ def _cursor_image(self) -> tuple[QImage, QPoint] | None:
199
+ """Cursor bitmap with alpha and its hotspot, or None when unavailable."""
200
+ return None
201
+
202
+
203
+ # --- fakes for tests (PRD 14.5: every backend-independent path runs against a fake) ---
204
+
205
+
206
+ def synthetic_image(size: QSize, seed: int = 0) -> QImage:
207
+ """A gradient image whose pixels differ by position, so crops are verifiable."""
208
+ image = QImage(size, QImage.Format.Format_ARGB32)
209
+ image.fill(QColor(0, 0, 0))
210
+ painter = QPainter(image)
211
+ gradient = QLinearGradient(0, 0, size.width(), size.height())
212
+ gradient.setColorAt(0.0, QColor((40 * seed) % 256, 80, 200))
213
+ gradient.setColorAt(1.0, QColor(255, (60 * seed + 120) % 256, 30))
214
+ painter.fillRect(image.rect(), gradient)
215
+ painter.end()
216
+ return image
217
+
218
+
219
+ class FakeCaptureBackend(CaptureBackend):
220
+ """Returns synthetic monitors and images; every knob is a plain attribute."""
221
+
222
+ name = "fake"
223
+
224
+ def __init__(self, monitors: list[MonitorInfo] | None = None) -> None:
225
+ self.fake_monitors: list[MonitorInfo] = monitors or [
226
+ MonitorInfo("fake-0", QRect(0, 0, 200, 100), QSize(200, 100), 1.0, True)
227
+ ]
228
+ self.fake_capabilities = BackendCapabilities(
229
+ full_screen=True, active_window=True, region=True, cursor=True, hotkeys=True, tray=True
230
+ )
231
+ self.fake_cursor_image: QImage | None = None
232
+ self.fake_cursor_hotspot = QPoint(0, 0)
233
+ self.fake_cursor_position = QPoint(10, 10)
234
+ self.fake_active_window: QRect | None = QRect(20, 10, 60, 40)
235
+ self.fake_window_title: str | None = "Fake Window"
236
+ self.fake_permission = PermissionState.NOT_REQUIRED
237
+ self.fail_with: str | None = None
238
+ self.grab_calls: list[bool] = []
239
+ self.permission_requests = 0
240
+
241
+ def capabilities(self) -> BackendCapabilities:
242
+ return self.fake_capabilities
243
+
244
+ def monitors(self) -> list[MonitorInfo]:
245
+ return list(self.fake_monitors)
246
+
247
+ def grab_screens(self, include_cursor: bool) -> ScreenGrab:
248
+ self.grab_calls.append(include_cursor)
249
+ if self.fail_with is not None:
250
+ raise CaptureError(self.fail_with)
251
+ images = {
252
+ m.name: synthetic_image(m.physical_size, seed=i)
253
+ for i, m in enumerate(self.fake_monitors)
254
+ }
255
+ grab = ScreenGrab(
256
+ images=images,
257
+ monitors=self.monitors(),
258
+ cursor_position=QPoint(self.fake_cursor_position),
259
+ taken_at=datetime.now(),
260
+ )
261
+ if include_cursor and self.fake_capabilities.cursor:
262
+ cursor = self.fake_cursor_image
263
+ if cursor is None:
264
+ cursor = QImage(8, 8, QImage.Format.Format_ARGB32)
265
+ cursor.fill(QColor(255, 255, 255, 255))
266
+ grab.cursor_image = cursor
267
+ grab.cursor_hotspot = QPoint(self.fake_cursor_hotspot)
268
+ return grab
269
+
270
+ def active_window_geometry(self) -> QRect | None:
271
+ return QRect(self.fake_active_window) if self.fake_active_window else None
272
+
273
+ def active_window_title(self) -> str | None:
274
+ return self.fake_window_title
275
+
276
+ def request_permission(self) -> PermissionState:
277
+ self.permission_requests += 1
278
+ return self.fake_permission
279
+
280
+
281
+ class FakeHotkeyBackend(HotkeyBackend):
282
+ """Accepts every registration except keys listed in ``refused``."""
283
+
284
+ name = "fake"
285
+
286
+ def __init__(self, parent: QObject | None = None) -> None:
287
+ super().__init__(parent)
288
+ self.refused: set[str] = set()
289
+ self.registered: dict[str, str] = {}
290
+
291
+ @property
292
+ def supported(self) -> bool:
293
+ return True
294
+
295
+ def register(self, binding: HotkeyBinding) -> bool:
296
+ if binding.key_text in self.refused:
297
+ binding.registered = False
298
+ binding.failure_reason = "In use by another application"
299
+ return False
300
+ self.registered[binding.action] = binding.key_text
301
+ binding.registered = True
302
+ binding.failure_reason = None
303
+ return True
304
+
305
+ def unregister(self, binding: HotkeyBinding) -> None:
306
+ self.registered.pop(binding.action, None)
307
+ binding.registered = False
308
+
309
+ def unregister_all(self) -> None:
310
+ self.registered.clear()
311
+
312
+ def fire(self, action: str) -> None:
313
+ """Simulate the operating system delivering the hotkey for *action*."""
314
+ if action in self.registered:
315
+ self.triggered.emit(action)
@@ -0,0 +1,62 @@
1
+ """Command-line invocation (PRD 3.5): ``snapmock --capture <mode> [--delay N]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class CaptureCommand:
10
+ """A capture request parsed from a command line. ``mode`` None means the default."""
11
+
12
+ mode: str | None = None
13
+ delay_seconds: int | None = None
14
+
15
+
16
+ def parse_capture_args(argv: list[str]) -> CaptureCommand | None:
17
+ """Return the capture command in *argv*, or None when no capture was requested.
18
+
19
+ Accepts ``--capture``, ``--capture <mode>``, ``--capture=<mode>``,
20
+ ``--delay <seconds>`` and ``--delay=<seconds>``. Unknown arguments are ignored.
21
+ """
22
+ return parse_command_line(argv)[0]
23
+
24
+
25
+ def parse_command_line(argv: list[str]) -> tuple[CaptureCommand | None, list[str]]:
26
+ """The capture command in *argv*, if any, and the files named on it.
27
+
28
+ *argv* is a whole command line; its first element is the program unless it is
29
+ an option. A file is any later argument that does not start with ``-`` and is
30
+ not the value of ``--capture`` or ``--delay``; the desktop entry's ``%F`` and
31
+ a shell both pass files that way (packaging silence 2). Unknown options are
32
+ ignored.
33
+ """
34
+ command: CaptureCommand | None = None
35
+ delay: int | None = None
36
+ files: list[str] = []
37
+ args = list(argv)
38
+ i = 0
39
+ while i < len(args):
40
+ arg = args[i]
41
+ if arg == "--capture" or arg.startswith("--capture="):
42
+ command = command or CaptureCommand()
43
+ if "=" in arg:
44
+ command.mode = arg.split("=", 1)[1] or None
45
+ elif i + 1 < len(args) and not args[i + 1].startswith("-"):
46
+ command.mode = args[i + 1]
47
+ i += 1
48
+ elif arg == "--delay" or arg.startswith("--delay="):
49
+ text = arg.split("=", 1)[1] if "=" in arg else ""
50
+ if not text and i + 1 < len(args):
51
+ text = args[i + 1]
52
+ i += 1
53
+ try:
54
+ delay = max(0, min(60, int(text)))
55
+ except ValueError:
56
+ delay = None
57
+ elif i > 0 and not arg.startswith("-"):
58
+ files.append(arg)
59
+ i += 1
60
+ if command is not None:
61
+ command.delay_seconds = delay
62
+ return command, files
@@ -0,0 +1,99 @@
1
+ """Pure image operations on a :class:`ScreenGrab` (PRD Sections 2.2 to 2.4, 4.2, 4.5).
2
+
3
+ Everything here is a function of the frozen grab; nothing reads the screen.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+
10
+ from PyQt6.QtCore import QBuffer, QIODevice, QPoint, QRect, QRectF
11
+ from PyQt6.QtGui import QColor, QImage, QPainter
12
+
13
+ from snapmock.capture.models import MonitorInfo, ScreenGrab
14
+
15
+
16
+ def physical_rect_for(logical_rect: QRect, ratio: float) -> QRect:
17
+ """Convert a logical rectangle to physical pixels, rounding outward (PRD 4.5)."""
18
+ left = math.floor(logical_rect.left() * ratio)
19
+ top = math.floor(logical_rect.top() * ratio)
20
+ right = math.ceil((logical_rect.left() + logical_rect.width()) * ratio)
21
+ bottom = math.ceil((logical_rect.top() + logical_rect.height()) * ratio)
22
+ return QRect(left, top, max(0, right - left), max(0, bottom - top))
23
+
24
+
25
+ def composite_cursor(grab: ScreenGrab) -> bool:
26
+ """Draw the cursor into the monitor image under it. Returns True when drawn."""
27
+ if grab.cursor_image is None or grab.cursor_position is None:
28
+ return False
29
+ monitor = grab.monitor_at(grab.cursor_position)
30
+ if monitor is None:
31
+ return False
32
+ image = grab.images.get(monitor.name)
33
+ if image is None or image.isNull():
34
+ return False
35
+ hotspot = grab.cursor_hotspot or QPoint(0, 0)
36
+ local = grab.cursor_position - monitor.logical_geometry.topLeft()
37
+ ratio = monitor.device_pixel_ratio
38
+ x = round(local.x() * ratio) - hotspot.x()
39
+ y = round(local.y() * ratio) - hotspot.y()
40
+ painter = QPainter(image)
41
+ painter.drawImage(QPoint(x, y), grab.cursor_image)
42
+ painter.end()
43
+ return True
44
+
45
+
46
+ def render_region(grab: ScreenGrab, logical_rect: QRect, ratio: float) -> QImage:
47
+ """Render *logical_rect* of the virtual desktop at *ratio* physical pixels per logical.
48
+
49
+ Each monitor's image is drawn at its own offset; a monitor whose ratio
50
+ differs from *ratio* is scaled to match (only All Monitors with mixed
51
+ ratios reaches that path). Areas no monitor covers stay black (PRD 4.4).
52
+ """
53
+ target_rect = physical_rect_for(logical_rect, ratio)
54
+ image = QImage(target_rect.size(), QImage.Format.Format_ARGB32)
55
+ image.fill(QColor(0, 0, 0))
56
+ if image.isNull() or target_rect.isEmpty():
57
+ return image
58
+ painter = QPainter(image)
59
+ painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
60
+ for monitor in grab.monitors:
61
+ source = grab.images.get(monitor.name)
62
+ if source is None or source.isNull():
63
+ continue
64
+ if not monitor.logical_geometry.intersects(logical_rect):
65
+ continue
66
+ # Where this monitor lands in the target image, in physical pixels.
67
+ dest_x = monitor.logical_geometry.x() * ratio - target_rect.x()
68
+ dest_y = monitor.logical_geometry.y() * ratio - target_rect.y()
69
+ if math.isclose(monitor.device_pixel_ratio, ratio):
70
+ painter.drawImage(QPoint(round(dest_x), round(dest_y)), source)
71
+ else:
72
+ dest = QRectF(
73
+ dest_x,
74
+ dest_y,
75
+ monitor.logical_geometry.width() * ratio,
76
+ monitor.logical_geometry.height() * ratio,
77
+ )
78
+ painter.drawImage(dest, source, QRectF(source.rect()))
79
+ painter.end()
80
+ return image
81
+
82
+
83
+ def monitor_region(grab: ScreenGrab, monitor: MonitorInfo) -> QImage:
84
+ """The physical-pixel image of one monitor, as captured."""
85
+ source = grab.images.get(monitor.name)
86
+ if source is None:
87
+ return QImage()
88
+ return source.copy()
89
+
90
+
91
+ def max_ratio(monitors: list[MonitorInfo]) -> float:
92
+ return max((m.device_pixel_ratio for m in monitors), default=1.0)
93
+
94
+
95
+ def image_to_png_bytes(image: QImage) -> bytes:
96
+ buf = QBuffer()
97
+ buf.open(QIODevice.OpenModeFlag.WriteOnly)
98
+ image.save(buf, "PNG")
99
+ return bytes(buf.data().data())