verdiclip 0.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 (50) hide show
  1. verdiclip/__init__.py +23 -0
  2. verdiclip/__main__.py +163 -0
  3. verdiclip/capture/__init__.py +3 -0
  4. verdiclip/capture/grabber.py +91 -0
  5. verdiclip/capture/models.py +98 -0
  6. verdiclip/capture/overlay.py +322 -0
  7. verdiclip/capture/service.py +216 -0
  8. verdiclip/cli.py +154 -0
  9. verdiclip/document/__init__.py +3 -0
  10. verdiclip/document/annotations.py +584 -0
  11. verdiclip/document/codec.py +261 -0
  12. verdiclip/document/commands.py +166 -0
  13. verdiclip/document/document.py +165 -0
  14. verdiclip/document/history.py +106 -0
  15. verdiclip/document/style.py +85 -0
  16. verdiclip/editor/__init__.py +3 -0
  17. verdiclip/editor/canvas.py +556 -0
  18. verdiclip/editor/chrome.py +60 -0
  19. verdiclip/editor/icons.py +228 -0
  20. verdiclip/editor/inline_editors.py +353 -0
  21. verdiclip/editor/session.py +267 -0
  22. verdiclip/editor/style_bar.py +275 -0
  23. verdiclip/editor/style_memory.py +81 -0
  24. verdiclip/editor/tools.py +750 -0
  25. verdiclip/editor/window.py +638 -0
  26. verdiclip/exceptions.py +31 -0
  27. verdiclip/geometry.py +167 -0
  28. verdiclip/output/__init__.py +3 -0
  29. verdiclip/output/delivery.py +153 -0
  30. verdiclip/output/naming.py +83 -0
  31. verdiclip/platform/__init__.py +1 -0
  32. verdiclip/platform/hotkeys.py +293 -0
  33. verdiclip/platform/startup.py +116 -0
  34. verdiclip/platform/windows.py +225 -0
  35. verdiclip/py.typed +0 -0
  36. verdiclip/render/__init__.py +3 -0
  37. verdiclip/render/renderer.py +355 -0
  38. verdiclip/settings.py +300 -0
  39. verdiclip/shell/__init__.py +3 -0
  40. verdiclip/shell/controller.py +334 -0
  41. verdiclip/shell/instance.py +89 -0
  42. verdiclip/shell/settings_dialog.py +321 -0
  43. verdiclip/shell/theme.py +25 -0
  44. verdiclip/shell/tray.py +82 -0
  45. verdiclip-0.2.0.dist-info/METADATA +133 -0
  46. verdiclip-0.2.0.dist-info/RECORD +50 -0
  47. verdiclip-0.2.0.dist-info/WHEEL +4 -0
  48. verdiclip-0.2.0.dist-info/entry_points.txt +6 -0
  49. verdiclip-0.2.0.dist-info/licenses/ATTRIBUTION.md +37 -0
  50. verdiclip-0.2.0.dist-info/licenses/LICENSE +21 -0
