32. Extending Signals
We’ve seen a basic introduction to signals already, but that only scratches the surface of what you can do with them. In this chapter we’ll look at how you can create your own signals and customize the data sent with them.
Custom Signals
So far we’ve only looked at signals that Qt itself provides on the built-in widgets. However, you can also make use of your own custom signals in your own code. This is a great way to decouple modular parts of your application, meaning parts of your app can respond to things happening elsewhere without needing to know anything about the structure of your app.
ë
One good indication that you need to decouple parts of your app
is the use of .parent() to access data on other unrelated widgets.
But it also applies to any place where you are referring to
objects through other objects, e.g.
self.my_other_window.dialog.some_method. This kind of code is
prone to breaking — in multiple places — when you change or
restructure your application. Avoid it wherever possible!
By putting these updates in the event queue you also help to keep your app responsive — rather than having one big update method, you can split the work up into multiple slot methods and trigger them all with a single signal.
You can define your own signals using the pyqtSignal method provided by PyQt6. Signals are defined as class attributes passing in the Python type (or types) that will be emitted with the signal. You can choose any valid Python variable name for the name of the signal, and any Python type for the signal type.
Listing 235. further/signals_custom.py
import sys
from PyQt6.QtCore import pyqtSignal
from PyQt6.QtWidgets import QApplication, QMainWindow
class MainWindow (QMainWindow):
message = pyqtSignal(str) ①
value = pyqtSignal(int, str, int) ②
another = pyqtSignal(list) ③
onemore = pyqtSignal(dict) ④
anything = pyqtSignal(object) ⑤
def __init__ (self):
super().__init__()
self.message.connect(self.custom_slot)
self.value.connect(self.custom_slot)
self.another.connect(self.custom_slot)
self.onemore.connect(self.custom_slot)
self.anything.connect(self.custom_slot)
self.message.emit("my message")
self.value.emit( 23 , "abc", 1 )
self.another.emit([ 1 , 2 , 3 , 4 , 5 ])
self.onemore.emit({"a": 2 , "b": 7 })
self.anything.emit( 1223 )
def custom_slot (self, *args):
print (args)
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app. exec ()
①Signal emitting a string.
②Signal emitting 3 different types.
③Signal emitting a list.
④Signal emitting a dictionary.
⑤Signal emitting anything.
As you can see the signals can be connected and emitted as normal. You can send any Python type, including multiple types, and compound types (e.g. dictionaries, lists).
If you define your signal as pyqtSignal(object) it will be able to transmit absolutely any Python type at all. But this isn’t usually a good idea as receiving slots will then need to deal with all types.
ë
You can create signals on any class that is a subclass of QObject.
That includes all widgets, including the main window and dialog
boxes.
Modifying Signal Data
Signals are connected to slots which are functions (or methods) which will be run every time the signal fires. Many signals also transmit data, providing information about the state change or widget that fired them. The receiving slot can use this data to perform different actions in response to the same signal.
However, there is a limitation — the signal can only emit the data it was designed to. Take for example, the QPushButton.clicked signal which fires when the button is clicked. The _clicked+ signal emits a single piece of data — the checked state of the button after being clicked.
ë For non-checkable buttons, this will always be False.
The slot receives this data, but nothing more. It does not know which widget
triggered it, or anything about it. This is usually fine. You can tie a particular widget to a unique function which does precisely what that widget requires. Sometimes however you want to add additional data so your slot methods can be a little smarter. There’s a neat trick to do just that.
The additional data you send could be the triggered widget itself, or some associated metadata which your slot needs to perform the intended result of the signal.
Intercepting the signal
Instead of connecting the signal directly to the target slot function, you use an intermediate function to intercept the signal, modify the signal data and forward that on to your target slot. If you define the intermediate function in a context that has access to the widget that emitted the signal, you can pass that with the signal too.
This slot function must accept the value sent by the signal (here the checked state) and then call the real slot, passing any additional data with the arguments.
def fn (checked):
self.button_clicked(checked, )
Rather than define this intermediate function like this, you can also achieve the same thing inline using a lambda function. As above, this accepts a single parameter checked and then calls the real slot.
lambda checked: self.button_clicked(checked, )
In both examples the can be replaced with anything you want to forward to your slot. In the example below we’re forwarding the QPushButton object action to the receiving slot.
btn = QPushButton()
btn.clicked.connect( lambda checked: self.button_clicked(checked, btn)
)
Our button_clicked slot method will receive both the original checked value and the QPushButton object. Our receiving slot could look something like this —
# a class method.
def button_clicked (self, checked, btn):
# do something here.
ë
You can reorder arguments in your intermediate function if you
like.
The following example shows it in practice, with our button_clicked slot receiving the check state and the widget object. In this example, we hide the button in the handler so you can’t click it again!
Listing 236. further/signals_extra_1.py
import sys
from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton
class MainWindow (QMainWindow):
def __init__ (self):
super().__init__()
btn = QPushButton("Press me")
btn.setCheckable(True)
btn.clicked.connect(
lambda checked: self.button_clicked(checked, btn)
)
self.setCentralWidget(btn)
def button_clicked (self, checked, btn):
print (btn, checked)
btn.hide()
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app. exec ()
Problems with loops
A common reason for wanting to connect signals in this way is when you’re building a series of widgets and connecting signals programmatically in a loop. Unfortunately, then things aren’t always so simple.
If you construct intercepted signals in a loop and want to pass the loop variable to the receiving slot, you’ll hit a problem. For example, in the following example, we’re creating a series of buttons, and trying to pass the sequence number with the signal. Clicking a button should update the label with the value of the button.
Listing 237. further/signals_extra_2.py
import sys
from PyQt6.QtWidgets import (
QApplication,
QHBoxLayout,
QLabel,
QMainWindow,
QPushButton,
QVBoxLayout,
QWidget,
)
class MainWindow (QMainWindow):
def __init__ (self):
super().__init__()
v = QVBoxLayout()
h = QHBoxLayout()
for a in range( 10 ):
button = QPushButton(str(a))
button.clicked.connect(
lambda checked: self.button_clicked(a)
) ①
h.addWidget(button)
v.addLayout(h)
self.label = QLabel("")
v.addWidget(self.label)
w = QWidget()
w.setLayout(v)
self.setCentralWidget(w)
def button_clicked (self, n):
self.label.setText(str(n))
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app. exec ()
①We accept the checked variable on our lambda but discard it. This button is not
checkable, so it will always be False.
If you run this you’ll see the problem — no matter which button you click, you get the same number (9) shown on the label. Why 9? It’s the last value of the loop.
Figure 234. No matter which button you press, the label always shows 9.
The issue is here —
for a in range( 10 ):
button = QPushButton(str(a))
button.clicked.connect(
lambda checked: self.button_clicked(a)
)
The problem is the line lambda: self.button_clicked(a) where we define the call to the final slot. Here we are passing a, but this remains bound to the loop variable. When the lambda is evaluated (when the signal fires) the value of a will be the value it had at the end of the loop , so clicking any of them will result in the same value being sent (here 9).
The solution is to pass the value in as a named parameter. By doing this the value is bound at the time the lambda is created, and will hold value of a at that iteration of the loop. This ensures the correct value whenever it is called.
ë
If this is gobbledygook, don’t worry! Just remember to always
used named parameters for your intermediate functions.
lambda checked, a=a: self.button_clicked(a) )
ë
You don’t have to use the same variable name, you could use
lambda val=a: self.button_clicked(val) if you prefer. The
important thing is to use named parameters.
Putting this into our loop, it would look like this:
Listing 238. further/signals_extra_3.py
for a in range( 10 ):
button = QPushButton(str(a))
button.clicked.connect(
lambda checked, a=a: self.button_clicked(a)
) ①
h.addWidget(button)
If you run this now, you’ll see the expected behavior — clicking on a button will show the correct value in the label.
Figure 235. When you press a button, the number pressed is shown below.
Below are a few more examples using inline lambda functions to modify the data sent with the MainWindow.windowTitleChanged signal. They will all fire once the .setWindowTitle line is reached and the my_custom_fn slot will output what they receive.
Listing 239. further/signals_extra_4.py
import sys
from PyQt6.QtWidgets import QApplication, QMainWindow
class MainWindow (QMainWindow): def init (self): super().init()
SIGNAL: The connected function will be called whenever the
window
title is changed. The new title will be passed to the
function. self.windowTitleChanged.connect(self.on_window_title_changed)
SIGNAL: The connected function will be called whenever the
window
title is changed. The new title is discarded in the lambda
and the
function is called without parameters.
self.windowTitleChanged.connect( lambda x: self.my_custom_fn())
SIGNAL: The connected function will be called whenever the
window
title is changed. The new title is passed to the function
and replaces the default parameter
self.windowTitleChanged.connect( lambda x: self.my_custom_fn( x))
SIGNAL: The connected function will be called whenever the
window
title is changed. The new title is passed to the function
and replaces the default parameter. Extra data is passed
from
within the lambda.
self.windowTitleChanged.connect( lambda x: self.my_custom_fn(x, 25 ) )
This sets the window title which will trigger all the above
signals
sending the new title to the attached functions or lambdas
as the
first parameter.
self.setWindowTitle("This will trigger all the signals.")
SLOT: This accepts a string, e.g. the window title, and prints
it def on_window_title_changed (self, s): print (s)
SLOT: This has default parameters and can be called without a
value def my_custom_fn (self, a="HELLLO!", b= 5 ): print (a, b)
app = QApplication(sys.argv)
window = MainWindow() window.show() app. exec ()
'PyQt5_' 카테고리의 다른 글
Working with Relative Paths (0) | 2023.03.13 |
---|---|
Extending Signals (0) | 2023.03.13 |
Timers (0) | 2023.03.13 |
Plotting with Matplotlib. (0) | 2023.03.13 |
Plotting with PyQtGraph (0) | 2023.03.13 |
댓글