【PySide6】Pyside6 信号、插槽和事件

用户行为和GUI事件的触发响应动作

此教程对PyQt6,PySide2和PyQt5同样适用

目前,我们已经创建了一个窗口,并在它上面添加了一个普通的按钮组件,但是这个按钮现在没有任何作用。这个窗口现在不是很有用–当你创建GUI应用程序,你通常想使用他们做某事!我们需要一个可以使当按钮被按下时可以使某些事情发生的方法。在Qt,这种方法由信号、插槽和事件提供。

信号&插槽

当某事发生时,信号是由部件发出的信息。“某事”可以是:按钮被按下、输入框的文字被编辑和窗口文字的改变等等。许多信号由用户动作发起,但这不是固定的规则。

除了通知正在发生的事情外,信号还可以发送数据以提供有关所发生事件的其他上下文。

你还可以创建自己的自定义信号,我们将在稍后探讨

插槽是Qt用来接收信号的名词。在Python中,你的应用程序的任何方法(或函数)可以作为一个插槽–只需将信号连接到它。如果信号发送数据,则接收函数也将接收该数据。许多Qt小部件也有自己的内置插槽,这意味着你可以直接将Qt小部件连接在一起。

让我们来看看Qt信号的基础知识,以及如何使用它们来连接部件,从而在应用程序中实现目标。

保存下面的代码为文件app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import sys
from PySide6.QtWidgets import QApplication, QMainWindow

class MainWindow(QMainWindow):

def __init__(self):
super().__init__()

self.setWindowTitle("My App")

app = QApplication(sys.argv)

window = MainWindow()
window.show()

app.exec()

QPushButton 信号

我们的简单应用目前有一个 QMainWindow,其中有一个 QPushButton 作为中心小部件。让我们开始将这个按钮与一个自定义的 Python 方法连接起来。这里我们创建一个简单的自定义槽 the_button_was_clicked(),该方法接受 QPushButton 对象发出的 clicked 信号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import sys
from PySide6.QtWidgets import QApplication, QMainWindow, QPushButton

class MainWindow(QMainWindow):

def __init__(self):
super().__init__()

self.setWindowTitle("我的应用")

button = QPushButton("按我!")
button.clicked.connect(self.the_button_was_clicked)

self.setCentralWidget(button)

def the_button_was_clicked(self):
print("按钮被点击!")

app = QApplication(sys.argv)

window = MainWindow()
window.show()

app.exec()

运行这段代码! 你会看到每次点击按钮时控制台都会打印出 "按钮被点击!"。

1
2
3
4
按钮被点击!
按钮被点击!
按钮被点击!
按钮被点击!

接收数据

这是一个好的开始!我们已经知道,信号还可以发送数据,提供有关刚刚发生的事情的更多信息。点击信号也不例外,它还为按钮提供了选中(或切换)状态。对于普通按钮,这始终为False,因此我们的第一个插槽忽略了这些数据。但是,我们可以使按钮可检查并查看效果。下面是一个例子,展示如何在槽中接收信号发送的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import sys
from PySide6.QtWidgets import QApplication, QMainWindow, QPushButton

class MainWindow(QMainWindow):

def __init__(self):
super().__init__()

self.setWindowTitle("我的应用")

button = QPushButton("按我!")
button.setCheckable(True)
button.clicked.connect(self.the_button_was_clicked)

self.setCentralWidget(button)

def the_button_was_clicked(self, checked):
print("被点击!")
print("选中?", checked)

app = QApplication(sys.argv)

window = MainWindow()
window.show()

app.exec()

运行这段代码! 每次点击按钮时,控制台会显示 "被点击!" 和按钮的状态。

1
2
3
4
5
6
7
8
9
10
被点击!
选中? True
被点击!
选中? False
被点击!
选中? True
被点击!
选中? False
被点击!
选中? True

你可以将任意多个插槽连接到一个信号,并且可以在插槽上同时响应不同版本的信号。

存储数据

通常,将小部件的当前状态存储在 Python 变量中是有用的。这样你可以像处理其他 Python 变量一样处理这些值,而无需访问原始小部件。你可以在实例变量中存储这些值。下面的例子展示了如何存储按钮被选中button_is_checked的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()