verdiclip/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """VerdiClip: fast, faithful screenshot capture and annotation for Windows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+ from typing import Final
7
+
8
+ APP_NAME: Final = "VerdiClip"
9
+
10
+
11
+ class _Version:
12
+ """Read the installed version; pyproject.toml is the single source."""
13
+
14
+ @staticmethod
15
+ def installed() -> str:
16
+ """Return the package version, or a placeholder when not installed."""
17
+ try:
18
+ return version("verdiclip")
19
+ except PackageNotFoundError:
20
+ return "0.0.0+unknown"
21
+
22
+
23
+ VERSION: Final = _Version.installed()
verdiclip/__main__.py ADDED
@@ -0,0 +1,163 @@
1
+ """Application entry point: tray app, headless capture, or open-in-editor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import signal
7
+ import sys
8
+ import time
9
+ from collections.abc import Sequence
10
+ from logging.handlers import RotatingFileHandler
11
+ from pathlib import Path
12
+ from types import FrameType
13
+ from typing import Final
14
+
15
+ from PySide6.QtCore import QTimer
16
+ from PySide6.QtWidgets import QApplication
17
+
18
+ from verdiclip import APP_NAME, VERSION
19
+ from verdiclip.capture.grabber import MssScreenSource
20
+ from verdiclip.cli import CliRequest, Command, CommandLine, HeadlessMode
21
+ from verdiclip.exceptions import AppError, CaptureError
22
+ from verdiclip.output.delivery import ImageDelivery
23
+ from verdiclip.platform.hotkeys import HotkeyService
24
+ from verdiclip.platform.startup import StartupRegistration
25
+ from verdiclip.platform.windows import WindowLocator
26
+ from verdiclip.settings import SettingsStore
27
+ from verdiclip.shell.controller import AppController
28
+ from verdiclip.shell.instance import SingleInstance
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ SIGNAL_POLL_MS: Final = 200
33
+ LOG_BYTES: Final = 2 * 1024 * 1024
34
+
35
+
36
+ class Application:
37
+ """Wire settings, logging, and services, then run the requested command."""
38
+
39
+ def __init__(self) -> None:
40
+ self._signal_timer: QTimer | None = None
41
+
42
+ @classmethod
43
+ def main(cls, argv: Sequence[str] | None = None) -> None:
44
+ """Run and exit with the resulting status code."""
45
+ sys.exit(cls().run(argv))
46
+
47
+ def run(self, argv: Sequence[str] | None = None) -> int:
48
+ """Run once and return a process exit code."""
49
+ request = CommandLine().parse(argv)
50
+ store = SettingsStore(SettingsStore.default_path())
51
+ self._configure_logging(request.log_level, store.path.parent / "logs")
52
+ try:
53
+ if request.command is Command.CAPTURE:
54
+ return self._run_capture(request, store)
55
+ return self._run_gui(request, store)
56
+ except AppError:
57
+ logger.exception("%s failed", APP_NAME)
58
+ return 1
59
+ except KeyboardInterrupt:
60
+ return 130
61
+
62
+ # Commands
63
+
64
+ def _run_gui(self, request: CliRequest, store: SettingsStore) -> int:
65
+ """Run the tray app, or forward to the one already running."""
66
+ app = QApplication.instance() or QApplication(sys.argv[:1])
67
+ if not isinstance(app, QApplication):
68
+ msg = "A non-widget Qt application is already running"
69
+ raise AppError(msg)
70
+ app.setApplicationName(APP_NAME)
71
+ app.setApplicationVersion(VERSION)
72
+ app.setQuitOnLastWindowClosed(False)
73
+ files = [str(p.resolve()) for p in request.files]
74
+ instance = SingleInstance()
75
+ if instance.forward(files):
76
+ logger.info("Forwarded to the running instance")
77
+ return 0
78
+ instance.listen()
79
+ controller = AppController(
80
+ store,
81
+ MssScreenSource(),
82
+ WindowLocator(),
83
+ HotkeyService(),
84
+ StartupRegistration(),
85
+ )
86
+ instance.message_received.connect(controller.handle_forwarded)
87
+ app.aboutToQuit.connect(controller.shutdown)
88
+ app.aboutToQuit.connect(instance.close)
89
+ self._install_sigint(app)
90
+ controller.start()
91
+ for path in request.files:
92
+ controller.open_image(path)
93
+ return app.exec()
94
+
95
+ def _run_capture(self, request: CliRequest, store: SettingsStore) -> int:
96
+ """Capture without UI, then save or copy."""
97
+ app = QApplication.instance() or QApplication(sys.argv[:1])
98
+ del app
99
+ if request.delay:
100
+ time.sleep(request.delay)
101
+ frozen = MssScreenSource().freeze()
102
+ if request.mode is HeadlessMode.SCREEN:
103
+ image = frozen.image
104
+ elif request.mode is HeadlessMode.REGION and request.region is not None:
105
+ image = frozen.crop(request.region)
106
+ else:
107
+ window = WindowLocator().foreground()
108
+ if window is None:
109
+ msg = "There is no active window to capture"
110
+ raise CaptureError(msg)
111
+ image = frozen.crop(window.bounds)
112
+ delivery = ImageDelivery(store.load().output)
113
+ if request.to_clipboard:
114
+ delivery.copy(image)
115
+ sys.stdout.write("Copied to clipboard\n")
116
+ return 0
117
+ path = request.output or delivery.auto_path()
118
+ sys.stdout.write(f"{delivery.save(image, path)}\n")
119
+ return 0
120
+
121
+ # Infrastructure
122
+
123
+ def _install_sigint(self, app: QApplication) -> None:
124
+ """Let Ctrl+C in the terminal quit promptly (UX-TRY-05)."""
125
+
126
+ def on_sigint(_signum: int, _frame: FrameType | None) -> None:
127
+ logger.info("Interrupted; quitting")
128
+ app.quit()
129
+
130
+ signal.signal(signal.SIGINT, on_sigint)
131
+ # Qt's event loop blocks Python signal handling; wake it periodically
132
+ timer = QTimer()
133
+ timer.timeout.connect(lambda: None)
134
+ timer.start(SIGNAL_POLL_MS)
135
+ self._signal_timer = timer
136
+
137
+ @staticmethod
138
+ def _configure_logging(level: str, log_dir: Path) -> None:
139
+ """Log to stderr and to a rotating file under the settings folder."""
140
+ handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
141
+ try:
142
+ log_dir.mkdir(parents=True, exist_ok=True)
143
+ handlers.append(
144
+ RotatingFileHandler(
145
+ log_dir / "verdiclip.log",
146
+ maxBytes=LOG_BYTES,
147
+ backupCount=2,
148
+ encoding="utf-8",
149
+ )
150
+ )
151
+ except OSError as err:
152
+ sys.stderr.write(f"File logging disabled: {err}\n")
153
+ logging.basicConfig(
154
+ level=level,
155
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
156
+ datefmt="%Y-%m-%dT%H:%M:%S",
157
+ handlers=handlers,
158
+ force=True,
159
+ )
160
+
161
+
162
+ if __name__ == "__main__":
163
+ Application.main()
@@ -0,0 +1,3 @@
1
+ """Screen capture: freezing, selection overlay, and capture modes."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,91 @@
1
+ """Grab screen pixels at physical resolution with mss."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from typing import Protocol
8
+
9
+ import mss
10
+ from PySide6.QtGui import QImage
11
+
12
+ from verdiclip.exceptions import CaptureError
13
+ from verdiclip.geometry import Rect
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class FrozenScreen:
20
+ """A snapshot of the whole virtual desktop.
21
+
22
+ ``bounds`` is the desktop rectangle in physical pixels; ``image`` pixel
23
+ (0, 0) corresponds to ``bounds.top_left``.
24
+ """
25
+
26
+ image: QImage
27
+ bounds: Rect
28
+
29
+ def crop(self, area: Rect) -> QImage:
30
+ """Return the pixels of ``area`` (physical desktop coordinates).
31
+
32
+ Raises:
33
+ CaptureError: If ``area`` does not overlap the desktop.
34
+ """
35
+ clipped = area.rounded().intersected(self.bounds)
36
+ if clipped.is_empty:
37
+ msg = f"Area {area} is outside the desktop {self.bounds}"
38
+ raise CaptureError(msg)
39
+ return self.image.copy(
40
+ int(clipped.x - self.bounds.x),
41
+ int(clipped.y - self.bounds.y),
42
+ int(clipped.width),
43
+ int(clipped.height),
44
+ )
45
+
46
+
47
+ class ScreenSource(Protocol):
48
+ """Anything that can snapshot the desktop."""
49
+
50
+ def freeze(self) -> FrozenScreen:
51
+ """Return a snapshot of every monitor."""
52
+
53
+
54
+ class MssScreenSource:
55
+ """Snapshot the desktop with mss (physical pixels, all monitors).
56
+
57
+ The mss handle is created on first use and reused; call from the GUI thread.
58
+ """
59
+
60
+ def __init__(self) -> None:
61
+ self._grabber: mss.MSS | None = None
62
+
63
+ def freeze(self) -> FrozenScreen:
64
+ """Return a snapshot of every monitor.
65
+
66
+ Raises:
67
+ CaptureError: If the screen cannot be read.
68
+ """
69
+ try:
70
+ if self._grabber is None:
71
+ self._grabber = mss.MSS()
72
+ desktop = self._grabber.monitors[0]
73
+ shot = self._grabber.grab(desktop)
74
+ except mss.ScreenShotError as err:
75
+ self.close()
76
+ msg = f"Could not capture the screen: {err}"
77
+ raise CaptureError(msg) from err
78
+ width, height = shot.width, shot.height
79
+ # Raw BGRA bytes are exactly QImage's little-endian RGB32 layout
80
+ image = QImage(
81
+ shot.raw, width, height, width * 4, QImage.Format.Format_RGB32
82
+ ).copy()
83
+ bounds = Rect(desktop["left"], desktop["top"], width, height)
84
+ logger.debug("Froze desktop %s", bounds)
85
+ return FrozenScreen(image=image, bounds=bounds)
86
+
87
+ def close(self) -> None:
88
+ """Release the mss handle."""
89
+ if self._grabber is not None:
90
+ self._grabber.close()
91
+ self._grabber = None
@@ -0,0 +1,98 @@
1
+ """Capture results and the screen layout they are taken from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from enum import StrEnum
8
+ from typing import Self
9
+
10
+ from PySide6.QtGui import QGuiApplication, QImage, QScreen
11
+
12
+ from verdiclip.geometry import Point, Rect
13
+
14
+
15
+ class CaptureMode(StrEnum):
16
+ """How a capture was taken."""
17
+
18
+ REGION = "region"
19
+ WINDOW = "window"
20
+ FULLSCREEN = "fullscreen"
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class Capture:
25
+ """A captured image and where it came from.
26
+
27
+ ``area`` is in physical virtual-desktop pixels so it can be re-captured.
28
+ """
29
+
30
+ image: QImage
31
+ mode: CaptureMode
32
+ area: Rect
33
+ title: str
34
+ taken_at: datetime
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class ScreenGeometry:
39
+ """One monitor in both Qt logical and physical pixel coordinates."""
40
+
41
+ name: str
42
+ logical: Rect
43
+ physical: Rect
44
+ scale: float
45
+
46
+ @classmethod
47
+ def from_screen(cls, screen: QScreen) -> Self:
48
+ """Describe ``screen``.
49
+
50
+ Qt on Windows keeps each screen's top-left at its native (physical)
51
+ position and scales only the size, so physical size = logical size * scale.
52
+ """
53
+ geo = screen.geometry()
54
+ scale = screen.devicePixelRatio()
55
+ return cls(
56
+ name=screen.name(),
57
+ logical=Rect(geo.x(), geo.y(), geo.width(), geo.height()),
58
+ physical=Rect(
59
+ geo.x(),
60
+ geo.y(),
61
+ round(geo.width() * scale),
62
+ round(geo.height() * scale),
63
+ ),
64
+ scale=scale,
65
+ )
66
+
67
+ def to_physical(self, local: Point) -> Point:
68
+ """Map a widget-local logical point on this screen to physical pixels."""
69
+ return Point(
70
+ self.physical.x + local.x * self.scale,
71
+ self.physical.y + local.y * self.scale,
72
+ )
73
+
74
+ def to_local(self, physical: Point) -> Point:
75
+ """Map a physical point to widget-local logical coordinates."""
76
+ return Point(
77
+ (physical.x - self.physical.x) / self.scale,
78
+ (physical.y - self.physical.y) / self.scale,
79
+ )
80
+
81
+ def local_rect(self, physical: Rect) -> Rect:
82
+ """Map a physical rectangle to widget-local logical coordinates."""
83
+ top_left = self.to_local(physical.top_left)
84
+ return Rect(
85
+ top_left.x,
86
+ top_left.y,
87
+ physical.width / self.scale,
88
+ physical.height / self.scale,
89
+ )
90
+
91
+
92
+ class ScreenLayout:
93
+ """The monitors currently attached."""
94
+
95
+ @staticmethod
96
+ def current() -> list[ScreenGeometry]:
97
+ """Return every screen known to Qt."""
98
+ return [ScreenGeometry.from_screen(s) for s in QGuiApplication.screens()]
@@ -0,0 +1,322 @@
1
+ """Full-screen overlay on one monitor: drag a region or click a window."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Final, Protocol, override
7
+
8
+ from PySide6.QtCore import QEvent, QPoint, QPointF, QRectF, Qt, Signal
9
+ from PySide6.QtGui import (
10
+ QColor,
11
+ QCursor,
12
+ QFont,
13
+ QImage,
14
+ QKeyEvent,
15
+ QMouseEvent,
16
+ QPainter,
17
+ QPaintEvent,
18
+ QPen,
19
+ QScreen,
20
+ )
21
+ from PySide6.QtWidgets import QWidget
22
+
23
+ from verdiclip.capture.grabber import FrozenScreen
24
+ from verdiclip.capture.models import ScreenGeometry
25
+ from verdiclip.geometry import Point, Rect
26
+
27
+ DRAG_THRESHOLD: Final = 3.0
28
+ MAGNIFIER_SOURCE_PX: Final = 30
29
+ MAGNIFIER_ZOOM: Final = 4
30
+ MAGNIFIER_OFFSET: Final = 24
31
+ DIM: Final = QColor(0, 0, 0, 110)
32
+ ACCENT: Final = QColor(0, 120, 215)
33
+
34
+
35
+ class WindowTarget(Protocol):
36
+ """A window the user can click to capture."""
37
+
38
+ @property
39
+ def title(self) -> str:
40
+ """Window title."""
41
+
42
+ @property
43
+ def bounds(self) -> Rect:
44
+ """Window rectangle in physical desktop pixels."""
45
+
46
+
47
+ class SelectionOverlay(QWidget):
48
+ """Shows the frozen screen of one monitor and reports the user's choice."""
49
+
50
+ region_chosen = Signal(object) # Rect (physical)
51
+ window_chosen = Signal(object, str) # Rect (physical), title
52
+ cancelled = Signal()
53
+
54
+ def __init__(
55
+ self,
56
+ geometry: ScreenGeometry,
57
+ screen: QScreen,
58
+ frozen: FrozenScreen,
59
+ windows: Sequence[WindowTarget],
60
+ *,
61
+ show_magnifier: bool,
62
+ ) -> None:
63
+ super().__init__(None)
64
+ self._geometry = geometry
65
+ self._frozen = frozen
66
+ self._image = frozen.crop(geometry.physical)
67
+ self._image.setDevicePixelRatio(geometry.scale)
68
+ self._windows = [w for w in windows if w.bounds.intersects(geometry.physical)]
69
+ self._show_magnifier = show_magnifier
70
+ self._press: Point | None = None
71
+ self._cursor: Point | None = None
72
+ self._dragging = False
73
+ self.setWindowFlags(
74
+ Qt.WindowType.FramelessWindowHint
75
+ | Qt.WindowType.WindowStaysOnTopHint
76
+ | Qt.WindowType.Tool
77
+ )
78
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
79
+ self.setMouseTracking(True)
80
+ self.setCursor(Qt.CursorShape.CrossCursor)
81
+ self.setScreen(screen)
82
+ logical = geometry.logical
83
+ self.setGeometry(
84
+ int(logical.x), int(logical.y), int(logical.width), int(logical.height)
85
+ )
86
+
87
+ @property
88
+ def screen_geometry(self) -> ScreenGeometry:
89
+ """Return the monitor this overlay covers."""
90
+ return self._geometry
91
+
92
+ def hovered_window(self) -> WindowTarget | None:
93
+ """Return the topmost window under the cursor, if any."""
94
+ if self._cursor is None:
95
+ return None
96
+ physical = self._geometry.to_physical(self._cursor)
97
+ return next((w for w in self._windows if w.bounds.contains(physical)), None)
98
+
99
+ def selection(self) -> Rect | None:
100
+ """Return the dragged rectangle in local logical coordinates."""
101
+ if not self._dragging or self._press is None or self._cursor is None:
102
+ return None
103
+ return Rect.from_points(self._press, self._clamped(self._cursor))
104
+
105
+ # Painting
106
+
107
+ @override
108
+ def paintEvent(self, event: QPaintEvent) -> None:
109
+ """Draw the frozen screen, dimming, highlight, crosshair, and magnifier."""
110
+ del event
111
+ painter = QPainter(self)
112
+ try:
113
+ painter.drawImage(QPointF(0, 0), self._image)
114
+ painter.fillRect(self.rect(), DIM)
115
+ self._paint_target(painter)
116
+ self._paint_crosshair(painter)
117
+ if self._show_magnifier and self._cursor is not None:
118
+ self._paint_magnifier(painter, self._cursor)
119
+ finally:
120
+ painter.end()
121
+
122
+ def _paint_target(self, painter: QPainter) -> None:
123
+ """Highlight the dragged region or the hovered window."""
124
+ selection = self.selection()
125
+ if selection is not None:
126
+ size = self._physical_rect(selection)
127
+ self._reveal(painter, selection, f"{int(size.width)} × {int(size.height)}") # noqa: RUF001
128
+ return
129
+ window = self.hovered_window()
130
+ if window is not None:
131
+ local = self._geometry.local_rect(window.bounds)
132
+ self._reveal(painter, local, window.title)
133
+
134
+ def _reveal(self, painter: QPainter, area: Rect, label: str) -> None:
135
+ """Undim ``area``, outline it, and show ``label`` beside it."""
136
+ rect = QRectF(area.x, area.y, area.width, area.height)
137
+ painter.drawImage(
138
+ rect,
139
+ self._image,
140
+ QRectF(
141
+ area.x * self._geometry.scale,
142
+ area.y * self._geometry.scale,
143
+ area.width * self._geometry.scale,
144
+ area.height * self._geometry.scale,
145
+ ),
146
+ )
147
+ painter.setPen(QPen(ACCENT, 2))
148
+ painter.setBrush(Qt.BrushStyle.NoBrush)
149
+ painter.drawRect(rect)
150
+ font = QFont("Segoe UI")
151
+ font.setPixelSize(13)
152
+ painter.setFont(font)
153
+ metrics = painter.fontMetrics()
154
+ text = metrics.elidedText(label, Qt.TextElideMode.ElideRight, 480)
155
+ box = QRectF(0, 0, metrics.horizontalAdvance(text) + 12, metrics.height() + 6)
156
+ above = area.y - box.height() - 4
157
+ box.moveTo(max(0.0, area.x), above if above >= 0 else area.bottom + 4)
158
+ painter.fillRect(box, QColor(20, 20, 20, 220))
159
+ painter.setPen(Qt.GlobalColor.white)
160
+ painter.drawText(box, int(Qt.AlignmentFlag.AlignCenter.value), text)
161
+
162
+ def _paint_crosshair(self, painter: QPainter) -> None:
163
+ """Draw guide lines through the cursor."""
164
+ if self._cursor is None or self._dragging:
165
+ return
166
+ painter.setPen(QPen(QColor(255, 255, 255, 140), 1, Qt.PenStyle.DashLine))
167
+ x, y = round(self._cursor.x), round(self._cursor.y)
168
+ painter.drawLine(x, 0, x, self.height())
169
+ painter.drawLine(0, y, self.width(), y)
170
+
171
+ def _paint_magnifier(self, painter: QPainter, cursor: Point) -> None:
172
+ """Draw a 4x loupe of the pixels around the cursor (UX-CAP-06)."""
173
+ physical = self._geometry.to_physical(cursor)
174
+ half = MAGNIFIER_SOURCE_PX // 2
175
+ source = Rect(
176
+ round(physical.x) - half,
177
+ round(physical.y) - half,
178
+ MAGNIFIER_SOURCE_PX,
179
+ MAGNIFIER_SOURCE_PX,
180
+ )
181
+ patch = self._patch(source)
182
+ size = MAGNIFIER_SOURCE_PX * MAGNIFIER_ZOOM
183
+ x = cursor.x + MAGNIFIER_OFFSET
184
+ y = cursor.y + MAGNIFIER_OFFSET
185
+ if x + size > self.width():
186
+ x = cursor.x - MAGNIFIER_OFFSET - size
187
+ if y + size + 20 > self.height():
188
+ y = cursor.y - MAGNIFIER_OFFSET - size - 20
189
+ target = QRectF(x, y, size, size)
190
+ painter.save()
191
+ painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, on=False)
192
+ painter.drawImage(target, patch)
193
+ painter.restore()
194
+ # Dark outer and light inner edge so the loupe reads on any content
195
+ painter.setPen(QPen(QColor(20, 20, 20, 230), 1))
196
+ painter.drawRect(target.adjusted(-2, -2, 2, 2))
197
+ painter.setPen(QPen(Qt.GlobalColor.white, 2))
198
+ painter.drawRect(target)
199
+ center = target.center()
200
+ painter.setPen(QPen(QColor(255, 0, 0, 200), 1))
201
+ painter.drawLine(
202
+ QPointF(center.x() - 8, center.y()), QPointF(center.x() + 8, center.y())
203
+ )
204
+ painter.drawLine(
205
+ QPointF(center.x(), center.y() - 8), QPointF(center.x(), center.y() + 8)
206
+ )
207
+ coords = QRectF(x, y + size, size, 20)
208
+ painter.fillRect(coords, QColor(20, 20, 20, 220))
209
+ painter.setPen(Qt.GlobalColor.white)
210
+ label = f"{int(physical.x)}, {int(physical.y)}"
211
+ painter.drawText(coords, int(Qt.AlignmentFlag.AlignCenter.value), label)
212
+
213
+ def _patch(self, source: Rect) -> QImage:
214
+ """Return ``source`` pixels from the frozen desktop, black outside it."""
215
+ patch = QImage(
216
+ int(source.width), int(source.height), QImage.Format.Format_RGB32
217
+ )
218
+ patch.fill(Qt.GlobalColor.black)
219
+ inside = source.intersected(self._frozen.bounds)
220
+ if not inside.is_empty:
221
+ painter = QPainter(patch)
222
+ painter.drawImage(
223
+ QPointF(inside.x - source.x, inside.y - source.y),
224
+ self._frozen.crop(inside),
225
+ )
226
+ painter.end()
227
+ return patch
228
+
229
+ # Input
230
+
231
+ @override
232
+ def mousePressEvent(self, event: QMouseEvent) -> None:
233
+ """Start a selection on left press; cancel on right press."""
234
+ if event.button() == Qt.MouseButton.RightButton:
235
+ self.cancelled.emit()
236
+ return
237
+ if event.button() == Qt.MouseButton.LeftButton:
238
+ self._press = self._local(event)
239
+ self._cursor = self._press
240
+ self._dragging = False
241
+
242
+ @override
243
+ def mouseMoveEvent(self, event: QMouseEvent) -> None:
244
+ """Track the cursor and start dragging past the threshold."""
245
+ self._cursor = self._local(event)
246
+ if (
247
+ self._press is not None
248
+ and self._press.distance_to(self._cursor) >= DRAG_THRESHOLD
249
+ ):
250
+ self._dragging = True
251
+ self.update()
252
+
253
+ @override
254
+ def mouseReleaseEvent(self, event: QMouseEvent) -> None:
255
+ """Report a dragged region, a clicked window, or this whole monitor."""
256
+ if event.button() != Qt.MouseButton.LeftButton or self._press is None:
257
+ return
258
+ self._cursor = self._local(event)
259
+ selection = self.selection()
260
+ self._press = None
261
+ self._dragging = False
262
+ if selection is not None:
263
+ area = self._physical_rect(selection)
264
+ if area.width >= DRAG_THRESHOLD and area.height >= DRAG_THRESHOLD:
265
+ self.region_chosen.emit(area)
266
+ self.update()
267
+ return
268
+ window = self.hovered_window()
269
+ if window is not None:
270
+ self.window_chosen.emit(
271
+ window.bounds.intersected(self._frozen.bounds), window.title
272
+ )
273
+ else:
274
+ self.region_chosen.emit(self._geometry.physical)
275
+
276
+ @override
277
+ def keyPressEvent(self, event: QKeyEvent) -> None:
278
+ """Cancel on Esc; move the cursor with arrow keys (Ctrl = 10 px)."""
279
+ if event.key() == Qt.Key.Key_Escape:
280
+ self.cancelled.emit()
281
+ return
282
+ step = 10 if event.modifiers() & Qt.KeyboardModifier.ControlModifier else 1
283
+ moves = {
284
+ Qt.Key.Key_Left: QPoint(-step, 0),
285
+ Qt.Key.Key_Right: QPoint(step, 0),
286
+ Qt.Key.Key_Up: QPoint(0, -step),
287
+ Qt.Key.Key_Down: QPoint(0, step),
288
+ }
289
+ delta = moves.get(Qt.Key(event.key()))
290
+ if delta is None:
291
+ super().keyPressEvent(event)
292
+ return
293
+ QCursor.setPos(QCursor.pos() + delta)
294
+
295
+ @override
296
+ def leaveEvent(self, event: QEvent) -> None:
297
+ """Hide the crosshair when the cursor moves to another monitor."""
298
+ del event
299
+ if self._press is None:
300
+ self._cursor = None
301
+ self.update()
302
+
303
+ # Internals
304
+
305
+ @staticmethod
306
+ def _local(event: QMouseEvent) -> Point:
307
+ """Return the event position in local logical coordinates."""
308
+ pos = event.position()
309
+ return Point(pos.x(), pos.y())
310
+
311
+ def _clamped(self, point: Point) -> Point:
312
+ """Clamp ``point`` to this overlay."""
313
+ return Point(
314
+ min(max(point.x, 0.0), float(self.width())),
315
+ min(max(point.y, 0.0), float(self.height())),
316
+ )
317
+
318
+ def _physical_rect(self, local: Rect) -> Rect:
319
+ """Map a local logical rectangle to whole physical pixels."""
320
+ a = self._geometry.to_physical(local.top_left)
321
+ b = self._geometry.to_physical(local.bottom_right)
322
+ return Rect.from_points(a, b).rounded()