• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dynobo / normcap / 6365911097 / 1

Source File

71.5
/normcap/gui/window.py
1
"""Normcap window.
1✔
2

3
Supposed to show a screenshot of the current desktop in full screen, on which the
4
user can draw a selection rectangle.
5

6
One instance (single-display) or multiple instances (multi-display) might be created.
7

8
On Linux's Wayland DE, where the compositor and not the application is supposed to
9
control the window's position, some platform specific workarounds are implemented to
10
nevertheless display potentially multiple windows in fullscreen on multiple displays.
11
"""
12

13

14
import logging
1✔
15
import sys
1✔
16
import tempfile
1✔
17
from pathlib import Path
1✔
18
from typing import Callable, NamedTuple, cast
1✔
19

20
from PySide6 import QtCore, QtGui, QtWidgets
1✔
21

22
from normcap.gui import system_info
1✔
23
from normcap.gui.models import CaptureMode, DesktopEnvironment, Rect, Screen
1✔
24
from normcap.gui.settings import Settings
1✔
25

26
try:
1✔
27
    from PySide6 import QtDBus
1✔
28

29
    HAS_QTDBUS = True
1✔
30
except ImportError:
×
31
    HAS_QTDBUS = False
×
32

33

34
logger = logging.getLogger(__name__)
1✔
35

36

37
class DebugInfo(NamedTuple):
1✔
38
    screen: Screen | None = None
1✔
39
    window: QtWidgets.QMainWindow | None = None
1✔
40
    scale_factor: float = 1
1✔
41

42

43
def _move_active_window_to_position_on_gnome(screen_rect: Rect) -> None:
1✔
44
    """Move currently active window to a certain position.
45

46
    This is a workaround for not being able to reposition windows on wayland.
47
    It only works on Gnome Shell.
48
    """
49
    if not HAS_QTDBUS or sys.platform != "linux" or not QtDBus:
1✔
50
        raise TypeError("QtDBus should only be called on Linux systems!")
1✔
51

52
    js_code = f"""
×
53
    const GLib = imports.gi.GLib;
54
    global.get_window_actors().forEach(function (w) {{
55
        var mw = w.meta_window;
56
        if (mw.has_focus()) {{
57
            mw.move_resize_frame(
58
                0,
59
                {screen_rect.left},
60
                {screen_rect.top},
61
                {screen_rect.width},
62
                {screen_rect.height}
63
            );
64
        }}
65
    }});
66
    """
67
    item = "org.gnome.Shell"
×
68
    interface = "org.gnome.Shell"
×
69
    path = "/org/gnome/Shell"
×
70

71
    bus = QtDBus.QDBusConnection.sessionBus()
×
72
    if not bus.isConnected():
×
73
        logger.error("Not connected to dbus!")
×
74

75
    shell_interface = QtDBus.QDBusInterface(item, path, interface, bus)
×
76
    if shell_interface.isValid():
×
77
        x = shell_interface.call("Eval", js_code)
×
78
        if x.errorName():
×
79
            logger.error("Failed move Window!")
×
80
            logger.error(x.errorMessage())
×
81
    else:
82
        logger.warning("Invalid dbus interface on Gnome")
×
83

84

85
def _move_active_window_to_position_on_kde(screen_rect: Rect) -> None:
1✔
86
    """Move currently active window to a certain position.
87

88
    This is a workaround for not being able to reposition windows on wayland.
89
    It only works on KDE.
90
    """
91
    if not HAS_QTDBUS or sys.platform != "linux" or not QtDBus:
1✔
92
        raise TypeError("QtDBus should only be called on Linux systems!")
1✔
93

94
    js_code = f"""
×
95
    client = workspace.activeClient;
96
    client.geometry = {{
97
        "x": {screen_rect.left},
98
        "y": {screen_rect.top},
99
        "width": {screen_rect.width},
100
        "height": {screen_rect.height}
101
    }};
102
    """
103
    with tempfile.NamedTemporaryFile(delete=False, suffix=".js") as script_file:
×
104
        script_file.write(js_code.encode())
×
105

106
    bus = QtDBus.QDBusConnection.sessionBus()
×
107
    if not bus.isConnected():
×
108
        logger.error("Not connected to dbus!")
×
109

110
    item = "org.kde.KWin"
×
111
    interface = "org.kde.kwin.Scripting"
×
112
    path = "/Scripting"
×
113
    shell_interface = QtDBus.QDBusInterface(item, path, interface, bus)
×
114

115
    # FIXME: shell_interface is not valid on latest KDE in Fedora 36.
116
    if shell_interface.isValid():
×
117
        x = shell_interface.call("loadScript", script_file.name)
×
118
        y = shell_interface.call("start")
×
119
        if x.errorName() or y.errorName():
