How to Build a Desktop Notes App in Python With PySide6 and Qt Designer
A step-by-step guide to designing a Python desktop GUI visually in Qt Designer, then wiring it up with PySide6 signals, slots, keyboard shortcuts, and JSON persistence.
If you already know Python, building a desktop application can feel like a detour into unfamiliar territory: a different import for every widget, layout code that is tedious to read, and no obvious way to see what the window will actually look like until you run it. Qt Designer solves the last problem by letting you build the window visually, and PySide6 (the official Python bindings for the Qt framework) lets you wire that window up to real Python code. In this tutorial you will design a small but fully working desktop notes app, a main window with a list of notes and an editor, plus a dialog for creating new notes, and you will finish with an application that saves your notes to disk and reloads them the next time you open it.
Table Of Content
- What You Will Build
- Understanding the Pieces: Qt Designer, PySide6, and .ui Files
- What Qt and PySide6 Actually Are
- What Qt Designer Is
- Two Ways to Turn a .ui File Into a Working Window
- Signals and Slots, in Plain Language
- Prerequisites
- Step 1: Create a Project Folder and a Virtual Environment
- Step 2: Install PySide6
- Step 3: Design the Main Window in Qt Designer
- Starting a New Form
- Adding the Central Widget Layout
- Adding Menu and Toolbar Actions
- Naming Widgets in the Property Editor
- Saving the Form
- Step 4: Design the New Note Dialog
- Choosing the Right Template
- Adding the Title Field
- Setting an Explicit Tab Order
- Wiring the Built-In Accept and Reject Signals
- Step 5: Compile the Main Window With pyside6-uic
- Step 6: Load the Dialog at Runtime With QUiLoader
- Step 7: Wire Up the Application Logic
- Opening the Dialog and Validating the Title
- Switching Between Notes Without Losing Edits
- Live Status Bar Updates
- Step 8: Add Keyboard Shortcuts
- Step 9: Save and Load Notes as JSON
- Step 10: Confirm Before Destructive Actions
- Step 11: Run the App
- Step 12: Verify the Whole App With an Automated, Headless Test
- Common Mistakes and Gotchas
- How to Confirm It All Works End to End
- Next Steps
- Sources and Further Reading
Every command, every line of code, and every piece of behavior described below was built and executed end to end in a real environment (Windows 11, Python 3.13.14, PySide6 6.11.1) before this tutorial was written, including an automated test suite that drives the finished app through keyboard shortcuts, dialog validation, note switching, disk persistence, and the unsaved-changes warning on exit. Where the tutorial describes something you do with your mouse (dragging a widget in Designer), that step is exactly what produces the file contents shown afterward, so you can always check your work against what is printed on the page.
What You Will Build
By the end of this tutorial you will have a small desktop application called Quick Notes with:
- A main window with a menu bar, a toolbar, a two-pane layout (a list of note titles next to a text editor), and a status bar that shows a live word count
- A dialog for creating a new note, with input validation, an explicit tab order, and a keyboard shortcut for confirming with the Enter key
- Signals and slots that keep the list, the editor, and the status bar in sync as you switch between notes
- Notes saved to a JSON file on disk, reloaded automatically the next time the app starts
- A confirmation prompt so you cannot accidentally lose unsaved work by closing the window
Understanding the Pieces: Qt Designer, PySide6, and .ui Files
A few terms will come up constantly, so it is worth defining them before you start clicking around.
What Qt and PySide6 Actually Are
Qt is a cross-platform application framework, originally written in C++, that provides ready-made building blocks (buttons, text boxes, layouts, dialogs) for desktop software. PySide6 is the official set of Python bindings for Qt 6, maintained by the Qt Company itself. Installing it with pip install pyside6 gives you Python classes like QMainWindow and QPushButton that map directly onto Qt’s underlying C++ widgets, plus a handful of command-line tools you will use in this tutorial. PySide6 6.11.1 (the version used throughout this tutorial) is distributed under the LGPLv3 license, with GPLv2 and GPLv3 as alternative options, and it supports Python 3.10 through 3.14.
What Qt Designer Is
Qt Designer is a separate visual editor, not a Python library, that lets you build a window by dragging widgets onto a blank form instead of writing layout code by hand. You do not install it separately: the pyside6 package on PyPI bundles a ready-to-run copy of it as pyside6-designer. When you save your work in Designer, it writes a .ui file, which is a plain XML document describing every widget, its properties, and how the widgets connect to each other. A .ui file does not depend on PySide6 specifically; the same file format is shared with PyQt and with Qt’s C++ tooling.
Two Ways to Turn a .ui File Into a Working Window
A .ui file is not Python code, so you need a bridge between the XML Designer produces and the widgets your app actually shows. PySide6 gives you two different bridges, and this tutorial deliberately uses both so you can see the tradeoff:
- Compile it ahead of time with the
pyside6-uiccommand-line tool, which turns a .ui file into a plain Python module containing asetupUi()method. You import that module like any other. This is the faster option at runtime, and your editor can autocomplete widget names because they are real Python attributes. The tradeoff is a manual step: every time you change the form in Designer, you have to re-runpyside6-uicto regenerate the Python file. - Load it at runtime with the
QUiLoaderclass, which reads the .ui file directly when your app starts, with no separate compile step. This is convenient while you are still iterating on the design, since you can edit the .ui file and just relaunch the app. The tradeoff is a small startup cost for parsing the XML, and your editor cannot autocomplete widget names, since they only exist once the file is loaded.
Most real applications use the compiled approach for their main windows, since those are edited rarely, and either approach for dialogs. This tutorial compiles the main window and loads the dialog at runtime, so you get hands-on practice with both.
Signals and Slots, in Plain Language
Qt widgets communicate through signals and slots. A signal is an event a widget announces, like “my text changed” or “I was clicked.” A slot is just a function that runs in response. You connect the two with some_widget.some_signal.connect(some_function). This is the same idea as an event listener in JavaScript or a callback in most GUI frameworks, just with Qt’s own vocabulary. You will connect several signals to slots by hand in Python later in this tutorial, and you will also see one connection that Qt Designer wires up for you automatically inside the .ui file itself.
Prerequisites
- A computer running Windows, macOS, or Linux. This tutorial was built and tested on Windows 11 with Python 3.13.14; the only OS-specific difference you will hit is how you activate a virtual environment, called out below.
- Python 3.10 through 3.14 installed (PySide6 6.11.1 does not support 3.15 yet). Check your version with
python --version. - Comfort with basic Python: functions, classes, dictionaries, and running commands in a terminal. No prior GUI programming or Qt experience is assumed.
- No paid software and no account of any kind. Qt Designer ships free inside the PySide6 pip package.
Step 1: Create a Project Folder and a Virtual Environment
Start with a clean, isolated folder so PySide6 does not mix with packages from other projects.
mkdir quick-notes
cd quick-notes
python -m venv venv
Activate it. On Windows (the environment this tutorial was tested in):
venv\Scripts\activate
On macOS or Linux, the equivalent is:
source venv/bin/activate
Your prompt should now show (venv) at the start of the line. Every command from here on assumes that virtual environment is active.
Step 2: Install PySide6
pip install pyside6
This single command installs the Python bindings, the compiled Qt libraries they depend on, and a set of command-line tools, including pyside6-designer (the visual editor) and pyside6-uic (the .ui-to-Python compiler). Confirm the install and check the version:
python -c "import PySide6; print(PySide6.__version__)"
Expected output:
6.11.1
(Your version may be newer if you are following this after a later PySide6 release; the concepts below have been stable across PySide6 for years.)
Step 3: Design the Main Window in Qt Designer
Launch the visual editor from your activated virtual environment:
pyside6-designer
On Windows this runs pyside6-designer.exe from your venv’s Scripts folder; the plain command works the same way on macOS and Linux.
Starting a New Form
Designer opens with a New Form dialog offering five templates. Pick the right one by what base class you want, since this decision is hard to change later:
| Template | Base class | What it gives you |
|---|---|---|
| Dialog with Buttons Bottom | QDialog | OK/Cancel buttons along the bottom-right |
| Dialog with Buttons Right | QDialog | OK/Cancel buttons stacked along the top-right |
| Dialog without Buttons | QDialog | A blank dialog |
| Main Window | QMainWindow | A menu bar, a status bar, and support for toolbars |
| Widget | QWidget | A blank, general-purpose widget |
Choose Main Window and click Create. You get a blank window with an empty menu bar reading “Type Here” and an empty status bar strip at the bottom, both added automatically because you picked the QMainWindow template.
Adding the Central Widget Layout
A QMainWindow’s middle area is called its central widget, and it can only hold one direct child, so you almost always put a layout or a container widget there first. From the widget box on the left (organized into categories like Layouts, Buttons, Display Widgets, and Item Widgets), drag a Splitter (Horizontal) into the central area. A splitter lets the user drag a divider to resize two panes, which is exactly what you want for a note list next to an editor. With the splitter selected, drag a List Widget into its left half and a Plain Text Edit into its right half.
Adding Menu and Toolbar Actions
Click the “Type Here” text in the menu bar and type &File (the ampersand marks F as the keyboard-accelerator letter, so Alt+F opens the menu on Windows and Linux). Press Enter, then type each menu item on its own line: &New Note, &Save All, a separator, &Delete Note, another separator, and E&xit. Each one you type becomes a QAction, which Designer lists in its Action Editor panel (usually docked at the bottom).
Now right-click an empty strip just below the menu bar and choose Add Tool Bar. Drag the same three actions (New Note, Save All, Delete Note) from the Action Editor onto the new toolbar. This is the detail beginners usually miss: you are not creating new buttons, you are reusing the exact same QAction objects in two places. Triggering the toolbar button and choosing the matching menu item both fire the same signal, and disabling or renaming the action later updates both automatically.
Naming Widgets in the Property Editor
Click each widget and use the Property Editor (docked on the right) to set its objectName, since this is the attribute name you will use to reach the widget from Python. Set these exactly, since the code later in this tutorial refers to them by name:
- The list widget:
noteList - The plain text edit:
noteBody - The four actions:
actionNewNote,actionSaveAll,actionDeleteNote,actionExit
While you have each action selected in the Action Editor, also set its Shortcut property: Ctrl+N, Ctrl+S, Ctrl+D, and Ctrl+Q respectively. That single property is all it takes to wire up a global keyboard shortcut; there is no separate registration step.
Saving the Form
Save as main_window.ui inside your quick-notes folder. Under the hood, Designer has been writing plain XML the entire time; here is the actual structure your clicking just produced (trimmed for readability), which you can compare against your own saved file:
<widget class="QMainWindow" name="MainWindow">
<widget class="QWidget" name="centralwidget">
<widget class="QSplitter" name="splitter">
<widget class="QListWidget" name="noteList"/>
<widget class="QPlainTextEdit" name="noteBody"/>
</widget>
</widget>
<widget class="QMenuBar" name="menubar">...</widget>
<widget class="QStatusBar" name="statusbar"/>
<widget class="QToolBar" name="toolBar">...</widget>
<action name="actionNewNote">
<property name="shortcut"><string>Ctrl+N</string></property>
</action>
...
</widget>
If your window does not look right, this is the file to open in a text editor and compare, widget by widget, against the structure above.
Step 4: Design the New Note Dialog
Choose File > New Form inside Designer (or restart it) to start a second, separate form for the dialog.
Choosing the Right Template
This time pick Dialog with Buttons Bottom. Designer creates a QDialog that already contains a QDialogButtonBox with OK and Cancel buttons laid out along the bottom-right corner, saving you from building that button row by hand.
Adding the Title Field
Drag a Label onto the form and set its text to Note title:, then a Line Edit below it (object name titleEdit), then a second Label with text Titles must be unique and cannot be empty. as a hint for the user. Arrange them in a vertical layout above the existing button box, and set the dialog’s own objectName to NewNoteDialog and its window title to New Note.
Setting an Explicit Tab Order
By default, Tab moves focus through widgets in the order you added them, which is not always the order you want. Switch to Edit > Edit Tab Order (or the matching toolbar icon). Designer overlays a numbered badge on each focusable widget; click titleEdit first and the button box second to make Tab move directly from the title field to the buttons. Save, and Designer records your clicks as a <tabstops> list in the .ui file.
Wiring the Built-In Accept and Reject Signals
A QDialogButtonBox’s OK and Cancel buttons do not automatically close the dialog; you still have to connect them. Switch to Edit > Edit Signals/Slots mode (F4). Click and drag from the button box to an empty part of the dialog itself; Designer draws a connecting line and pops up a dialog listing the button box’s signals on the left and the dialog’s slots on the right. Pick accepted() on the left and accept() on the right, confirm, then repeat the drag once more for rejected() to reject(). These two connections mean clicking OK calls the dialog’s built-in accept() method (which closes it and makes dialog.exec() return QDialog.Accepted back in your Python code) and Cancel calls reject() the same way.
Save as new_note_dialog.ui. The connections you just drew appear in the file as:
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>NewNoteDialog</receiver>
<slot>accept()</slot>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>NewNoteDialog</receiver>
<slot>reject()</slot>
</connection>
</connections>
Gotcha: if you hand-edit a .ui file instead of using Designer (useful once you understand the format, and exactly how this tutorial’s example files were produced for testing) the XML schema is stricter than it looks.
<tabstops>must be a direct child of<ui>, as a sibling of the root<widget>, not nested inside it. Nesting it inside the widget, which feels natural since it is describing that widget’s tab order, produces a compile error frompyside6-uic:Unexpected element tabstops. This is exactly the kind of mistake the compiler will catch immediately, so it is a safe thing to get wrong once.
Step 5: Compile the Main Window With pyside6-uic
Back in your terminal, with the virtual environment still active, compile the main window’s .ui file into a Python module:
pyside6-uic main_window.ui -o ui_main_window.py
Open the generated file and read through it; you never edit this file by hand, since regenerating it overwrites your changes, but reading it once demystifies what Designer actually built. You will find a Ui_MainWindow class with a setupUi() method that creates every widget, assigns it as an attribute (self.noteList = QListWidget(...)), and lays them out exactly as you arranged them visually. Every objectName you set in the Property Editor becomes the attribute name here, which is why getting those names right in Step 3 matters.
Step 6: Load the Dialog at Runtime With QUiLoader
For the dialog, skip compilation and load the .ui file directly when the app needs it. This is the second bridge described earlier, and it needs its own small wrapper function. Create notes_app.py in your project folder now; this is the one file the rest of the tutorial builds up piece by piece, so start it with:
import sys
from pathlib import Path
from PySide6.QtCore import QFile, QIODevice
from PySide6.QtUiTools import QUiLoader
DIALOG_UI_PATH = Path(__file__).with_name("new_note_dialog.ui")
def load_dialog_from_ui(parent):
loader = QUiLoader()
ui_file = QFile(str(DIALOG_UI_PATH))
if not ui_file.open(QIODevice.ReadOnly):
raise RuntimeError(f"Could not open {DIALOG_UI_PATH}: {ui_file.errorString()}")
dialog = loader.load(ui_file, parent)
ui_file.close()
if dialog is None:
raise RuntimeError(loader.errorString())
return dialog
loader.load() reads the XML and builds real widget objects from it on the spot, returning the dialog itself. Because nothing was compiled ahead of time, there is no Ui_NewNoteDialog class and no self.titleEdit attribute; instead, you reach child widgets with Qt’s general-purpose findChild() method, matched by the object name you set back in Designer, the way new_note() does in Step 7:
title_edit = dialog.findChild(QLineEdit, "titleEdit")
button_box = dialog.findChild(QDialogButtonBox, "buttonBox")
(QLineEdit and QDialogButtonBox get imported together with the rest of the widgets in Step 7, so do not add that import twice.)
Keep both files (main_window.ui and new_note_dialog.ui) in your project folder; the compiled approach only needs ui_main_window.py at runtime, but the runtime-loaded approach reads new_note_dialog.ui directly every time the dialog opens.
Step 7: Wire Up the Application Logic
Below the load_dialog_from_ui() function you just added, bring in the rest of the imports this file needs and start the window class and its state:
import json
from PySide6.QtWidgets import (
QApplication,
QDialog,
QDialogButtonBox,
QLineEdit,
QListWidgetItem,
QMainWindow,
QMessageBox,
)
from ui_main_window import Ui_MainWindow
NOTES_FILE = Path(__file__).with_name("notes.json")
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.notes = {} # title -> body, the in-memory source of truth
self.current_title = None
self.dirty = False # True when the on-screen note has unsaved edits
self.load_notes_from_disk()
self.ui.actionNewNote.triggered.connect(self.new_note)
self.ui.actionSaveAll.triggered.connect(self.save_all)
self.ui.actionDeleteNote.triggered.connect(self.delete_current_note)
self.ui.actionExit.triggered.connect(self.close)
self.ui.noteList.currentItemChanged.connect(self.switch_note)
self.ui.noteBody.textChanged.connect(self.mark_dirty)
self.update_status()
self.ui = Ui_MainWindow() followed by self.ui.setupUi(self) is the standard pattern for the compiled approach: it builds every widget from Step 5 and attaches them to self.ui, so self.ui.noteList and self.ui.actionNewNote both work exactly as their object names suggest. Notice that actionExit.triggered connects straight to self.close, Qt’s own built-in method for closing a window; you do not need to write a one-line wrapper just to call it.
Opening the Dialog and Validating the Title
def new_note(self):
dialog = load_dialog_from_ui(self)
title_edit = dialog.findChild(QLineEdit, "titleEdit")
button_box = dialog.findChild(QDialogButtonBox, "buttonBox")
ok_button = button_box.button(QDialogButtonBox.Ok)
ok_button.setEnabled(False)
def validate(text):
text = text.strip()
ok_button.setEnabled(bool(text) and text not in self.notes)
title_edit.textChanged.connect(validate)
if dialog.exec() == QDialog.Accepted:
title = title_edit.text().strip()
self.flush_current_note_to_memory()
self.notes[title] = ""
item = QListWidgetItem(title)
self.ui.noteList.addItem(item)
self.ui.noteList.setCurrentItem(item)
self.mark_dirty()
Two details are doing real work here. First, ok_button.setEnabled(False) plus the validate() slot connected to textChanged is how you implement live form validation: the button starts disabled, and every keystroke re-checks whether the current text is both non-empty and not already used as a title. Second, dialog.exec() is a modal call, meaning it blocks and does not return control to the rest of your program until the dialog closes; its return value tells you whether the user confirmed (QDialog.Accepted, produced by the accept() connection you drew in Designer) or cancelled (QDialog.Rejected).
Switching Between Notes Without Losing Edits
def flush_current_note_to_memory(self):
"""Copy whatever is on screen back into self.notes before we move away from it."""
if self.current_title is not None:
self.notes[self.current_title] = self.ui.noteBody.toPlainText()
def switch_note(self, current, _previous):
self.flush_current_note_to_memory()
if current is None:
self.current_title = None
self.ui.noteBody.blockSignals(True)
self.ui.noteBody.clear()
self.ui.noteBody.blockSignals(False)
return
self.current_title = current.text()
self.ui.noteBody.blockSignals(True)
self.ui.noteBody.setPlainText(self.notes.get(self.current_title, ""))
self.ui.noteBody.blockSignals(False)
This is the trickiest logic in the whole app, and it is worth slowing down for. noteList.currentItemChanged fires whenever the user clicks a different note, and the very first thing the handler does is save the outgoing note’s text with flush_current_note_to_memory(), before it loads anything new. Skip that ordering and switching notes silently discards whatever the user just typed.
The blockSignals(True) / blockSignals(False) pair around setPlainText() is the second subtlety. Setting a text widget’s content programmatically also fires its textChanged signal, exactly as if the user had typed it. Without blocking signals, loading a saved note would immediately re-trigger mark_dirty() and flag a freshly loaded, completely unmodified note as having unsaved changes. Blocking signals during a programmatic update, then unblocking them immediately after, is the standard Qt pattern for telling the difference between “the user changed this” and “my own code changed this.”
Live Status Bar Updates
def mark_dirty(self):
self.dirty = True
self.update_status()
def update_status(self, message=None):
if message is None:
words = len(self.ui.noteBody.toPlainText().split())
state = "Unsaved changes" if self.dirty else "Saved"
message = f"{len(self.notes)} note(s) | {words} word(s) in current note | {state}"
self.ui.statusbar.showMessage(message)
QMainWindow.statusBar().showMessage() (accessed here through the Designer-created self.ui.statusbar) is the standard place for this kind of transient, low-priority feedback. It does not interrupt the user the way a popup would, and it updates on every keystroke since mark_dirty() is connected to noteBody.textChanged.
Step 8: Add Keyboard Shortcuts
You already added Ctrl+N, Ctrl+S, Ctrl+D, and Ctrl+Q as Shortcut properties on the four actions back in Step 3, and because Python code connects to those same QAction objects, the shortcuts work with no further code. There is one more shortcut you get for free rather than by setting a property: a QDialogButtonBox automatically marks its Ok-role button as the dialog’s default button, which means pressing Enter anywhere in the dialog (including inside the titleEdit line edit) triggers that button, as long as it is enabled. That is why typing a valid title and pressing Enter submits the New Note dialog without ever touching the mouse.
Step 9: Save and Load Notes as JSON
def load_notes_from_disk(self):
if NOTES_FILE.exists():
self.notes = json.loads(NOTES_FILE.read_text(encoding="utf-8"))
else:
self.notes = {}
self.ui.noteList.clear()
for title in self.notes:
self.ui.noteList.addItem(QListWidgetItem(title))
if self.ui.noteList.count():
self.ui.noteList.setCurrentRow(0)
def save_all(self):
self.flush_current_note_to_memory()
NOTES_FILE.write_text(json.dumps(self.notes, indent=2), encoding="utf-8")
self.dirty = False
self.update_status("All notes saved")
self.notes is a plain Python dictionary the whole time the app runs; JSON only enters the picture at the two edges, reading it back in load_notes_from_disk() and writing it out in save_all(). Because flush_current_note_to_memory() runs first inside save_all(), saving always includes whatever you are actively typing, not just notes you have already clicked away from.
Step 10: Confirm Before Destructive Actions
Deleting a note and closing the app with unsaved changes are both hard to undo, so both get a confirmation prompt using QMessageBox:
def delete_current_note(self):
row = self.ui.noteList.currentRow()
if row < 0:
return
title = self.ui.noteList.item(row).text()
answer = QMessageBox.question(
self, "Delete note", f'Delete "{title}"? This cannot be undone.',
)
if answer != QMessageBox.Yes:
return
self.notes.pop(title, None)
if self.current_title == title:
self.current_title = None
self.ui.noteList.takeItem(row)
self.mark_dirty()
def closeEvent(self, event):
self.flush_current_note_to_memory()
if self.dirty:
answer = QMessageBox.question(
self, "Unsaved changes", "You have unsaved changes. Save before exiting?",
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
)
if answer == QMessageBox.Cancel:
event.ignore()
return
if answer == QMessageBox.Yes:
self.save_all()
event.accept()
closeEvent() is a method Qt calls automatically whenever something tries to close the window, whether that is the Exit action, the window's own close button, or Alt+F4. Calling event.ignore() inside it cancels the close entirely and leaves the window open, which is exactly what should happen if the user picks Cancel on the unsaved-changes prompt.
Finally, add a small main() function at the bottom of the file:
def main():
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Step 11: Run the App
python notes_app.py
You should see the Quick Notes window open with an empty list. Press Ctrl+N, type a title, press Enter, then type something in the editor on the right and watch the status bar's word count update as you type. Press Ctrl+S, close the window, and reopen it (python notes_app.py again); your note should still be there, loaded straight from the notes.json file that appeared in your project folder.
Step 12: Verify the Whole App With an Automated, Headless Test
Clicking through the app by hand is fine for a first check, but it does not scale, and it is exactly how a subtle bug like the note-switching one in Step 7 slips past a quick manual test. Qt ships a testing module, QtTest, that can simulate real keystrokes and mouse clicks against real widgets, and Qt's offscreen platform plugin lets the whole thing run without a visible window, which is how this tutorial's own example app was verified and how you would run GUI tests in a CI pipeline with no display attached.
Save this as test_notes_app.py in the same folder:
import sys
from pathlib import Path
from PySide6.QtCore import Qt, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QLineEdit
sys.path.insert(0, str(Path(__file__).parent))
NOTES_FILE = Path(__file__).with_name("notes.json")
if NOTES_FILE.exists():
NOTES_FILE.unlink() # start from a clean slate, even if you saved notes in Step 11
app = QApplication(sys.argv)
import notes_app
window = notes_app.MainWindow()
window.show()
window.activateWindow()
window.raise_()
app.processEvents()
def fill_and_accept(title_text):
dialog = app.activeModalWidget()
title_edit = dialog.findChild(QLineEdit, "titleEdit")
button_box = dialog.findChild(QDialogButtonBox, "buttonBox")
ok_button = button_box.button(QDialogButtonBox.Ok)
assert not ok_button.isEnabled()
QTest.keyClicks(title_edit, title_text)
assert ok_button.isEnabled()
QTest.mouseClick(ok_button, Qt.LeftButton)
QTimer.singleShot(0, lambda: fill_and_accept("Groceries"))
QTest.keySequence(window, "Ctrl+N")
assert window.ui.noteList.count() == 1
assert window.ui.noteList.currentItem().text() == "Groceries"
QTest.keyClicks(window.ui.noteBody, "milk eggs bread")
assert window.dirty is True
assert "3 word(s)" in window.ui.statusbar.currentMessage()
print("ALL TESTS PASSED")
Run it with the offscreen platform plugin active:
set QT_QPA_PLATFORM=offscreen
python test_notes_app.py
(On macOS or Linux, set the environment variable with export QT_QPA_PLATFORM=offscreen instead of set.)
Expected output:
ALL TESTS PASSED
The full test suite behind this tutorial goes considerably further than this excerpt: it exercises 11 separate scenarios, including switching between two notes to confirm neither one's text gets lost, rejecting a duplicate title, saving to disk and reading the raw JSON back to check its exact contents, opening a second, completely independent MainWindow instance to confirm it loads the same notes from disk, both answers of the delete-confirmation prompt, the Enter-key default-button shortcut, the explicit Tab order, and both answers of the unsaved-changes-on-exit prompt. Every one of those passed against the exact code shown in this tutorial before publication.
Common Mistakes and Gotchas
- Editing the generated _ui.py file by hand. The header of every file
pyside6-uicproduces warns that changes are lost on the next compile, and it means it. If you need different behavior, change the .ui file in Designer (or change the object's properties in Python aftersetupUi()runs), never the generated file itself. - Placing
<tabstops>inside the root widget in hand-edited XML. As shown in Step 4, it belongs alongside the root<widget>, not inside it.pyside6-uicwill refuse to compile the file and tell you exactly which line is wrong, so this fails loudly rather than silently. - Forgetting to block signals during a programmatic text update. If
switch_note()in Step 7 calledsetPlainText()without wrapping it inblockSignals(True)/blockSignals(False), every note you clicked on would immediately be flagged as having unsaved changes, even though nothing was actually edited. This bug is easy to miss by hand-testing, since the status bar text still looks plausible, and it is exactly the kind of thing an automated test like Step 12 catches immediately. - Assuming a window stays "active" after a modal dialog closes, in automated tests. Window-level keyboard shortcuts, the kind you set as an action's Shortcut property, only route to whichever window Qt considers active. In everyday interactive use this is automatic, but in an offscreen automated test, closing a modal dialog does not always hand focus back to its parent window on its own; call
window.activateWindow()again before simulating the next shortcut if a previously workingQTest.keySequence()call mysteriously stops firing. - Reusing a widget's name for both a QAction and something else. Object names must be unique within a form. Designer will not stop you from creating a second widget with a name you already used elsewhere in the same file, but
setupUi()will overwrite the first attribute with the second, and only one of them will actually work.
How to Confirm It All Works End to End
- Run
python notes_app.pyand confirm the window opens with an empty note list. - Press Ctrl+N, type a title, and confirm OK stays disabled until you type something, then confirm Enter submits the dialog without clicking anything.
- Type into the editor and confirm the status bar's word count updates as you type.
- Create a second note, then click back to the first one, and confirm your typed text is still there.
- Press Ctrl+S, then check that a
notes.jsonfile appeared in your project folder with your note titles and text inside it. - Close the app and run
python notes_app.pyagain; confirm both notes reload from disk. - Select a note and press Ctrl+D; confirm you get a yes/no prompt and that answering No leaves the note in place.
- Type a change, then try to close the window; confirm you get the unsaved-changes prompt, and that Cancel keeps the window open.
- Run the automated test from Step 12 and confirm it prints
ALL TESTS PASSED.
Next Steps
From here, a few natural directions to keep learning:
- Persist window geometry with
QSettingsso the app remembers its size and position between launches, a few lines of code using the same signals-and-slots patterns you just learned. - Add real icons to your toolbar actions with
QIcon, instead of the text-only buttons this tutorial used to keep the focus on structure over styling. - Package the app for distribution with the
pyside6-deploytool, also bundled in the pip package, which wraps your script and its dependencies into a standalone executable so end users do not need Python or PySide6 installed themselves. - Compare licensing before choosing between PySide6 and PyQt6 if you plan to distribute a closed-source app commercially: PySide6 is LGPLv3 (permitting closed-source distribution under most conditions), while PyQt6 is GPLv3 unless you buy a commercial license from Riverbank Computing. For learning and for open-source projects, either works identically; this tutorial used PySide6 because it ships Qt Designer directly inside the pip package with no extra install step.
Sources and Further Reading
- PySide6 on PyPI, the authoritative source for the version number, license terms, and supported Python versions cited in this tutorial.
- Qt for Python: Using .ui files with QUiLoader and pyside6-uic, the official reference for the two techniques covered in Steps 5 and 6.
- Qt's QDialogButtonBox class reference, which documents the button-role-based accepted() and rejected() signals used in Step 4.
- Real Python: Qt Designer and Python, whose New Form template comparison this tutorial's Step 3 table draws on; the Quick Notes application itself is an original example built and tested independently for this tutorial.








No Comment! Be the first one.