1#!/usr/local/bin/python3.8
2# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
3
4
5__license__   = 'GPL v3'
6__copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>'
7__docformat__ = 'restructuredtext en'
8
9import sys
10from qt.core import (
11    QAction, QApplication, QCheckBox, QDialog, QDialogButtonBox, QGridLayout, QIcon,
12    QKeySequence, QLabel, QPainter, QPlainTextEdit, QSize, QSizePolicy, Qt,
13    QTextBrowser, QTextDocument, QVBoxLayout, QWidget, pyqtSignal
14)
15
16from calibre.constants import __version__, isfrozen
17from calibre.gui2 import gprefs
18
19
20class Icon(QWidget):
21
22    def __init__(self, parent=None, size=None):
23        QWidget.__init__(self, parent)
24        self.pixmap = None
25        self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
26        self.size = size or 64
27
28    def set_icon(self, qicon):
29        self.pixmap = qicon.pixmap(self.size, self.size)
30        self.update()
31
32    def sizeHint(self):
33        return QSize(self.size, self.size)
34
35    def paintEvent(self, ev):
36        if self.pixmap is not None:
37            x = (self.width() - self.size) // 2
38            y = (self.height() - self.size) // 2
39            p = QPainter(self)
40            p.drawPixmap(x, y, self.size, self.size, self.pixmap)
41
42
43class MessageBox(QDialog):  # {{{
44
45    ERROR = 0
46    WARNING = 1
47    INFO = 2
48    QUESTION = 3
49
50    resize_needed = pyqtSignal()
51
52    def setup_ui(self):
53        self.setObjectName("Dialog")
54        self.resize(497, 235)
55        self.gridLayout = l = QGridLayout(self)
56        l.setObjectName("gridLayout")
57        self.icon_widget = Icon(self)
58        l.addWidget(self.icon_widget)
59        self.msg = la = QLabel(self)
60        la.setWordWrap(True), la.setMinimumWidth(400)
61        la.setOpenExternalLinks(True)
62        la.setObjectName("msg")
63        l.addWidget(la, 0, 1, 1, 1)
64        self.det_msg = dm = QTextBrowser(self)
65        dm.setReadOnly(True)
66        dm.setObjectName("det_msg")
67        l.addWidget(dm, 1, 0, 1, 2)
68        self.bb = bb = QDialogButtonBox(self)
69        bb.setStandardButtons(QDialogButtonBox.StandardButton.Ok)
70        bb.setObjectName("bb")
71        bb.accepted.connect(self.accept)
72        bb.rejected.connect(self.reject)
73        l.addWidget(bb, 3, 0, 1, 2)
74        self.toggle_checkbox = tc = QCheckBox(self)
75        tc.setObjectName("toggle_checkbox")
76        l.addWidget(tc, 2, 0, 1, 2)
77
78    def __init__(self, type_, title, msg,
79                 det_msg='',
80                 q_icon=None,
81                 show_copy_button=True,
82                 parent=None, default_yes=True,
83                 yes_text=None, no_text=None, yes_icon=None, no_icon=None,
84                 add_abort_button=False,
85                 only_copy_details=False
86    ):
87        QDialog.__init__(self, parent)
88        self.only_copy_details = only_copy_details
89        self.aborted = False
90        if q_icon is None:
91            icon = {
92                    self.ERROR : 'error',
93                    self.WARNING: 'warning',
94                    self.INFO:    'information',
95                    self.QUESTION: 'question',
96            }[type_]
97            icon = 'dialog_%s.png'%icon
98            self.icon = QIcon(I(icon))
99        else:
100            self.icon = q_icon if isinstance(q_icon, QIcon) else QIcon(I(q_icon))
101        self.setup_ui()
102
103        self.setWindowTitle(title)
104        self.setWindowIcon(self.icon)
105        self.icon_widget.set_icon(self.icon)
106        self.msg.setText(msg)
107        if det_msg and Qt.mightBeRichText(det_msg):
108            self.det_msg.setHtml(det_msg)
109        else:
110            self.det_msg.setPlainText(det_msg)
111        self.det_msg.setVisible(False)
112        self.toggle_checkbox.setVisible(False)
113
114        if show_copy_button:
115            self.ctc_button = self.bb.addButton(_('&Copy to clipboard'),
116                    QDialogButtonBox.ButtonRole.ActionRole)
117            self.ctc_button.clicked.connect(self.copy_to_clipboard)
118
119        self.show_det_msg = _('Show &details')
120        self.hide_det_msg = _('Hide &details')
121        self.det_msg_toggle = self.bb.addButton(self.show_det_msg, QDialogButtonBox.ButtonRole.ActionRole)
122        self.det_msg_toggle.clicked.connect(self.toggle_det_msg)
123        self.det_msg_toggle.setToolTip(
124                _('Show detailed information about this error'))
125
126        self.copy_action = QAction(self)
127        self.addAction(self.copy_action)
128        self.copy_action.setShortcuts(QKeySequence.StandardKey.Copy)
129        self.copy_action.triggered.connect(self.copy_to_clipboard)
130
131        self.is_question = type_ == self.QUESTION
132        if self.is_question:
133            self.bb.setStandardButtons(QDialogButtonBox.StandardButton.Yes|QDialogButtonBox.StandardButton.No)
134            self.bb.button(QDialogButtonBox.StandardButton.Yes if default_yes else QDialogButtonBox.StandardButton.No
135                    ).setDefault(True)
136            self.default_yes = default_yes
137            if yes_text is not None:
138                self.bb.button(QDialogButtonBox.StandardButton.Yes).setText(yes_text)
139            if no_text is not None:
140                self.bb.button(QDialogButtonBox.StandardButton.No).setText(no_text)
141            if yes_icon is not None:
142                self.bb.button(QDialogButtonBox.StandardButton.Yes).setIcon(yes_icon if isinstance(yes_icon, QIcon) else QIcon(I(yes_icon)))
143            if no_icon is not None:
144                self.bb.button(QDialogButtonBox.StandardButton.No).setIcon(no_icon if isinstance(no_icon, QIcon) else QIcon(I(no_icon)))
145        else:
146            self.bb.button(QDialogButtonBox.StandardButton.Ok).setDefault(True)
147
148        if add_abort_button:
149            self.bb.addButton(QDialogButtonBox.StandardButton.Abort).clicked.connect(self.on_abort)
150
151        if not det_msg:
152            self.det_msg_toggle.setVisible(False)
153
154        self.resize_needed.connect(self.do_resize, type=Qt.ConnectionType.QueuedConnection)
155        self.do_resize()
156
157    def on_abort(self):
158        self.aborted = True
159
160    def sizeHint(self):
161        ans = QDialog.sizeHint(self)
162        ans.setWidth(max(min(ans.width(), 500), self.bb.sizeHint().width() + 100))
163        ans.setHeight(min(ans.height(), 500))
164        return ans
165
166    def toggle_det_msg(self, *args):
167        vis = self.det_msg.isVisible()
168        self.det_msg.setVisible(not vis)
169        self.det_msg_toggle.setText(self.show_det_msg if vis else self.hide_det_msg)
170        self.resize_needed.emit()
171
172    def do_resize(self):
173        self.resize(self.sizeHint())
174
175    def copy_to_clipboard(self, *args):
176        text = self.det_msg.toPlainText()
177        if not self.only_copy_details:
178            text = f'calibre, version {__version__}\n{self.windowTitle()}: {self.msg.text()}\n\n{text}'
179        QApplication.clipboard().setText(text)
180        if hasattr(self, 'ctc_button'):
181            self.ctc_button.setText(_('Copied'))
182
183    def showEvent(self, ev):
184        ret = QDialog.showEvent(self, ev)
185        if self.is_question:
186            try:
187                self.bb.button(QDialogButtonBox.StandardButton.Yes if self.default_yes else QDialogButtonBox.StandardButton.No
188                        ).setFocus(Qt.FocusReason.OtherFocusReason)
189            except:
190                pass  # Buttons were changed
191        else:
192            self.bb.button(QDialogButtonBox.StandardButton.Ok).setFocus(Qt.FocusReason.OtherFocusReason)
193        return ret
194
195    def set_details(self, msg):
196        if not msg:
197            msg = ''
198        if Qt.mightBeRichText(msg):
199            self.det_msg.setHtml(msg)
200        else:
201            self.det_msg.setPlainText(msg)
202        self.det_msg_toggle.setText(self.show_det_msg)
203        self.det_msg_toggle.setVisible(bool(msg))
204        self.det_msg.setVisible(False)
205        self.resize_needed.emit()
206# }}}
207
208
209class ViewLog(QDialog):  # {{{
210
211    def __init__(self, title, html, parent=None, unique_name=None):
212        QDialog.__init__(self, parent)
213        self.l = l = QVBoxLayout()
214        self.setLayout(l)
215
216        self.tb = QTextBrowser(self)
217        self.tb.setHtml('<pre style="font-family: monospace">%s</pre>' % html)
218        l.addWidget(self.tb)
219
220        self.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
221        self.bb.accepted.connect(self.accept)
222        self.bb.rejected.connect(self.reject)
223        self.copy_button = self.bb.addButton(_('Copy to clipboard'),
224                QDialogButtonBox.ButtonRole.ActionRole)
225        self.copy_button.setIcon(QIcon(I('edit-copy.png')))
226        self.copy_button.clicked.connect(self.copy_to_clipboard)
227        l.addWidget(self.bb)
228
229        self.unique_name = unique_name or 'view-log-dialog'
230        self.finished.connect(self.dialog_closing)
231        self.resize(QSize(700, 500))
232        geom = gprefs.get(self.unique_name, None)
233        if geom is not None:
234            QApplication.instance().safe_restore_geometry(self, geom)
235
236        self.setModal(False)
237        self.setWindowTitle(title)
238        self.setWindowIcon(QIcon(I('debug.png')))
239        self.show()
240
241    def copy_to_clipboard(self):
242        txt = self.tb.toPlainText()
243        QApplication.clipboard().setText(txt)
244
245    def dialog_closing(self, result):
246        gprefs[self.unique_name] = bytearray(self.saveGeometry())
247# }}}
248
249
250_proceed_memory = []
251
252
253class ProceedNotification(MessageBox):  # {{{
254
255    '''
256    WARNING: This class is deprecated. DO not use it as some users have
257    reported crashes when closing the dialog box generated by this class.
258    Instead use: gui.proceed_question(...) The arguments are the same as for
259    this class.
260    '''
261
262    def __init__(self, callback, payload, html_log, log_viewer_title, title, msg,
263            det_msg='', show_copy_button=False, parent=None,
264            cancel_callback=None, log_is_file=False):
265        '''
266        A non modal popup that notifies the user that a background task has
267        been completed.
268
269        :param callback: A callable that is called with payload if the user
270        asks to proceed. Note that this is always called in the GUI thread.
271        :param cancel_callback: A callable that is called with the payload if
272        the users asks not to proceed.
273        :param payload: Arbitrary object, passed to callback
274        :param html_log: An HTML or plain text log
275        :param log_viewer_title: The title for the log viewer window
276        :param title: The title for this popup
277        :param msg: The msg to display
278        :param det_msg: Detailed message
279        :param log_is_file: If True the html_log parameter is interpreted as
280        the path to a file on disk containing the log encoded with utf-8
281        '''
282        MessageBox.__init__(self, MessageBox.QUESTION, title, msg,
283                det_msg=det_msg, show_copy_button=show_copy_button,
284                parent=parent)
285        self.payload = payload
286        self.html_log = html_log
287        self.log_is_file = log_is_file
288        self.log_viewer_title = log_viewer_title
289
290        self.vlb = self.bb.addButton(_('&View log'), QDialogButtonBox.ButtonRole.ActionRole)
291        self.vlb.setIcon(QIcon(I('debug.png')))
292        self.vlb.clicked.connect(self.show_log)
293        self.det_msg_toggle.setVisible(bool(det_msg))
294        self.setModal(False)
295        self.callback, self.cancel_callback = callback, cancel_callback
296        _proceed_memory.append(self)
297
298    def show_log(self):
299        log = self.html_log
300        if self.log_is_file:
301            with open(log, 'rb') as f:
302                log = f.read().decode('utf-8')
303        self.log_viewer = ViewLog(self.log_viewer_title, log,
304                parent=self)
305
306    def do_proceed(self, result):
307        from calibre.gui2.ui import get_gui
308        func = (self.callback if result == QDialog.DialogCode.Accepted else
309                self.cancel_callback)
310        gui = get_gui()
311        gui.proceed_requested.emit(func, self.payload)
312        # Ensure this notification is garbage collected
313        self.vlb.clicked.disconnect()
314        self.callback = self.cancel_callback = self.payload = None
315        self.setParent(None)
316        _proceed_memory.remove(self)
317
318    def done(self, r):
319        self.do_proceed(r)
320        return MessageBox.done(self, r)
321
322# }}}
323
324
325class ErrorNotification(MessageBox):  # {{{
326
327    def __init__(self, html_log, log_viewer_title, title, msg,
328            det_msg='', show_copy_button=False, parent=None):
329        '''
330        A non modal popup that notifies the user that a background task has
331        errored.
332
333        :param html_log: An HTML or plain text log
334        :param log_viewer_title: The title for the log viewer window
335        :param title: The title for this popup
336        :param msg: The msg to display
337        :param det_msg: Detailed message
338        '''
339        MessageBox.__init__(self, MessageBox.ERROR, title, msg,
340                det_msg=det_msg, show_copy_button=show_copy_button,
341                parent=parent)
342        self.html_log = html_log
343        self.log_viewer_title = log_viewer_title
344        self.finished.connect(self.do_close, type=Qt.ConnectionType.QueuedConnection)
345
346        self.vlb = self.bb.addButton(_('&View log'), QDialogButtonBox.ButtonRole.ActionRole)
347        self.vlb.setIcon(QIcon(I('debug.png')))
348        self.vlb.clicked.connect(self.show_log)
349        self.det_msg_toggle.setVisible(bool(det_msg))
350        self.setModal(False)
351        _proceed_memory.append(self)
352
353    def show_log(self):
354        self.log_viewer = ViewLog(self.log_viewer_title, self.html_log,
355                parent=self)
356
357    def do_close(self, result):
358        # Ensure this notification is garbage collected
359        self.setParent(None)
360        self.finished.disconnect()
361        self.vlb.clicked.disconnect()
362        _proceed_memory.remove(self)
363# }}}
364
365
366class JobError(QDialog):  # {{{
367
368    WIDTH = 600
369    do_pop = pyqtSignal()
370
371    def __init__(self, parent):
372        QDialog.__init__(self, parent)
373        self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
374        self.queue = []
375        self.do_pop.connect(self.pop, type=Qt.ConnectionType.QueuedConnection)
376
377        self._layout = l = QGridLayout()
378        self.setLayout(l)
379        self.icon = QIcon(I('dialog_error.png'))
380        self.setWindowIcon(self.icon)
381        self.icon_widget = Icon(self)
382        self.icon_widget.set_icon(self.icon)
383        self.msg_label = QLabel('<p>&nbsp;')
384        self.msg_label.setStyleSheet('QLabel { margin-top: 1ex; }')
385        self.msg_label.setWordWrap(True)
386        self.msg_label.setTextFormat(Qt.TextFormat.RichText)
387        self.det_msg = QPlainTextEdit(self)
388        self.det_msg.setVisible(False)
389
390        self.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self)
391        self.bb.accepted.connect(self.accept)
392        self.bb.rejected.connect(self.reject)
393        self.ctc_button = self.bb.addButton(_('&Copy to clipboard'),
394                QDialogButtonBox.ButtonRole.ActionRole)
395        self.ctc_button.clicked.connect(self.copy_to_clipboard)
396        self.retry_button = self.bb.addButton(_('&Retry'), QDialogButtonBox.ButtonRole.ActionRole)
397        self.retry_button.clicked.connect(self.retry)
398        self.retry_func = None
399        self.show_det_msg = _('Show &details')
400        self.hide_det_msg = _('Hide &details')
401        self.det_msg_toggle = self.bb.addButton(self.show_det_msg, QDialogButtonBox.ButtonRole.ActionRole)
402        self.det_msg_toggle.clicked.connect(self.toggle_det_msg)
403        self.det_msg_toggle.setToolTip(
404                _('Show detailed information about this error'))
405        self.suppress = QCheckBox(self)
406
407        l.addWidget(self.icon_widget, 0, 0, 1, 1)
408        l.addWidget(self.msg_label,  0, 1, 1, 1)
409        l.addWidget(self.det_msg,    1, 0, 1, 2)
410        l.addWidget(self.suppress,   2, 0, 1, 2, Qt.AlignmentFlag.AlignLeft|Qt.AlignmentFlag.AlignBottom)
411        l.addWidget(self.bb,         3, 0, 1, 2, Qt.AlignmentFlag.AlignRight|Qt.AlignmentFlag.AlignBottom)
412        l.setColumnStretch(1, 100)
413
414        self.setModal(False)
415        self.suppress.setVisible(False)
416        self.do_resize()
417
418    def retry(self):
419        if self.retry_func is not None:
420            self.accept()
421            self.retry_func()
422
423    def update_suppress_state(self):
424        self.suppress.setText(ngettext(
425            'Hide the remaining error message',
426            'Hide the {} remaining error messages', len(self.queue)).format(len(self.queue)))
427        self.suppress.setVisible(len(self.queue) > 3)
428        self.do_resize()
429
430    def copy_to_clipboard(self, *args):
431        d = QTextDocument()
432        d.setHtml(self.msg_label.text())
433        QApplication.clipboard().setText(
434                'calibre, version %s (%s, embedded-python: %s)\n%s: %s\n\n%s' %
435                (__version__, sys.platform, isfrozen,
436                    str(self.windowTitle()), str(d.toPlainText()),
437                    str(self.det_msg.toPlainText())))
438        if hasattr(self, 'ctc_button'):
439            self.ctc_button.setText(_('Copied'))
440
441    def toggle_det_msg(self, *args):
442        vis = str(self.det_msg_toggle.text()) == self.hide_det_msg
443        self.det_msg_toggle.setText(self.show_det_msg if vis else
444                self.hide_det_msg)
445        self.det_msg.setVisible(not vis)
446        self.do_resize()
447
448    def do_resize(self):
449        h = self.sizeHint().height()
450        self.setMinimumHeight(0)  # Needed as this gets set if det_msg is shown
451        # Needed otherwise re-showing the box after showing det_msg causes the box
452        # to not reduce in height
453        self.setMaximumHeight(h)
454        self.resize(QSize(self.WIDTH, h))
455
456    def showEvent(self, ev):
457        ret = QDialog.showEvent(self, ev)
458        self.bb.button(QDialogButtonBox.StandardButton.Close).setFocus(Qt.FocusReason.OtherFocusReason)
459        return ret
460
461    def show_error(self, title, msg, det_msg='', retry_func=None):
462        self.queue.append((title, msg, det_msg, retry_func))
463        self.update_suppress_state()
464        self.pop()
465
466    def pop(self):
467        if not self.queue or self.isVisible():
468            return
469        title, msg, det_msg, retry_func = self.queue.pop(0)
470        self.setWindowTitle(title)
471        self.msg_label.setText(msg)
472        self.det_msg.setPlainText(det_msg)
473        self.det_msg.setVisible(False)
474        self.det_msg_toggle.setText(self.show_det_msg)
475        self.det_msg_toggle.setVisible(True)
476        self.suppress.setChecked(False)
477        self.update_suppress_state()
478        if not det_msg:
479            self.det_msg_toggle.setVisible(False)
480        self.retry_button.setVisible(retry_func is not None)
481        self.retry_func = retry_func
482        self.do_resize()
483        self.show()
484
485    def done(self, r):
486        if self.suppress.isChecked():
487            self.queue = []
488        QDialog.done(self, r)
489        self.do_pop.emit()
490
491# }}}
492
493
494if __name__ == '__main__':
495    from calibre.gui2 import Application, question_dialog
496    from calibre import prepare_string_for_xml
497    app = Application([])
498    merged = {'Kovid Goyal': ['Waterloo', 'Doomed'], 'Someone Else': ['Some other book ' * 1000]}
499    lines = []
500    for author in sorted(merged):
501        lines.append(f'<b><i>{prepare_string_for_xml(author)}</i></b><ol style="margin-top: 0">')
502        for title in sorted(merged[author]):
503            lines.append(f'<li>{prepare_string_for_xml(title)}</li>')
504        lines.append('</ol>')
505
506    print(question_dialog(None, 'title', 'msg <a href="http://google.com">goog</a> ',
507            det_msg='\n'.join(lines),
508            show_copy_button=True))
509