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> ') 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