PySide6 実践講座 #4 統計画面 — QPainter で草むらヒートマップを描く

読了 4分

統計画面の主役は、GitHub の草むらスタイルのヒートマップです。直近数週間を格子状に敷き、チェックした日ほど濃い色を塗るあの画面です。問題は、こういうウィジェットが Qt にないこと。今回がこのシリーズでカスタム描画を学ぶ回である理由です。必要なウィジェットがなければ、描けばいい。 そしてその過程で、カスタムウィジェットの 4 大要素(paintEvent、sizeHint、イベント、update)をすべて経験します。

統計ロジックは core に — 描く前にまず計算 #

ヒートマップを描くには「日付ごとのチェック有無」が、サマリーカードには「達成率と現在の連続記録」が必要です。この計算は画面と無関係なルールなので core 層に置きます。第 1 回の「core は Qt を知らない」ルールの実践です。

src/daily/core/stats.py
# src/daily/core/stats.py
from datetime import date, timedelta


def current_streak(checked: set[str], today: date) -> int:
    """今日(または昨日)からさかのぼって続いた連続チェック日数。"""
    streak = 0
    day = today
    # 今日まだチェックしていなければ昨日から数え始める
    if day.isoformat() not in checked:
        day -= timedelta(days=1)
    while day.isoformat() in checked:
        streak += 1
        day -= timedelta(days=1)
    return streak


def completion_rate(checked: set[str], start: date, end: date) -> float:
    total = (end - start).days + 1
    done = sum(
        1 for i in range(total)
        if (start + timedelta(days=i)).isoformat() in checked
    )
    return done / total if total else 0.0

入力が値で出力も値の純粋関数なので、第 7 回で GUI なしの pytest でそのまま検証できます。「今日まだチェックしていなかったら連続は切れたことになるのか?」といったポリシーの疑問も、この関数のテストケースとして表現されます。

paintEvent — 描くコードの住む場所 #

カスタムウィジェットの心臓は paintEvent です。Qt が「このウィジェットを描くべきとき」に呼ぶメソッドで、その中で QPainter を使って描きます。

src/daily/ui/heatmap.py
# src/daily/ui/heatmap.py
from datetime import date, timedelta

from PySide6.QtCore import QRect, QSize, Qt
from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import QWidget

CELL = 14        # セルの一辺(px)
GAP = 3          # セル間の隙間
WEEKS = 20       # 表示する週数


class HeatmapWidget(QWidget):
    def __init__(self) -> None:
        super().__init__()
        self._checked: set[str] = set()
        self.setMouseTracking(True)   # クリックなしでも mouseMoveEvent を受けるため

    def set_data(self, checked: set[str]) -> None:
        self._checked = checked
        self.update()                 # 再描画の「依頼」

    # --- サイズの契約 ---
    def sizeHint(self) -> QSize:
        return QSize(WEEKS * (CELL + GAP), 7 * (CELL + GAP))

    # --- 描画 ---
    def paintEvent(self, event) -> None:
        painter = QPainter(self)
        today = date.today()
        # 格子の開始日: WEEKS 週前のその週の月曜日
        start = today - timedelta(days=today.weekday() + 7 * (WEEKS - 1))

        for week in range(WEEKS):
            for dow in range(7):
                day = start + timedelta(weeks=week, days=dow)
                if day > today:
                    continue
                rect = QRect(
                    week * (CELL + GAP), dow * (CELL + GAP), CELL, CELL
                )
                on = day.isoformat() in self._checked
                color = QColor("#39d353") if on else QColor("#2d333b")
                painter.fillRect(rect, color)

    # --- ヒットテスト: マウスの下のセルを探す ---
    def mouseMoveEvent(self, event) -> None:
        pos = event.position().toPoint()
        week, dow = pos.x() // (CELL + GAP), pos.y() // (CELL + GAP)
        if 0 <= week < WEEKS and 0 <= dow < 7:
            today = date.today()
            start = today - timedelta(days=today.weekday() + 7 * (WEEKS - 1))
            day = start + timedelta(weeks=int(week), days=int(dow))
            if day <= today:
                state = "完了" if day.isoformat() in self._checked else "未完了"
                self.setToolTip(f"{day.isoformat()} — {state}")

この短いウィジェットに、カスタム描画のルールがすべて入っています。

  • 描画は依頼によってのみ起こります。 データが変わったとき paintEvent を直接呼ぶのではなく update() を呼びます。Qt が適切なタイミングで paintEvent を呼んでくれます。直接呼びたくなる瞬間が、カスタムウィジェット入門者の最初の罠です。
  • 座標はすべて計算です。「何週目、何曜日」というデータ座標をピクセル座標に変える算数(week * (CELL + GAP))が描画の大部分で、マウスのヒットテストは正確にその逆算です。同じ定数(CELL、GAP)を両方が共有しているので、ずれようがありません。
  • sizeHint がレイアウトとの契約です。 レイアウトマネージャーはこのヒントでウィジェットの適正サイズを決めます。これを忘れると、ウィジェットがゼロ近くまでつぶれるという、カスタムウィジェットの定番の症状が出ます。

統計ページの組み立て #

src/daily/ui/stats_page.py
# src/daily/ui/stats_page.py
from datetime import date, timedelta

from PySide6.QtWidgets import QComboBox, QLabel, QVBoxLayout, QWidget

from daily.core.stats import completion_rate, current_streak
from daily.data.repository import HabitRepository
from daily.ui.heatmap import HeatmapWidget


class StatsPage(QWidget):
    def __init__(self, repo: HabitRepository) -> None:
        super().__init__()
        self._repo = repo
        self._select = QComboBox()
        self._summary = QLabel()
        self._heatmap = HeatmapWidget()

        layout = QVBoxLayout(self)
        layout.addWidget(self._select)
        layout.addWidget(self._summary)
        layout.addWidget(self._heatmap)
        layout.addStretch()

        self._select.currentIndexChanged.connect(self._refresh)
        self.reload_habits()

    def reload_habits(self) -> None:
        self._select.clear()
        for habit in self._repo.active_habits():
            self._select.addItem(habit.name, userData=habit.id)

    def _refresh(self) -> None:
        habit_id = self._select.currentData()
        if habit_id is None:
            return
        checked = self._repo.checked_days(habit_id)
        today = date.today()
        rate = completion_rate(checked, today - timedelta(days=27), today)
        self._summary.setText(
            f"直近 4 週の達成率 {rate:.0%} · 現在の連続 {current_streak(checked, today)} 日"
        )
        self._heatmap.set_data(checked)

計算は core の関数を呼び、描画はヒートマップウィジェットに任せ、ページは組み立てだけをします。層が分かれていると、ページのコードはこれだけ短くなります。

最後の接続がひとつ残っています。今日画面でチェックを変えたら統計も更新されるべきです。第 3 回のモデルの dataChanged シグナルをメインウィンドウで stats_page._refresh につなげば、画面間の同期はシグナル 1 行で終わります。ウィジェット同士が互いを直接知る代わりにシグナルで緩くつなぐこと。入門 #3 で学んだ原則の実戦回収です。

まとめ #

  • 統計計算(連続記録、達成率)は Qt を知らない core の純粋関数に置きます。ポリシーの疑問がテストケースになります。
  • カスタムウィジェットの心臓は paintEvent + QPainter で、再描画は update() の呼び出しで依頼します。paintEvent を直接呼びません。
  • データ座標 ↔ ピクセル座標の計算が描画の本体で、マウスのヒットテストはその逆算です。定数を共有すればずれません。
  • sizeHint はレイアウトとのサイズ契約です。忘れるとウィジェットがつぶれます。
  • 画面間の同期はモデルの dataChanged シグナル接続 1 行で解決します。次回はアプリをデスクトップらしくする、トレイ常駐とリマインダーです。
X