×
120
            logger.error("Failed move Window!")
×
121
            logger.error(x.errorMessage(), y.errorMessage())
×
122
    else:
123
        logger.warning("Invalid dbus interface on KDE")
×
124

125
    Path(script_file.name).unlink()
×
126

127

128
class Communicate(QtCore.QObject):
1✔
129
    """Window's communication bus."""
1✔
130

131
    on_esc_key_pressed = QtCore.Signal()
1✔
132
    on_region_selected = QtCore.Signal(Rect)
1✔
133
    on_window_positioned = QtCore.Signal()
1✔
134

135

136
class Window(QtWidgets.QMainWindow):
1✔
137
    """Provide fullscreen UI for interacting with NormCap."""
1✔
138

139
    def __init__(
1✔
140
        self,
141
        screen: Screen,
142
        settings: Settings,
143
        parent: QtWidgets.QWidget | None = None,
144
    ) -> None:
145
        """Initialize window."""
146
        super().__init__(parent=parent)
1✔
147
        logger.debug("Create window for screen %s", screen.index)
1✔
148

149
        self.settings = settings
1✔
150
        self.screen_ = screen
1✔
151

152
        self.com = Communicate(parent=self)
1✔
153
        self.color: QtGui.QColor = QtGui.QColor(str(settings.value("color")))
1✔
154
        self.is_positioned: bool = False
1✔
155

156
        self.setWindowTitle("NormCap")
1✔
157
        self.setWindowIcon(QtGui.QIcon(":normcap"))
1✔
158
        self.setAnimated(False)
1✔
159
        self.setEnabled(True)
1✔
160

161
        self.selection_rect: QtCore.QRect = QtCore.QRect()
1✔
162

163
        self._add_image_container()
1✔
164
        self._add_ui_container()
1✔
165

166
    def _get_scale_factor(self) -> float:
1✔
167
        """Calculate scale factor from image and screen dimensions."""
168
        if not self.screen_.screenshot:
1✔
169
            raise ValueError("Screenshot image is missing!")
1✔
170
        return self.screen_.screenshot.width() / self.screen_.width
1✔
171

172
    def _add_image_container(self) -> None:
1✔
173
        """Add widget showing screenshot."""
174
        self.image_container = QtWidgets.QLabel()
1✔
175
        self.image_container.setScaledContents(True)
1✔
176
        self.setCentralWidget(self.image_container)
1✔
177

178
    def _add_ui_container(self) -> None:
1✔
179
        """Add widget for showing selection rectangle and settings button."""
180
        self.ui_container = UiContainerLabel(
1✔
181
            parent=self, color=self.color, capture_mode_func=self.get_capture_mode
182
        )
183

184
        if logger.getEffectiveLevel() is logging.DEBUG:
1✔
185
            self.ui_container.debug_info = DebugInfo(
1✔
186
                scale_factor=self._get_scale_factor(), screen=self.screen_, window=self
187
            )
188

189
        self.ui_container.color = self.color
1✔
190
        self.ui_container.setGeometry(self.image_container.geometry())
1✔
191
        self.ui_container.raise_()
1✔
192

193
    def _draw_background_image(self) -> None:
1✔
194
        """Draw screenshot as background image."""
195
        if not self.screen_.screenshot:
1✔
196
            raise ValueError("Screenshot image is missing!")
×
197

198
        pixmap = QtGui.QPixmap()
1✔
199
        pixmap.convertFromImage(self.screen_.screenshot)
1✔
200
        self.image_container.setPixmap(pixmap)
1✔
201

202
    def _position_windows_on_wayland(self) -> None:
1✔
203
        """Move window to respective monitor on Wayland.
204

205
        In Wayland, the compositor has the responsibility for positioning windows, the
206
        client itself can't do this. However, there are DE dependent workarounds.
207
        """
208
        self.setFocus()
×
209
        logger.debug("Move window %s to %s", self.screen_.index, self.screen_.rect)
×
210
        if system_info.desktop_environment() == DesktopEnvironment.GNOME:
×
211
            _move_active_window_to_position_on_gnome(self.screen_.rect)
×
212
        elif system_info.desktop_environment() == DesktopEnvironment.KDE:
×
213
            _move_active_window_to_position_on_kde(self.screen_.rect)
×
214
        self.is_positioned = True
×
215

216
    def set_fullscreen(self) -> None:
1✔
217
        """Set window to full screen using platform specific methods."""
218
        logger.debug("Set window of screen %s to fullscreen", self.screen_.index)
1✔
219

220
        self.setWindowFlags(
1✔
221
            QtGui.Qt.WindowType.FramelessWindowHint
222
            | QtGui.Qt.WindowType.CustomizeWindowHint
223
            | QtGui.Qt.WindowType.WindowStaysOnTopHint
224
        )
