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

dynobo / normcap / 14280746188

05 Apr 2025 09:50AM UTC coverage: 84.841% (-2.2%) from 87.002%
14280746188

Pull #690

github

web-flow
Merge d28da9558 into c7b17ed4a
Pull Request #690: feature/add-qr-support

318 of 410 new or added lines in 35 files covered. (77.56%)

5 existing lines in 2 files now uncovered.

2569 of 3028 relevant lines covered (84.84%)

3.24 hits per line

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

95.41
/normcap/gui/notification.py
1
import logging
4✔
2
import os
4✔
3
import shutil
4✔
4
import subprocess
4✔
5
import sys
4✔
6
import tempfile
4✔
7
import textwrap
4✔
8
from pathlib import Path
4✔
9
from typing import Optional
4✔
10

11
from PySide6 import QtCore, QtGui, QtWidgets
4✔
12

13
from normcap.detection.models import TextDetector, TextType
4✔
14
from normcap.gui import system_info
4✔
15
from normcap.gui.localization import _, translate
4✔
16

17
logger = logging.getLogger(__name__)
4✔
18

19

20
def _compose_title(text: str, result_type: TextType, detector: TextDetector) -> str:
4✔
21
    if not text:
4✔
NEW
22
        return ""
×
23

24
    if detector == TextDetector.QR:
4✔
25
        count = text.count(os.linesep) + 1
4✔
26
        # L10N: Notification title.
27
        # Do NOT translate the variables in curly brackets "{some_variable}"!
28
        title = translate.ngettext(
4✔
29
            "1 QR code detected", "{count} QR codes detected", count
30
        ).format(count=count)
31
    elif detector == TextDetector.BARCODE:
4✔
NEW
32
        count = text.count(os.linesep) + 1
×
33
        # L10N: Notification title.
34
        # Do NOT translate the variables in curly brackets "{some_variable}"!
NEW
35
        title = translate.ngettext(
×
36
            "1 barcode detected", "{count} barcodes detected", count
37
        ).format(count=count)
38
    elif detector == TextDetector.QR_AND_BARCODE:
4✔
39
        count = text.count(os.linesep) + 1
4✔
40
        # L10N: Notification title.
41
        # Do NOT translate the variables in curly brackets "{some_variable}"!
42
        title = translate.ngettext(
4✔
43
            "1 code detected", "{count} codes detected", count
44
        ).format(count=count)
45
    elif result_type == TextType.PARAGRAPH:
4✔
46
        count = text.count(os.linesep * 2) + 1
4✔
47
        # L10N: Notification title.
48
        # Do NOT translate the variables in curly brackets "{some_variable}"!
49
        title = translate.ngettext(
4✔
50
            "1 paragraph captured", "{count} paragraphs captured", count
51
        ).format(count=count)
52
    elif result_type == TextType.MAIL:
4✔
53
        count = text.count("@")
4✔
54
        # L10N: Notification title.
55
        # Do NOT translate the variables in curly brackets "{some_variable}"!
56
        title = translate.ngettext(
4✔
57
            "1 email captured", "{count} emails captured", count
58
        ).format(count=count)
59
    elif result_type == TextType.SINGLE_LINE:
4✔
60
        count = text.count(" ") + 1
4✔
61
        # L10N: Notification title.
62
        # Do NOT translate the variables in curly brackets "{some_variable}"!
63
        title = translate.ngettext(
4✔
64
            "1 word captured", "{count} words captured", count
65
        ).format(count=count)
66
    elif result_type == TextType.MULTI_LINE:
4✔
67
        count = text.count(os.linesep) + 1
4✔
68
        # L10N: Notification title.
69
        # Do NOT translate the variables in curly brackets "{some_variable}"!
70
        title = translate.ngettext(
4✔
71
            "1 line captured", "{count} lines captured", count
72
        ).format(count=count)
73
    elif result_type == TextType.URL:
4✔
74
        count = text.count(os.linesep) + 1
4✔
75
        # L10N: Notification title.
76
        # Do NOT translate the variables in curly brackets "{some_variable}"!
77
        title = translate.ngettext(
4✔
78
            "1 URL captured", "{count} URLs captured", count
79
        ).format(count=count)
80
    else:
81
        count = len(text)
4✔
82
        # Count linesep only as single char:
83
        count -= (len(os.linesep) - 1) * text.count(os.linesep)
4✔
84
        # L10N: Notification title.
85
        # Do NOT translate the variables in curly brackets "{some_variable}"!
86
        title = translate.ngettext(
4✔
87
            "1 character captured", "{count} characters captured", count
88
        ).format(count=count)
89
    return title
4✔
90

91

92
def _compose_text(text: str) -> str:
4✔
93
    if not text:
4✔
NEW
94
        return ""
×
95

96
    text = text.strip().replace(os.linesep, " ")
4✔
97
    shortened = textwrap.shorten(text, width=45, placeholder=" […]").strip()
4✔
98
    if shortened == "[…]":
4✔
99
        # We have one long word which shorten() can not handle
NEW
100
        shortened = text
×
101

102
    return shortened
4✔
103

104

105
def _compose_notification(
4✔
106
    text: str, result_type: TextType, detector: TextDetector
107
) -> tuple[str, str]:
108
    """Extract message text out of captures object and include icon."""
109
    # Compose message text
110
    if text and text.strip():
4✔
111
        title = _compose_title(text=text, result_type=result_type, detector=detector)
4✔
112
        text = _compose_text(text=text)
4✔
113
    else:
114
        # L10N: Notification title
115
        title = _("Nothing captured!")
4✔
116
        # L10N: Notification text
117
        text = _("Please try again.")
4✔
118

119
    return title, text
4✔
120

121

