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...