Build a Desktop App with PySide6 #3: Signals and Slots — the Core of Event Handling
The todo app from the last post has a screen but no behavior. Deciding what happens when a button is clicked is this post’s subject, and the model responsible for that wiring in Qt is signals and slots. This one model runs through all of Qt programming, so getting it right here makes every remaining part easier.
It runs in seven parts.
- #1 What PySide6 is — desktop apps with Qt and Python
- #2 Widgets and layouts — assembling the screen
- #3 Signals and slots — the core of event handling ← this post
- #4 Qt Designer and UI files — draw the screen, then load it
- #5 Model/View — connecting data to lists and tables
- #6 Threads and timers — keeping the UI responsive
- #7 Packaging and distribution — building executables with PyInstaller
Signals and slots — sender and receiver #
The model boils down to two words. When something happens to a widget, a signal is emitted, and if you have connected a slot — a function to be executed on receipt — to that signal, it gets called automatically.
def on_add_clicked():
print("Add button was clicked")
self.add_button.clicked.connect(on_add_clicked)clicked is a signal QPushButton comes with. The instant the button is pressed, Qt calls the connected function. What matters is that we never wrote any code that calls the function. The structure is: the event loop decides when to run it; connect only registers what to run.
The connection target is usually a class method. Wired into the TodoWindow from the last post, it looks like this.
class TodoWindow(QMainWindow):
def __init__(self):
super().__init__()
# ... screen assembly code from #2 ...
self.add_button.clicked.connect(self.add_todo)
def add_todo(self):
print("Add button was clicked")Signals that carry arguments #
Signals go beyond simple notifications and deliver values. An input field’s textChanged passes the changed text as a str, and a combo box’s currentIndexChanged passes the selected position as an int.
def on_text_changed(text: str):
print(f"Input: {text}")
def on_index_changed(index: int):
print(f"Selected position: {index}")
self.todo_input.textChanged.connect(on_text_changed)
combo.currentIndexChanged.connect(on_index_changed)The slot’s parameters receive exactly the values the signal sends. Which signal sends which values is laid out on each widget’s page in the official docs, and in practice the usual flow is checking signal names through autocomplete.
Wiring behavior into the todo app #
Now we connect real behavior to the screen skeleton: add, delete, and clear all.
class TodoWindow(QMainWindow):
def __init__(self):
super().__init__()
# ... screen assembly code from #2 ...
# Signal connections — gathered after screen assembly
self.add_button.clicked.connect(self.add_todo)
self.todo_input.returnPressed.connect(self.add_todo)
self.delete_button.clicked.connect(self.delete_selected)
self.clear_button.clicked.connect(self.todo_list.clear)
def add_todo(self):
text = self.todo_input.text().strip()
if not text:
return
self.todo_list.addItem(text)
self.todo_input.clear()
def delete_selected(self):
for item in self.todo_list.selectedItems():
row = self.todo_list.row(item)
self.todo_list.takeItem(row)All three connection styles appear here.
- Multiple signals into one slot — the add button’s clicked and the input field’s returnPressed (Enter key) both connect to the same
add_todo. That is why a button click and an Enter press do the same thing. - Connecting a widget’s built-in slot directly — clear all needs no function of ours; we connect
QListWidget.clearas is. Functions you wrote are not the only things that can receive a signal. - Input validation inside the slot — like skipping empty strings, the slot reads the state at execution time and decides.
Run it and you have an app where input, add, delete selected, and clear all work end to end.
Custom signals — your classes become senders too #
There comes a moment when built-in signals are not enough. Say you split the input area into its own widget class: that widget now needs to tell the outside world that “a new todo was submitted.” This is where you declare a signal yourself with Signal.
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLineEdit, QPushButton
class TodoInput(QWidget):
submitted = Signal(str) # declared as a class attribute
def __init__(self):
super().__init__()
self.line = QLineEdit()
button = QPushButton("Add")
layout = QHBoxLayout(self)
layout.addWidget(self.line)
layout.addWidget(button)
button.clicked.connect(self.submit)
self.line.returnPressed.connect(self.submit)
def submit(self):
text = self.line.text().strip()
if text:
self.submitted.emit(text) # emit the signal
self.line.clear()The consuming side connects without knowing anything about this widget’s internals.
self.todo_input = TodoInput()
self.todo_input.submitted.connect(self.todo_list.addItem)You declare a signal as a class attribute with Signal(type) and emit it with emit(value). This way the input widget only announces “something was submitted,” and whether that value goes into a list or into a file is decided by whoever connects to it.
How this differs from callbacks #
In that you register a function to be called later, signals and slots look like callbacks. The difference lies in the direction of coupling.
- The sender does not know the receiver. TodoInput has no idea who receives submitted, and it does not need to. Unlike a callback, the sender does not hold “the function to call.”
- Many-to-many connections work. One signal can connect to several slots, and several signals can converge on one slot. The code does not grow with each connection.
Thanks to these properties, widgets stay loosely coupled even when you split them into parts. It is a structure whose payoff grows with the app.
button.clicked.disconnect(slot), and when you want to keep signals from cascading while you change a value in code, silence the widget temporarily with widget.blockSignals(True) and restore it with False. When you suspect an infinite loop where a slot changes a widget value that calls the same slot again, these are the first tools to reach for.Wrap-up #
This post comes down to three points.
- Event handling in Qt is made of signal emission and slot connection, and the event loop decides when things run.
- Multiple signals can connect to one slot, and built-in slots are valid targets. The todo app’s add and delete were completed this way.
- With a
Signal(type)declaration and emit, your own classes become senders, keeping a loose structure where the sender does not know the receiver.
In the next post, “Build a Desktop App with PySide6 #4: Qt Designer and UI Files — Draw the Screen, Then Load It,” we cover assembling screens with the mouse instead of code — drawing the screen in Qt Designer, loading the result from Python, and connecting the signals there.