PySide6 実践講座 #6 設定画面 — QSettings、ダークテーマ、データバックアップ

読了 4分

ここまでリマインダーの時刻は 21 時に、閉じる動作は非表示にとコードに埋め込まれていました。良い道具はこうした決定をユーザーに返します。今回は設定画面を作り、テーマを着せ、データをエクスポートする、アプリの完成度を引き上げる回です。

QSettings — 設定保存の標準解 #

設定を自前の JSON ファイルで管理することもできますが、Qt にはすでに標準解があります。QSettings はキーと値をプラットフォームごとの標準の場所(macOS は plist、Windows はレジストリ、Linux は ini ファイル)に保存してくれます。第 1 回で setOrganizationName と setApplicationName を設定しておいたおかげで、引数なしで作っても正しい場所に書き込まれます。

src/daily/settings.py
# src/daily/settings.py
from PySide6.QtCore import QSettings


class AppSettings:
    """QSettings を包み、キーのタイプミスと型のミスを一か所で防ぐ。"""

    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)

ラッパークラスを一枚かませる理由は、QSettings の有名な罠のためです。保存した値がプラットフォームによっては文字列で返ってくることがあります。 bool を保存したのに "true" という文字列が返ってきて if の判定がひっくり返る事故が典型です。type=bool の指定や明示的な変換をラッパーの中に集めておけば、この罠をアプリ全体で一度だけ処理すれば済みます。キー名も "reminder/hour" のようにグループ/名前形式の定数になり、タイプミスが消えます。

設定画面と動作の接続 #

設定ページ自体は入門で学んだフォームの組み立てです。要点はウィジェットではなく接続です。

src/daily/ui/settings_page.py
# src/daily/ui/settings_page.py(核心のみ)
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("閉じるボタンでトレイに隠す")
        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)          # 実行中のリマインダーに即反映

原則は 2 行です。保存と適用を一緒にやる。 設定値を QSettings に書いて終わりにすると、「再起動しないと反映されない」アプリになります。第 5 回の Reminder に set_hour メソッドを足し、closeEvent がフラグの代わりに settings.close_to_tray を読むように直せば、設定はその場で生きて動きます。そして 「OK/キャンセル」ダイアログではなく即時適用を選びました。ローカルツールアプリの設定は、変えたらすぐ反映されるほうが今の慣例に合います。

テーマ — スタイルシートの差し替えとシステム検知 #

Qt ウィジェットの外見は、CSS に似たスタイルシート(QSS)で変えられます。テーマ実装の骨組みは「ダーク用 QSS 文字列とライト用 QSS 文字列を用意しておき、app.setStyleSheet で差し替える」です。

src/daily/ui/theme.py
# src/daily/ui/theme.py(抜粋)
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()          # OS のライト/ダーク設定
        mode = "dark" if scheme == Qt.ColorScheme.Dark else "light"
    app.setStyleSheet(DARK_QSS if mode == "dark" else "")

押さえるポイントが 2 つあります。第一に、“system” モードは styleHints().colorScheme() で OS の設定を読み、colorSchemeChanged シグナルにつないでおけば、OS のテーマが変わったときアプリも追従します。第二に、第 4 回のヒートマップのように自分で描くウィジェットはスタイルシートの影響を受けません。 ハードコードしていた色をパレットやテーママジュールの定数から読むように直さないと、テーマ切り替えに反応しません。カスタム描画の隠れたコストがこういうところに現れます。

データのエクスポート — ユーザーのデータはユーザーのもの #

ローカルアプリの最後の礼儀は、データを人質に取らないことです。2 つを提供します。

src/daily/data/export.py
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 エクスポート: 保存先は QFileDialog.getSaveFileName でユーザーが選びます。表計算ソフトで開ける形式であること自体が、「自分のデータを持ち出せる」という信頼になります。
  • DB バックアップ: sqlite3 の Connection.backup() を使えば、使用中の DB も安全にコピーできます。ファイルをそのままコピーするより安全な、SQLite が提供する正式なバックアップ経路です。リポジトリに backup_to(path) メソッドとして追加しておきます。

まとめ #

  • 設定は QSettings が標準です。プラットフォームごとの保存場所を自動処理し、型の罠(文字列で返る bool)はラッパークラスで一度に防ぎます。
  • 設定は保存と同時に適用します。再起動しないと反映されない設定は未完成です。
  • テーマは QSS の差し替えが骨組みで、system モードは colorScheme の検知と変更シグナルで作ります。自分で描くウィジェットは色をテーマ定数から読むように直す必要があります。
  • CSV エクスポートと backup() ベースの DB バックアップで、ユーザーデータの移動の自由を保証します。
  • 機能はこれで完成です。次回はこのアプリが完成状態であり続けるための装置、pytest-qt テストです。
X