PyQt - Lecture 4

Introduction to Signals And Slots

Signals And Slots Overview

What are Signals And Slots?

signals_and_slots.png
Signals and slots are used for communication between objects.
For example, if a user clicks a Close button, we probably want the window's close() function to be called.
A signal is s notification that the event has happened
A slot is a function that is called, when that event occurs.
In order to establish communication between objects, we connect a signal to a slot to achieve the desired action

What are Signals And Slots?

A Signal is a special object property that can be emitted in response to a event.
Usually, we emit signals when the object's internal state has changed.
A Slot is an object method that can receive a signal and act in response to it.
In fact, a slot can be not only a Qt object method, it can be any Python callable.
When a signal is emitted, the slots connected to it are usually executed immediately

Signals and Slots Example

A QPushButton has a clicked signal that is emitted whenever the button is clicked by a user.
The QWidget class has a close() slot that causes it to close if it's a top-level window.
We could connect the two like this:

            self.btn_cancel = qtw.QPushButton('Cancel')
            self.btn_cancel.clicked.connect(self.close)
        
All classes descended from QObject (including all QWidget classes) can send and receive signals.

Different combinations of signals and slots

One signal can be connected to one or many slots
If a signal is connected to several slots they will be executed one after the other, in the order they have been connected, when the signal is emitted.
Many signals can be connected to the same slot
A signal can be connected to other signals
Connections can be removed

Common predefined Signals and Slots

All buttons have next signals and slots: QAbstractButton Slots and Signals
QLineEdit object has: QLineEdit Slots And Signals
All QWidget objects have next QWidget Slots and QWidget Signals

Passing data between signals and slots

Passing data between signals and slots

Signals can also pass data to the slots they are connected.
For example, QLineEdit object has a textChanged signal that sends the text entered into the widget along with the signal
The QLineEdit object also has a setText() slot that accepts a string argument. We could connect them like this:

                self.line_edit1 = qtw.QLineEdit()
                self.line_edit2 = qtw.QLineEdit()

                self.line_edit1.textChanged.connect(self.line_edit2.setText)

                self.mainLayout = qtw.QVBoxLayout()
                self.mainLayout.addWidget(self.line_edit1)
                self.mainLayout.addWidget(self.line_edit2)

                self.setLayout(self.mainLayout)
            

Task: Print in console the text in a lineEdit

Create a Widget with 1 QLineEdit object (line_edit1) in it
We want on every change to the content of line_edit1 that content to be printed in the console.

Solution


            import sys
            from PyQt6 import QtWidgets as qtw

            class MainWindow(qtw.QWidget):
                def __init__(self, *args, **kwargs):
                    super().__init__(*args, **kwargs)

                    # Set up the main layout
                    main_layout = qtw.QVBoxLayout(self)

                    # Create and add the QLineEdit to the layout
                    self.leTest = qtw.QLineEdit()
                    main_layout.addWidget(self.leTest)

                    # Connect the textChanged signal to the custom slot
                    self.leTest.textChanged.connect(self.print_content)

                    self.setWindowTitle('Demo')
                    self.show()

                def print_content(self, text):
                    print(f'Content: {text}')

            # Run the application
            if __name__ == '__main__':
                app = qtw.QApplication(sys.argv)
                window = MainWindow()
                sys.exit(app.exec())

        
Signals can even be connected to other signals, for example:

                self.line_edit1.editingFinished.connect(lambda: print('Edit Done'))
                self.line_edit2.returnPressed.connect(self.line_edit1.editingFinished)
            
When you connect a signal to another signal, the event and data are passed from one signal to the next

Custom signals and slots

Custom slots

Custom Slots

Any Python callable can play the role of a slot.
Custom slots are called when a signal is emitted.

            self.line_edit.textChanged.connect(self.some_slot)

            def some_slot(signal, text, x):
                print(text, x)

            # TypeError: some_slot() missing 1 required positional argument: 'x'
        

Type-safety using slots decorators

QtCore contains a pyqtSlot() function, which we can use to decorate a Python callable as a slot.
pyqtSlot() is a decorator that takes a Python callable and returns a slot object.
The slot object is a wrapper around the Python callable that can be connected to a signal.
Using the pyqtSlot() decorator allows us to specify the type of the arguments that will be passed to the slot.

            @qtc.pyqtSlot(str)
            def some_slot(*args):
                for arg in args:
                    print(arg)
        

Custom Signals

Defining Custom Signals

Custom signals are defined using the pyqtSignal() function.
pyqtSignal() returns a signal object, which is is a wrapper around the Python callable that can be connected to a slot.
Custom signals are defined as class attributes.
Custom signals have the emit()method, which is used to emit the signal.
Reference:
Defining New Signals with pyqtSignal
emit

            # create custom signal which will carry a string data type data:
            sig_submit = qtc.pyqtSignal(str)

            @qtc.pyqtSlot(bool)
            def onSubmit(self):
                self.sig_submit.emit(self.edit.text())
                self.close()
        

Why using custom signals

Custom signals help decouple components by allowing different widgets or objects to communicate without directly calling each other's methods
Custom signals can be used to define specific events that aren't covered by the built-in PyQt signals, such as model changes, data updates, or complex state transitions
They improve code modularity, as components can emit signals without needing to know which slot is handling them, reducing dependencies
They provide a way to safely handle communication between different threads in a PyQt application
Custom signals offer flexibility for complex applications, making it easier to manage and maintain large codebases by centralizing communication through signals