225
        self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
1✔
226

227
        # Moving window to corresponding monitor
228
        self.setGeometry(*self.screen_.rect.geometry)
1✔
229

230
        # On unity, setting min/max window size breaks fullscreen.
231
        if system_info.desktop_environment() != DesktopEnvironment.UNITY:
1✔
232
            self.setMinimumSize(self.geometry().size())
1✔
233
            self.setMaximumSize(self.geometry().size())
1✔
234

235
        self.showFullScreen()
1✔
236

237
    def clear_selection(self) -> None:
1✔
238
        self.selection_rect = QtCore.QRect()
1✔
239
        self.ui_container.rect = self.selection_rect
1✔
240
        self.update()
1✔
241

242
    def get_capture_mode(self) -> CaptureMode:
1✔
243
        """Read current capture mode from application settings."""
244
        mode_setting = str(self.settings.value("mode"))
×
245
        try:
×
246
            mode = CaptureMode[mode_setting.upper()]
×
247
        except ValueError:
×
248
            logger.warning("Unknown capture mode: %s. Fallback to PARSE.", mode_setting)
×
249
            mode = CaptureMode.PARSE
×
250
        return mode
×
251

252
    def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:  # noqa: N802
1✔
253
        """Handle ESC key pressed.
254

255
        Cancels the selection progress (if ongoing), otherwise emit signal.
256
        """
257
        super().keyPressEvent(event)
1✔
258
        if event.key() == QtCore.Qt.Key.Key_Escape:
1✔
259
            if self.selection_rect:
1✔
260
                self.clear_selection()
1✔
261
            else:
262
                self.com.on_esc_key_pressed.emit()
1✔
263

264
    def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:  # noqa: N802
1✔
265
        """Handle left mouse button clicked.
266

267
        Creates a new selection rectangle with both points set to pointer position.
268
        Note that the points "topLeft" and "bottomRight" are more like start and end
269
        points and not necessarily relate to the geometrical position.
270

271
        The selection rectangle will be normalized just before emitting the signal in
272
        the mouseReleaseEvent.
273
        """
274
        super().mousePressEvent(event)
1✔
275
        if event.button() == QtCore.Qt.MouseButton.LeftButton:
1✔
276
            self.selection_rect = QtCore.QRect()
1✔
277
            self.selection_rect.setTopLeft(event.position().toPoint())
1✔
278
            self.selection_rect.setBottomRight(event.position().toPoint())
1✔
279
            self.ui_container.rect = self.selection_rect
1✔
280
            self.update()
1✔
281

282
    def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:  # noqa: N802
1✔
283
        """Update position of bottom right point of selection rectangle."""
284
        super().mouseMoveEvent(event)
1✔
285
        if self.selection_rect:
1✔
286
            self.selection_rect.setBottomRight(event.position().toPoint())
1✔
287
            self.ui_container.rect = self.selection_rect
1✔
288
            self.update()
1✔
289

290
    def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None:  # noqa: N802
1✔
291
        """Start OCR workflow on left mouse button release."""
292
        super().mouseReleaseEvent(event)
1✔
293
        if (
1✔
294
            event.button() != QtCore.Qt.MouseButton.LeftButton
295
            or not self.selection_rect
296
        ):
297
            return
×
298

299
        self.selection_rect.setBottomRight(event.position().toPoint())
1✔
300

301
        selection_coords = cast(tuple, self.selection_rect.normalized().getCoords())
1✔
302
        scaled_selection_rect = Rect(*selection_coords).scaled(self._get_scale_factor())
1✔
303

304
        self.clear_selection()
1✔
305

306
        # Emit as last action, cause self might get destroyed by the slots
307
        self.com.on_region_selected.emit((scaled_selection_rect, self.screen_.index))
1✔
308

309
    def changeEvent(self, event: QtCore.QEvent) -> None:  # noqa: N802
1✔
310
        """Update position on Wayland.
311

312
        This is a workaround to move windows to different displays in multi monitor
313
        setups on Wayland. Necessary, because under Wayland the application is not
314
        supposed to control the position of its windows.
315
        """
316
        super().changeEvent(event)
1✔
317
        if (
1✔
318
            event.type() == QtCore.QEvent.Type.ActivationChange
319
            and system_info.display_manager_is_wayland()
320
            and self.isActiveWindow()
321
            and not self.is_positioned
322
        ):
323
            self._position_windows_on_wayland()
×
324
            self.com.on_window_positioned.emit()
×
325

326
    def resizeEvent(self, event: QtGui.QResizeEvent) -> None:  # noqa: N802
1✔
327
        """Adjust child widget on resize."""
328
        super().resizeEvent(event)
