PySide6 実践講座 #3 今日画面 — カスタムモデルとチェックのトグル

読了 5分

データ層ができたので、いよいよユーザーが出会う最初の画面、「今日」タブを作ります。習慣の一覧が表示され、クリックで今日のチェックをオン・オフする画面です。入門 #5 でモデル/ビューの概念を扱ったなら、今回はその知識が実際のアプリの中でどんな形になるのかを見ます。

なぜ QListWidget でやらないのか #

いちばん速い道は QListWidget にアイテムを直接詰めることです。実際、プロトタイプならそれが正解です。しかしこのアプリでは、同じデータが 2 つの画面(今日の一覧、統計)で使われ、チェック状態が DB と同期していなければならず、第 7 回では画面なしでテストもする必要があります。ウィジェットにデータを直接入れる方式は、この要求のすべてできしみます。データの真実がウィジェットの中に閉じ込められるからです。

モデル/ビューの答えは分離です。データの真実はモデルが持ち、ビューは描くだけ。 モデルはリポジトリを包み、ビュー(QListView)はモデルに「何行目に何を描く?」と尋ねるだけです。

カスタムモデル — QAbstractListModel の継承 #

リスト型カスタムモデルの最小の契約は 3 つです。行数(rowCount)、データ(data)、そして編集があるなら setData と flags です。

src/daily/ui/today_model.py
# src/daily/ui/today_model.py
from datetime import date

from PySide6.QtCore import QAbstractListModel, QModelIndex, Qt

from daily.data.repository import HabitRepository


class TodayModel(QAbstractListModel):
    def __init__(self, repo: HabitRepository) -> None:
        super().__init__()
        self._repo = repo
        self._habits = []       # [(Habit, checked: bool)]
        self.reload()

    def reload(self) -> None:
        self.beginResetModel()
        today = date.today().isoformat()
        self._habits = [
            (h, today in self._repo.checked_days(h.id))
            for h in self._repo.active_habits()
        ]
        self.endResetModel()

    # --- 読み取りの契約 ---
    def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
        return len(self._habits)

    def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
        habit, checked = self._habits[index.row()]
        if role == Qt.ItemDataRole.DisplayRole:
            return habit.name
        if role == Qt.ItemDataRole.CheckStateRole:
            return Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked
        return None

    # --- 書き込みの契約 ---
    def flags(self, index: QModelIndex) -> Qt.ItemFlag:
        return (
            Qt.ItemFlag.ItemIsEnabled
            | Qt.ItemFlag.ItemIsSelectable
            | Qt.ItemFlag.ItemIsUserCheckable
        )

    def setData(self, index: QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool:
        if role != Qt.ItemDataRole.CheckStateRole:
            return False
        habit, _ = self._habits[index.row()]
        checked = Qt.CheckState(value) == Qt.CheckState.Checked
        self._repo.set_checked(habit.id, date.today(), checked)   # DB に反映
        self._habits[index.row()] = (habit, checked)              # キャッシュに反映
        self.dataChanged.emit(index, index, [Qt.ItemDataRole.CheckStateRole])
        return True

読み込んでおくべきポイントが 3 つあります。

  • ロール(role)がデータの複数の顔です。 同じ行でも、DisplayRole で聞かれれば名前を、CheckStateRole で聞かれればチェック状態を返します。ビューとデリゲートは必要なロールだけを選んで使います。「1 行 = 値ひとつ」ではなく「1 行 = ロールごとの値たち」と捉えるのが、モデル/ビュー理解の半分です。
  • 書き込みの経路が 1 本に整理されます。 ユーザーがチェックボックスをクリックするとビューが setData を呼び、モデルが DB と内部キャッシュを更新してから dataChanged を放ちます。データ変更が通るドアがひとつだけなので、バグが出ても見る場所は一か所です。
  • dataChanged には変わった範囲とロールを正確に知らせます。 全体リセット(beginResetModel)は一覧の構成そのものが変わるとき(reload)だけに使います。1 行のチェックが変わっただけで全体をリセットすると、選択状態やスクロールが飛ぶ、ユーザーの目に見える品質低下が起きます。

画面の組み立て — QListView と追加ダイアログ #

src/daily/ui/today_page.py
# src/daily/ui/today_page.py
from PySide6.QtWidgets import (
    QInputDialog, QListView, QPushButton, QVBoxLayout, QWidget,
)

from daily.ui.today_model import TodayModel


class TodayPage(QWidget):
    def __init__(self, model: TodayModel) -> None:
        super().__init__()
        self._model = model

        view = QListView()
        view.setModel(model)

        add_btn = QPushButton("習慣を追加")
        add_btn.clicked.connect(self._add_habit)

        layout = QVBoxLayout(self)
        layout.addWidget(view, stretch=1)
        layout.addWidget(add_btn)

    def _add_habit(self) -> None:
        name, ok = QInputDialog.getText(self, "習慣を追加", "名前:")
        if ok and name.strip():
            self._model._repo.add_habit(name.strip())
            self._model.reload()

QListView は CheckStateRole を返すモデルに出会うと、チェックボックスを勝手に描いてくれます。カスタムデリゲートなしでも「名前 + チェックボックス」の一覧が完成するのです。もっと凝った行(連続日数バッジ、色タグ)が必要になったときがデリゲートの出番ですが、このアプリでは第 4 回のヒートマップがカスタム描画の舞台になるので、一覧はデフォルトの描画で十分です。

ひとつ改善すべき点があります。self._model._repo のようにモデルの内部に手を入れるのは良い兆候ではありません。実務では、モデルに add_habit(name) メソッドを追加して「モデルの公開インターフェースだけを使う」形に整えるのが適切です。こうしたリファクタリングの判断基準そのものが、このシリーズで身につける判断力です。

メインウィンドウでは、第 1 回のプレースホルダーを実際の画面に差し替えます。

src/daily/ui/main_window.py
# main_window.py の修正
self.today_model = TodayModel(repo)
self.pages.addWidget(TodayPage(self.today_model))   # index 0

動かしてみる #

uv run python -m daily で実行すると、習慣を追加してチェックをトグルでき、アプリを再起動しても状態が残ります。データは DB にあり、画面はそれを映す鏡にすぎないという構造が、初めて体感できる地点です。

まとめ #

  • ウィジェットにデータを直接入れる方式は、複数画面、DB 同期、テストの要求の前で崩れます。真実はモデルに、描画はビューに置きます。
  • カスタムリストモデルの契約は rowCount、data、編集があるなら flags と setData です。ロールが 1 行の複数の顔を公開します。
  • チェックのトグルは、ビュー → setData → DB・キャッシュ更新 → dataChanged という単一経路で流れます。変更のドアがひとつならデバッグもひとつです。
  • dataChanged は変わった範囲とロールだけを正確に知らせ、全体リセットは一覧の構成が変わるときだけ使います。
  • QListView は CheckStateRole だけでチェックボックスを描いてくれます。次回は既製ウィジェットのない画面、QPainter で自作するヒートマップです。
X