PySide6 in Practice #6 The Settings Screen: QSettings, Dark Theme, Data Backup
Until now the reminder hour was hardcoded to 21:00 and close behavior to hide. Good tools give these decisions back to the user. This part builds the settings screen, applies themes, and exports data — the round that raises the app’s finish quality.
QSettings: the standard answer for storing settings #
You could manage a JSON file yourself, but Qt already has the standard answer. QSettings stores key-value pairs in each platform’s standard location (plists on macOS, the registry on Windows, ini files on Linux). Because part 1 set setOrganizationName and setApplicationName, a no-argument construction writes to the right place.
# src/daily/settings.py
from PySide6.QtCore import QSettings
class AppSettings:
"""Wrap QSettings so key typos and type mistakes are stopped in one place."""
def __init__(self) -> None:
self._s = QSettings()
@property
def reminder_hour(self) -> int:
return int(self._s.value("reminder/hour", 21))
@reminder_hour.setter
def reminder_hour(self, value: int) -> None:
self._s.setValue("reminder/hour", value)
@property
def close_to_tray(self) -> bool:
return self._s.value("window/close_to_tray", True, type=bool)
@close_to_tray.setter
def close_to_tray(self, value: bool) -> None:
self._s.setValue("window/close_to_tray", value)
@property
def theme(self) -> str: # "system" | "light" | "dark"
return str(self._s.value("appearance/theme", "system"))
@theme.setter
def theme(self, value: str) -> None:
self._s.setValue("appearance/theme", value)The wrapper class exists because of QSettings’ famous trap: stored values can come back as strings depending on the platform. Saving a bool and getting back the string "true" — flipping your if — is the classic accident. Concentrating type=bool hints and explicit conversions in the wrapper means the trap is handled once for the whole app, and keys become constants in "group/name" form so typos disappear.
Wiring settings to behavior #
The settings page itself is form assembly from the intro series. The point is not the widgets but the wiring.
# src/daily/ui/settings_page.py (essentials)
class SettingsPage(QWidget):
def __init__(self, settings: AppSettings, reminder, main_window) -> None:
super().__init__()
self._settings = settings
hour = QSpinBox()
hour.setRange(0, 23)
hour.setValue(settings.reminder_hour)
hour.valueChanged.connect(self._on_hour_changed)
close_tray = QCheckBox("Hide to tray when the close button is pressed")
close_tray.setChecked(settings.close_to_tray)
close_tray.toggled.connect(self._on_close_mode_changed)
...
def _on_hour_changed(self, value: int) -> None:
self._settings.reminder_hour = value
self._reminder.set_hour(value) # apply to the running reminder nowTwo principles. Save and apply together. Writing to QSettings alone yields an app that “needs a restart to take effect.” Add set_hour to part 5’s Reminder, and change closeEvent to read settings.close_to_tray instead of a flag, and settings come alive on the spot. And apply-immediately over OK/Cancel dialogs — for a local tool app, instant application matches current conventions.
Themes: stylesheet swapping and system detection #
Qt widget appearance is styled with QSS, a CSS-like stylesheet language. The skeleton of theming: keep a dark QSS string and a light one, and swap with app.setStyleSheet.
# src/daily/ui/theme.py (excerpt)
DARK_QSS = """
QWidget { background: #1e1f22; color: #e6e6e6; }
QPushButton { background: #2d333b; border: 1px solid #444c56; padding: 6px 12px; border-radius: 4px; }
QPushButton:hover { background: #39404a; }
QListView { background: #16171a; border: none; }
"""
def apply_theme(app: QApplication, mode: str) -> None:
if mode == "system":
scheme = app.styleHints().colorScheme() # the OS light/dark setting
mode = "dark" if scheme == Qt.ColorScheme.Dark else "light"
app.setStyleSheet(DARK_QSS if mode == "dark" else "")Two points to catch. First, “system” mode reads the OS setting via styleHints().colorScheme(), and connecting to the colorSchemeChanged signal makes the app follow live OS theme switches. Second, widgets that paint themselves — like part 4’s heatmap — are untouched by stylesheets. Their hardcoded colors must move to palette- or theme-module constants to participate in theme switching. This is where custom drawing’s hidden cost shows up.
Data export: the user’s data belongs to the user #
A local app’s final courtesy is not holding data hostage. We provide two things.
def export_csv(repo: HabitRepository, path: Path) -> None:
import csv
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["habit", "day"])
for habit in repo.active_habits():
for day in sorted(repo.checked_days(habit.id)):
writer.writerow([habit.name, day])- CSV export: the destination comes from
QFileDialog.getSaveFileName. A format that opens in a spreadsheet is itself the trust signal — “I can take my data with me.” - Database backup: sqlite3’s
Connection.backup()copies even a live database safely — SQLite’s official backup path, safer than copying the file. Add it to the repository as abackup_to(path)method.
Summary #
- QSettings is the standard for settings, handling per-platform storage; its type trap (bools returning as strings) is stopped once in a wrapper class.
- Save and apply settings simultaneously. A setting that needs a restart is unfinished.
- Themes are QSS swaps at the core; system mode uses colorScheme detection and its change signal. Self-painting widgets must read colors from theme constants.
- CSV export and backup()-based DB backup guarantee the user’s freedom to leave with their data.
- Features are now complete. Next part: the device that keeps them complete — pytest-qt tests.