[Python] How to best manage Theme and HDPI Scaling with QSettings in PyQt?

Discussão em 'Python' iniciado por Stack, Julho 7, 2025.

  1. Stack

    Stack Membro Participativo

    I'm developing a PyQt tools library and want to create a way to manage user preferences for UI theme (Light/Dark) and HDPI zoom levels. I've created a QSettings wrapper class, AppSettingsManager, to handle these preferences persistently.

    My current implementation uses a Singleton pattern for AppSettingsManager and has a static method prepare_qt_environment that must be called before QtWidgets.QApplication is instantiated to properly set environment variables for HDPI scaling (QT_SCALE_FACTOR). Theme changes are applied dynamically by setting the QApplication palette, while scale factor changes require an application restart (handled via os.execl).

    While this solution works, I'm not entirely convinced it's the most robust or idiomatic way to handle these settings in a Qt application. I'm looking for feedback and advice on best practices.

    Here's a simplified reproducible example demonstrating its usage:

    import os
    import sys
    import typing
    import warnings

    from PyQt6 import QtCore, QtWidgets, QtGui
    from PyQt6.QtGui import QPalette, QColor

    # Define colors
    DARK_GREY = QColor(53, 53, 53)
    WHITE = QColor(255, 255, 255)
    BLACK = QColor(25, 25, 25)
    RED = QColor(255, 0, 0)
    LIGHT_BLUE = QColor(42, 130, 218)
    LIGHT_GREY = QColor(240, 240, 240)
    MID_GREY = QColor(220, 220, 220)

    # Define light palette
    light_palette = QtGui.QPalette()
    light_palette.setColor(QtGui.QPalette.ColorRole.Window, LIGHT_GREY)
    light_palette.setColor(QtGui.QPalette.ColorRole.WindowText, BLACK)
    light_palette.setColor(QtGui.QPalette.ColorRole.Base, WHITE)
    light_palette.setColor(QtGui.QPalette.ColorRole.AlternateBase, LIGHT_GREY)
    light_palette.setColor(QtGui.QPalette.ColorRole.ToolTipBase, WHITE)
    light_palette.setColor(QtGui.QPalette.ColorRole.ToolTipText, BLACK)
    light_palette.setColor(QtGui.QPalette.ColorRole.Text, BLACK)
    light_palette.setColor(QtGui.QPalette.ColorRole.Button, MID_GREY)
    light_palette.setColor(QtGui.QPalette.ColorRole.ButtonText, BLACK)
    light_palette.setColor(QtGui.QPalette.ColorRole.BrightText, RED)
    light_palette.setColor(QtGui.QPalette.ColorRole.Link, LIGHT_BLUE)
    light_palette.setColor(QtGui.QPalette.ColorRole.Highlight, LIGHT_BLUE)
    light_palette.setColor(QtGui.QPalette.ColorRole.HighlightedText, WHITE)
    LIGHT_PALETTE = light_palette

    # Define dark palette
    dark_palette = QtGui.QPalette()
    dark_palette.setColor(QtGui.QPalette.ColorRole.Window, DARK_GREY)
    dark_palette.setColor(QtGui.QPalette.ColorRole.WindowText, WHITE)
    dark_palette.setColor(QtGui.QPalette.ColorRole.Base, BLACK)
    dark_palette.setColor(QtGui.QPalette.ColorRole.AlternateBase, DARK_GREY)
    dark_palette.setColor(QtGui.QPalette.ColorRole.ToolTipBase, WHITE)
    dark_palette.setColor(QtGui.QPalette.ColorRole.ToolTipText, WHITE)
    dark_palette.setColor(QtGui.QPalette.ColorRole.Text, WHITE)
    dark_palette.setColor(QtGui.QPalette.ColorRole.Button, DARK_GREY)
    dark_palette.setColor(QtGui.QPalette.ColorRole.ButtonText, WHITE)
    dark_palette.setColor(QtGui.QPalette.ColorRole.BrightText, RED)
    dark_palette.setColor(QtGui.QPalette.ColorRole.Link, LIGHT_BLUE)
    dark_palette.setColor(QtGui.QPalette.ColorRole.Highlight, LIGHT_BLUE)
    dark_palette.setColor(QtGui.QPalette.ColorRole.HighlightedText, BLACK)
    DARK_PALETTE = dark_palette

    DEFAULT_SETTINGS_ORG = "org"
    DEFAULT_SETTINGS_APP = "app"

    DEFAULT_ZOOM_FACTOR = 1.0
    DEFAULT_COLOR_SCHEME = QtCore.Qt.ColorScheme.Light


    class AppSettingsManager(QtCore.QObject):
    _instance = None
    _qt_environment_prepared_flag = False

    color_scheme_changed = QtCore.pyqtSignal(QtCore.Qt.ColorScheme)
    scale_factor_changed = QtCore.pyqtSignal(float)

    @classmethod
    def prepare_qt_environment(cls, org_name: str = DEFAULT_SETTINGS_ORG, app_name: str = DEFAULT_SETTINGS_APP) -> None:
    if QtWidgets.QApplication.instance() is not None:
    warnings.warn("AppSettingsManager.prepare_qt_environment() was called AFTER QtWidgets.QApplication was instantiated. Environment variables for scaling might not take effect.", UserWarning)

    os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1"

    QtWidgets.QApplication.setOrganizationName(org_name)
    QtWidgets.QApplication.setApplicationName(app_name)

    settings = QtCore.QSettings()
    preferred_scale_factor_str = settings.value("ui_scale_factor", str(DEFAULT_ZOOM_FACTOR), type=str)
    os.environ["QT_SCALE_FACTOR"] = preferred_scale_factor_str

    cls._qt_environment_prepared_flag = True

    def __new__(cls, *args, **kwargs):
    if cls._instance is not None:
    raise RuntimeError("AppSettingsManager can only be instantiated once.")
    cls._instance = super(AppSettingsManager, cls).__new__(cls)
    return cls._instance

    def __init__(self, parent: typing.Optional[QtCore.QObject] = None):
    if not AppSettingsManager._qt_environment_prepared_flag:
    warnings.warn("AppSettingsManager.prepare_qt_environment() was not called before QtWidgets.QApplication was instantiated. UI scaling might not be applied correctly.", UserWarning)
    super().__init__(parent)

    self._app = QtWidgets.QApplication.instance()
    self._settings = QtCore.QSettings()

    initial_color_scheme_int = self._settings.value("color_scheme", int(DEFAULT_COLOR_SCHEME.value), type=int)
    self._color_scheme: QtCore.Qt.ColorScheme = QtCore.Qt.ColorScheme(initial_color_scheme_int)
    self._apply_color_scheme(self._color_scheme)

    initial_scale_factor_str = self._settings.value("ui_scale_factor", str(DEFAULT_ZOOM_FACTOR), type=str)
    self._scale_factor: float = float(initial_scale_factor_str)

    @property
    def settings(self) -> QtCore.QSettings:
    return self._settings

    @property
    def color_scheme(self) -> QtCore.Qt.ColorScheme:
    return self._color_scheme

    @color_scheme.setter
    def color_scheme(self, color_scheme: QtCore.Qt.ColorScheme):
    if self._color_scheme == color_scheme:
    return

    self._color_scheme = color_scheme
    self._apply_color_scheme(color_scheme)

    self._settings.setValue("color_scheme", int(color_scheme.value))
    self._settings.sync()

    self.color_scheme_changed.emit(color_scheme)

    def _apply_color_scheme(self, color_scheme: QtCore.Qt.ColorScheme) -> None:
    if color_scheme == QtCore.Qt.ColorScheme.Dark:
    self._app.setPalette(DARK_PALETTE)
    else:
    self._app.setPalette(LIGHT_PALETTE)

    @property
    def scale_factor(self) -> float:
    return self._scale_factor

    @scale_factor.setter
    def scale_factor(self, factor: float):
    if self._scale_factor == factor:
    return

    self._scale_factor = factor

    self._settings.setValue("ui_scale_factor", str(factor))
    self._settings.sync()

    self.scale_factor_changed.emit(factor)

    def clear_settings(self) -> None:
    self._settings.clear()
    self._settings.sync()

    self.color_scheme = DEFAULT_COLOR_SCHEME

    if self._scale_factor != DEFAULT_ZOOM_FACTOR:
    self._scale_factor = DEFAULT_ZOOM_FACTOR
    self.scale_factor_changed.emit(DEFAULT_ZOOM_FACTOR)


    class MainWindow(QtWidgets.QMainWindow):
    zoom_factors = {"0.8x": 0.8, "1.0x (Default)": 1.0, "1.25x": 1.25, "1.5x": 1.5, "2.0x": 2.0}

    def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
    super().__init__(*args, **kwargs)

    self._app = QtWidgets.QApplication.instance()
    self._settings_manager = AppSettingsManager()

    self.setWindowTitle("PyQt Settings Example")

    central_widget = QtWidgets.QWidget()
    self.setCentralWidget(central_widget)
    main_layout = QtWidgets.QVBoxLayout(central_widget)

    theme_group_box = QtWidgets.QGroupBox("Theme Control")
    theme_layout = QtWidgets.QHBoxLayout(theme_group_box)

    self.theme_combobox = QtWidgets.QComboBox()
    self.theme_combobox.addItem("Light", userData=QtCore.Qt.ColorScheme.Light)
    self.theme_combobox.addItem("Dark", userData=QtCore.Qt.ColorScheme.Dark)
    theme_layout.addWidget(self.theme_combobox)
    main_layout.addWidget(theme_group_box)

    zoom_group_box = QtWidgets.QGroupBox("Application Zoom")
    zoom_layout = QtWidgets.QHBoxLayout(zoom_group_box)

    self.zoom_combobox = QtWidgets.QComboBox()
    for text in self.zoom_factors.keys():
    self.zoom_combobox.addItem(text, userData=self.zoom_factors[text])
    zoom_layout.addWidget(self.zoom_combobox)
    main_layout.addWidget(zoom_group_box)

    clear_group_box = QtWidgets.QGroupBox("Reset Settings")
    clear_layout = QtWidgets.QVBoxLayout(clear_group_box)

    self.clear_button = QtWidgets.QPushButton("Clear Settings")
    clear_layout.addWidget(self.clear_button)
    main_layout.addWidget(clear_group_box)

    self.theme_combobox.activated.connect(self._handle_theme_selection)
    self.zoom_combobox.activated.connect(self._handle_zoom_selection)
    self.clear_button.clicked.connect(self._handle_clear_settings)

    self._settings_manager.color_scheme_changed.connect(self._update_theme_combobox)
    self._settings_manager.scale_factor_changed.connect(self._update_zoom_combobox)

    self._update_theme_combobox(self._settings_manager.color_scheme)
    self._update_zoom_combobox(self._settings_manager.scale_factor)

    self.resize(320, 160)

    def _handle_theme_selection(self, index: int) -> None:
    selected_color_scheme = self.theme_combobox.itemData(index)
    self._settings_manager.color_scheme = selected_color_scheme

    def _update_theme_combobox(self, color_scheme: QtCore.Qt.ColorScheme) -> None:
    index = self.theme_combobox.findData(color_scheme)
    if index != -1:
    self.theme_combobox.setCurrentIndex(index)

    def _update_zoom_combobox(self, factor: float) -> None:
    index = self.zoom_combobox.findData(factor)
    if index != -1:
    self.zoom_combobox.setCurrentIndex(index)

    def _handle_zoom_selection(self, index: int) -> None:
    selected_factor = self.zoom_combobox.itemData(index)
    current_saved_factor = self._settings_manager.scale_factor

    if selected_factor != current_saved_factor:
    self._set_scale_factor_and_restart(selected_factor)

    def _handle_clear_settings(self) -> None:
    self._settings_manager.clear_settings()
    self._restart_application()

    def _set_scale_factor_and_restart(self, factor: float) -> None:
    self._settings_manager.scale_factor = factor
    self._restart_application()

    @staticmethod
    def _restart_application() -> None:
    os.execl(sys.executable, sys.executable, *sys.argv)


    if __name__ == "__main__":
    AppSettingsManager.prepare_qt_environment("Example", "Theme&ScaleDemoApp")
    app = QtWidgets.QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec())

    Continue reading...

Compartilhe esta Página