self.button_is_checked = True

self.setWindowTitle("My App")

button = QPushButton("Press Me!")
button.setCheckable(True)
button.clicked.connect(self.the_button_was_toggled)
button.setChecked(self.button_is_checked)

self.setCentralWidget(button)

def the_button_was_toggled(self, checked):
self.button_is_checked = checked

print(self.button_is_checked)

首先,我们将变量的默认值设置为True,然后使用默认值设置小部件的初始状态。当小部件状态发生变化时,我们会收到信号并更新变量以匹配。

您可以将此模式用于任何PySide小部件。如果小部件不提供发送当前状态的信号,则需要在处理程序中直接从小部件检索值。例如,我们在这里检查按下的处理程序中的已检查状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()

self.button_is_checked = True

self.setWindowTitle("My App")

self.button = QPushButton("Press Me!")
self.button.setCheckable(True)
self.button.released.connect(self.the_button_was_released)
self.button.setChecked(self.button_is_checked)

self.setCentralWidget(self.button)

def the_button_was_released(self):
self.button_is_checked = self.button.isChecked()

print(self.button_is_checked)

我们需要在self上保留一个按钮的引用,这样我们就可以在我们的插槽中访问它。

改变界面

到目前为止,我们已经了解了如何接受信号并将输出打印到控制台。但是,当我们点击按钮时,在界面中发生一些事情怎么样?让我们更新我们的插槽方法来修改按钮,更改文本并禁用按钮,使其不再可点击。我们还将暂时关闭可检查状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()

self.setWindowTitle("My App")

self.button = QPushButton("Press Me!")
self.button.clicked.connect(self.the_button_was_clicked)

self.setCentralWidget(self.button)

def the_button_was_clicked(self):
self.button.setText("You already clicked me.")
self.button.setEnabled(False)

# Also change the window title.
self.setWindowTitle("My Oneshot App")

同样,因为我们需要能够访问the_button_was_clicked方法中的按钮,所以我们自己保留了对它的引用。通过向setText()传递字符串来更改按钮的文本。要禁用按钮,请使用False调用setEnabled()

运行这段代码! 如果单击按钮,文本将更改,按钮将变得无法单击。

不限于更改触发信号的按钮,你可以在插槽方法中做任何您想做的事情。例如,尝试将以下行添加到the_button_was_clicked()方法中,以更改窗口标题:

1
self.setWindowTitle("A new window title")

大多数小部件都有自己的信号,我们用于窗口的QMainWindow也不例外。在以下更复杂的示例中,我们将QMainWindow上的windowTitleChanged信号连接到自定义插槽方法。

在以下示例中,我们将QMainWindow上的windowTitleChanged信号连接到方法槽the_window_title_changed()。此插槽还接收新的窗口标题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import sys
from random import choice

from PySide6.QtWidgets import QApplication, QMainWindow, QPushButton

window_titles = [
"My App",
"My App",
"Still My App",
"Still My App",
"What on earth",
"What on earth",
"This is surprising",
"This is surprising",
"Something went wrong",
]

class MainWindow(QMainWindow):
def __init__(self):
super().__init__()

self.n_times_clicked = 0

self.setWindowTitle("My App")

self.button = QPushButton("Press Me!")
self.button.clicked.connect(self.the_button_was_clicked)

self.windowTitleChanged.connect(self.the_window_title_changed)

self.setCentralWidget(self.button)

def the_button_was_clicked(self):
print("Clicked.")
new_window_title = choice(window_titles)
print("Setting title: %s" % new_window_title)
self.setWindowTitle(new_window_title)

def the_window_title_changed(self, window_title):
print("Window title changed: %s" % window_title)

if window_title == "Something went wrong":
self.button.setDisabled(True)

app = QApplication(sys.argv)

window = MainWindow()
window.show()

app.exec()

首先,我们设置一个窗口标题列表——我们将使用Python内置的random.choice()从这个列表中随机选择一个。我们将自定义插槽方法the_window_title_changed()挂钩到窗口的windowTitleChanged信号。

