PySide6 in Practice #4 The Stats Screen: Painting a Contribution Heatmap with QPainter
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
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.0Values 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
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 callupdate(), 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
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.