1__license__ = 'GPL v3' 2__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>' 3 4""" The GUI """ 5 6import glob 7import os 8import signal 9import sys 10import threading 11from contextlib import contextmanager 12from qt.core import ( 13 QT_VERSION, QApplication, QBuffer, QByteArray, QColor, QCoreApplication, 14 QDateTime, QDesktopServices, QDialog, QDialogButtonBox, QEvent, QFileDialog, 15 QFileIconProvider, QFileInfo, QFont, QFontDatabase, QFontInfo, QFontMetrics, 16 QGuiApplication, QIcon, QIODevice, QLocale, QNetworkProxyFactory, QObject, 17 QPalette, QSettings, QSocketNotifier, QStringListModel, QStyle, Qt, QThread, 18 QTimer, QTranslator, QUrl, pyqtSignal 19) 20from threading import Lock, RLock 21 22from calibre import as_unicode, prints 23from calibre.constants import ( 24 DEBUG, __appname__ as APP_UID, __version__, config_dir, filesystem_encoding, 25 is_running_from_develop, isbsd, isfrozen, islinux, ismacos, iswindows, isxp, 26 plugins_loc 27) 28from calibre.ebooks.metadata import MetaInformation 29from calibre.gui2.linux_file_dialogs import ( 30 check_for_linux_native_dialogs, linux_native_dialog 31) 32from calibre.gui2.qt_file_dialogs import FileDialog 33from calibre.ptempfile import base_dir 34from calibre.utils.config import Config, ConfigProxy, JSONConfig, dynamic 35from calibre.utils.date import UNDEFINED_DATE 36from calibre.utils.file_type_icons import EXT_MAP 37from calibre.utils.localization import get_lang 38from polyglot import queue 39from polyglot.builtins import iteritems, itervalues, string_or_bytes 40 41try: 42 NO_URL_FORMATTING = QUrl.UrlFormattingOption.None_ 43except AttributeError: 44 NO_URL_FORMATTING = getattr(QUrl, 'None') 45 46 47# Setup gprefs {{{ 48gprefs = JSONConfig('gui') 49 50 51native_menubar_defaults = { 52 'action-layout-menubar': ( 53 'Add Books', 'Edit Metadata', 'Convert Books', 54 'Choose Library', 'Save To Disk', 'Preferences', 55 'Help', 56 ), 57 'action-layout-menubar-device': ( 58 'Add Books', 'Edit Metadata', 'Convert Books', 59 'Location Manager', 'Send To Device', 60 'Save To Disk', 'Preferences', 'Help', 61 ) 62} 63 64 65def create_defs(): 66 defs = gprefs.defaults 67 if ismacos: 68 defs['action-layout-menubar'] = native_menubar_defaults['action-layout-menubar'] 69 defs['action-layout-menubar-device'] = native_menubar_defaults['action-layout-menubar-device'] 70 defs['action-layout-toolbar'] = ( 71 'Add Books', 'Edit Metadata', None, 'Convert Books', 'View', None, 72 'Choose Library', 'Donate', None, 'Fetch News', 'Store', 'Save To Disk', 73 'Connect Share', None, 'Remove Books', 'Tweak ePub' 74 ) 75 defs['action-layout-toolbar-device'] = ( 76 'Add Books', 'Edit Metadata', None, 'Convert Books', 'View', 77 'Send To Device', None, None, 'Location Manager', None, None, 78 'Fetch News', 'Store', 'Save To Disk', 'Connect Share', None, 79 'Remove Books', 80 ) 81 else: 82 defs['action-layout-menubar'] = () 83 defs['action-layout-menubar-device'] = () 84 defs['action-layout-toolbar'] = ( 85 'Add Books', 'Edit Metadata', None, 'Convert Books', 'View', None, 86 'Store', 'Donate', 'Fetch News', 'Help', None, 87 'Remove Books', 'Choose Library', 'Save To Disk', 88 'Connect Share', 'Tweak ePub', 'Preferences', 89 ) 90 defs['action-layout-toolbar-device'] = ( 91 'Add Books', 'Edit Metadata', None, 'Convert Books', 'View', 92 'Send To Device', None, None, 'Location Manager', None, None, 93 'Fetch News', 'Save To Disk', 'Store', 'Connect Share', None, 94 'Remove Books', None, 'Help', 'Preferences', 95 ) 96 97 defs['action-layout-toolbar-child'] = () 98 99 defs['action-layout-context-menu'] = ( 100 'Edit Metadata', 'Send To Device', 'Save To Disk', 101 'Connect Share', 'Copy To Library', None, 102 'Convert Books', 'View', 'Open Folder', 'Show Book Details', 103 'Similar Books', 'Tweak ePub', None, 'Remove Books', 104 ) 105 106 defs['action-layout-context-menu-split'] = ( 107 'Edit Metadata', 'Send To Device', 'Save To Disk', 108 'Connect Share', 'Copy To Library', None, 109 'Convert Books', 'View', 'Open Folder', 'Show Book Details', 110 'Similar Books', 'Tweak ePub', None, 'Remove Books', 111 ) 112 113 defs['action-layout-context-menu-device'] = ( 114 'View', 'Save To Disk', None, 'Remove Books', None, 115 'Add To Library', 'Edit Collections', 'Match Books', 116 'Show Matched Book In Library' 117 ) 118 119 defs['action-layout-context-menu-cover-browser'] = ( 120 'Edit Metadata', 'Send To Device', 'Save To Disk', 121 'Connect Share', 'Copy To Library', None, 122 'Convert Books', 'View', 'Open Folder', 'Show Book Details', 123 'Similar Books', 'Tweak ePub', None, 'Remove Books', None, 124 'Autoscroll Books' 125 ) 126 127 defs['show_splash_screen'] = True 128 defs['toolbar_icon_size'] = 'medium' 129 defs['automerge'] = 'ignore' 130 defs['toolbar_text'] = 'always' 131 defs['font'] = None 132 defs['tags_browser_partition_method'] = 'first letter' 133 defs['tags_browser_collapse_at'] = 100 134 defs['tags_browser_collapse_fl_at'] = 5 135 defs['tag_browser_dont_collapse'] = [] 136 defs['edit_metadata_single_layout'] = 'default' 137 defs['preserve_date_on_ctl'] = True 138 defs['manual_add_auto_convert'] = False 139 defs['auto_convert_same_fmt'] = False 140 defs['cb_fullscreen'] = False 141 defs['worker_max_time'] = 0 142 defs['show_files_after_save'] = True 143 defs['auto_add_path'] = None 144 defs['auto_add_check_for_duplicates'] = False 145 defs['blocked_auto_formats'] = [] 146 defs['auto_add_auto_convert'] = True 147 defs['auto_add_everything'] = False 148 defs['ui_style'] = 'calibre' if iswindows or ismacos else 'system' 149 defs['tag_browser_old_look'] = False 150 defs['tag_browser_hide_empty_categories'] = False 151 defs['tag_browser_always_autocollapse'] = False 152 defs['tag_browser_allow_keyboard_focus'] = False 153 defs['book_list_tooltips'] = True 154 defs['show_layout_buttons'] = False 155 defs['bd_show_cover'] = True 156 defs['bd_overlay_cover_size'] = False 157 defs['tags_browser_category_icons'] = {} 158 defs['cover_browser_reflections'] = True 159 defs['book_list_extra_row_spacing'] = 0 160 defs['refresh_book_list_on_bulk_edit'] = True 161 defs['cover_grid_width'] = 0 162 defs['cover_grid_height'] = 0 163 defs['cover_grid_spacing'] = 0 164 defs['cover_grid_color'] = (80, 80, 80) 165 defs['cover_grid_cache_size_multiple'] = 5 166 defs['cover_grid_disk_cache_size'] = 2500 167 defs['cover_grid_show_title'] = False 168 defs['cover_grid_texture'] = None 169 defs['show_vl_tabs'] = False 170 defs['vl_tabs_closable'] = True 171 defs['show_highlight_toggle_button'] = False 172 defs['add_comments_to_email'] = False 173 defs['cb_preserve_aspect_ratio'] = False 174 defs['cb_double_click_to_activate'] = False 175 defs['gpm_template_editor_font_size'] = 10 176 defs['show_emblems'] = False 177 defs['emblem_size'] = 32 178 defs['emblem_position'] = 'left' 179 defs['metadata_diff_mark_rejected'] = False 180 defs['tag_browser_show_counts'] = True 181 defs['tag_browser_show_tooltips'] = True 182 defs['row_numbers_in_book_list'] = True 183 defs['hidpi'] = 'auto' 184 defs['tag_browser_item_padding'] = 0.5 185 defs['paste_isbn_prefixes'] = ['isbn', 'url', 'amazon', 'google'] 186 defs['qv_respects_vls'] = True 187 defs['qv_dclick_changes_column'] = True 188 defs['qv_retkey_changes_column'] = True 189 defs['qv_follows_column'] = False 190 defs['book_details_comments_heading_pos'] = 'hide' 191 defs['book_list_split'] = False 192 defs['wrap_toolbar_text'] = False 193 defs['dnd_merge'] = True 194 defs['booklist_grid'] = False 195 defs['browse_annots_restrict_to_user'] = None 196 defs['browse_annots_restrict_to_type'] = None 197 defs['browse_annots_use_stemmer'] = True 198 defs['annots_export_format'] = 'txt' 199 defs['books_autoscroll_time'] = 2.0 200 201 202create_defs() 203del create_defs 204# }}} 205 206UNDEFINED_QDATETIME = QDateTime(UNDEFINED_DATE) 207QT_HIDDEN_CLEAR_ACTION = '_q_qlineeditclearaction' 208ALL_COLUMNS = ['title', 'ondevice', 'authors', 'size', 'timestamp', 'rating', 'publisher', 209 'tags', 'series', 'pubdate'] 210 211 212def _config(): # {{{ 213 c = Config('gui', 'preferences for the calibre GUI') 214 c.add_opt('send_to_storage_card_by_default', default=False, 215 help=_('Send file to storage card instead of main memory by default')) 216 c.add_opt('confirm_delete', default=False, 217 help=_('Confirm before deleting')) 218 c.add_opt('main_window_geometry', default=None, 219 help=_('Main window geometry')) 220 c.add_opt('new_version_notification', default=True, 221 help=_('Notify when a new version is available')) 222 c.add_opt('use_roman_numerals_for_series_number', default=True, 223 help=_('Use Roman numerals for series number')) 224 c.add_opt('sort_tags_by', default='name', 225 help=_('Sort tags list by name, popularity, or rating')) 226 c.add_opt('match_tags_type', default='any', 227 help=_('Match tags by any or all.')) 228 c.add_opt('cover_flow_queue_length', default=6, 229 help=_('Number of covers to show in the cover browsing mode')) 230 c.add_opt('LRF_conversion_defaults', default=[], 231 help=_('Defaults for conversion to LRF')) 232 c.add_opt('LRF_ebook_viewer_options', default=None, 233 help=_('Options for the LRF e-book viewer')) 234 c.add_opt('internally_viewed_formats', default=['LRF', 'EPUB', 'LIT', 235 'MOBI', 'PRC', 'POBI', 'AZW', 'AZW3', 'HTML', 'FB2', 'FBZ', 'PDB', 'RB', 236 'SNB', 'HTMLZ', 'KEPUB'], help=_( 237 'Formats that are viewed using the internal viewer')) 238 c.add_opt('column_map', default=ALL_COLUMNS, 239 help=_('Columns to be displayed in the book list')) 240 c.add_opt('autolaunch_server', default=False, help=_('Automatically launch Content server on application startup')) 241 c.add_opt('oldest_news', default=60, help=_('Oldest news kept in database')) 242 c.add_opt('systray_icon', default=False, help=_('Show system tray icon')) 243 c.add_opt('upload_news_to_device', default=True, 244 help=_('Upload downloaded news to device')) 245 c.add_opt('delete_news_from_library_on_upload', default=False, 246 help=_('Delete news books from library after uploading to device')) 247 c.add_opt('separate_cover_flow', default=False, 248 help=_('Show the cover flow in a separate window instead of in the main calibre window')) 249 c.add_opt('disable_tray_notification', default=False, 250 help=_('Disable notifications from the system tray icon')) 251 c.add_opt('default_send_to_device_action', default=None, 252 help=_('Default action to perform when the "Send to device" button is ' 253 'clicked')) 254 c.add_opt('asked_library_thing_password', default=False, 255 help='Asked library thing password at least once.') 256 c.add_opt('search_as_you_type', default=False, 257 help=_('Start searching as you type. If this is disabled then search will ' 258 'only take place when the Enter key is pressed.')) 259 c.add_opt('highlight_search_matches', default=False, 260 help=_('When searching, show all books with search results ' 261 'highlighted instead of showing only the matches. You can use the ' 262 'N or F3 keys to go to the next match.')) 263 c.add_opt('save_to_disk_template_history', default=[], 264 help='Previously used Save to disk templates') 265 c.add_opt('send_to_device_template_history', default=[], 266 help='Previously used Send to Device templates') 267 c.add_opt('main_search_history', default=[], 268 help='Search history for the main GUI') 269 c.add_opt('viewer_search_history', default=[], 270 help='Search history for the e-book viewer') 271 c.add_opt('viewer_toc_search_history', default=[], 272 help='Search history for the ToC in the e-book viewer') 273 c.add_opt('lrf_viewer_search_history', default=[], 274 help='Search history for the LRF viewer') 275 c.add_opt('scheduler_search_history', default=[], 276 help='Search history for the recipe scheduler') 277 c.add_opt('plugin_search_history', default=[], 278 help='Search history for the plugin preferences') 279 c.add_opt('shortcuts_search_history', default=[], 280 help='Search history for the keyboard preferences') 281 c.add_opt('jobs_search_history', default=[], 282 help='Search history for the tweaks preferences') 283 c.add_opt('tweaks_search_history', default=[], 284 help='Search history for tweaks') 285 c.add_opt('worker_limit', default=6, 286 help=_( 287 'Maximum number of simultaneous conversion/news download jobs. ' 288 'This number is twice the actual value for historical reasons.')) 289 c.add_opt('get_social_metadata', default=True, 290 help=_('Download social metadata (tags/rating/etc.)')) 291 c.add_opt('overwrite_author_title_metadata', default=True, 292 help=_('Overwrite author and title with new metadata')) 293 c.add_opt('auto_download_cover', default=False, 294 help=_('Automatically download the cover, if available')) 295 c.add_opt('enforce_cpu_limit', default=True, 296 help=_('Limit max simultaneous jobs to number of CPUs')) 297 c.add_opt('gui_layout', choices=['wide', 'narrow'], 298 help=_('The layout of the user interface. Wide has the ' 299 'Book details panel on the right and narrow has ' 300 'it at the bottom.'), default='wide') 301 c.add_opt('show_avg_rating', default=True, 302 help=_('Show the average rating per item indication in the Tag browser')) 303 c.add_opt('disable_animations', default=False, 304 help=_('Disable UI animations')) 305 306 # This option is no longer used. It remains for compatibility with upgrades 307 # so the value can be migrated 308 c.add_opt('tag_browser_hidden_categories', default=set(), 309 help=_('Tag browser categories not to display')) 310 311 c.add_opt 312 return ConfigProxy(c) 313 314 315config = _config() 316 317# }}} 318 319QSettings.setPath(QSettings.Format.IniFormat, QSettings.Scope.UserScope, config_dir) 320QSettings.setPath(QSettings.Format.IniFormat, QSettings.Scope.SystemScope, config_dir) 321QSettings.setDefaultFormat(QSettings.Format.IniFormat) 322 323 324def default_author_link(): 325 from calibre.ebooks.metadata.book.render import DEFAULT_AUTHOR_LINK 326 ans = gprefs.get('default_author_link') 327 if ans == 'https://en.wikipedia.org/w/index.php?search={author}': 328 # The old default value for this setting 329 ans = DEFAULT_AUTHOR_LINK 330 return ans or DEFAULT_AUTHOR_LINK 331 332 333def available_heights(): 334 return tuple(s.availableSize().height() for s in QGuiApplication.screens()) 335 336 337def available_height(): 338 return QApplication.instance().primaryScreen().availableSize().height() 339 340 341def available_width(): 342 return QApplication.instance().primaryScreen().availableSize().width() 343 344 345def max_available_height(): 346 return max(available_heights()) 347 348 349def min_available_height(): 350 return min(available_heights()) 351 352 353def get_screen_dpi(): 354 d = QApplication.desktop() 355 return (d.logicalDpiX(), d.logicalDpiY()) 356 357 358_is_widescreen = None 359 360 361def is_widescreen(): 362 global _is_widescreen 363 if _is_widescreen is None: 364 try: 365 _is_widescreen = available_width()/available_height() > 1.4 366 except: 367 _is_widescreen = False 368 return _is_widescreen 369 370 371def extension(path): 372 return os.path.splitext(path)[1][1:].lower() 373 374 375def warning_dialog(parent, title, msg, det_msg='', show=False, 376 show_copy_button=True): 377 from calibre.gui2.dialogs.message_box import MessageBox 378 d = MessageBox(MessageBox.WARNING, _('WARNING:' 379 )+ ' ' + title, msg, det_msg, parent=parent, 380 show_copy_button=show_copy_button) 381 if show: 382 return d.exec() 383 return d 384 385 386def error_dialog(parent, title, msg, det_msg='', show=False, 387 show_copy_button=True): 388 from calibre.gui2.dialogs.message_box import MessageBox 389 d = MessageBox(MessageBox.ERROR, _('ERROR:' 390 ) + ' ' + title, msg, det_msg, parent=parent, 391 show_copy_button=show_copy_button) 392 if show: 393 return d.exec() 394 return d 395 396 397class Aborted(Exception): 398 pass 399 400 401def question_dialog(parent, title, msg, det_msg='', show_copy_button=False, 402 default_yes=True, 403 # Skippable dialogs 404 # Set skip_dialog_name to a unique name for this dialog 405 # Set skip_dialog_msg to a message displayed to the user 406 skip_dialog_name=None, skip_dialog_msg=_('Show this confirmation again'), 407 skip_dialog_skipped_value=True, skip_dialog_skip_precheck=True, 408 # Override icon (QIcon to be used as the icon for this dialog or string for I()) 409 override_icon=None, 410 # Change the text/icons of the yes and no buttons. 411 # The icons must be QIcon objects or strings for I() 412 yes_text=None, no_text=None, yes_icon=None, no_icon=None, 413 # Add an Abort button which if clicked will cause this function to raise 414 # the Aborted exception 415 add_abort_button=False, 416): 417 from calibre.gui2.dialogs.message_box import MessageBox 418 prefs = gui_prefs() 419 420 if not isinstance(skip_dialog_name, str): 421 skip_dialog_name = None 422 try: 423 auto_skip = set(prefs.get('questions_to_auto_skip', ())) 424 except Exception: 425 auto_skip = set() 426 if (skip_dialog_name is not None and skip_dialog_name in auto_skip): 427 return bool(skip_dialog_skipped_value) 428 429 d = MessageBox(MessageBox.QUESTION, title, msg, det_msg, parent=parent, 430 show_copy_button=show_copy_button, default_yes=default_yes, 431 q_icon=override_icon, yes_text=yes_text, no_text=no_text, 432 yes_icon=yes_icon, no_icon=no_icon, add_abort_button=add_abort_button) 433 434 if skip_dialog_name is not None and skip_dialog_msg: 435 tc = d.toggle_checkbox 436 tc.setVisible(True) 437 tc.setText(skip_dialog_msg) 438 tc.setChecked(bool(skip_dialog_skip_precheck)) 439 d.resize_needed.emit() 440 441 ret = d.exec() == QDialog.DialogCode.Accepted 442 if add_abort_button and d.aborted: 443 raise Aborted() 444 445 if skip_dialog_name is not None and not d.toggle_checkbox.isChecked(): 446 auto_skip.add(skip_dialog_name) 447 prefs.set('questions_to_auto_skip', list(auto_skip)) 448 449 return ret 450 451 452def info_dialog(parent, title, msg, det_msg='', show=False, 453 show_copy_button=True, only_copy_details=False): 454 from calibre.gui2.dialogs.message_box import MessageBox 455 d = MessageBox(MessageBox.INFO, title, msg, det_msg, parent=parent, 456 show_copy_button=show_copy_button, only_copy_details=only_copy_details) 457 458 if show: 459 return d.exec() 460 return d 461 462 463def show_restart_warning(msg, parent=None): 464 d = warning_dialog(parent, _('Restart needed'), msg, 465 show_copy_button=False) 466 b = d.bb.addButton(_('&Restart calibre now'), QDialogButtonBox.ButtonRole.AcceptRole) 467 b.setIcon(QIcon(I('lt.png'))) 468 d.do_restart = False 469 470 def rf(): 471 d.do_restart = True 472 b.clicked.connect(rf) 473 d.set_details('') 474 d.exec() 475 b.clicked.disconnect() 476 return d.do_restart 477 478 479class Dispatcher(QObject): 480 ''' 481 Convenience class to use Qt signals with arbitrary python callables. 482 By default, ensures that a function call always happens in the 483 thread this Dispatcher was created in. 484 485 Note that if you create the Dispatcher in a thread without an event loop of 486 its own, the function call will happen in the GUI thread (I think). 487 ''' 488 dispatch_signal = pyqtSignal(object, object) 489 490 def __init__(self, func, queued=True, parent=None): 491 QObject.__init__(self, parent) 492 self.func = func 493 typ = Qt.ConnectionType.QueuedConnection 494 if not queued: 495 typ = Qt.ConnectionType.AutoConnection if queued is None else Qt.ConnectionType.DirectConnection 496 self.dispatch_signal.connect(self.dispatch, type=typ) 497 498 def __call__(self, *args, **kwargs): 499 self.dispatch_signal.emit(args, kwargs) 500 501 def dispatch(self, args, kwargs): 502 self.func(*args, **kwargs) 503 504 505class FunctionDispatcher(QObject): 506 ''' 507 Convenience class to use Qt signals with arbitrary python functions. 508 By default, ensures that a function call always happens in the 509 thread this FunctionDispatcher was created in. 510 511 Note that you must create FunctionDispatcher objects in the GUI thread. 512 ''' 513 dispatch_signal = pyqtSignal(object, object, object) 514 515 def __init__(self, func, queued=True, parent=None): 516 global gui_thread 517 if gui_thread is None: 518 gui_thread = QThread.currentThread() 519 if not is_gui_thread(): 520 raise ValueError( 521 'You can only create a FunctionDispatcher in the GUI thread') 522 523 QObject.__init__(self, parent) 524 self.func = func 525 typ = Qt.ConnectionType.QueuedConnection 526 if not queued: 527 typ = Qt.ConnectionType.AutoConnection if queued is None else Qt.ConnectionType.DirectConnection 528 self.dispatch_signal.connect(self.dispatch, type=typ) 529 self.q = queue.Queue() 530 self.lock = threading.Lock() 531 532 def __call__(self, *args, **kwargs): 533 if is_gui_thread(): 534 return self.func(*args, **kwargs) 535 with self.lock: 536 self.dispatch_signal.emit(self.q, args, kwargs) 537 res = self.q.get() 538 return res 539 540 def dispatch(self, q, args, kwargs): 541 try: 542 res = self.func(*args, **kwargs) 543 except: 544 res = None 545 q.put(res) 546 547 548class GetMetadata(QObject): 549 ''' 550 Convenience class to ensure that metadata readers are used only in the 551 GUI thread. Must be instantiated in the GUI thread. 552 ''' 553 554 edispatch = pyqtSignal(object, object, object) 555 idispatch = pyqtSignal(object, object, object) 556 metadataf = pyqtSignal(object, object) 557 metadata = pyqtSignal(object, object) 558 559 def __init__(self): 560 QObject.__init__(self) 561 self.edispatch.connect(self._get_metadata, type=Qt.ConnectionType.QueuedConnection) 562 self.idispatch.connect(self._from_formats, type=Qt.ConnectionType.QueuedConnection) 563 564 def __call__(self, id, *args, **kwargs): 565 self.edispatch.emit(id, args, kwargs) 566 567 def from_formats(self, id, *args, **kwargs): 568 self.idispatch.emit(id, args, kwargs) 569 570 def _from_formats(self, id, args, kwargs): 571 from calibre.ebooks.metadata.meta import metadata_from_formats 572 try: 573 mi = metadata_from_formats(*args, **kwargs) 574 except: 575 mi = MetaInformation('', [_('Unknown')]) 576 self.metadataf.emit(id, mi) 577 578 def _get_metadata(self, id, args, kwargs): 579 from calibre.ebooks.metadata.meta import get_metadata 580 try: 581 mi = get_metadata(*args, **kwargs) 582 except: 583 mi = MetaInformation('', [_('Unknown')]) 584 self.metadata.emit(id, mi) 585 586 587class FileIconProvider(QFileIconProvider): 588 589 ICONS = EXT_MAP 590 591 def __init__(self): 592 QFileIconProvider.__init__(self) 593 upath, bpath = I('mimetypes'), I('mimetypes', allow_user_override=False) 594 if upath != bpath: 595 # User has chosen to override mimetype icons 596 path_map = {v:I('mimetypes/%s.png' % v) for v in set(itervalues(self.ICONS))} 597 icons = self.ICONS.copy() 598 for uicon in glob.glob(os.path.join(upath, '*.png')): 599 ukey = os.path.basename(uicon).rpartition('.')[0].lower() 600 if ukey not in path_map: 601 path_map[ukey] = uicon 602 icons[ukey] = ukey 603 else: 604 path_map = {v:os.path.join(bpath, v + '.png') for v in set(itervalues(self.ICONS))} 605 icons = self.ICONS 606 self.icons = {k:path_map[v] for k, v in iteritems(icons)} 607 self.icons['calibre'] = I('lt.png', allow_user_override=False) 608 for i in ('dir', 'default', 'zero'): 609 self.icons[i] = QIcon(self.icons[i]) 610 611 def key_from_ext(self, ext): 612 key = ext if ext in list(self.icons.keys()) else 'default' 613 if key == 'default' and ext.count('.') > 0: 614 ext = ext.rpartition('.')[2] 615 key = ext if ext in list(self.icons.keys()) else 'default' 616 return key 617 618 def cached_icon(self, key): 619 candidate = self.icons[key] 620 if isinstance(candidate, QIcon): 621 return candidate 622 icon = QIcon(candidate) 623 self.icons[key] = icon 624 return icon 625 626 def icon_from_ext(self, ext): 627 key = self.key_from_ext(ext.lower() if ext else '') 628 return self.cached_icon(key) 629 630 def load_icon(self, fileinfo): 631 key = 'default' 632 icons = self.icons 633 if fileinfo.isSymLink(): 634 if not fileinfo.exists(): 635 return icons['zero'] 636 fileinfo = QFileInfo(fileinfo.readLink()) 637 if fileinfo.isDir(): 638 key = 'dir' 639 else: 640 ext = str(fileinfo.completeSuffix()).lower() 641 key = self.key_from_ext(ext) 642 return self.cached_icon(key) 643 644 def icon(self, arg): 645 if isinstance(arg, QFileInfo): 646 return self.load_icon(arg) 647 if arg == QFileIconProvider.IconType.Folder: 648 return self.icons['dir'] 649 if arg == QFileIconProvider.IconType.File: 650 return self.icons['default'] 651 return QFileIconProvider.icon(self, arg) 652 653 654_file_icon_provider = None 655 656 657def initialize_file_icon_provider(): 658 global _file_icon_provider 659 if _file_icon_provider is None: 660 _file_icon_provider = FileIconProvider() 661 662 663def file_icon_provider(): 664 global _file_icon_provider 665 initialize_file_icon_provider() 666 return _file_icon_provider 667 668 669has_windows_file_dialog_helper = False 670if iswindows and 'CALIBRE_NO_NATIVE_FILEDIALOGS' not in os.environ: 671 from calibre.gui2.win_file_dialogs import is_ok as has_windows_file_dialog_helper 672 has_windows_file_dialog_helper = has_windows_file_dialog_helper() 673has_linux_file_dialog_helper = False 674if not iswindows and not ismacos and 'CALIBRE_NO_NATIVE_FILEDIALOGS' not in os.environ and getattr(sys, 'frozen', False): 675 has_linux_file_dialog_helper = check_for_linux_native_dialogs() 676 677if has_windows_file_dialog_helper: 678 from calibre.gui2.win_file_dialogs import ( 679 choose_dir, choose_files, choose_images, choose_save_file 680 ) 681elif has_linux_file_dialog_helper: 682 choose_dir, choose_files, choose_save_file, choose_images = map( 683 linux_native_dialog, 'dir files save_file images'.split()) 684else: 685 from calibre.gui2.qt_file_dialogs import ( 686 choose_dir, choose_files, choose_images, choose_save_file 687 ) 688 choose_files, choose_images, choose_dir, choose_save_file 689 690 691def choose_files_and_remember_all_files( 692 window, name, title, filters=[], select_only_single_file=False, default_dir='~' 693): 694 pref_name = f'{name}-last-used-filter-spec-all-files' 695 lufs = dynamic.get(pref_name, False) 696 af = _('All files'), ['*'] 697 filters = list(filters) 698 filters.insert(0, af) if lufs else filters.append(af) 699 paths = choose_files(window, name, title, list(filters), False, select_only_single_file, default_dir) 700 if paths: 701 ext = paths[0].rpartition(os.extsep)[-1].lower() 702 used_all_files = True 703 for i, (name, exts) in enumerate(filters): 704 if ext in exts: 705 used_all_files = False 706 break 707 dynamic.set(pref_name, used_all_files) 708 return paths 709 710 711def is_dark_theme(): 712 pal = QApplication.instance().palette() 713 col = pal.color(QPalette.ColorRole.Window) 714 return max(col.getRgb()[:3]) < 115 715 716 717def choose_osx_app(window, name, title, default_dir='/Applications'): 718 fd = FileDialog(title=title, parent=window, name=name, mode=QFileDialog.FileMode.ExistingFile, 719 default_dir=default_dir) 720 app = fd.get_files() 721 fd.setParent(None) 722 if app: 723 return app 724 725 726def pixmap_to_data(pixmap, format='JPEG', quality=None): 727 ''' 728 Return the QPixmap pixmap as a string saved in the specified format. 729 ''' 730 if quality is None: 731 if format.upper() == "PNG": 732 # For some reason on windows with Qt 5.6 using a quality of 90 733 # generates invalid PNG data. Many other quality values work 734 # but we use -1 for the default quality which is most likely to 735 # work 736 quality = -1 737 else: 738 quality = 90 739 ba = QByteArray() 740 buf = QBuffer(ba) 741 buf.open(QIODevice.OpenModeFlag.WriteOnly) 742 pixmap.save(buf, format, quality=quality) 743 return ba.data() 744 745 746def decouple(prefix): 747 ' Ensure that config files used by utility code are not the same as those used by the main calibre GUI ' 748 dynamic.decouple(prefix) 749 from calibre.gui2.widgets import history 750 history.decouple(prefix) 751 752 753_gui_prefs = gprefs 754 755 756def gui_prefs(): 757 return _gui_prefs 758 759 760def set_gui_prefs(prefs): 761 global _gui_prefs 762 _gui_prefs = prefs 763 764 765class ResizableDialog(QDialog): 766 767 # This class is present only for backwards compat with third party plugins 768 # that might use it. Do not use it in new code. 769 770 def __init__(self, *args, **kwargs): 771 QDialog.__init__(self, *args) 772 self.setupUi(self) 773 desktop = QCoreApplication.instance().desktop() 774 geom = desktop.availableGeometry(self) 775 nh, nw = max(550, geom.height()-25), max(700, geom.width()-10) 776 nh = min(self.height(), nh) 777 nw = min(self.width(), nw) 778 self.resize(nw, nh) 779 780 781class Translator(QTranslator): 782 ''' 783 Translator to load translations for strings in Qt from the calibre 784 translations. Does not support advanced features of Qt like disambiguation 785 and plural forms. 786 ''' 787 788 def translate(self, *args, **kwargs): 789 try: 790 src = str(args[1]) 791 except: 792 return '' 793 t = _ 794 return t(src) 795 796 797gui_thread = None 798qt_app = None 799 800 801def calibre_font_files(): 802 return glob.glob(P('fonts/liberation/*.?tf')) + [P('fonts/calibreSymbols.otf')] + \ 803 glob.glob(os.path.join(config_dir, 'fonts', '*.?tf')) 804 805 806def load_builtin_fonts(): 807 global _rating_font, builtin_fonts_loaded 808 # Load the builtin fonts and any fonts added to calibre by the user to 809 # Qt 810 if hasattr(load_builtin_fonts, 'done'): 811 return 812 load_builtin_fonts.done = True 813 for ff in calibre_font_files(): 814 if ff.rpartition('.')[-1].lower() in {'ttf', 'otf'}: 815 with open(ff, 'rb') as s: 816 # Windows requires font files to be executable for them to be 817 # loaded successfully, so we use the in memory loader 818 fid = QFontDatabase.addApplicationFontFromData(s.read()) 819 if fid > -1: 820 fam = QFontDatabase.applicationFontFamilies(fid) 821 fam = set(map(str, fam)) 822 if 'calibre Symbols' in fam: 823 _rating_font = 'calibre Symbols' 824 825 826def setup_gui_option_parser(parser): 827 if islinux: 828 parser.add_option('--detach', default=False, action='store_true', 829 help=_('Detach from the controlling terminal, if any (Linux only)')) 830 831 832def show_temp_dir_error(err): 833 import traceback 834 extra = _('Click "Show details" for more information.') 835 if 'CALIBRE_TEMP_DIR' in os.environ: 836 extra = _('The %s environment variable is set. Try unsetting it.') % 'CALIBRE_TEMP_DIR' 837 error_dialog(None, _('Could not create temporary folder'), _( 838 'Could not create temporary folder, calibre cannot start.') + ' ' + extra, det_msg=traceback.format_exc(), show=True) 839 840 841def setup_hidpi(): 842 # This requires Qt >= 5.6 843 has_env_setting = False 844 env_vars = ('QT_AUTO_SCREEN_SCALE_FACTOR', 'QT_SCALE_FACTOR', 'QT_SCREEN_SCALE_FACTORS', 'QT_DEVICE_PIXEL_RATIO') 845 for v in env_vars: 846 if os.environ.get(v): 847 has_env_setting = True 848 break 849 hidpi = gprefs['hidpi'] 850 if hidpi == 'on' or (hidpi == 'auto' and not has_env_setting): 851 if DEBUG: 852 prints('Turning on automatic hidpi scaling') 853 QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True) 854 elif hidpi == 'off': 855 if DEBUG: 856 prints('Turning off automatic hidpi scaling') 857 QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, False) 858 for p in env_vars: 859 os.environ.pop(p, None) 860 elif DEBUG: 861 prints('Not controlling automatic hidpi scaling') 862 863 864def setup_unix_signals(self): 865 if hasattr(os, 'pipe2'): 866 read_fd, write_fd = os.pipe2(os.O_CLOEXEC | os.O_NONBLOCK) 867 else: 868 import fcntl 869 read_fd, write_fd = os.pipe() 870 cloexec_flag = getattr(fcntl, 'FD_CLOEXEC', 1) 871 for fd in (read_fd, write_fd): 872 flags = fcntl.fcntl(fd, fcntl.F_GETFD) 873 if flags != -1: 874 fcntl.fcntl(fd, fcntl.F_SETFD, flags | cloexec_flag) 875 flags = fcntl.fcntl(fd, fcntl.F_GETFL) 876 if flags != -1: 877 fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) 878 879 original_handlers = {} 880 for sig in (signal.SIGINT, signal.SIGTERM): 881 original_handlers[sig] = signal.signal(sig, lambda x, y: None) 882 signal.siginterrupt(sig, False) 883 signal.set_wakeup_fd(write_fd) 884 self.signal_notifier = QSocketNotifier(read_fd, QSocketNotifier.Type.Read, self) 885 self.signal_notifier.setEnabled(True) 886 self.signal_notifier.activated.connect(self.signal_received, type=Qt.ConnectionType.QueuedConnection) 887 return original_handlers 888 889 890class Application(QApplication): 891 892 shutdown_signal_received = pyqtSignal() 893 palette_changed = pyqtSignal() 894 895 def __init__(self, args, force_calibre_style=False, override_program_name=None, headless=False, color_prefs=gprefs, windows_app_uid=None): 896 self.ignore_palette_changes = False 897 QNetworkProxyFactory.setUseSystemConfiguration(True) 898 if iswindows: 899 self.windows_app_uid = None 900 if windows_app_uid: 901 windows_app_uid = str(windows_app_uid) 902 if set_app_uid(windows_app_uid): 903 self.windows_app_uid = windows_app_uid 904 self.file_event_hook = None 905 if isfrozen and QT_VERSION <= 0x050700 and 'wayland' in os.environ.get('QT_QPA_PLATFORM', ''): 906 os.environ['QT_QPA_PLATFORM'] = 'xcb' 907 if override_program_name: 908 args = [override_program_name] + args[1:] 909 if headless: 910 if not args: 911 args = sys.argv[:1] 912 args.extend(['-platformpluginpath', plugins_loc, '-platform', 'headless']) 913 self.headless = headless 914 qargs = [i.encode('utf-8') if isinstance(i, str) else i for i in args] 915 from calibre_extensions import progress_indicator 916 self.pi = progress_indicator 917 if not ismacos and not headless: 918 # On OS X high dpi scaling is turned on automatically by the OS, so we dont need to set it explicitly 919 setup_hidpi() 920 QApplication.setOrganizationName('calibre-ebook.com') 921 QApplication.setOrganizationDomain(QApplication.organizationName()) 922 QApplication.setApplicationVersion(__version__) 923 QApplication.setApplicationName(APP_UID) 924 if override_program_name and hasattr(QApplication, 'setDesktopFileName'): 925 QApplication.setDesktopFileName(override_program_name) 926 QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True) # needed for webengine 927 QApplication.__init__(self, qargs) 928 sh = self.styleHints() 929 if hasattr(sh, 'setShowShortcutsInContextMenus'): 930 sh.setShowShortcutsInContextMenus(True) 931 if ismacos: 932 from calibre_extensions.cocoa import disable_cocoa_ui_elements 933 disable_cocoa_ui_elements() 934 self.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps) 935 self.setAttribute(Qt.ApplicationAttribute.AA_SynthesizeTouchForUnhandledMouseEvents, False) 936 try: 937 base_dir() 938 except OSError as err: 939 if not headless: 940 show_temp_dir_error(err) 941 raise SystemExit('Failed to create temporary folder') 942 if DEBUG and not headless: 943 prints('devicePixelRatio:', self.devicePixelRatio()) 944 s = self.primaryScreen() 945 if s: 946 prints('logicalDpi:', s.logicalDotsPerInchX(), 'x', s.logicalDotsPerInchY()) 947 prints('physicalDpi:', s.physicalDotsPerInchX(), 'x', s.physicalDotsPerInchY()) 948 if not iswindows: 949 self.setup_unix_signals() 950 if islinux or isbsd: 951 self.setAttribute(Qt.ApplicationAttribute.AA_DontUseNativeMenuBar, 'CALIBRE_NO_NATIVE_MENUBAR' in os.environ) 952 self.setup_styles(force_calibre_style) 953 self.setup_ui_font() 954 if not self.using_calibre_style and self.style().objectName() == 'fusion': 955 # Since Qt is using the fusion style anyway, specialize it 956 self.load_calibre_style() 957 fi = gprefs['font'] 958 if fi is not None: 959 font = QFont(*(fi[:4])) 960 s = gprefs.get('font_stretch', None) 961 if s is not None: 962 font.setStretch(s) 963 QApplication.setFont(font) 964 if not ismacos and not iswindows: 965 # Qt 5.10.1 on Linux resets the global font on first event loop tick. 966 # So workaround it by setting the font once again in a timer. 967 font_from_prefs = self.font() 968 QTimer.singleShot(0, lambda : QApplication.setFont(font_from_prefs)) 969 self.line_height = max(12, QFontMetrics(self.font()).lineSpacing()) 970 971 dl = QLocale(get_lang()) 972 if str(dl.bcp47Name()) != 'C': 973 QLocale.setDefault(dl) 974 global gui_thread, qt_app 975 gui_thread = QThread.currentThread() 976 self._translator = None 977 self.load_translations() 978 qt_app = self 979 self._file_open_paths = [] 980 self._file_open_lock = RLock() 981 982 if not ismacos: 983 # OS X uses a native color dialog that does not support custom 984 # colors 985 self.color_prefs = color_prefs 986 self.read_custom_colors() 987 self.lastWindowClosed.connect(self.save_custom_colors) 988 989 if isxp: 990 error_dialog(None, _('Windows XP not supported'), '<p>' + _( 991 'calibre versions newer than 2.0 do not run on Windows XP. This is' 992 ' because the graphics toolkit calibre uses (Qt 5) crashes a lot' 993 ' on Windows XP. We suggest you stay with <a href="%s">calibre 1.48</a>' 994 ' which works well on Windows XP.') % 'https://download.calibre-ebook.com/1.48.0/', show=True) 995 raise SystemExit(1) 996 997 if iswindows: 998 # On windows the highlighted colors for inactive widgets are the 999 # same as non highlighted colors. This is a regression from Qt 4. 1000 # https://bugreports.qt-project.org/browse/QTBUG-41060 1001 p = self.palette() 1002 for role in (QPalette.ColorRole.Highlight, QPalette.ColorRole.HighlightedText, QPalette.ColorRole.Base, QPalette.ColorRole.AlternateBase): 1003 p.setColor(QPalette.ColorGroup.Inactive, role, p.color(QPalette.ColorGroup.Active, role)) 1004 self.setPalette(p) 1005 1006 # Prevent text copied to the clipboard from being lost on quit due to 1007 # Qt 5 bug: https://bugreports.qt-project.org/browse/QTBUG-41125 1008 self.aboutToQuit.connect(self.flush_clipboard) 1009 1010 if ismacos: 1011 from calibre_extensions.cocoa import cursor_blink_time 1012 cft = cursor_blink_time() 1013 if cft >= 0: 1014 self.setCursorFlashTime(int(cft)) 1015 1016 def safe_restore_geometry(self, widget, geom): 1017 # See https://bugreports.qt.io/browse/QTBUG-77385 1018 if not geom: 1019 return 1020 restored = widget.restoreGeometry(geom) 1021 self.ensure_window_on_screen(widget) 1022 return restored 1023 1024 def ensure_window_on_screen(self, widget): 1025 screen_rect = self.desktop().availableGeometry(widget) 1026 g = widget.geometry() 1027 w = min(screen_rect.width(), g.width()) 1028 h = min(screen_rect.height(), g.height()) 1029 if w != g.width() or h != g.height(): 1030 widget.resize(w, h) 1031 if not widget.geometry().intersects(screen_rect): 1032 w = min(widget.width(), screen_rect.width() - 10) 1033 h = min(widget.height(), screen_rect.height() - 10) 1034 widget.resize(w, h) 1035 widget.move((screen_rect.width() - w) // 2, (screen_rect.height() - h) // 2) 1036 1037 def setup_ui_font(self): 1038 f = QFont(QApplication.font()) 1039 q = (f.family(), f.pointSize()) 1040 if iswindows: 1041 if q == ('MS Shell Dlg 2', 8): # Qt default setting 1042 # Microsoft recommends the default font be Segoe UI at 9 pt 1043 # https://msdn.microsoft.com/en-us/library/windows/desktop/dn742483(v=vs.85).aspx 1044 f.setFamily('Segoe UI') 1045 f.setPointSize(9) 1046 QApplication.setFont(f) 1047 else: 1048 if q == ('Sans Serif', 9): # Hard coded Qt settings, no user preference detected 1049 f.setPointSize(10) 1050 QApplication.setFont(f) 1051 f = QFontInfo(f) 1052 self.original_font = (f.family(), f.pointSize(), f.weight(), f.italic(), 100) 1053 1054 def flush_clipboard(self): 1055 try: 1056 if self.clipboard().ownsClipboard(): 1057 import ctypes 1058 ctypes.WinDLL('ole32.dll').OleFlushClipboard() 1059 except Exception: 1060 import traceback 1061 traceback.print_exc() 1062 1063 def load_builtin_fonts(self, scan_for_fonts=False): 1064 if scan_for_fonts: 1065 from calibre.utils.fonts.scanner import font_scanner 1066 1067 # Start scanning the users computer for fonts 1068 font_scanner 1069 1070 load_builtin_fonts() 1071 1072 def set_dark_mode_palette(self): 1073 from calibre.gui2.palette import dark_palette 1074 self.set_palette(dark_palette()) 1075 1076 def setup_styles(self, force_calibre_style): 1077 if iswindows or ismacos: 1078 using_calibre_style = gprefs['ui_style'] != 'system' 1079 else: 1080 using_calibre_style = os.environ.get('CALIBRE_USE_SYSTEM_THEME', '0') == '0' 1081 if force_calibre_style: 1082 using_calibre_style = True 1083 if using_calibre_style: 1084 use_dark_palette = False 1085 if 'CALIBRE_USE_DARK_PALETTE' in os.environ: 1086 if not ismacos: 1087 use_dark_palette = os.environ['CALIBRE_USE_DARK_PALETTE'] != '0' 1088 else: 1089 if iswindows: 1090 use_dark_palette = windows_is_system_dark_mode_enabled() 1091 if use_dark_palette: 1092 self.set_dark_mode_palette() 1093 1094 self.using_calibre_style = using_calibre_style 1095 if DEBUG: 1096 prints('Using calibre Qt style:', self.using_calibre_style) 1097 if self.using_calibre_style: 1098 self.load_calibre_style() 1099 self.paletteChanged.connect(self.on_palette_change) 1100 self.on_palette_change() 1101 1102 def fix_combobox_text_color(self): 1103 # Workaround for https://bugreports.qt.io/browse/QTBUG-75321 1104 # Buttontext is set to black for some reason 1105 pal = QPalette(self.palette()) 1106 pal.setColor(QPalette.ColorRole.ButtonText, pal.color(QPalette.ColorRole.WindowText)) 1107 self.ignore_palette_changes = True 1108 self.setPalette(pal, 'QComboBox') 1109 self.ignore_palette_changes = False 1110 1111 def set_palette(self, pal): 1112 self.ignore_palette_changes = True 1113 self.setPalette(pal) 1114 # Needed otherwise Qt does not emit the paletteChanged signal when 1115 # appearance is changed. And it has to be after current event 1116 # processing finishes as of Qt 5.14 otherwise the palette change is 1117 # ignored. 1118 QTimer.singleShot(1000, lambda: QApplication.instance().setAttribute(Qt.ApplicationAttribute.AA_SetPalette, False)) 1119 self.ignore_palette_changes = False 1120 1121 def on_palette_change(self): 1122 if self.ignore_palette_changes: 1123 return 1124 self.is_dark_theme = is_dark_theme() 1125 self.setProperty('is_dark_theme', self.is_dark_theme) 1126 if ismacos and self.is_dark_theme and self.using_calibre_style: 1127 QTimer.singleShot(0, self.fix_combobox_text_color) 1128 if self.using_calibre_style: 1129 ss = 'QTabBar::tab:selected { font-style: italic }\n\n' 1130 if self.is_dark_theme: 1131 ss += 'QMenu { border: 1px solid palette(shadow); }' 1132 self.setStyleSheet(ss) 1133 self.palette_changed.emit() 1134 1135 def stylesheet_for_line_edit(self, is_error=False): 1136 return 'QLineEdit { border: 2px solid %s; border-radius: 3px }' % ( 1137 '#FF2400' if is_error else '#50c878') 1138 1139 def load_calibre_style(self): 1140 icon_map = self.__icon_map_memory_ = {} 1141 pcache = {} 1142 for k, v in iteritems({ 1143 'DialogYesButton': 'ok.png', 1144 'DialogNoButton': 'window-close.png', 1145 'DialogCloseButton': 'window-close.png', 1146 'DialogOkButton': 'ok.png', 1147 'DialogCancelButton': 'window-close.png', 1148 'DialogHelpButton': 'help.png', 1149 'DialogOpenButton': 'document_open.png', 1150 'DialogSaveButton': 'save.png', 1151 'DialogApplyButton': 'ok.png', 1152 'DialogDiscardButton': 'trash.png', 1153 'MessageBoxInformation': 'dialog_information.png', 1154 'MessageBoxWarning': 'dialog_warning.png', 1155 'MessageBoxCritical': 'dialog_error.png', 1156 'MessageBoxQuestion': 'dialog_question.png', 1157 'BrowserReload': 'view-refresh.png', 1158 'LineEditClearButton': 'clear_left.png', 1159 'ToolBarHorizontalExtensionButton': 'v-ellipsis.png', 1160 'ToolBarVerticalExtensionButton': 'h-ellipsis.png', 1161 }): 1162 if v not in pcache: 1163 p = I(v) 1164 if isinstance(p, bytes): 1165 p = p.decode(filesystem_encoding) 1166 # if not os.path.exists(p): raise ValueError(p) 1167 pcache[v] = p 1168 v = pcache[v] 1169 icon_map[getattr(QStyle.StandardPixmap, 'SP_'+k)] = v 1170 transient_scroller = 0 1171 if ismacos: 1172 from calibre_extensions.cocoa import transient_scroller 1173 transient_scroller = transient_scroller() 1174 icon_map[(QStyle.StandardPixmap.SP_CustomBase & 0xf0000000) + 1] = I('close-for-light-theme.png') 1175 icon_map[(QStyle.StandardPixmap.SP_CustomBase & 0xf0000000) + 2] = I('close-for-dark-theme.png') 1176 try: 1177 self.pi.load_style(icon_map, transient_scroller) 1178 except OverflowError: # running from source without updated runtime 1179 self.pi.load_style({}, transient_scroller) 1180 1181 def _send_file_open_events(self): 1182 with self._file_open_lock: 1183 if self._file_open_paths: 1184 self.file_event_hook(self._file_open_paths) 1185 self._file_open_paths = [] 1186 1187 def load_translations(self): 1188 if self._translator is not None: 1189 self.removeTranslator(self._translator) 1190 self._translator = Translator(self) 1191 self.installTranslator(self._translator) 1192 1193 def event(self, e): 1194 if callable(self.file_event_hook) and e.type() == QEvent.Type.FileOpen: 1195 url = e.url().toString(QUrl.ComponentFormattingOption.FullyEncoded) 1196 if url and url.startswith('calibre://'): 1197 with self._file_open_lock: 1198 self._file_open_paths.append(url) 1199 QTimer.singleShot(1000, self._send_file_open_events) 1200 return True 1201 path = str(e.file()) 1202 if os.access(path, os.R_OK): 1203 with self._file_open_lock: 1204 self._file_open_paths.append(path) 1205 QTimer.singleShot(1000, self._send_file_open_events) 1206 return True 1207 else: 1208 return QApplication.event(self, e) 1209 1210 @property 1211 def current_custom_colors(self): 1212 from qt.core import QColorDialog 1213 1214 return [col.getRgb() for col in 1215 (QColorDialog.customColor(i) for i in range(QColorDialog.customCount()))] 1216 1217 @current_custom_colors.setter 1218 def current_custom_colors(self, colors): 1219 from qt.core import QColorDialog 1220 num = min(len(colors), QColorDialog.customCount()) 1221 for i in range(num): 1222 QColorDialog.setCustomColor(i, QColor(*colors[i])) 1223 1224 def read_custom_colors(self): 1225 colors = self.color_prefs.get('custom_colors_for_color_dialog', None) 1226 if colors is not None: 1227 self.current_custom_colors = colors 1228 1229 def save_custom_colors(self): 1230 # Qt 5 regression, it no longer saves custom colors 1231 colors = self.current_custom_colors 1232 if colors != self.color_prefs.get('custom_colors_for_color_dialog', None): 1233 self.color_prefs.set('custom_colors_for_color_dialog', colors) 1234 1235 def __enter__(self): 1236 self.setQuitOnLastWindowClosed(False) 1237 1238 def __exit__(self, *args): 1239 self.setQuitOnLastWindowClosed(True) 1240 1241 def setup_unix_signals(self): 1242 setup_unix_signals(self) 1243 1244 def signal_received(self): 1245 try: 1246 os.read(int(self.signal_notifier.socket()), 1024) 1247 except OSError: 1248 return 1249 self.shutdown_signal_received.emit() 1250 1251 1252_store_app = None 1253 1254 1255@contextmanager 1256def sanitize_env_vars(): 1257 '''Unset various environment variables that calibre uses. This 1258 is needed to prevent library conflicts when launching external utilities.''' 1259 1260 if islinux and isfrozen: 1261 env_vars = {'LD_LIBRARY_PATH':'/lib'} 1262 elif iswindows: 1263 env_vars = {} 1264 elif ismacos: 1265 env_vars = {k:None for k in ( 1266 'FONTCONFIG_FILE FONTCONFIG_PATH SSL_CERT_FILE').split()} 1267 else: 1268 env_vars = {} 1269 1270 originals = {x:os.environ.get(x, '') for x in env_vars} 1271 changed = {x:False for x in env_vars} 1272 for var, suffix in iteritems(env_vars): 1273 paths = [x for x in originals[var].split(os.pathsep) if x] 1274 npaths = [] if suffix is None else [x for x in paths if x != (sys.frozen_path + suffix)] 1275 if len(npaths) < len(paths): 1276 if npaths: 1277 os.environ[var] = os.pathsep.join(npaths) 1278 else: 1279 del os.environ[var] 1280 changed[var] = True 1281 1282 try: 1283 yield 1284 finally: 1285 for var, orig in iteritems(originals): 1286 if changed[var]: 1287 if orig: 1288 os.environ[var] = orig 1289 elif var in os.environ: 1290 del os.environ[var] 1291 1292 1293SanitizeLibraryPath = sanitize_env_vars # For old plugins 1294 1295 1296def open_url(qurl): 1297 # Qt 5 requires QApplication to be constructed before trying to use 1298 # QDesktopServices::openUrl() 1299 ensure_app() 1300 if isinstance(qurl, string_or_bytes): 1301 qurl = QUrl(qurl) 1302 with sanitize_env_vars(): 1303 QDesktopServices.openUrl(qurl) 1304 1305 1306def safe_open_url(qurl): 1307 if isinstance(qurl, string_or_bytes): 1308 qurl = QUrl(qurl) 1309 if qurl.scheme() in ('', 'file'): 1310 path = qurl.toLocalFile() 1311 ext = os.path.splitext(path)[-1].lower()[1:] 1312 if ext in ('exe', 'com', 'cmd', 'bat', 'sh', 'psh', 'ps1', 'vbs', 'js', 'wsf', 'vba', 'py', 'rb', 'pl', 'app'): 1313 prints('Refusing to open file:', path) 1314 return 1315 open_url(qurl) 1316 1317 1318def get_current_db(): 1319 ''' 1320 This method will try to return the current database in use by the user as 1321 efficiently as possible, i.e. without constructing duplicate 1322 LibraryDatabase objects. 1323 ''' 1324 from calibre.gui2.ui import get_gui 1325 gui = get_gui() 1326 if gui is not None and gui.current_db is not None: 1327 return gui.current_db 1328 from calibre.library import db 1329 return db() 1330 1331 1332def open_local_file(path): 1333 if iswindows: 1334 with sanitize_env_vars(): 1335 os.startfile(os.path.normpath(path)) 1336 else: 1337 url = QUrl.fromLocalFile(path) 1338 open_url(url) 1339 1340 1341_ea_lock = Lock() 1342 1343 1344def ensure_app(headless=True): 1345 global _store_app 1346 with _ea_lock: 1347 if _store_app is None and QApplication.instance() is None: 1348 args = sys.argv[:1] 1349 has_headless = ismacos or islinux or isbsd 1350 if headless and has_headless: 1351 args += ['-platformpluginpath', plugins_loc, '-platform', 'headless'] 1352 if ismacos: 1353 os.environ['QT_MAC_DISABLE_FOREGROUND_APPLICATION_TRANSFORM'] = '1' 1354 if headless and iswindows: 1355 QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseSoftwareOpenGL, True) 1356 _store_app = QApplication(args) 1357 if headless and has_headless: 1358 _store_app.headless = True 1359 import traceback 1360 1361 # This is needed because as of PyQt 5.4 if sys.execpthook == 1362 # sys.__excepthook__ PyQt will abort the application on an 1363 # unhandled python exception in a slot or virtual method. Since ensure_app() 1364 # is used in worker processes for background work like rendering html 1365 # or running a headless browser, we circumvent this as I really 1366 # dont feel like going through all the code and making sure no 1367 # unhandled exceptions ever occur. All the actual GUI apps already 1368 # override sys.except_hook with a proper error handler. 1369 1370 def eh(t, v, tb): 1371 try: 1372 traceback.print_exception(t, v, tb, file=sys.stderr) 1373 except: 1374 pass 1375 sys.excepthook = eh 1376 return _store_app 1377 1378 1379def destroy_app(): 1380 global _store_app 1381 _store_app = None 1382 1383 1384def app_is_headless(): 1385 return getattr(_store_app, 'headless', False) 1386 1387 1388def must_use_qt(headless=True): 1389 ''' This function should be called if you want to use Qt for some non-GUI 1390 task like rendering HTML/SVG or using a headless browser. It will raise a 1391 RuntimeError if using Qt is not possible, which will happen if the current 1392 thread is not the main GUI thread. On linux, it uses a special QPA headless 1393 plugin, so that the X server does not need to be running. ''' 1394 global gui_thread 1395 ensure_app(headless=headless) 1396 if gui_thread is None: 1397 gui_thread = QThread.currentThread() 1398 if gui_thread is not QThread.currentThread(): 1399 raise RuntimeError('Cannot use Qt in non GUI thread') 1400 1401 1402def is_ok_to_use_qt(): 1403 try: 1404 must_use_qt() 1405 except RuntimeError: 1406 return False 1407 return True 1408 1409 1410def is_gui_thread(): 1411 global gui_thread 1412 return gui_thread is QThread.currentThread() 1413 1414 1415_rating_font = 'Arial Unicode MS' if iswindows else 'sans-serif' 1416 1417 1418def rating_font(): 1419 global _rating_font 1420 return _rating_font 1421 1422 1423def elided_text(text, font=None, width=300, pos='middle'): 1424 ''' Return a version of text that is no wider than width pixels when 1425 rendered, replacing characters from the left, middle or right (as per pos) 1426 of the string with an ellipsis. Results in a string much closer to the 1427 limit than Qt's elidedText().''' 1428 from qt.core import QApplication, QFontMetrics 1429 if font is None: 1430 font = QApplication.instance().font() 1431 fm = (font if isinstance(font, QFontMetrics) else QFontMetrics(font)) 1432 delta = 4 1433 ellipsis = '\u2026' 1434 1435 def remove_middle(x): 1436 mid = len(x) // 2 1437 return x[:max(0, mid - (delta//2))] + ellipsis + x[mid + (delta//2):] 1438 1439 chomp = {'middle':remove_middle, 'left':lambda x:(ellipsis + x[delta:]), 'right':lambda x:(x[:-delta] + ellipsis)}[pos] 1440 while len(text) > delta and fm.width(text) > width: 1441 text = chomp(text) 1442 return str(text) 1443 1444 1445if is_running_from_develop: 1446 from calibre.build_forms import build_forms 1447 build_forms(os.environ['CALIBRE_DEVELOP_FROM'], check_for_migration=True) 1448 1449 1450def event_type_name(ev_or_etype): 1451 etype = ev_or_etype.type() if isinstance(ev_or_etype, QEvent) else ev_or_etype 1452 for name, num in iteritems(vars(QEvent)): 1453 if num == etype: 1454 return name 1455 return 'UnknownEventType' 1456 1457 1458empty_model = QStringListModel(['']) 1459empty_index = empty_model.index(0) 1460 1461 1462def set_app_uid(val): 1463 import ctypes 1464 from ctypes import HRESULT, wintypes 1465 try: 1466 AppUserModelID = ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID 1467 except Exception: # Vista has no app uids 1468 return False 1469 AppUserModelID.argtypes = [wintypes.LPCWSTR] 1470 AppUserModelID.restype = HRESULT 1471 try: 1472 AppUserModelID(str(val)) 1473 except Exception as err: 1474 prints('Failed to set app uid with error:', as_unicode(err)) 1475 return False 1476 return True 1477 1478 1479def add_to_recent_docs(path): 1480 from calibre_extensions import winutil 1481 app = QApplication.instance() 1482 winutil.add_to_recent_docs(str(path), app.windows_app_uid) 1483 1484 1485def windows_is_system_dark_mode_enabled(): 1486 s = QSettings(r"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", QSettings.Format.NativeFormat) 1487 if s.status() == QSettings.Status.NoError: 1488 return s.value("AppsUseLightTheme") == 0 1489 return False 1490 1491 1492def make_view_use_window_background(view): 1493 p = view.palette() 1494 p.setColor(QPalette.ColorRole.Base, p.color(QPalette.ColorRole.Window)) 1495 p.setColor(QPalette.ColorRole.AlternateBase, p.color(QPalette.ColorRole.Window)) 1496 view.setPalette(p) 1497 return view 1498