PySide6 in Practice #4 The Stats Screen: Painting a Contribution Heatmap with QPainter

5 min read

The star of the stats screen is a GitHub-contribution-style heatmap: recent weeks laid out as a grid, cells darker where checks happened. The problem: Qt has no such widget. That is why this is the series’ custom-drawing chapter — when the widget you need does not exist, you paint it. Along the way we meet all four pillars of custom widgets: paintEvent, sizeHint, events, and update.

Statistics logic goes in core: compute before you paint #

The heatmap needs “checked or not, per day,” and the summary card needs completion rate and current streak. These calculations are screen-independent rules, so they live in core — part 1’s “core knows no Qt” rule in action.

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


def current_streak(checked: set[str], today: date) -> int:
    """Consecutive checked days counting back from today (or yesterday)."""
    streak = 0
    day = today
    # if today isn't checked yet, start counting from yesterday
    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

Values in, values out — pure functions, verified with plain pytest in part 7 with no GUI. Policy questions like “does an unchecked today break the streak?” become test cases of exactly these functions.

paintEvent: where drawing code lives #

The heart of a custom widget is paintEvent — the method Qt calls whenever the widget must be drawn, and the place you use 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        # cell edge (px)
GAP = 3          # gap between cells
WEEKS = 20       # weeks to display


class HeatmapWidget(QWidget):
    def __init__(self) -> None:
        super().__init__()
        self._checked: set[str] = set()
        self.setMouseTracking(True)   # receive mouseMoveEvent without clicks

    def set_data(self, checked: set[str]) -> None:
        self._checked = checked
        self.update()                 # a *request* to repaint

    # --- size contract ---
    def sizeHint(self) -> QSize:
        return QSize(WEEKS * (CELL + GAP), 7 * (CELL + GAP))

    # --- painting ---
    def paintEvent(self, event) -> None:
        painter = QPainter(self)
        today = date.today()
        # grid start: Monday of the week WEEKS ago
        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)

    # --- hit test: which cell is under the mouse ---
    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 = "done" if day.isoformat() in self._checked else "not done"
                self.setToolTip(f"{day.isoformat()} — {state}")

This short widget contains all the rules of custom drawing.

  • Painting happens only by request. When data changes you do not call paintEvent — you call update(), and Qt invokes paintEvent at the right moment. Wanting to call it directly is the first trap for custom-widget beginners.
  • Coordinates are all arithmetic. Converting data coordinates (“week N, weekday D”) to pixel coordinates (week * (CELL + GAP)) is most of the painting, and mouse hit-testing is exactly the inverse. Both sides share the same constants (CELL, GAP), so they cannot drift apart.
  • sizeHint is the contract with layouts. Layout managers size the widget from this hint. Omit it and you get the classic symptom: a custom widget squashed to near-zero.

Assembling the stats page #

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-week completion {rate:.0%} · current streak {current_streak(checked, today)} days"
        )
        self._heatmap.set_data(checked)

Computation calls core functions, drawing is delegated to the heatmap widget, and the page only assembles. When layers are separated, page code gets this short.

One connection remains: toggling a check on the Today screen should refresh the stats. Wire part 3’s model dataChanged signal to stats_page._refresh in the main window, and cross-screen sync is one signal connection. Linking widgets loosely through signals instead of letting them know each other directly — the principle from intro #3, collected here in practice.

Summary #

  • Statistics (streaks, completion rate) are pure functions in Qt-free core; policy questions become test cases.
  • A custom widget’s heart is paintEvent + QPainter, and repaints are requested via update() — never call paintEvent yourself.
  • Data-to-pixel coordinate math is the body of the painting, and hit-testing is its inverse; shared constants keep them aligned.
  • sizeHint is the size contract with layouts — omit it and the widget gets squashed.
  • Cross-screen sync is one dataChanged connection. Next part: making the app properly desktop — tray residency and reminders.
X