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

dynobo / normcap / 18953976799

30 Oct 2025 08:21PM UTC coverage: 80.119% (-1.4%) from 81.509%
18953976799

Pull #798

github

web-flow
Merge a4477b844 into 50be529a6
Pull Request #798: Refactoring

858 of 1175 new or added lines in 51 files covered. (73.02%)

23 existing lines in 5 files now uncovered.

2974 of 3712 relevant lines covered (80.12%)

4.57 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.92
/normcap/gui/window.py
1
"""NormCap window.
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
import logging
6✔
14
from collections.abc import Callable
6✔
15
from dataclasses import dataclass
6✔
16
from typing import cast
6✔
17

18
from PySide6 import QtCore, QtGui, QtWidgets
6✔
19

20
from normcap import positioning
6✔
21
from normcap.gui.menu_button import MenuButton
6✔
22
from normcap.gui.settings import Settings
6✔
23
from normcap.platform import system_info
6✔
24
from normcap.platform.models import DesktopEnvironment, Rect, Screen
6✔
25

26
logger = logging.getLogger(__name__)
6✔
27

28

29
@dataclass
6✔
30
class DebugInfo:
6✔
31
    screen: Screen | None = None
6✔
32
    window: QtWidgets.QMainWindow | None = None
6✔
33
    scale_factor: float = 1
6✔
34

35

36
class UiContainerLabel(QtWidgets.QLabel):
6✔
37
    """Widget to draw border, selection rectangle and potentially debug infos."""
38

39
    def __init__(
6✔
40
        self,
41
        parent: QtWidgets.QWidget,
42
        color: QtGui.QColor,
43
        parse_text_func: Callable,
44
    ) -> None:
45
        super().__init__(parent)
6✔
46

47
        self.color: QtGui.QColor = color
6✔
48

49
        self.debug_info: DebugInfo | None = None
6✔
50

51
        self.rect: QtCore.QRect = QtCore.QRect()
6✔
52
        self.rect_pen = QtGui.QPen(self.color, 2, QtCore.Qt.PenStyle.DashLine)
6✔
53
        self.get_parse_text = parse_text_func
6✔
54

55
        self.setObjectName("ui_container")
6✔
56
        self.setStyleSheet(f"#ui_container {{border: 3px solid {self.color.name()};}}")
6✔
57
        self.setCursor(QtCore.Qt.CursorShape.CrossCursor)
6✔
58
        self.setScaledContents(True)
6✔
59

60
    def _draw_debug_infos(self, painter: QtGui.QPainter, rect: QtCore.QRect) -> None:
6✔
61
        """Draw debug information to top left."""
NEW
62
        if (
×
63
            not self.debug_info
64
            or not self.debug_info.screen
65
            or not self.debug_info.screen.screenshot
66
            or not self.debug_info.window
67
        ):
NEW
68
            return
×
69

NEW
70
        selection = Rect(*cast(tuple, rect.normalized().getCoords()))
×
NEW
71
        selection_scaled = selection.scale(self.debug_info.scale_factor)
×
72

NEW
73
        lines = (
×
74
            "[ Screen ]",
75
            f"Size: {self.debug_info.screen.size}",
76
            f"Position: {self.debug_info.screen.coords}",
77
            f"Device pixel ratio: {self.debug_info.screen.device_pixel_ratio}",
78
            "",
79
            "[ Window ]",
80
            f"Size: {self.debug_info.window.size().toTuple()}",
81
            f"Position: {cast(tuple, self.debug_info.window.geometry().getCoords())}",
82
            f"Device pixel ratio: {self.debug_info.window.devicePixelRatio()}",
83
            f"Selected region: {selection.coords}",
84
            "",
85
            "[ Screenshot ]",
86
            f"Size: {self.debug_info.screen.screenshot.size().toTuple()}",
87
            f"Selected region (scaled): {selection_scaled.coords}",
88
            "",
89
            "[ Scaling detected ]",
90
            f"Factor: {self.debug_info.scale_factor:.2f}",
91
        )
92

NEW
93
        painter.setPen(QtGui.QColor(0, 0, 0, 0))
×
NEW
94
        painter.setBrush(QtGui.QColor(0, 0, 0, 175))
×
NEW
95
        painter.drawRect(3, 3, 300, 20 * len(lines) + 5)
×
NEW
96
        painter.setBrush(QtGui.QColor(0, 0, 0, 0))
×
97

NEW
98
        painter.setPen(self.color)
×
NEW
99
        painter.setFont(QtGui.QFont(QtGui.QFont().family(), 10, 600))
×
NEW
100
        for idx, line in enumerate(lines):
×
NEW
101
            painter.drawText(10, 20 * (idx + 1), line)
×
102

103
    def paintEvent(self, event: QtGui.QPaintEvent) -> None:  # noqa: N802
6✔
104
        """Draw selection rectangle and mode indicator icon."""
105
        super().paintEvent(event)
6✔
106

107
        if not (self.rect or self.debug_info):
6✔
108
            return
6✔
109

NEW
110
        painter = QtGui.QPainter(self)
×
NEW
111
        self.rect = self.rect.normalized()
×
112

NEW
113
        if self.debug_info:
×
NEW
114
            self._draw_debug_infos(painter, self.rect)
×
115

NEW
116
        if not self.rect:
×
NEW
117
            return
×
118

NEW
119
        painter.setPen(self.rect_pen)
×
NEW
120
        painter.drawRect(self.rect)
×
121

NEW
122
        if self.get_parse_text():
×
NEW
123
            selection_icon = QtGui.QIcon(":parse")
×
124
        else:
NEW
125
            selection_icon = QtGui.QIcon(":raw")
×
NEW
126
        selection_icon.paint(
×
127
            painter, self.rect.right() - 24, self.rect.top() - 30, 24, 24
128
        )
129

NEW
130
        painter.end()
×
131

132

133
class Communicate(QtCore.QObject):
6✔
134
    """Window's communication bus."""