122
class Communicate(QtCore.QObject):
4✔
123
    """Notifier's communication bus."""
124

125
    send_notification = QtCore.Signal(str, str, str)
4✔
126
    on_notification_sent = QtCore.Signal()
4✔
127

128

129
class Notifier(QtCore.QObject):
4✔
130
    """Send notifications."""
131

132
    def __init__(self, parent: Optional[QtCore.QObject]) -> None:
4✔
133
        super().__init__(parent=parent)
4✔
134
        self.com = Communicate(parent=self)
4✔
135
        self.com.send_notification.connect(self._send_notification)
4✔
136

137
    @QtCore.Slot(str, str, str)  # type: ignore  # pyside typhint bug?
4✔
138
    def _send_notification(
4✔
139
        self, text: str, result_type: TextType, detector: TextDetector
140
    ) -> None:
141
        """Show tray icon then send notification."""
142
        title, message = _compose_notification(
4✔
143
            text=text, result_type=result_type, detector=detector
144
        )
145
        if sys.platform == "linux" and shutil.which("notify-send"):
4✔
146
            self._send_via_libnotify(title=title, message=message)
4✔
147
        else:
148
            self._send_via_qt_tray(
4✔
149
                title=title,
150
                message=message,
151
                text=text,
152
                text_type=result_type,
153
            )
154
        self.com.on_notification_sent.emit()
4✔
155

156
    @staticmethod
4✔
157
    def _send_via_libnotify(title: str, message: str) -> None:
4✔
158
        """Send via notify-send.
159

160
        Seems to work more reliable on Linux + Gnome, but requires libnotify.
161
        Running in detached mode to avoid freezing KDE bar in some distributions.
162

163
        A drawback is, that it's difficult to receive clicks on the notification
164
        like it's done with the Qt method. `notify-send` _is_ able to support this,
165
        but it would require leaving the subprocess running and monitoring its output,
166
        which doesn't feel very solid.
167

168
        ONHOLD: Switch from notify-send to org.freedesktop.Notifications.
169
        A cleaner way would be to use DBUS org.freedesktop.Notifications instead of
170
        notify-send, but this seems to be quite difficult to implement with QtDbus,
171
        where the types seem not to be correctly casted to DBUS-Types. A future PySide
172
        version might improve the situation.
173
        """
174
        logger.debug("Send notification via notify-send")
4✔
175
        icon_path = system_info.get_resources_path() / "icons" / "notification.png"
4✔
176

177
        # Escape chars interpreted by notify-send
178
        message = message.replace("\\", "\\\\")
4✔
179
        message = message.replace("-", "\\-")
4✔
180

181
        cmds = [
4✔
182
            "notify-send",
183
            f"--icon={icon_path.resolve()}",
184
            "--app-name=NormCap",
185
            f"{title}",
186
            f"{message}",
187
        ]
188

189
        # Left detached on purpose!
190
        subprocess.Popen(cmds, start_new_session=True)  # noqa: S603
4✔
191

192
    def _send_via_qt_tray(
4✔
193
        self,
194
        title: str,
195
        message: str,
196
        text: str,
197
        text_type: TextType,
198
    ) -> None:
199
        """Send via QSystemTrayIcon.
200

201
        Used for:
202
            - Windows
203
            - macOS
204
            - Linux (Fallback in case no notify-send)
205

206
        On Linux, this method has draw backs (probably Qt Bugs):
207
            - The custom icon is ignored. Instead the default icon
208
              `QtWidgets.QSystemTrayIcon.MessageIcon.Information` is shown.
209
            - Notifications clicks are not received. It _does_ work, if
210
              `QtWidgets.QSystemTrayIcon.MessageIcon.Critical` is used as icon.
211
        """
212
        logger.debug("Send notification via QT")
4✔
213

214
        parent = self.parent()
4✔
215

216
        if not isinstance(parent, QtWidgets.QSystemTrayIcon):
4✔
217
            raise TypeError("Parent is expected to be of type QSystemTrayIcon.")
4✔
218

219
        # Because clicks on different notifications can not be distinguished in Qt,
220
        # only the last notification is associated with an action/signal. All previous
221
        # get removed.
222
        if parent.isSignalConnected(
4✔
223
            QtCore.QMetaMethod.fromSignal(parent.messageClicked)
224
        ):
225
            parent.messageClicked.disconnect()
4✔
226

227
        # It only makes sense to act on notification clicks, if we have a result.
228
        if text and len(text.strip()) >= 1:
4✔
229
            parent.messageClicked.connect(
4✔
230
                lambda: self._open_ocr_result(text=text, text_type=text_type)
231
            )
232

233
        parent.show()
4✔
234
        parent.showMessage(title, message, QtGui.QIcon(":notification"))
4✔
235

236
    @staticmethod
4✔
237
    def _open_ocr_result(text: str, text_type: TextType) -> None:
4✔
238
        logger.debug("Notification clicked.")
4✔
239

240
        urls = []
4✔
241
        if text_type == TextType.URL:
4✔
242
            urls = text.split()
4✔
243
        elif text_type == TextType.MAIL:
4✔
244
            urls = [f"mailto:{text.replace(',', ';').replace(' ', '')}"]
4✔
245
        else:
246
            temp_file = Path(tempfile.gettempdir()) / "normcap_temporary_result.txt"
4✔
247
            temp_file.write_text(text)
4✔
248
            urls = [temp_file.as_uri()]
4✔
249

250
        for url in urls:
4✔
251
            logger.debug("Opening URI %s...", url)
4✔
252
            result = QtGui.QDesktopServices.openUrl(
4✔
253
                QtCore.QUrl(url, QtCore.QUrl.ParsingMode.TolerantMode)
254
            )
255
            logger.debug("Opened URI with result=%s", result)
4✔
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