diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index 50e600ab86..ebd5cefe4b 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -15,8 +15,8 @@ from qt.core import ( QDialogButtonBox, QEvent, QFile, QFileDialog, QFileIconProvider, QFileInfo, QFont, QFontDatabase, QFontInfo, QFontMetrics, QGuiApplication, QIcon, QIODevice, QLocale, QNetworkProxyFactory, QObject, QPalette, QResource, QSettings, - QSocketNotifier, QStringListModel, QStyle, Qt, QThread, QTimer, QTranslator, - QUrl, pyqtSignal, pyqtSlot + QSocketNotifier, QStringListModel, Qt, QThread, QTimer, QTranslator, + QUrl, pyqtSignal ) from threading import Lock, RLock @@ -30,7 +30,7 @@ from calibre.ebooks.metadata import MetaInformation from calibre.gui2.linux_file_dialogs import ( check_for_linux_native_dialogs, linux_native_dialog ) -from calibre.gui2.palette import dark_palette, fix_palette_colors +from calibre.gui2.palette import PaletteManager from calibre.gui2.qt_file_dialogs import FileDialog from calibre.ptempfile import base_dir from calibre.utils.config import Config, ConfigProxy, JSONConfig, dynamic @@ -44,8 +44,6 @@ from polyglot.builtins import iteritems, string_or_bytes del pqc NO_URL_FORMATTING = QUrl.UrlFormattingOption.None_ -if islinux: - from qt.dbus import QDBusConnection, QDBusMessage, QDBusVariant class IconResourceManager: @@ -1050,11 +1048,6 @@ class Application(QApplication): if not args: args = sys.argv[:1] args = [args[0]] - if ismacos and not headless: - from calibre_extensions.cocoa import set_appearance - if gprefs['color_palette'] != 'system': - set_appearance(gprefs['color_palette']) - self.ignore_palette_changes = False QNetworkProxyFactory.setUseSystemConfiguration(True) # Allow import of webengine after construction of QApplication on new # enough PyQt @@ -1068,18 +1061,11 @@ class Application(QApplication): self.file_event_hook = None if override_program_name: args = [override_program_name] + args[1:] + self.palette_manager = PaletteManager(gprefs['color_palette'], gprefs['ui_style'], force_calibre_style, headless) if headless: args.extend(['-platformpluginpath', plugins_loc, '-platform', 'headless']) else: - if iswindows: - # passing darkmode=1 turns on dark window frames when windows - # is dark and darkmode=2 makes everything dark, but we have our - # own dark mode implementation when using calibre style so - # prefer that and use darkmode=1 - if gprefs['ui_style'] == 'system' and not force_calibre_style: - args.extend(['-platform', 'windows:darkmode=2']) - else: - args.extend(['-platform', 'windows:darkmode=1']) + args.extend(self.palette_manager.args_to_qt) self.headless = headless from calibre_extensions import progress_indicator @@ -1092,11 +1078,7 @@ class Application(QApplication): QApplication.setDesktopFileName(override_program_name) QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True) # needed for webengine QApplication.__init__(self, args) - self.original_palette = self.palette() - self.original_palette_modified = fix_palette_colors(self.original_palette) - if iswindows: - self.win_event_filter = WinEventFilter() - self.installNativeEventFilter(self.win_event_filter) + self.palette_manager.initialize() icon_resource_manager.initialize() sh = self.styleHints() if hasattr(sh, 'setShowShortcutsInContextMenus'): @@ -1122,11 +1104,8 @@ class Application(QApplication): self.setup_unix_signals() if islinux or isbsd: self.setAttribute(Qt.ApplicationAttribute.AA_DontUseNativeMenuBar, 'CALIBRE_NO_NATIVE_MENUBAR' in os.environ) - self.setup_styles(force_calibre_style) + self.palette_manager.setup_styles() self.setup_ui_font() - if not self.using_calibre_style and self.style().objectName() == 'fusion': - # Since Qt is using the fusion style anyway, specialize it - self.load_calibre_style() fi = gprefs['font'] if fi is not None: font = QFont(*(fi[:4])) @@ -1178,6 +1157,10 @@ class Application(QApplication): if cft >= 0: self.setCursorFlashTime(int(cft)) + @property + def is_dark_theme(self): + return self.palette_manager.is_dark_theme + def safe_restore_geometry(self, widget, geom): # See https://bugreports.qt.io/browse/QTBUG-77385 if not geom: @@ -1234,74 +1217,6 @@ class Application(QApplication): load_builtin_fonts() - def set_dark_mode_palette(self): - self.set_palette(dark_palette()) - - def setup_styles(self, force_calibre_style): - if iswindows or ismacos: - using_calibre_style = gprefs['ui_style'] != 'system' - else: - using_calibre_style = os.environ.get('CALIBRE_USE_SYSTEM_THEME', '0') == '0' - if force_calibre_style: - using_calibre_style = True - if using_calibre_style: - if iswindows: - use_dark_palette = gprefs['color_palette'] == 'dark' or (gprefs['color_palette'] == 'system' and windows_is_system_dark_mode_enabled()) - elif ismacos: - use_dark_palette = gprefs['color_palette'] == 'dark' - else: - use_dark_palette = gprefs['color_palette'] == 'dark' or (gprefs['color_palette'] == 'system' and linux_is_system_dark_mode_enabled()) - bus = QDBusConnection.sessionBus() - bus.connect( - 'org.freedesktop.portal.Desktop', '/org/freedesktop/portal/desktop', - 'org.freedesktop.portal.Settings', 'SettingChanged', 'ssv', self.linux_desktop_setting_changed) - if use_dark_palette: - self.set_dark_mode_palette() - elif self.original_palette_modified: - self.set_palette(self.original_palette) - - self.using_calibre_style = using_calibre_style - if DEBUG: - prints('Using calibre Qt style:', self.using_calibre_style) - if self.using_calibre_style: - self.load_calibre_style() - self.on_palette_change() - - if islinux: - @pyqtSlot(str, str, QDBusVariant) - def linux_desktop_setting_changed(self, namespace, key, val): - if (namespace, key) == ('org.freedesktop.appearance', 'color-scheme'): - if gprefs['color_palette'] != 'system': - return - use_dark_palette = val.variant() == 1 - if use_dark_palette != bool(self.is_dark_theme): - if use_dark_palette: - self.set_dark_mode_palette() - else: - self.set_palette(self.original_palette) - self.on_palette_change() - - def check_for_windows_palette_change(self): - if gprefs['color_palette'] != 'system': - return - use_dark_palette = bool(windows_is_system_dark_mode_enabled()) - if bool(self.is_dark_theme) != use_dark_palette: - if use_dark_palette: - self.set_dark_mode_palette() - else: - self.set_palette(self.original_palette) - self.on_palette_change() - - def set_palette(self, pal): - self.ignore_palette_changes = True - self.setPalette(pal) - # Needed otherwise Qt does not emit the paletteChanged signal when - # appearance is changed. And it has to be after current event - # processing finishes as of Qt 5.14 otherwise the palette change is - # ignored. - QTimer.singleShot(1000, lambda: QApplication.instance().setAttribute(Qt.ApplicationAttribute.AA_SetPalette, False)) - self.ignore_palette_changes = False - @lru_cache(maxsize=256) def cached_qimage(self, name): return self.cached_qpixmap(name).toImage() @@ -1311,80 +1226,10 @@ class Application(QApplication): ic = QIcon.ic(name) return ic.pixmap((ic.availableSizes() or (256,))[0]) - def on_palette_change(self): - self.cached_qimage.cache_clear() - self.cached_qpixmap.cache_clear() - self.is_dark_theme = is_dark_theme() - self.update_icon_theme() - self.setProperty('is_dark_theme', self.is_dark_theme) - if self.using_calibre_style: - ss = 'QTabBar::tab:selected { font-style: italic }\n\n' - if self.is_dark_theme: - ss += 'QMenu { border: 1px solid palette(shadow); }' - self.setStyleSheet(ss) - self.palette_changed.emit() - - def update_icon_theme(self): - icon_resource_manager.set_theme() - def stylesheet_for_line_edit(self, is_error=False): return 'QLineEdit { border: 2px solid %s; border-radius: 3px }' % ( '#FF2400' if is_error else '#50c878') - def load_calibre_style(self): - icon_map = self.__icon_map_memory_ = {} - user_path = icon_resource_manager.override_icon_path - if user_path: - user_path = os.path.join(user_path, 'images') - - @lru_cache(maxsize=64) - def check_for_custom_icon(v): - if user_path: - q = os.path.join(user_path, v) - if os.path.exists(q): - return q - return v.rpartition('.')[0] - - for k, v in { - 'DialogYesButton': 'ok.png', - 'DialogNoButton': 'window-close.png', - 'DialogCloseButton': 'close.png', - 'DialogOkButton': 'ok.png', - 'DialogCancelButton': 'window-close.png', - 'DialogHelpButton': 'help.png', - 'DialogOpenButton': 'document_open.png', - 'DialogSaveButton': 'save.png', - 'DialogApplyButton': 'ok.png', - 'DialogDiscardButton': 'trash.png', - 'MessageBoxInformation': 'dialog_information.png', - 'MessageBoxWarning': 'dialog_warning.png', - 'MessageBoxCritical': 'dialog_error.png', - 'MessageBoxQuestion': 'dialog_question.png', - 'BrowserReload': 'view-refresh.png', - 'LineEditClearButton': 'clear_left.png', - 'ToolBarHorizontalExtensionButton': 'v-ellipsis.png', - 'ToolBarVerticalExtensionButton': 'h-ellipsis.png', - 'FileDialogBack': 'back.png', - 'ArrowRight': 'forward.png', - 'ArrowLeft': 'back.png', - 'ArrowBack': 'back.png', - 'ArrowForward': 'forward.png', - 'ArrowUp': 'arrow-up.png', - 'ArrowDown': 'arrow-down.png', - 'FileDialogToParent': 'arrow-up.png', - 'FileDialogNewFolder': 'tb_folder.png', - 'FileDialogListView': 'format-list-unordered.png', - 'FileDialogDetailedView': 'format-list-ordered.png', - }.items(): - icon_map[getattr(QStyle.StandardPixmap, 'SP_'+k).value] = check_for_custom_icon(v) - transient_scroller = 0 - if ismacos: - from calibre_extensions.cocoa import transient_scroller - transient_scroller = transient_scroller() - self.calibre_style = style = self.pi.CalibreStyle(transient_scroller) - style.set_icon_map(icon_map) - self.setStyle(style) - def _send_file_open_events(self): with self._file_open_lock: if self._file_open_paths: @@ -1414,19 +1259,7 @@ class Application(QApplication): return True else: if etype == QEvent.Type.ApplicationPaletteChange: - if DEBUG: - if self.ignore_palette_changes: - print('ApplicationPaletteChange event ignored', file=sys.stderr) - else: - print('ApplicationPaletteChange event received', file=sys.stderr) - if not self.ignore_palette_changes: - if gprefs['color_palette'] != 'system': - pal = dark_palette() if gprefs['color_palette'] == 'dark' else self.original_palette - if self.palette().color(QPalette.ColorRole.Window) != pal.color(QPalette.ColorRole.Window): - if DEBUG: - print('Detected a spontaneous palette change in windows, reverting it', file=sys.stderr) - self.set_palette(pal) - self.on_palette_change() + self.palette_manager.on_qt_palette_change() return QApplication.event(self, e) @property @@ -1705,48 +1538,6 @@ def add_to_recent_docs(path): winutil.add_to_recent_docs(str(path), app.windows_app_uid) -if iswindows: - import ctypes - from qt.core import QAbstractNativeEventFilter - - class WinEventFilter(QAbstractNativeEventFilter): - - def nativeEventFilter(self, eventType, message): - if eventType == b"windows_generic_MSG": - msg = ctypes.wintypes.MSG.from_address(message.__int__()) - # https://docs.microsoft.com/en-us/windows/win32/winmsg/wm-settingchange - if msg.message == 0x001A and msg.lParam: # WM_SETTINGCHANGE - try: - s = ctypes.wstring_at(msg.lParam) - except OSError: - pass - else: - if s == 'ImmersiveColorSet': - QApplication.instance().check_for_windows_palette_change() - # prevent Qt from handling this event - return True, 0 - return False, 0 - - -def windows_is_system_dark_mode_enabled(): - s = QSettings(r"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", QSettings.Format.NativeFormat) - if s.status() == QSettings.Status.NoError: - return s.value("AppsUseLightTheme") == 0 - return False - - -def linux_is_system_dark_mode_enabled(): - bus = QDBusConnection.sessionBus() - m = QDBusMessage.createMethodCall( - 'org.freedesktop.portal.Desktop', '/org/freedesktop/portal/desktop', - 'org.freedesktop.portal.Settings', 'Read' - ) - m.setArguments(['org.freedesktop.appearance', 'color-scheme']) - reply = bus.call(m, timeout=1000) - a = reply.arguments() - return len(a) and isinstance(a[0], int) and a[0] == 1 - - def make_view_use_window_background(view): p = view.palette() p.setColor(QPalette.ColorRole.Base, p.color(QPalette.ColorRole.Window)) diff --git a/src/calibre/gui2/palette.py b/src/calibre/gui2/palette.py index af4e994b5c..39fced09a6 100644 --- a/src/calibre/gui2/palette.py +++ b/src/calibre/gui2/palette.py @@ -1,15 +1,65 @@ #!/usr/bin/env python # License: GPL v3 Copyright: 2019, Kovid Goyal -from qt.core import QColor, QPalette, Qt -from calibre.constants import dark_link_color, iswindows +import os +import sys +from contextlib import contextmanager +from functools import lru_cache +from qt.core import ( + QAbstractNativeEventFilter, QApplication, QColor, QIcon, QPalette, QSettings, + QStyle, Qt, QTimer, pyqtSlot, QObject +) +from calibre.constants import DEBUG, dark_link_color, ismacos, iswindows dark_link_color = QColor(dark_link_color) dark_color = QColor(45,45,45) dark_text_color = QColor('#ddd') +if iswindows: + import ctypes + + class WinEventFilter(QAbstractNativeEventFilter): + + def nativeEventFilter(self, eventType, message): + if eventType == b"windows_generic_MSG": + msg = ctypes.wintypes.MSG.from_address(message.__int__()) + # https://docs.microsoft.com/en-us/windows/win32/winmsg/wm-settingchange + if msg.message == 0x001A and msg.lParam: # WM_SETTINGCHANGE + try: + s = ctypes.wstring_at(msg.lParam) + except OSError: + pass + else: + if s == 'ImmersiveColorSet': + QApplication.instance().palette_manager.check_for_windows_palette_change() + # prevent Qt from handling this event + return True, 0 + return False, 0 +if not iswindows and not ismacos: + from qt.dbus import QDBusConnection, QDBusMessage, QDBusVariant + + +def windows_is_system_dark_mode_enabled(): + s = QSettings(r"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", QSettings.Format.NativeFormat) + if s.status() == QSettings.Status.NoError: + return s.value("AppsUseLightTheme") == 0 + return False + + +def linux_is_system_dark_mode_enabled(): + bus = QDBusConnection.sessionBus() + m = QDBusMessage.createMethodCall( + 'org.freedesktop.portal.Desktop', '/org/freedesktop/portal/desktop', + 'org.freedesktop.portal.Settings', 'Read' + ) + m.setArguments(['org.freedesktop.appearance', 'color-scheme']) + reply = bus.call(m, timeout=1000) + a = reply.arguments() + return len(a) and isinstance(a[0], int) and a[0] == 1 + + def palette_is_dark(self): return self.color(QPalette.ColorRole.Window).lightnessF() < self.color(QPalette.ColorRole.WindowText).lightnessF() @@ -51,3 +101,209 @@ def dark_palette(): p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.HighlightedText, disabled_color) return p + + +class PaletteManager(QObject): + + color_palette: str + has_fixed_palette: bool + using_calibre_style: bool + original_palette_modified: bool + is_dark_theme: bool + + def __init__(self, color_palette, ui_style, force_calibre_style, headless): + super().__init__() + self.color_palette = color_palette + self.is_dark_theme = False + self.ignore_palette_changes = False + + if force_calibre_style: + self.using_calibre_style = True + else: + if iswindows or ismacos: + self.using_calibre_style = ui_style != 'system' + else: + self.using_calibre_style = os.environ.get('CALIBRE_USE_SYSTEM_THEME', '0') == '0' + self.has_fixed_palette = self.color_palette != 'system' and self.using_calibre_style + + args = [] + if iswindows: + # passing darkmode=1 turns on dark window frames when windows + # is dark and darkmode=2 makes everything dark, but we have our + # own dark mode implementation when using calibre style so + # prefer that and use darkmode=1 + args.append('-platform') + args.append('windows:darkmode=' + '1' if self.using_calibre_style else '2') + self.args_to_qt = tuple(args) + if ismacos and not headless and self.has_fixed_palette: + from calibre_extensions.cocoa import set_appearance + set_appearance(color_palette) + + def initialize(self): + app = QApplication.instance() + self.setParent(app) + if not self.using_calibre_style and self.style().objectName() == 'fusion': + # Since Qt is using the fusion style anyway, specialize it + self.using_calibre_style = True + self.original_palette = QPalette(app.palette()) + self.original_palette_modified = fix_palette_colors(self.original_palette) + if iswindows: + self.win_event_filter = WinEventFilter() + app.installNativeEventFilter(self.win_event_filter) + + def setup_styles(self): + if self.using_calibre_style: + if iswindows: + use_dark_palette = self.color_palette == 'dark' or (self.color_palette == 'system' and windows_is_system_dark_mode_enabled()) + elif ismacos: + use_dark_palette = self.color_palette == 'dark' + else: + use_dark_palette = self.color_palette == 'dark' or (self.color_palette == 'system' and linux_is_system_dark_mode_enabled()) + bus = QDBusConnection.sessionBus() + bus.connect( + 'org.freedesktop.portal.Desktop', '/org/freedesktop/portal/desktop', + 'org.freedesktop.portal.Settings', 'SettingChanged', 'ssv', self.linux_desktop_setting_changed) + if use_dark_palette: + self.set_dark_mode_palette() + elif self.original_palette_modified: + self.set_palette(self.original_palette) + + if DEBUG: + print('Using calibre Qt style:', self.using_calibre_style, file=sys.stderr) + if self.using_calibre_style: + self.load_calibre_style() + self.on_palette_change() + + def load_calibre_style(self): + icon_map = self.__icon_map_memory_ = {} + user_path = QIcon.ic.override_icon_path + if user_path: + user_path = os.path.join(user_path, 'images') + + @lru_cache(maxsize=64) + def check_for_custom_icon(v): + if user_path: + q = os.path.join(user_path, v) + if os.path.exists(q): + return q + return v.rpartition('.')[0] + + for k, v in { + 'DialogYesButton': 'ok.png', + 'DialogNoButton': 'window-close.png', + 'DialogCloseButton': 'close.png', + 'DialogOkButton': 'ok.png', + 'DialogCancelButton': 'window-close.png', + 'DialogHelpButton': 'help.png', + 'DialogOpenButton': 'document_open.png', + 'DialogSaveButton': 'save.png', + 'DialogApplyButton': 'ok.png', + 'DialogDiscardButton': 'trash.png', + 'MessageBoxInformation': 'dialog_information.png', + 'MessageBoxWarning': 'dialog_warning.png', + 'MessageBoxCritical': 'dialog_error.png', + 'MessageBoxQuestion': 'dialog_question.png', + 'BrowserReload': 'view-refresh.png', + 'LineEditClearButton': 'clear_left.png', + 'ToolBarHorizontalExtensionButton': 'v-ellipsis.png', + 'ToolBarVerticalExtensionButton': 'h-ellipsis.png', + 'FileDialogBack': 'back.png', + 'ArrowRight': 'forward.png', + 'ArrowLeft': 'back.png', + 'ArrowBack': 'back.png', + 'ArrowForward': 'forward.png', + 'ArrowUp': 'arrow-up.png', + 'ArrowDown': 'arrow-down.png', + 'FileDialogToParent': 'arrow-up.png', + 'FileDialogNewFolder': 'tb_folder.png', + 'FileDialogListView': 'format-list-unordered.png', + 'FileDialogDetailedView': 'format-list-ordered.png', + }.items(): + icon_map[getattr(QStyle.StandardPixmap, 'SP_'+k).value] = check_for_custom_icon(v) + transient_scroller = 0 + if ismacos: + from calibre_extensions.cocoa import transient_scroller + transient_scroller = transient_scroller() + app = QApplication.instance() + from calibre_extensions.progress_indicator import CalibreStyle + self.calibre_style = style = CalibreStyle(transient_scroller) + style.set_icon_map(icon_map) + app.setStyle(style) + + def on_palette_change(self): + app = QApplication.instance() + app.cached_qimage.cache_clear() + app.cached_qpixmap.cache_clear() + self.is_dark_theme = app.palette().is_dark_theme() + QIcon.ic.set_theme() + app.setProperty('is_dark_theme', self.is_dark_theme) + if self.using_calibre_style: + ss = 'QTabBar::tab:selected { font-style: italic }\n\n' + if self.is_dark_theme: + ss += 'QMenu { border: 1px solid palette(shadow); }' + app.setStyleSheet(ss) + app.palette_changed.emit() + + def set_dark_mode_palette(self): + self.set_palette(dark_palette()) + + if not iswindows and not ismacos: + @pyqtSlot(str, str, QDBusVariant) + def linux_desktop_setting_changed(self, namespace, key, val): + if (namespace, key) == ('org.freedesktop.appearance', 'color-scheme'): + if self.has_fixed_palette: + return + use_dark_palette = val.variant() == 1 + if use_dark_palette != bool(self.is_dark_theme): + if use_dark_palette: + self.set_dark_mode_palette() + else: + self.set_palette(self.original_palette) + self.on_palette_change() + + def check_for_windows_palette_change(self): + if self.has_fixed_palette: + return + use_dark_palette = bool(windows_is_system_dark_mode_enabled()) + if bool(self.is_dark_theme) != use_dark_palette: + if use_dark_palette: + self.set_dark_mode_palette() + else: + self.set_palette(self.original_palette) + self.on_palette_change() + + @contextmanager + def changing_palette(self): + orig = self.ignore_palette_changes + self.ignore_palette_changes = True + try: + yield + finally: + self.ignore_palette_changes = orig + + def set_palette(self, pal): + with self.changing_palette(): + QApplication.instance().setPalette(pal) + # Needed otherwise Qt does not emit the paletteChanged signal when + # appearance is changed. And it has to be after current event + # processing finishes as of Qt 5.14 otherwise the palette change is + # ignored. + QTimer.singleShot(1000, self.mark_palette_as_unchanged_for_qt) + + def mark_palette_as_unchanged_for_qt(self): + QApplication.instance().setAttribute(Qt.ApplicationAttribute.AA_SetPalette, False) + + def on_qt_palette_change(self): + if self.ignore_palette_changes: + if DEBUG: + print('ApplicationPaletteChange event ignored', file=sys.stderr) + else: + if DEBUG: + print('ApplicationPaletteChange event received', file=sys.stderr) + if self.has_fixed_palette: + pal = dark_palette() if self.color_palette == 'dark' else self.original_palette + if QApplication.instance().palette().color(QPalette.ColorRole.Window) != pal.color(QPalette.ColorRole.Window): + if DEBUG: + print('Detected a spontaneous palette change by Qt, reverting it', file=sys.stderr) + self.set_palette(pal) + self.on_palette_change()