Sharing data between widgets using custom signals and slots

Sharing data between widgets using custom signals and slots

Common scenario

Suppose you have a program that pops up a form window. When the user finishes filling the form and submits it, we need to get the entered data back to the main application class for processing.
There are a few ways we could approach this:
One is for the main application to watch for click events on the pop-up window's Submit button, then grab the data from its fields before destroying the dialog. But that approach requires the main form to know all about the pop-up dialog's widgets, and any refactoring of the pop-up would risk breaking code in the main application.
Another option is to pass the main application a reference to the dialog's fields, but this approach still requires tight coupling between the two classes.
The preferred approach is to use custom signals and slots to emit the form data when the user clicks Submit, allowing the main application to handle the data without needing to know the inner workings of the dialog.

Demo: tightly-coupled approach

Note that MainWindow must know DataEntryDialog's implementation


            import sys
            from PyQt6 import QtWidgets as qtw
            from PyQt6 import QtCore as qtc
            from PyQt6 import QtGui as qtg

            class DataEntryDialog(qtw.QDialog):
                def __init__(self , *args, **kwargs):
                    super().__init__(*args, **kwargs)
                    self.setWindowTitle('My Form')
                    self.setGeometry(500,400,200,100)

                    # ------------------------- create and atach widgets ------------------------- #
                    self.edit = qtw.QLineEdit()
                    self.btn_submit = qtw.QPushButton('Submit')

                    self.setLayout(qtw.QVBoxLayout())
                    self.layout().addWidget(self.edit)
                    self.layout().addWidget(self.btn_submit)

            class MainWindow(qtw.QWidget):
                def __init__(self , *args, **kwargs):
                    super().__init__(*args, **kwargs)
                    self.setWindowTitle('My App')
                    self.setGeometry(400,300,300,200)

                    # ------------------------- create and atach widgets ------------------------- #
                    self.label = qtw.QLabel('Initial Text')
                    self.btn_change = qtw.QPushButton('change text')

                    self.main_layout = qtw.QVBoxLayout()
                    self.main_layout.addWidget(self.label)
                    self.main_layout.addWidget(self.btn_change)
                    self.setLayout(self.main_layout)

                    # ---------------------------------- signals --------------------------------- #
                    self.btn_change.clicked.connect(self.onChangeClicked)

                    self.show()

                def onChangeClicked(self):
                    self.dialog = DataEntryDialog(self)
                    # tightly-coupled approach: we must know dialog's implementation
                    self.dialog.edit.setText(self.label.text())
                    self.dialog.btn_submit.clicked.connect(self.on_dialog_text_changed)

                    self.dialog.show()


                def on_dialog_text_changed(self):
                    self.label.setText(self.dialog.edit.text())
                    self.dialog.close()


            if __name__ == '__main__':
                app = qtw.QApplication(sys.argv);

                window = MainWindow()

                sys.exit(app.exec())
        

Demo: loosely-coupled approach

Note that MainWindow don't care about DataEntryDialog's implementation, it just pass and receive data


            import sys
            from PyQt6 import QtWidgets as qtw
            from PyQt6 import QtCore as qtc
            from PyQt6 import QtGui as qtg

            class DataEntryDialog(qtw.QDialog):
                data_submitted = qtc.pyqtSignal(str)

                def __init__(self , parent, msg):
                    super().__init__(parent)
                    self.setWindowTitle('My Form')
                    self.setGeometry(500,400,200,100)

                    # ------------------------- create and atach widgets ------------------------- #
                    self.edit = qtw.QLineEdit()
                    self.edit.setText(msg)
                    self.btn_submit = qtw.QPushButton('Submit')

                    self.setLayout(qtw.QVBoxLayout())
                    self.layout().addWidget(self.edit)
                    self.layout().addWidget(self.btn_submit)

                     # Connect the button click to emit the custom signal
                    self.btn_submit.clicked.connect(self.emit_data)

                def emit_data(self):
                    self.data_submitted.emit(self.edit.text())

            class MainWindow(qtw.QWidget):
                def __init__(self , *args, **kwargs):
                    super().__init__(*args, **kwargs)
                    self.setWindowTitle('My App')
                    self.setGeometry(400,300,300,200)

                    # ------------------------- create and atach widgets ------------------------- #
                    self.label = qtw.QLabel('Initial Text')
                    self.btn_change = qtw.QPushButton('change text')

                    self.main_layout = qtw.QVBoxLayout()
                    self.main_layout.addWidget(self.label)
                    self.main_layout.addWidget(self.btn_change)
                    self.setLayout(self.main_layout)

                    # ---------------------------------- signals --------------------------------- #
                    self.btn_change.clicked.connect(self.onChangeClicked)

                    self.show()

                def onChangeClicked(self):
                    self.dialog = DataEntryDialog(parent=self, msg=self.label.text())
                    # Connect the dialog's custom signal to update the label text in the main window
                    self.dialog.data_submitted.connect(self.on_dialog_text_changed)
                    self.dialog.show()


                def on_dialog_text_changed(self, msg):
                    self.label.setText(msg)
                    self.dialog.close()


            if __name__ == '__main__':
                app = qtw.QApplication(sys.argv);

                window = MainWindow()

                sys.exit(app.exec())