1✔
329
        self.ui_container.resize(self.size())
1✔
330

331
    def showEvent(self, event: QtGui.QShowEvent) -> None:  # noqa: N802
1✔
332
        """Update background image on show/reshow."""
333
        super().showEvent(event)
1✔
334
        self._draw_background_image()
1✔
335

336

337
class UiContainerLabel(QtWidgets.QLabel):
1✔
338
    """Widget to draw border, selection rectangle and potentially debug infos."""
1✔
339

340
    def __init__(
1✔
341
        self,
342
        parent: QtWidgets.QWidget,
343
        color: QtGui.QColor,
344
        capture_mode_func: Callable,
345
    ) -> None:
346
        super().__init__(parent)
1✔
347

348
        self.color: QtGui.QColor = color
1✔
349

350
        self.debug_info: DebugInfo | None = None
1✔
351

352
        self.rect: QtCore.QRect = QtCore.QRect()
1✔
353
        self.rect_pen = QtGui.QPen(self.color, 2, QtCore.Qt.PenStyle.DashLine)
1✔
354
        self.get_capture_mode = capture_mode_func
1✔
355

356
        self.setObjectName("ui_container")
1✔
357
        self.setStyleSheet(f"#ui_container {{border: 3px solid {self.color.name()};}}")
1✔
358
        self.setCursor(QtCore.Qt.CursorShape.CrossCursor)
1✔
359
        self.setScaledContents(True)
1✔
360

361
    def _draw_debug_infos(self, painter: QtGui.QPainter, rect: QtCore.QRect) -> None:
1✔
362
        """Draw debug information to top left."""
363
        if (
1✔
364
            not self.debug_info
365
            or not self.debug_info.screen
366
            or not self.debug_info.screen.screenshot
367
            or not self.debug_info.window
368
        ):
369
            return
×
370

371
        selection = Rect(*cast(tuple, rect.normalized().getCoords()))
1✔
372
        selection_scaled = selection.scaled(self.debug_info.scale_factor)
1✔
373

374
        lines = (
1✔
375
            "[ Screen ]",
376
            f"Size: {self.debug_info.screen.size}",
377
            f"Position: {self.debug_info.screen.rect.coords}",
378
            f"Selected region: {selection.geometry}",
379
            f"Device pixel ratio: {self.debug_info.screen.device_pixel_ratio}",
380
            "",
381
            "[ Window ]",
382
            f"Size: {self.debug_info.window.size().toTuple()}",
383
            f"Position: {cast(tuple, self.debug_info.window.geometry().getCoords())}",
384
            f"Device pixel ratio: {self.debug_info.window.devicePixelRatio()}",
385
            "",
386
            "[ Screenshot ]",
387
            f"Size: {self.debug_info.screen.screenshot.size().toTuple()}",
388
            f"Selected region (scaled): {selection_scaled.geometry}",
389
            "",
390
            "[ Scaling detected ]",
391
            f"Factor: {self.debug_info.scale_factor:.2f}",
392
        )
393

394
        painter.setPen(QtGui.QColor(0, 0, 0, 0))
1✔
395
        painter.setBrush(QtGui.QColor(0, 0, 0, 175))
1✔
396
        painter.drawRect(3, 3, 300, 20 * len(lines) + 5)
1✔
397
        painter.setBrush(QtGui.QColor(0, 0, 0, 0))
1✔
398

399
        painter.setPen(self.color)
1✔
400
        painter.setFont(QtGui.QFont(QtGui.QFont().family(), 10, 600))
1✔
401
        for idx, line in enumerate(lines):
1✔
402
            painter.drawText(10, 20 * (idx + 1), line)
1✔
403

404
    def paintEvent(self, event: QtGui.QPaintEvent) -> None:  # noqa: N802
1✔
405
        """Draw selection rectangle and mode indicator icon."""
406
        super().paintEvent(event)
1✔
407

408
        if not (self.rect or self.debug_info):
1✔
409
            return
×
410

411
        painter = QtGui.QPainter(self)
1✔
412
        self.rect = self.rect.normalized()
1✔
413

414
        if self.debug_info:
1✔
415
            self._draw_debug_infos(painter, self.rect)
1✔
416

417
        if not self.rect:
1✔
418
            return
1✔
419

420
        painter.setPen(self.rect_pen)
×
421
        painter.drawRect(self.rect)
×
422

423
        if self.get_capture_mode() is CaptureMode.PARSE:
×
424
            mode_icon = QtGui.QIcon(":parse")
×
425
        else:
426
            mode_icon = QtGui.QIcon(":raw")
×
427
        mode_icon.paint(painter, self.rect.right() - 24, self.rect.top() - 30, 24, 24)
×
428

429
        painter.end()
×
  • Back to Build 6365911097
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc