PySide6 실전 강좌 #4 통계 화면 — QPainter로 잔디 히트맵 그리기

4 분 소요

통계 화면의 주인공은 깃허브 잔디밭 스타일의 히트맵입니다. 최근 몇 주를 격자로 깔고, 체크한 날일수록 진한 색을 칠하는 그 화면입니다. 문제는 이런 위젯이 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가 레이아웃과의 계약입니다. 레이아웃 매니저는 이 힌트로 위젯의 적정 크기를 정합니다. 이것을 빠뜨리면 위젯이 0에 가깝게 찌그러지는, 커스텀 위젯의 단골 증상이 나옵니다.

통계 페이지 조립 #

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에 연결하면, 화면 간 동기화가 시그널 한 줄로 끝납니다. 위젯끼리 서로를 직접 아는 대신 시그널로 느슨하게 잇는 것, 입문 #3에서 배운 원칙의 실전 회수입니다.

정리 #

  • 통계 계산(연속 기록, 달성률)은 Qt를 모르는 core의 순수 함수로 둡니다. 정책 질문이 테스트 케이스가 됩니다.
  • 커스텀 위젯의 심장은 paintEvent + QPainter이고, 다시 그리기는 update() 호출로 요청합니다. paintEvent를 직접 부르지 않습니다.
  • 데이터 좌표 ↔ 픽셀 좌표의 계산이 그리기의 본체이며, 마우스 히트 테스트는 그 역산입니다. 상수를 공유하면 어긋나지 않습니다.
  • sizeHint는 레이아웃과의 크기 계약입니다. 빠뜨리면 위젯이 찌그러집니다.
  • 화면 간 동기화는 모델의 dataChanged 시그널 연결 한 줄로 해결합니다. 다음 편은 앱을 데스크톱답게 만드는 트레이 상주와 리마인더입니다.
X