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.
- verdiclip/__init__.py +23 -0
- verdiclip/__main__.py +163 -0
- verdiclip/capture/__init__.py +3 -0
- verdiclip/capture/grabber.py +91 -0
- verdiclip/capture/models.py +98 -0
- verdiclip/capture/overlay.py +322 -0
- verdiclip/capture/service.py +216 -0
- verdiclip/cli.py +154 -0
- verdiclip/document/__init__.py +3 -0
- verdiclip/document/annotations.py +584 -0
- verdiclip/document/codec.py +261 -0
- verdiclip/document/commands.py +166 -0
- verdiclip/document/document.py +165 -0
- verdiclip/document/history.py +106 -0
- verdiclip/document/style.py +85 -0
- verdiclip/editor/__init__.py +3 -0
- verdiclip/editor/canvas.py +556 -0
- verdiclip/editor/chrome.py +60 -0
- verdiclip/editor/icons.py +228 -0
- verdiclip/editor/inline_editors.py +353 -0
- verdiclip/editor/session.py +267 -0
- verdiclip/editor/style_bar.py +275 -0
- verdiclip/editor/style_memory.py +81 -0
- verdiclip/editor/tools.py +750 -0
- verdiclip/editor/window.py +638 -0
- verdiclip/exceptions.py +31 -0
- verdiclip/geometry.py +167 -0
- verdiclip/output/__init__.py +3 -0
- verdiclip/output/delivery.py +153 -0
- verdiclip/output/naming.py +83 -0
- verdiclip/platform/__init__.py +1 -0
- verdiclip/platform/hotkeys.py +293 -0
- verdiclip/platform/startup.py +116 -0
- verdiclip/platform/windows.py +225 -0
- verdiclip/py.typed +0 -0
- verdiclip/render/__init__.py +3 -0
- verdiclip/render/renderer.py +355 -0
- verdiclip/settings.py +300 -0
- verdiclip/shell/__init__.py +3 -0
- verdiclip/shell/controller.py +334 -0
- verdiclip/shell/instance.py +89 -0
- verdiclip/shell/settings_dialog.py +321 -0
- verdiclip/shell/theme.py +25 -0
- verdiclip/shell/tray.py +82 -0
- verdiclip-0.2.0.dist-info/METADATA +133 -0
- verdiclip-0.2.0.dist-info/RECORD +50 -0
- verdiclip-0.2.0.dist-info/WHEEL +4 -0
- verdiclip-0.2.0.dist-info/entry_points.txt +6 -0
- verdiclip-0.2.0.dist-info/licenses/ATTRIBUTION.md +37 -0
- 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,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()
|