当我们单击按钮时,窗口标题将随机更改。如果新窗口标题等于“Something went wrong”,则按钮将被禁用。

运行这段代码! 反复单击按钮,直到标题变为“Something went wrong”,按钮将被禁用。

在这个例子中有几件事需要注意。

首先,在设置窗口标题时,并不总是发出windowTitleChanged信号。只有当新标题与前一个标题不同时,才会发出信号。如果您多次设置同一标题,则信号将仅在第一次发出。重要的是要仔细检查信号触发的条件,以避免在应用程序中使用它们时感到惊讶。

其次,请注意我们如何使用信号将事物联系在一起。发生的一件事——按下按钮——可以触发其他多件事依次发生。这些后续效应不需要知道是什么导致了它们,而只需要遵循简单的规则。在构建GUI应用程序时,将效果与其触发器解耦是需要理解的关键概念之一。

在本节中,我们介绍了信号和插槽。我们已经演示了一些简单的信号,以及如何使用它们在应用程序周围传递数据和状态。接下来,我们将看看Qt为您的应用程序提供的小部件,以及它们提供的信号。

直接连接小部件

到目前为止,我们已经看到了将小部件信号连接到Python方法的示例。当小部件发出信号时,我们的Python方法会被调用并从信号中接收数据。但你并不总是需要使用Python函数来处理信号——你也可以直接将Qt小部件相互连接。

在下面的示例中,我们将 QLineEdit小部件和QLabel添加到窗口中。在窗口的__init__()中,我们将行编辑textChanged信号连接到QLabel上的setText()方法。现在,每当QLineEdit中的文本发生变化时,QLabel都会将该文本接收到其setText()方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import sys

from PySide6.QtWidgets import (
QApplication,
QLabel,
QLineEdit,
QMainWindow,
QVBoxLayout,
QWidget,
)

class MainWindow(QMainWindow):
def __init__(self):
super().__init__()

self.setWindowTitle("My App")

self.label = QLabel()

self.input = QLineEdit()
self.input.textChanged.connect(self.label.setText)

layout = QVBoxLayout()
layout.addWidget(self.input)
layout.addWidget(self.label)

container = QWidget()
container.setLayout(layout)

self.setCentralWidget(container)

app = QApplication(sys.argv)

window = MainWindow()
window.show()

app.exec()

请注意,为了将输入连接到标签,必须同时定义输入和标签。这段代码将两个小部件添加到布局中,并在窗口上进行设置。稍后我们将详细介绍布局,您现在可以忽略它。

运行这段代码! 在上方的框中键入一些文本,您将看到它立即出现在标签上。

signals-direct.webp

事件

用户与Qt应用程序的每次交互都是一个事件。有许多类型的事件,每种事件都代表不同类型的交互。Qt使用事件对象表示这些事件,这些事件对象打包了有关发生的事情的信息。这些事件被传递给发生交互的小部件上的特定事件处理程序。

通过定义自定义或扩展的事件处理程序,您可以更改小部件对这些事件的响应方式。事件处理程序的定义与任何其他方法一样,但名称特定于它们处理的事件类型。

小部件接收的主要事件之一是QMouseEvent。QMouseEvent事件是为小部件上的每次鼠标移动和按钮点击创建的。以下事件处理程序可用于处理鼠标事件:

事件处理程序 事件类型
mouseMoveEvent() 鼠标移动
mousePressEvent() 鼠标按钮按下
mouseReleaseEvent() 鼠标按钮释放
mouseDoubleClickEvent() 检测到双击

例如,单击小部件将导致QMouseEvent被发送到该小部件上的mousePressEvent()事件处理程序。此处理程序可以使用事件对象来查找有关发生了什么的信息,例如触发事件的原因以及具体发生在哪里。

您可以通过子类化和重写类上的处理程序方法来拦截事件。您可以选择过滤、修改或忽略事件,通过使用super()调用父类函数,将它们传递给事件的正常处理程序。这些可以添加到您的主窗口类中,如下所示。在每种情况下,e都会收到传入的事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import sys

from PySide6.QtWidgets import QApplication, QLabel, QMainWindow

class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.label = QLabel("Click in this window")
self.setCentralWidget(self.label)