135

136
    on_esc_key_pressed = QtCore.Signal()
6✔
137
    on_region_selected = QtCore.Signal(Rect, int)
6✔
138

139

140
class Window(QtWidgets.QMainWindow):
6✔
141
    """Provide fullscreen UI for interacting with NormCap."""
142

143
    def __init__(
6✔
144
        self,
145
        screen: Screen,
146
        index: int,
147
        settings: Settings,
148
        installed_languages: list[str],
149
        debug_language_manager: bool = False,
150
    ) -> None:
151
        """Initialize window."""
152
        super().__init__()
6✔
153
        logger.debug("Create window for screen %s", screen.index)
6✔
154

155
        self.screen_ = screen
6✔
156
        self.index = index
6✔
157
        self.settings = settings
6✔
158
        self.installed_languages = installed_languages
6✔
159
        self.debug_language_manager = debug_language_manager
6✔
160

161
        self.com = Communicate(parent=self)
6✔
162
        self.color: QtGui.QColor = QtGui.QColor(str(settings.value("color")))
6✔
163

164
        self.setWindowTitle(f"NormCap [{screen.index}]")
6✔
165
        self.setWindowIcon(QtGui.QIcon(":normcap"))
6✔
166
        self.setWindowFlags(
6✔
167
            QtGui.Qt.WindowType.FramelessWindowHint
168
            | QtGui.Qt.WindowType.CustomizeWindowHint
169
            | QtGui.Qt.WindowType.WindowStaysOnTopHint
170
        )
171
        self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
6✔
172
        self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose)
6✔
173
        self.setAnimated(False)
6✔
174
        self.setEnabled(True)
6✔
175

176
        self.selection_rect: QtCore.QRect = QtCore.QRect()
6✔
177

178
        self.image_container = QtWidgets.QLabel(scaledContents=True)
6✔
179
        self.setCentralWidget(self.image_container)
6✔
180
        self.ui_container = self._create_ui_container(
6✔
181
            geometry=self.image_container.geometry()
182
        )
183

184
        self.menu_button = None
6✔
185

186
        if self.index == 0:
6✔
187
            self.menu_button = self._create_menu_button()
6✔
188
            layout = self._create_layout()
6✔
189
            layout.addWidget(self.menu_button, 0, 1)
