PySide6 in Practice #1 Project Design: App Architecture Through a Habit Tracker
The Build a Desktop App with PySide6 introductory series covered the parts: widgets, signals, model/view, packaging. But between knowing the parts and finishing an app lies a valley. How to split files, where data lives, how to avoid “it works but the code is a mess” — introductory examples never say. This practice series crosses that valley: one app, eight parts, from design to a shippable state.
What we build: a local habit tracker called “Daily” #
The app is a habit tracker: register daily habits (exercise, reading, water), check them off each day, and watch your consistency on a GitHub-contribution-graph-style heatmap — all local. The subject was chosen for learning density.
- CRUD + persistence: habits and check records stored in SQLite (part 2).
- Model/view for real: a custom model and list screen (part 3).
- Custom drawing: no stock widget draws a heatmap, so we paint one with QPainter (part 4).
- Desktop-ness: tray residency and reminder notifications (part 5), settings and themes (part 6).
- Quality and shipping: pytest-qt tests (part 7), icons, versioning, and CI for release (part 8).
Write feature requirements as a list of screens from the start. This app has three: Today (habit list + checks), Stats (heatmap and completion rate), Settings (theme, reminder time). The screen list becomes the work list — and the boundary of “how far this part goes.”
Structure: three layers — UI, core, data #
Structure is the first thing to collapse in practice. Once SQL creeps into button handlers, you have an untestable, unmodifiable lump. This series splits three ways from day one.
daily/
├── pyproject.toml
└── src/daily/
├── __main__.py # entry point: python -m daily
├── app.py # QApplication creation and assembly
├── ui/ # screens: widgets, windows, view models
│ └── main_window.py
├── core/ # rules: habit/streak logic, no Qt
│ └── habits.py
└── data/ # storage: SQLite access (built in part 2)
└── repository.pyTwo rules summarize the layers: dependencies point one way, ui → core → data, and core never imports PySide6. Keeping Qt out of core pays compound interest: rules like “14-day streak achieved” become testable with plain pytest (part 7), and if the app ever moves to a CLI or the web, core travels intact. Since “logic fused to the UI” is the classic way desktop apps rot, this one rule is the spine of the whole series.
Project setup #
Setup uses uv. If environment management is new, start with the Python Packaging series.
uv init daily --package
cd daily
uv add pyside6
uv add --dev pytest pytest-qt--package creates a src-layout package project. Starting as a package rather than a single script pays off in part 8 — both PyInstaller bundling and test imports have fewer problems with a package structure.
The main window skeleton #
This part’s code goal is the screen-switching structure: three screens in a QStackedWidget, switched by side buttons — the frame the next seven parts ride on.
# src/daily/ui/main_window.py
from PySide6.QtWidgets import (
QHBoxLayout, QLabel, QMainWindow, QPushButton,
QStackedWidget, QVBoxLayout, QWidget,
)
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("Daily")
self.resize(480, 640)
# screen slots: placeholder labels now, real pages from the next parts
self.pages = QStackedWidget()
self.pages.addWidget(QLabel("Today")) # index 0: built in part 3
self.pages.addWidget(QLabel("Stats")) # index 1: built in part 4
self.pages.addWidget(QLabel("Settings")) # index 2: built in part 6
nav = QVBoxLayout()
for i, name in enumerate(["Today", "Stats", "Settings"]):
btn = QPushButton(name)
btn.clicked.connect(lambda _=False, idx=i: self.pages.setCurrentIndex(idx))
nav.addWidget(btn)
nav.addStretch()
root = QWidget()
layout = QHBoxLayout(root)
layout.addLayout(nav)
layout.addWidget(self.pages, stretch=1)
self.setCentralWidget(root)# src/daily/app.py
import sys
from PySide6.QtWidgets import QApplication
from daily.ui.main_window import MainWindow
def main() -> int:
app = QApplication(sys.argv)
app.setApplicationName("daily")
app.setOrganizationName("daily-app") # QSettings in part 6 uses this
window = MainWindow()
window.show()
return app.exec()# src/daily/__main__.py
import sys
from daily.app import main
sys.exit(main())Run uv run python -m daily and a window appears with left-side navigation and three empty pages. Pinning idx=i in the lambda is the closure trap from intro #3, applied for real. Seemingly minor lines like setOrganizationName are deliberate foreshadowing for later parts (QSettings), so they go in now.
The map of this series #
| Part | What we build | Key techniques |
|---|---|---|
| 1 (this post) | Design and skeleton | 3-layer structure, QStackedWidget |
| 2 | Data layer | SQLite, repository, migrations |
| 3 | Today screen | custom model, delegates |
| 4 | Stats screen | QPainter heatmap |
| 5 | Tray residency | QSystemTrayIcon, notifications, single instance |
| 6 | Settings | QSettings, themes, backup |
| 7 | Tests | pytest-qt, per-layer strategy |
| 8 | Release polish | icon/version, signing overview, GitHub Actions |
Summary #
- This series completes one habit-tracker app across eight parts, from design to shippable. The screen list (Today, Stats, Settings) bounds the work.
- The structure is three layers with one-way dependencies, ui → core → data, and core never imports PySide6 — the root of testability and maintainability.
- The project starts as a uv src-layout package, a choice that pays off in packaging and testing.
- The main window is a QStackedWidget-based switching skeleton. Next part: the app’s heart, the SQLite data layer.