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