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.
- snapmock/__init__.py +3 -0
- snapmock/__main__.py +6 -0
- snapmock/app.py +131 -0
- snapmock/capture/__init__.py +66 -0
- snapmock/capture/backend.py +315 -0
- snapmock/capture/cli.py +62 -0
- snapmock/capture/compositing.py +99 -0
- snapmock/capture/countdown.py +73 -0
- snapmock/capture/macos.py +69 -0
- snapmock/capture/manager.py +705 -0
- snapmock/capture/models.py +259 -0
- snapmock/capture/onboarding.py +197 -0
- snapmock/capture/overlay.py +672 -0
- snapmock/capture/single_instance.py +154 -0
- snapmock/capture/sound.py +32 -0
- snapmock/capture/tray.py +12 -0
- snapmock/capture/wayland_portal.py +217 -0
- snapmock/capture/window_state.py +103 -0
- snapmock/capture/windows.py +738 -0
- snapmock/capture/x11.py +651 -0
- snapmock/commands/__init__.py +5 -0
- snapmock/commands/add_item.py +37 -0
- snapmock/commands/arrange_commands.py +278 -0
- snapmock/commands/blur_commands.py +52 -0
- snapmock/commands/canvas_property_commands.py +63 -0
- snapmock/commands/canvas_transform_commands.py +113 -0
- snapmock/commands/eyedropper_commands.py +56 -0
- snapmock/commands/geometry_commands.py +138 -0
- snapmock/commands/group_commands.py +221 -0
- snapmock/commands/guide_commands.py +86 -0
- snapmock/commands/layer_commands.py +259 -0
- snapmock/commands/macro_command.py +23 -0
- snapmock/commands/marker_commands.py +136 -0
- snapmock/commands/merge_commands.py +163 -0
- snapmock/commands/modify_property.py +93 -0
- snapmock/commands/move_item_layer.py +51 -0
- snapmock/commands/move_items.py +45 -0
- snapmock/commands/move_tail_command.py +93 -0
- snapmock/commands/raster_commands.py +335 -0
- snapmock/commands/remove_item.py +36 -0
- snapmock/commands/scale_geometry_command.py +27 -0
- snapmock/commands/text_edit_command.py +109 -0
- snapmock/commands/text_format_command.py +85 -0
- snapmock/commands/transform_item.py +39 -0
- snapmock/config/__init__.py +1 -0
- snapmock/config/constants.py +487 -0
- snapmock/config/migration.py +294 -0
- snapmock/config/packaging.py +174 -0
- snapmock/config/settings.py +718 -0
- snapmock/config/shortcuts.py +112 -0
- snapmock/core/__init__.py +1 -0
- snapmock/core/clipboard_manager.py +131 -0
- snapmock/core/command_stack.py +206 -0
- snapmock/core/document.py +120 -0
- snapmock/core/document_manager.py +137 -0
- snapmock/core/emoji_data.py +258 -0
- snapmock/core/guides.py +81 -0
- snapmock/core/layer.py +137 -0
- snapmock/core/layer_manager.py +212 -0
- snapmock/core/path_utils.py +430 -0
- snapmock/core/process_memory.py +56 -0
- snapmock/core/render_engine.py +314 -0
- snapmock/core/rich_text_mixin.py +275 -0
- snapmock/core/scene.py +247 -0
- snapmock/core/selection_manager.py +90 -0
- snapmock/core/stamp_library.py +362 -0
- snapmock/core/theme_manager.py +430 -0
- snapmock/core/tool_themes.py +900 -0
- snapmock/core/update_check.py +246 -0
- snapmock/core/view.py +1451 -0
- snapmock/io/__init__.py +1 -0
- snapmock/io/exporter.py +467 -0
- snapmock/io/importer.py +53 -0
- snapmock/io/project_serializer.py +344 -0
- snapmock/io/rtf_utils.py +123 -0
- snapmock/io/snagit_reader.py +400 -0
- snapmock/io/snagit_writer.py +570 -0
- snapmock/items/__init__.py +1 -0
- snapmock/items/arc_item.py +338 -0
- snapmock/items/arrow_item.py +576 -0
- snapmock/items/base_item.py +310 -0
- snapmock/items/blur_item.py +671 -0
- snapmock/items/callout_item.py +786 -0
- snapmock/items/ellipse_item.py +80 -0
- snapmock/items/emoji_item.py +257 -0
- snapmock/items/freehand_item.py +578 -0
- snapmock/items/group_item.py +258 -0
- snapmock/items/highlight_item.py +205 -0
- snapmock/items/line_item.py +91 -0
- snapmock/items/mask_utils.py +178 -0
- snapmock/items/numbered_step_item.py +655 -0
- snapmock/items/polygon_item.py +317 -0
- snapmock/items/raster_region_item.py +105 -0
- snapmock/items/rectangle_item.py +245 -0
- snapmock/items/shadow.py +298 -0
- snapmock/items/stamp_item.py +351 -0
- snapmock/items/text_item.py +591 -0
- snapmock/items/vector_item.py +353 -0
- snapmock/library/__init__.py +1 -0
- snapmock/library/commands.py +129 -0
- snapmock/library/file_info.py +84 -0
- snapmock/library/manager.py +632 -0
- snapmock/library/model.py +574 -0
- snapmock/library/render.py +80 -0
- snapmock/main_window.py +4478 -0
- snapmock/resources/__init__.py +1 -0
- snapmock/resources/emoji/LICENSE +41 -0
- snapmock/resources/emoji/README.md +27 -0
- snapmock/resources/emoji/emoji.json +1 -0
- snapmock/resources/icons/snapmockit.svg +16 -0
- snapmock/resources/icons/tabler/LICENSE +21 -0
- snapmock/resources/icons/tabler/README.md +17 -0
- snapmock/resources/icons/tabler/app-window.svg +21 -0
- snapmock/resources/icons/tabler/arrow-back-up.svg +20 -0
- snapmock/resources/icons/tabler/arrow-bar-to-down.svg +22 -0
- snapmock/resources/icons/tabler/arrow-bar-to-up.svg +22 -0
- snapmock/resources/icons/tabler/arrow-down.svg +21 -0
- snapmock/resources/icons/tabler/arrow-forward-up.svg +20 -0
- snapmock/resources/icons/tabler/arrow-up-right.svg +20 -0
- snapmock/resources/icons/tabler/arrow-up.svg +21 -0
- snapmock/resources/icons/tabler/aspect-ratio.svg +21 -0
- snapmock/resources/icons/tabler/blur.svg +25 -0
- snapmock/resources/icons/tabler/book.svg +23 -0
- snapmock/resources/icons/tabler/border-inner.svg +32 -0
- snapmock/resources/icons/tabler/bug.svg +27 -0
- snapmock/resources/icons/tabler/camera.svg +20 -0
- snapmock/resources/icons/tabler/chevron-left.svg +19 -0
- snapmock/resources/icons/tabler/chevron-right.svg +19 -0
- snapmock/resources/icons/tabler/circle-number-1.svg +20 -0
- snapmock/resources/icons/tabler/circle.svg +19 -0
- snapmock/resources/icons/tabler/clipboard-check.svg +21 -0
- snapmock/resources/icons/tabler/clipboard.svg +20 -0
- snapmock/resources/icons/tabler/color-picker.svg +20 -0
- snapmock/resources/icons/tabler/color-swatch.svg +22 -0
- snapmock/resources/icons/tabler/components-off.svg +23 -0
- snapmock/resources/icons/tabler/components.svg +22 -0
- snapmock/resources/icons/tabler/copy-plus.svg +22 -0
- snapmock/resources/icons/tabler/copy.svg +20 -0
- snapmock/resources/icons/tabler/crop.svg +20 -0
- snapmock/resources/icons/tabler/crosshair.svg +24 -0
- snapmock/resources/icons/tabler/cut.svg +22 -0
- snapmock/resources/icons/tabler/deselect.svg +35 -0
- snapmock/resources/icons/tabler/device-desktop.svg +22 -0
- snapmock/resources/icons/tabler/device-floppy.svg +21 -0
- snapmock/resources/icons/tabler/download.svg +21 -0
- snapmock/resources/icons/tabler/external-link.svg +21 -0
- snapmock/resources/icons/tabler/eye-off.svg +21 -0
- snapmock/resources/icons/tabler/eye.svg +20 -0
- snapmock/resources/icons/tabler/file-export.svg +20 -0
- snapmock/resources/icons/tabler/file-plus.svg +22 -0
- snapmock/resources/icons/tabler/flip-horizontal.svg +21 -0
- snapmock/resources/icons/tabler/flip-vertical.svg +21 -0
- snapmock/resources/icons/tabler/focus-centered.svg +23 -0
- snapmock/resources/icons/tabler/folder-open.svg +19 -0
- snapmock/resources/icons/tabler/folder-plus.svg +21 -0
- snapmock/resources/icons/tabler/folder-symlink.svg +21 -0
- snapmock/resources/icons/tabler/folder.svg +19 -0
- snapmock/resources/icons/tabler/grid-dots.svg +27 -0
- snapmock/resources/icons/tabler/hand-move.svg +24 -0
- snapmock/resources/icons/tabler/highlight.svg +22 -0
- snapmock/resources/icons/tabler/history.svg +20 -0
- snapmock/resources/icons/tabler/home.svg +21 -0
- snapmock/resources/icons/tabler/info-circle.svg +21 -0
- snapmock/resources/icons/tabler/keyboard.svg +26 -0
- snapmock/resources/icons/tabler/lasso.svg +21 -0
- snapmock/resources/icons/tabler/layers-difference.svg +23 -0
- snapmock/resources/icons/tabler/layers-intersect.svg +20 -0
- snapmock/resources/icons/tabler/layers-union.svg +19 -0
- snapmock/resources/icons/tabler/layout-align-bottom.svg +20 -0
- snapmock/resources/icons/tabler/layout-align-center.svg +21 -0
- snapmock/resources/icons/tabler/layout-align-left.svg +20 -0
- snapmock/resources/icons/tabler/layout-align-middle.svg +21 -0
- snapmock/resources/icons/tabler/layout-align-right.svg +20 -0
- snapmock/resources/icons/tabler/layout-align-top.svg +20 -0
- snapmock/resources/icons/tabler/layout-bottombar.svg +20 -0
- snapmock/resources/icons/tabler/layout-dashboard.svg +22 -0
- snapmock/resources/icons/tabler/layout-distribute-horizontal.svg +21 -0
- snapmock/resources/icons/tabler/layout-distribute-vertical.svg +21 -0
- snapmock/resources/icons/tabler/line.svg +21 -0
- snapmock/resources/icons/tabler/link-off.svg +22 -0
- snapmock/resources/icons/tabler/link.svg +21 -0
- snapmock/resources/icons/tabler/lock-open.svg +21 -0
- snapmock/resources/icons/tabler/lock.svg +21 -0
- snapmock/resources/icons/tabler/magnet.svg +21 -0
- snapmock/resources/icons/tabler/marquee-2.svg +19 -0
- snapmock/resources/icons/tabler/message-2.svg +21 -0
- snapmock/resources/icons/tabler/minus.svg +19 -0
- snapmock/resources/icons/tabler/mood-smile.svg +22 -0
- snapmock/resources/icons/tabler/moon.svg +19 -0
- snapmock/resources/icons/tabler/new-section.svg +21 -0
- snapmock/resources/icons/tabler/palette.svg +22 -0
- snapmock/resources/icons/tabler/pencil.svg +20 -0
- snapmock/resources/icons/tabler/photo-plus.svg +24 -0
- snapmock/resources/icons/tabler/plus.svg +20 -0
- snapmock/resources/icons/tabler/pointer.svg +19 -0
- snapmock/resources/icons/tabler/polygon.svg +22 -0
- snapmock/resources/icons/tabler/power.svg +20 -0
- snapmock/resources/icons/tabler/printer.svg +21 -0
- snapmock/resources/icons/tabler/refresh.svg +20 -0
- snapmock/resources/icons/tabler/resize.svg +20 -0
- snapmock/resources/icons/tabler/rotate-clockwise.svg +19 -0
- snapmock/resources/icons/tabler/rotate.svg +19 -0
- snapmock/resources/icons/tabler/rubber-stamp.svg +20 -0
- snapmock/resources/icons/tabler/ruler.svg +25 -0
- snapmock/resources/icons/tabler/scissors.svg +22 -0
- snapmock/resources/icons/tabler/screenshot.svg +27 -0
- snapmock/resources/icons/tabler/select-all.svg +35 -0
- snapmock/resources/icons/tabler/settings.svg +20 -0
- snapmock/resources/icons/tabler/square.svg +19 -0
- snapmock/resources/icons/tabler/text-recognition.svg +24 -0
- snapmock/resources/icons/tabler/trash.svg +23 -0
- snapmock/resources/icons/tabler/typography.svg +23 -0
- snapmock/resources/icons/tabler/vector-bezier-arc.svg +21 -0
- snapmock/resources/icons/tabler/x.svg +20 -0
- snapmock/resources/icons/tabler/zoom-in-area.svg +27 -0
- snapmock/resources/icons/tabler/zoom-in.svg +22 -0
- snapmock/resources/icons/tabler/zoom-out.svg +21 -0
- snapmock/resources/icons/tabler/zoom-reset.svg +21 -0
- snapmock/resources/icons/tabler/zoom-scan.svg +24 -0
- snapmock/resources/sounds/shutter.wav +0 -0
- snapmock/resources/stamps/LICENSE +21 -0
- snapmock/resources/stamps/README.md +84 -0
- snapmock/resources/stamps/arrows/curved-arrow-ccw.svg +14 -0
- snapmock/resources/stamps/arrows/curved-arrow-cw.svg +14 -0
- snapmock/resources/stamps/arrows/double-arrow-horizontal.svg +16 -0
- snapmock/resources/stamps/arrows/double-arrow-vertical.svg +16 -0
- snapmock/resources/stamps/arrows/magnifying-glass.svg +17 -0
- snapmock/resources/stamps/arrows/pointer-hand.svg +17 -0
- snapmock/resources/stamps/arrows/target.svg +16 -0
- snapmock/resources/stamps/arrows/thick-arrow-down.svg +14 -0
- snapmock/resources/stamps/arrows/thick-arrow-left.svg +14 -0
- snapmock/resources/stamps/arrows/thick-arrow-right.svg +14 -0
- snapmock/resources/stamps/arrows/thick-arrow-up.svg +14 -0
- snapmock/resources/stamps/decorative/box-highlight.svg +2 -0
- snapmock/resources/stamps/decorative/bracket-left.svg +2 -0
- snapmock/resources/stamps/decorative/bracket-right.svg +2 -0
- snapmock/resources/stamps/decorative/brackets.svg +2 -0
- snapmock/resources/stamps/decorative/circle-highlight.svg +2 -0
- snapmock/resources/stamps/decorative/ribbon.svg +2 -0
- snapmock/resources/stamps/decorative/speech-bubble.svg +14 -0
- snapmock/resources/stamps/decorative/starburst.svg +2 -0
- snapmock/resources/stamps/decorative/swoosh.svg +2 -0
- snapmock/resources/stamps/decorative/thought-bubble.svg +2 -0
- snapmock/resources/stamps/shapes/bolt.svg +14 -0
- snapmock/resources/stamps/shapes/bookmark.svg +14 -0
- snapmock/resources/stamps/shapes/checkmark.svg +14 -0
- snapmock/resources/stamps/shapes/eye-off.svg +16 -0
- snapmock/resources/stamps/shapes/eye.svg +15 -0
- snapmock/resources/stamps/shapes/gear.svg +15 -0
- snapmock/resources/stamps/shapes/link.svg +16 -0
- snapmock/resources/stamps/shapes/lock.svg +16 -0
- snapmock/resources/stamps/shapes/minus.svg +14 -0
- snapmock/resources/stamps/shapes/pencil.svg +15 -0
- snapmock/resources/stamps/shapes/plus.svg +15 -0
- snapmock/resources/stamps/shapes/trash.svg +18 -0
- snapmock/resources/stamps/shapes/unlink.svg +17 -0
- snapmock/resources/stamps/shapes/unlock.svg +16 -0
- snapmock/resources/stamps/shapes/warning.svg +16 -0
- snapmock/resources/stamps/shapes/x-mark.svg +15 -0
- snapmock/resources/stamps/stamp_index.json +1193 -0
- snapmock/resources/stamps/status/approved.svg +15 -0
- snapmock/resources/stamps/status/attention.svg +16 -0
- snapmock/resources/stamps/status/draft.svg +2 -0
- snapmock/resources/stamps/status/heart.svg +14 -0
- snapmock/resources/stamps/status/info.svg +16 -0
- snapmock/resources/stamps/status/question.svg +16 -0
- snapmock/resources/stamps/status/rejected.svg +15 -0
- snapmock/resources/stamps/status/star.svg +14 -0
- snapmock/resources/stamps/status/thumbs-down.svg +14 -0
- snapmock/resources/stamps/status/thumbs-up.svg +14 -0
- snapmock/resources/stamps/ui/click.svg +2 -0
- snapmock/resources/stamps/ui/cursor-arrow.svg +14 -0
- snapmock/resources/stamps/ui/cursor-hand.svg +21 -0
- snapmock/resources/stamps/ui/double-click.svg +2 -0
- snapmock/resources/stamps/ui/drag.svg +19 -0
- snapmock/resources/stamps/ui/key.svg +21 -0
- snapmock/resources/stamps/ui/right-click.svg +2 -0
- snapmock/resources/stamps/ui/scroll.svg +15 -0
- snapmock/resources/stamps/ui/tap.svg +2 -0
- snapmock/resources/themes/dark.qss +359 -0
- snapmock/resources/themes/light.qss +359 -0
- snapmock/tools/__init__.py +1 -0
- snapmock/tools/arc_tool.py +347 -0
- snapmock/tools/arrow_tool.py +222 -0
- snapmock/tools/base_tool.py +342 -0
- snapmock/tools/blur_edit.py +161 -0
- snapmock/tools/blur_tool.py +659 -0
- snapmock/tools/callout_tool.py +347 -0
- snapmock/tools/crop_tool.py +305 -0
- snapmock/tools/ellipse_tool.py +147 -0
- snapmock/tools/emoji_tool.py +482 -0
- snapmock/tools/eyedropper_tool.py +400 -0
- snapmock/tools/freehand_tool.py +316 -0
- snapmock/tools/highlight_tool.py +387 -0
- snapmock/tools/lasso_select_tool.py +345 -0
- snapmock/tools/line_tool.py +123 -0
- snapmock/tools/numbered_step_tool.py +387 -0
- snapmock/tools/pan_tool.py +83 -0
- snapmock/tools/point_edit.py +687 -0
- snapmock/tools/polygon_tool.py +506 -0
- snapmock/tools/raster_select_tool.py +341 -0
- snapmock/tools/rectangle_tool.py +259 -0
- snapmock/tools/select_tool.py +1582 -0
- snapmock/tools/stamp_tool.py +468 -0
- snapmock/tools/text_tool.py +808 -0
- snapmock/tools/tool_manager.py +152 -0
- snapmock/tools/zoom_tool.py +165 -0
- snapmock/ui/__init__.py +1 -0
- snapmock/ui/about_dialog.py +119 -0
- snapmock/ui/accessibility.py +271 -0
- snapmock/ui/collapsible_section.py +76 -0
- snapmock/ui/color_picker.py +687 -0
- snapmock/ui/context_menus.py +319 -0
- snapmock/ui/crop_overlay.py +197 -0
- snapmock/ui/cursors.py +341 -0
- snapmock/ui/dimension_overlay.py +260 -0
- snapmock/ui/document_tabs.py +269 -0
- snapmock/ui/emoji_picker.py +395 -0
- snapmock/ui/export_dialog.py +565 -0
- snapmock/ui/find_replace_bar.py +328 -0
- snapmock/ui/find_replace_color_dialog.py +123 -0
- snapmock/ui/icons.py +239 -0
- snapmock/ui/item_properties_dialog.py +261 -0
- snapmock/ui/layer_panel.py +1117 -0
- snapmock/ui/layer_properties_dialog.py +117 -0
- snapmock/ui/library_panel.py +1132 -0
- snapmock/ui/loupe_overlay.py +311 -0
- snapmock/ui/manage_presets_dialog.py +286 -0
- snapmock/ui/panel_modes.py +50 -0
- snapmock/ui/preferences_dialog.py +748 -0
- snapmock/ui/property_panel.py +3156 -0
- snapmock/ui/resize_canvas_dialog.py +179 -0
- snapmock/ui/resize_image_dialog.py +161 -0
- snapmock/ui/ruler_widget.py +229 -0
- snapmock/ui/selection_overlay.py +188 -0
- snapmock/ui/shortcuts_dialog.py +214 -0
- snapmock/ui/stamp_library_panel.py +375 -0
- snapmock/ui/status_bar.py +203 -0
- snapmock/ui/step_inline_editor.py +169 -0
- snapmock/ui/toast.py +73 -0
- snapmock/ui/tool_options_bar.py +1514 -0
- snapmock/ui/tool_themes_dialog.py +362 -0
- snapmock/ui/toolbar.py +329 -0
- snapmock/ui/transform_handles.py +314 -0
- snapmock/ui/unmet_requirements.py +42 -0
- snapmock/ui/unsaved_changes_dialog.py +113 -0
- snapmock/ui/welcome_panel.py +391 -0
- snapmockit-1.2.0.dist-info/METADATA +179 -0
- snapmockit-1.2.0.dist-info/RECORD +352 -0
- snapmockit-1.2.0.dist-info/WHEEL +4 -0
- snapmockit-1.2.0.dist-info/entry_points.txt +2 -0
- snapmockit-1.2.0.dist-info/licenses/LICENSE +21 -0
snapmock/__init__.py
ADDED
snapmock/__main__.py
ADDED
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)
|
snapmock/capture/cli.py
ADDED
|
@@ -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())
|