6✔
190
            self.ui_container.setLayout(layout)
6✔
191

192
    def _get_scale_factor(self) -> float:
6✔
193
        """Calculate scale factor from image and screen dimensions."""
194
        if not self.screen_.screenshot:
6✔
195
            raise ValueError("Screenshot image is missing!")
6✔
196
        return self.screen_.screenshot.width() / self.width()
6✔
197

198
    def _create_ui_container(self, geometry: QtCore.QRect) -> UiContainerLabel:
6✔
199
        """Add widget for showing selection rectangle and settings button."""
200
        ui_container = UiContainerLabel(
6✔
201
            parent=self,
202
            color=self.color,
203
            parse_text_func=lambda: bool(self.settings.value("parse-text", type=bool)),
204
        )
205

206
        if logger.getEffectiveLevel() is logging.DEBUG:
6✔
NEW
207
            ui_container.debug_info = DebugInfo(
×
208
                scale_factor=self._get_scale_factor(), screen=self.screen_, window=self
209
            )
210

211
        ui_container.color = self.color
6✔
212
        ui_container.setGeometry(geometry)
6✔
213
        ui_container.raise_()
6✔
214
        return ui_container
6✔
215

216
    def _draw_background_image(self) -> None:
6✔
217
        """Draw screenshot as background image."""
218
        pixmap = QtGui.QPixmap()
6✔
219
        pixmap.convertFromImage(self.screen_.screenshot)
6✔
220
        self.image_container.setPixmap(pixmap)
6✔
221

222
    def _create_menu_button(self) -> QtWidgets.QWidget:
6✔
223
        menu_button = MenuButton(
6✔
224
            settings=self.settings,
225
            show_language_manager=self.debug_language_manager
226
            or system_info.is_packaged(),
227
            installed_languages=self.installed_languages,
228
        )
229
        return menu_button
6✔
230

231
    @staticmethod
6✔
232
    def _create_layout() -> QtWidgets.QGridLayout:
6✔
233
        layout = QtWidgets.QGridLayout()
6✔
234
        layout.setContentsMargins(26, 26, 26, 26)
6✔
235
        layout.setRowStretch(1, 1)
6✔
236
        layout.setColumnStretch(0, 1)
6✔
237
        return layout
6✔
238

239
    def set_fullscreen(self) -> None:
6✔
240
        """Set window to full screen using platform specific methods."""
241
        # TODO: Test in Multi Display setups with different scaling
242
        # TODO: Test in Multi Display setups with different position
243
        # TODO: Position in Multi Display probably problematic!
244
        logger.debug("Set window of screen %s to fullscreen", self.screen_.index)
6✔
245

246
        if not self.screen_.screenshot:
6✔
247
            raise ValueError(f"Screenshot is missing on screen {self.screen_}")
×
248

249
        # Using scaled window dims to fit sizing with dpr in case scaling is enabled
250
        # See: https://github.com/dynobo/normcap/issues/397
251
        if (
6✔
252
            system_info.display_manager_is_wayland()
253
            and self.screen_.size == self.screen_.screenshot.size().toTuple()
254
            and self.screen_.device_pixel_ratio != 1
255
        ):
256
            # TODO: Check if still necessary on latest supported Ubuntu.
257
            # If not, remove Screen.scale() and this condition.
258
            self.setGeometry(*self.screen_.scale().geometry)
×
259
        else:
260
            self.setGeometry(*self.screen_.geometry)
6✔
261

262
        if system_info.desktop_environment() != DesktopEnvironment.UNITY:
6✔
263
            # On some DEs, setting a fixed window size can enforce the correct size.
264
            # However, on Unity, it breaks the full screen view.
265
            self.setMinimumSize(self.geometry().size())
6✔
266
            self.setMaximumSize(self.geometry().size())
6✔
267

268
        if system_info.display_manager_is_wayland():
6✔
269
            # For unknown reason .showFullScreen() on Ubuntu 24.04 does not show the
270
            # window. Showing the Window in normal state upfront seems to help.
271
            # (It seems like .setWindowState(WindowFullScreen) should not be set before
