PySide6 in Practice #3 The Today Screen: A Custom Model and Check Toggling
With the data layer in place, we build the first screen users meet: the “Today” tab, showing the habit list with click-to-toggle checks for today. Where intro #5 covered model/view concepts, this part shows what that knowledge looks like inside a real app.
Why not QListWidget #
The fastest route is stuffing items straight into a QListWidget — and for a prototype, that is the right call. But this app shows the same data on two screens (Today and Stats), must keep check state in sync with the database, and needs headless tests in part 7. Putting data directly into widgets creaks against all three demands, because the truth about the data gets trapped inside the widget.
Model/view’s answer is separation: the model owns the truth; the view only draws. The model wraps the repository, and the view (QListView) merely asks it “what goes on row N?”
The custom model: subclassing QAbstractListModel #
A list model’s minimum contract is three parts: row count (rowCount), data (data), and — if editable — setData plus flags.
# 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()
# --- read contract ---
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
# --- write contract ---
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) # write to DB
self._habits[index.row()] = (habit, checked) # update cache
self.dataChanged.emit(index, index, [Qt.ItemDataRole.CheckStateRole])
return TrueThree things to read closely.
- Roles are the data’s multiple faces. The same row returns the name for DisplayRole and the check state for CheckStateRole. Views and delegates pick only the roles they need. Half of understanding model/view is replacing “one row = one value” with “one row = values per role.”
- Writes travel a single path. A checkbox click makes the view call
setData; the model updates the DB and its cache, then emitsdataChanged. With one door for all changes, a bug means one place to look. dataChangedreports the exact range and roles. Full resets (beginResetModel) are only for when the list’s composition changes (reload). Resetting everything because one row’s check flipped makes selection and scroll position jump — a quality drop users can see.
Assembling the screen: QListView and an add dialog #
# 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 habit")
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, "Add habit", "Name:")
if ok and name.strip():
self._model._repo.add_habit(name.strip())
self._model.reload()Meet a model returning CheckStateRole and QListView draws the checkboxes for you — a “name + checkbox” list with no custom delegate at all. The moment to reach for a delegate is when rows need to get fancy (streak badges, color tags); in this app, part 4’s heatmap is the stage for custom drawing, so the list stays on default rendering.
One issue worth flagging: reaching into the model’s internals via self._model._repo is not a good sign. The practical approach is to add an add_habit(name) method to the model and use only its public interface — and developing that judgment is exactly the skill this series aims to build.
In the main window, replace part 1’s placeholder with the real page:
# main_window.py update
self.today_model = TodayModel(repo)
self.pages.addWidget(TodayPage(self.today_model)) # index 0Running it #
uv run python -m daily now lets you add habits, toggle checks, and see state survive a restart. This is the first moment the structure becomes tangible: the data lives in the DB, and the screen is just a mirror.
Summary #
- Putting data directly into widgets collapses under multi-screen, DB-sync, and testing demands. Truth goes in the model; drawing goes in the view.
- A custom list model’s contract is rowCount, data, and — for editing — flags and setData. Roles expose a row’s multiple faces.
- Check toggles flow one path: view → setData → DB and cache update → dataChanged. One door for changes means one place to debug.
- dataChanged reports exactly the changed range and roles; full resets are only for composition changes.
- QListView draws checkboxes from CheckStateRole alone. Next part: the screen no stock widget provides — a QPainter heatmap.