def mouseMoveEvent(self, e):
self.label.setText("mouseMoveEvent")

def mousePressEvent(self, e):
self.label.setText("mousePressEvent")

def mouseReleaseEvent(self, e):
self.label.setText("mouseReleaseEvent")

def mouseDoubleClickEvent(self, e):
self.label.setText("mouseDoubleClickEvent")

app = QApplication(sys.argv)

window = MainWindow()
window.show()

app.exec()

运行这段代码! 尝试在窗口中移动和单击(并双击),然后观察事件的显示。

您会注意到,只有当您按下按钮时,鼠标移动事件才会被注册。您可以通过在窗口上调用self.setMouseTracking(True)来更改此设置。您可能还会注意到,按下按钮时,按下(单击)和双击事件都会触发。只有松开按钮时才会触发释放事件。通常,要注册用户的点击,您应该注意鼠标按下和释放。

在事件处理程序中,您可以访问事件对象。此对象包含有关事件的信息,可用于根据具体发生的情况做出不同的响应。接下来我们将查看鼠标事件对象。

鼠标事件

Qt中的所有鼠标事件都使用QMouseEvent对象进行跟踪,有关事件的信息可以从以下事件方法中读取:

方法 返回值
.button() 触发此事件的特定按钮
.buttons() 所有鼠标按钮的状态(按位或标志)
.globalPos() QPoint 形式的应用程序全局位置
.globalX() 应用程序全局 水平 X 位置
.globalY() 应用程序全局 垂直 Y 位置
.pos() QPoint 整数 形式的小部件相对位置
.posF() QPointF 浮点数 形式的小部件相对位置

你可以在事件处理程序中使用这些方法来以不同方式响应不同的事件或完全忽略它们。位置方法提供全局和本地(窗口小部件相对)位置信息作为 QPoint 对象,而按钮使用 Qt.MouseButton 命名空间中的鼠标按钮类型进行报告。

例如,以下内容允许我们对窗口上的左键、右键或中键单击做出不同的响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import sys

from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow

class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.label = QLabel("Click in this window")
self.setCentralWidget(self.label)

def mousePressEvent(self, e):
if e.button() == Qt.MouseButton.LeftButton:
# handle the left-button press in here
self.label.setText("mousePressEvent LEFT")

elif e.button() == Qt.MouseButton.MiddleButton:
# handle the middle-button press in here.
self.label.setText("mousePressEvent MIDDLE")

elif e.button() == Qt.MouseButton.RightButton:
# handle the right-button press in here.
self.label.setText("mousePressEvent RIGHT")

def mouseReleaseEvent(self, e):
if e.button() == Qt.MouseButton.LeftButton:
self.label.setText("mouseReleaseEvent LEFT")

elif e.button() == Qt.MouseButton.MiddleButton:
self.label.setText("mouseReleaseEvent MIDDLE")

elif e.button() == Qt.MouseButton.RightButton:
self.label.setText("mouseReleaseEvent RIGHT")

def mouseDoubleClickEvent(self, e):
if e.button() == Qt.MouseButton.LeftButton:
self.label.setText("mouseDoubleClickEvent LEFT")

elif e.button() == Qt.MouseButton.MiddleButton:
self.label.setText("mouseDoubleClickEvent MIDDLE")

elif e.button() == Qt.MouseButton.RightButton:
self.label.setText("mouseDoubleClickEvent RIGHT")

app = QApplication(sys.argv)

window = MainWindow()
window.show()

app.exec()

按钮标识符在Qt.MouseButton命名空间中定义。,如下所示:

标识符 值(二进制) 表示
Qt.MouseButton.NoButton 0 (000) 未按下任何按钮,或事件与按钮按下无关。
Qt.MouseButton.LeftButton 1 (001) 左键被按下
Qt.MouseButton.RightButton 2 (010) 右键被按下
Qt.MouseButton.MiddleButton 4 (100) 中键被按下

在左手鼠标上,左右按钮的位置是相反的,即按下最右侧的按钮将返回Qt.MouseButton.LeftButton。这意味着您不需要在代码中考虑鼠标方向。

上下文菜单