272
            # .setVisible(True) on that system. Might be a QT bug.)
273
            self.show()
×
274

275
        self.showFullScreen()
6✔
276
        self.setFocus()
6✔
277

278
        # On Wayland, setting geometry doesn't move the window to the right screen, as
279
        # only the compositor is allowed to do this. That's why in case of multi-display
280
        # setups, we need to use hacks to position the window:
281
        if system_info.display_manager_is_wayland():
6✔
282
            # The delay should ensure window is active & registered in window manager.
283
            QtCore.QTimer.singleShot(
×
284
                20, lambda: positioning.move(window=self, screen=self.screen_)
285
            )
286

287
    def clear_selection(self) -> None:
6✔
288
        self.selection_rect = QtCore.QRect()
6✔
289
        self.ui_container.rect = self.selection_rect
6✔
290
        self.update()
6✔
291

292
    def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:  # noqa: N802
6✔
293
        """Handle ESC key pressed.
294

295
        Cancels the selection progress (if ongoing), otherwise emit signal.
296
        """
297
        super().keyPressEvent(event)
6✔
298
        if event.key() == QtCore.Qt.Key.Key_Escape:
6✔
299
            if self.selection_rect:
6✔
300
                self.clear_selection()
6✔
301
            else:
302
                self.com.on_esc_key_pressed.emit()
6✔
303

304
    def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:  # noqa: N802
6✔
305
        """Handle left mouse button clicked.
306

307
        Creates a new selection rectangle with both points set to pointer position.
308
        Note that the points "topLeft" and "bottomRight" are more like start and end
309
        points and not necessarily relate to the geometrical position.
310

311
        The selection rectangle will be normalized just before emitting the signal in
312
        the mouseReleaseEvent.
313
        """
314
        super().mousePressEvent(event)
6✔
315
        if event.button() == QtCore.Qt.MouseButton.LeftButton:
6✔
316
            self.selection_rect = QtCore.QRect()
6✔
317
            self.selection_rect.setTopLeft(event.position().toPoint())
6✔
318
            self.selection_rect.setBottomRight(event.position().toPoint())
6✔
319
            self.ui_container.rect = self.selection_rect
6✔
320
            self.update()
6✔
321

322
    def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:  # noqa: N802
6✔
323
        """Update position of bottom right point of selection rectangle."""
324
        super().mouseMoveEvent(event)
6✔
325
        if self.selection_rect:
6✔
326
            self.selection_rect.setBottomRight(event.position().toPoint())
6✔
327
            self.ui_container.rect = self.selection_rect
6✔
328
            self.update()
6✔
329

330
    def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None:  # noqa: N802
6✔
331
        """Start OCR workflow on left mouse button release."""
332
        super().mouseReleaseEvent(event)
6✔
333
        if (
6✔
334
            event.button() != QtCore.Qt.MouseButton.LeftButton
335
            or not self.selection_rect
336
        ):
337
            return
×
338

339
        self.selection_rect.setBottomRight(event.position().toPoint())
6✔
340

341
        selection_coords = cast(tuple, self.selection_rect.normalized().getCoords())
6✔
342
        scaled_selection_rect = Rect(*selection_coords).scale(self._get_scale_factor())
6✔
343

344
        self.clear_selection()
6✔
345

346
        # Emit as last action, cause self might get destroyed by the slots
347
        self.com.on_region_selected.emit(scaled_selection_rect, self.screen_.index)
6✔
348

349
    def resizeEvent(self, event: QtGui.QResizeEvent) -> None:  # noqa: N802
6✔
350
        """Adjust child widget on resize."""
351
        super().resizeEvent(event)
6✔
352
        self.ui_container.resize(self.size())
6✔
353
        if self.ui_container.debug_info:
6✔
UNCOV
354
            self.ui_container.debug_info.scale_factor = self._get_scale_factor()
×
355

356
    def showEvent(self, event: QtGui.QShowEvent) -> None:  # noqa: N802
6✔
357
        """Update background image on show/reshow."""
358
        super().showEvent(event)
6✔
359
        self._draw_background_image()
6✔
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

© 2026 Coveralls, Inc