上下文菜单是小型上下文相关菜单,通常在右键单击窗口时出现。Qt支持生成这些菜单,小部件有一个特定的事件用于触发它们。在下面的示例中,我们将在QMainWindow中拦截contextMenuEvent()。每当要显示上下文菜单时,就会触发此事件,并传递QContextMenuEvent类型的单值事件。

为了拦截事件,我们只需用同名的新方法覆盖对象方法。因此,在这种情况下,我们可以在MainWindow子类上创建一个名为contextMenuEvent()的方法,它将接收此类型的所有事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import sys

from PySide6.QtGui import QAction
from PySide6.QtWidgets import QApplication, QMainWindow, QMenu

class MainWindow(QMainWindow):
def __init__(self):
super().__init__()

def contextMenuEvent(self, e):
context = QMenu(self)
context.addAction(QAction("test 1", self))
context.addAction(QAction("test 2", self))
context.addAction(QAction("test 3", self))
context.exec(e.globalPos())

app = QApplication(sys.argv)

window = MainWindow()
window.show()

app.exec()

如果您运行以上代码并在窗口中右键单击,您将看到出现一个上下文菜单。您可以在菜单操作上按正常方式设置触发槽triggered slots(并重新使用为菜单和工具栏定义的操作)。

当将初始位置传递给exec()方法时,这必须相对于定义时传递的父级。在这种情况下,我们将self作为父级传递,因此我们可以使用全局位置。

为了完整起见,实际上有一种基于信号的方法来创建上下文菜单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.show()

self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.on_context_menu)

def on_context_menu(self, pos):
context = QMenu(self)
context.addAction(QAction("test 1", self))
context.addAction(QAction("test 2", self))
context.addAction(QAction("test 3", self))
context.exec(self.mapToGlobal(pos))

这完全取决于你的选择。

事件层次结构

在PySide6中,每个小部件都是两个不同层次结构的一部分:Python对象层次结构和Qt布局层次结构。如何响应或忽略事件会影响UI的行为。

Python继承转发

通常,你可能希望拦截一个事件并对其进行处理,但仍会触发默认的事件处理行为。如果你的对象是从标准小部件继承的,那么默认情况下它可能会实现合理的行为。您可以通过使用super()调用父级的实现来触发此操作。

这是Python父类,而不是PySide的父类:

1
2
3
def mousePressEvent(self, event):
print("Mouse pressed!")
super().mousePressEvent(event)

事件将继续正常运行,但您添加了一些非干扰行为。

布局转发

当你在应用程序中添加一个小部件时,它也会从布局中获得另一个父级。通过调用.parent()可以找到小部件的父级。有时您手动指定这些父级,例如QMenuQDialog,通常是自动指定的。例如,当您将小部件添加到主窗口时,主窗口将成为小部件的父窗口。

当为用户与UI的交互创建事件时,这些事件会传递给UI中最上面的小部件。因此,如果您在窗口上有一个按钮并单击该按钮,则该按钮将首先接收事件。

如果第一个小部件无法处理该事件或选择不处理,则该事件将向上冒泡到父小部件,父小部件将被轮流处理。这种冒泡一直持续到嵌套的小部件,直到事件被处理或到达主窗口。

在您自己的事件处理程序中,您可以选择通过调用accept()方法将事件标记为已处理:

1
2
3
class CustomButton(QPushButton)
def mousePressEvent(self, e):
e.accept()

或者,您可以通过在事件对象上调用ignore()将其标记为未处理。在这种情况下,事件将继续在层次结构中冒泡:

1
2
3
class CustomButton(QPushButton)
def event(self, e):
e.ignore()

如果你想让你的小部件对事件透明,你可以安全地忽略你以某种方式实际响应的事件。同样,你可以选择接受你没有回应的事件,以使它们沉默。

原文信息:《PySide6 Signals, Slots & Events》
原作者:Martin Fitzpatrick
出处:https://www.pythonguis.com/tutorials/pyside6-signals-slots-events/
免责声明:本翻译内容仅代表翻译者的个人观点与理解,原作者对此翻译版本并未进行认可,翻译过程中可能存在误差,读者应自行核实原文内容。