PyQt 学习笔记

下面的笔记是24年甚至23年的了……?这里又要捡起 PyQt 了,把这篇写到一半的笔记发出来。


学习 PyQt 有两个作用:

  1. Krita 插件编程需要 PyQt
  2. 能够用来编写更多类型的小工具来帮助生产生活实践,当前很多时候都是现写 nodejs 代码,限制多,重用也不方便

so,here we are。参考资料是

环境搭建

1
2
pip install pyqt5
pip install pyqt5-tools # qt designer

执行 pyqt5-tools designer 启动 Qt Designer 用来可视化编辑 QML。

Hello World

qt 项目至少需要一个 QWidget,不然一个窗口都没有。最后一个 QWidget 关闭时,应用关闭。

Hello World 示例如下,避免使用from .. import * 的语法来污染作用域:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 导入依赖
import sys
from PyQt5.QtWidgets import QApplication, QWidget, QLabel

# 实例化 QApplication,传入控制台参数
# QApplication 需要且只需要一个实例
app = QApplication(sys.argv)

# 创建组件,任何没有父组件的组件将会成为单独的窗口,再创建一个 QWidget 并调用 show 的话,会有两个窗口
# 关闭这两个窗口就会关闭应用
window = QWidget()
window.show()
# 但如果只是拷贝这两行的话,只会显示第一个窗口,应该是__del__在作妖

# 开始事件循环
app.exec()

# 直到应用关闭才会到达这里
print("application terminated")

QMainWindow

QMainWindow 提供了一些和窗口相关的功能,比如设置窗口标题等。下面通过继承来使用 QMainWindow,这是一种抽象,去自包含一些东西。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import sys
from PyQt5.QtCore import QSize, Qt # Qt 中是关于属性和的枚举
from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton # 核心的 Qt 组件总是从 PyQt5.QtWidgets 导入
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
# 设置窗口标题
self.setWindowTitle("My App")
# 设置窗口大小,如果设定 fixedSize,窗口就无法缩放了
# 这个方法是任何 QWidget 共有的
self.setFixedSize(QSize(400, 300))
# 按钮
button = QPushButton("Press Me!")
# 设置窗口中心的组件,该组件似乎会尝试占满窗口
self.setCentralWidget(button)

app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()

QWidget总是单个元素,如果需要组织多个元素,需要使用 layout

事件循环

TODO

signal & slot

事件循环是类似浏览器 js 那样的机制,用户缩放窗口,点击按钮,按下按键时,都会触发事件,执行相应 js,在 js 中称为事件/事件监听器,在 qt 中称为信号 signal /槽 slot。

触发事件时,事件会丢到事件队列中,一个事件处理线程会按序处理队列中的事件;事件的处理总是单线程的。QApplication持有事件队列。

下面的例子创建一个包含选中状态的 QPushButton,并绑定了两个 slot,其中第二个 slot 能够获取当前选中状态,这证明 signal 能够携带信息。

需要注意,这里的 QPushButton 的行为类似不受管组件,有自己的状态(isChecked),每次点击时,它会先修改自己的状态,再执行 slot。

可以定义变量在 checked 状态变化时将它保存起来,供外部使用,这是一种单向绑定。

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
import sys
from PyQt5.QtCore import QSize, Qt
from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
button = QPushButton("Press Me!")
# 使 btn 行为类似 checkbox
button.setCheckable(True)
# clicked 是 QPushButton 的一个 signal
# connect 绑定 slot 和 signal
button.clicked.connect(self.on_click)
# 可以绑定多个 slot
button.clicked.connect(self.another_on_click)
self.setCentralWidget(button)
# 一个 slot
def on_click(self):
print("btn clicked")
# slot 可以传递数据,传递什么数据和 slot 绑定的 signal 相关,比如 clicked signal 会提供当前选中状态
def another_on_click(self, checked: bool):
print(f"btn {checked=}")
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()

下面是另一个例子,其使用 pressed 和 released signal 来实时改变按钮中的文字,clicked signal 在 released 后发送:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import sys
from PyQt5.QtCore import QSize, Qt, pyqtSignal
from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
button = QPushButton("Press Me!")
self.button = button
button.pressed.connect(self.on_press)
button.released.connect(self.on_release)
self.setCentralWidget(button)
self.count = 1
def on_press(self):
self.button.setText(f'Pressing...{self.count=}')
self.count += 1
def on_release(self):
self.button.setText("Press Me!")
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()

很多时候能直接通过组件之间的方法来连接两个组件,比如下面的示例,它引入了 QVBoxLayout,顾名思义,一个 vertical 的布局,以在主窗口中放置多个组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.label = QLabel() # 文字
self.input = QLineEdit() # 输入框
# 直接绑定输入框的内容改变 signal,label 的 setText 作为 slot,改变后的文字内容会传递给 slot
self.input.textChanged.connect(self.label.setText)
layout = QVBoxLayout()
layout.addWidget(self.input)
layout.addWidget(self.label)
# layout 需要 QWidget 作为容器
container = QWidget()
container.setLayout(layout)
# Set the central widget of the Window.
self.setCentralWidget(container)

signal 和 slot 的文档在文档中可以查看,比如这里是 QLabel 的文档 https://doc.qt.io/qt-5/qlabel.html#public-slots[QLabel]

event & eventHandler

qt 中也有 event 的概念,它和 signal 类似,但是是更加底层的,主要是来自操作系统的信息,比如鼠标/键盘抬起按下,滚轮,触屏,数位笔绘制…

不得不怀疑 signal 是对 event 的抽象。在 js 里,signal 和 event 是混在一起的,但这里区分了开来,用户能自己定义 signal,但不能自己定义 event

每个 widget 都可以去处理事件,通过继承父类并实现相应的方法,下面重写了主窗口和两个组件的鼠标按下事件,并展示了冒泡机制:

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
class MainWindow(QMainWindow):
def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None:
super().mousePressEvent(ev) # 父类的事件处理函数
print(f'QMainWindow clicked, {ev.x()=}, {ev.y()=}')
def __init__(self):
class MyQLabel(QLabel):
def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None:
super().mousePressEvent(ev)# 父类的事件处理函数
ev.accept() # 表示事件不再冒泡,这样点击 label 时,不会触发 mainWindow 的相应事件
print(f'QLabel clicked, {ev.x()=}, {ev.y()=}')
class MyQLineEdit(QLineEdit):
def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None:
super().mousePressEvent(ev) # 父类的事件处理函数
ev.ignore() # 表示事件冒泡,这样点击 input 时,会触发 mainWindow 的相应事件
print(f'QLineEdit clicked, {ev.x()=}, {ev.y()=}')
super().__init__()
self.label = MyQLabel() # 文字
self.input = MyQLineEdit() # 输入框
# 直接绑定输入框的内容改变 signal,label 的 setText 作为 slot,改变后的文字内容会传递给 slot
self.input.textChanged.connect(self.label.setText)
layout = QVBoxLayout()
layout.addWidget(self.input)
layout.addWidget(self.label)
# layout 需要 QWidget 作为容器
container = QWidget()
container.setLayout(layout)
# Set the central widget of the Window.
self.setCentralWidget(container)

事件能够冒泡,比如这里如果不继承 QLabel,在 MainWindow 里重载 mouseMoveEvent,则鼠标在 label 处移动会触发 MainWindow 的事件。

QEventLoop 异步转同步

QEventLoop很适用于“异步转同步”,它看上去会阻塞整个线程,有点吓人,但其实并非如此——在执行QEventLoop.exec时,会启动一个新的事件循环,去替代原事件循环,直到自己exit;而现象上,就是当前“程序流”而非线程阻塞在这里,线程自己仍旧能处理其它事件,实现了一种类似于协程的效果。

这种替代非常奇特——它是自旋——就像(父循环的)递归调用。QThread.currentThread().loopLevel()可以获取当前循环层级来证明这一点。实际上,打印当前调用栈,能够看到子循环的exec在父循环的exec上面,而循环层级过深(100层左右)时程序会直接退出,猜测是因为栈溢出。

下面是QEventLoop的一种常见的使用模式:

1
2
3
4
5
loop = QEventLoop()
def cb():
loop.exit()
some_async_fn(cb) # 一些基于回调的函数
loop.exec()

事实上可以直接利用QEventLoop实现Promise,但这……感觉会引起滥用。

常见 QWidget

Widget What it does
QLabel label
QPushButton 按钮
QRadioButton 单选框集合
QComboBox 下拉单选框
QCheckbox 复选框
QSlider 滑条
QLineEdit 文字编辑
QDateEdit 日期编辑框
QDateTimeEdit 日期-时间编辑框
QTimeEdit 时间编辑框
QSpinBox 整数编辑框
QDoubleSpinbox 浮点数编辑框
QDial 旋钮
QLCDNumber LCD 显示数字
QProgressBar 进度条
QFontComboBox 字体选择

标签 QLabel

QLabel 可以用来显示文字和图片。

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
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
# 这玩意行为很像flex,默认尺寸是所有组件加起来的最小尺寸,一旦高度增加,子元素就尝试占据更多高度(除了图片)
layout = QVBoxLayout()
# 初始化,可使用setText方法设置内容
example0 = QLabel("Hello我是谁world")
# 设置大小,粗体斜体
example1 = QLabel("多就是好,大就是强!")
# 这里显然是返回了copy,需要设置回去
font = example1.font()
font.setPointSize(30)
font.setBold(True)
font.setItalic(True)
example1.setFont(font)

# 对齐方式,可以设置水平对齐和竖直对齐
example2 = QLabel("Position Zero")
example2.setAlignment(Qt.AlignVCenter | Qt.AlignHCenter)

# 另一种中央对齐的方式
example3 = QLabel("Position Zero")
example3.setAlignment(Qt.AlignCenter)

# 显示图片...??
example4 = QLabel()
example4.setPixmap(QPixmap("81280af55a7a908e2f8c34835da9fc18.jpg"))
example4.setScaledContents(True) # 允许宽度上的缩放(放大),但不会保证比例

layout.addWidget(example0)
layout.addWidget(example1)
layout.addWidget(example2)
layout.addWidget(example3)
layout.addWidget(example4)
widget = QWidget()
widget.setLayout(layout)
self.setCentralWidget(widget)

复选框 QCheckBox

复选框,Qt的复选框自带label,且允许一种中间状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
layout = QVBoxLayout()

# 0=unchecked, 1=partiallyChecked, 2=checked
def print_current_state(state: int):
print('current state:', state, type(state))

# 复选框,自带label
example0 = QCheckBox("Remember Me")
example0.setCheckState(Qt.Checked) # 设置状态
example0.stateChanged.connect(print_current_state)

# 三状态复选框,有个中间状态
example1 = QCheckBox("Remember Me")
example1.setTristate(True)
example1.stateChanged.connect(print_current_state)

layout.addWidget(example0)
layout.addWidget(example1)
widget = QWidget()
widget.setLayout(layout)
self.setCentralWidget(widget)

下拉单选框 QComboBox

下拉单选框,Qt允许编辑下拉框。

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
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
layout = QVBoxLayout()

# 选项变化时有两个signal,一个返回数据的文字,一个返回数据的下标
def index_change(i: int):
print('index changed: ', i)
def text_change(text: str):
print('text changed: ', text)

example0 = QComboBox()
example0.addItems(["One", "Two", "Three", 'One'])
example0.currentIndexChanged.connect(index_change)
# 虽然名字叫currentTextChanged,但文字没变时它也会emit……搞毛呢
example0.currentTextChanged.connect(text_change)

# 复选框可设置为可编辑
example1 = QComboBox()
example1.addItems(["One", "Two", "Three", 'One'])
example1.setEditable(True)
# 编辑策略,QComboBox.XXX,NoInsert表示不允许插入,这可能才是最符合直觉的
example1.setInsertPolicy(QComboBox.NoInsert)
# 编辑后按回车会新增一项,触发currentIndexChanged
example1.currentIndexChanged.connect(index_change)
# 编辑时会触发currentTextChanged
example1.currentTextChanged.connect(text_change)

layout.addWidget(example0)
layout.addWidget(example1)
widget = QWidget()
widget.setLayout(layout)
self.setCentralWidget(widget)

列表选择框 QListWidget

html中没有类似的组件,这玩意长的就像下拉单选框把框的内容直接放外面一样。

TODO

QTextEdit

TODO

QSpinBox and QDoubleSpinBox

TODO

QSlider

Layout

TODO

MetaObject

TODO

运行时操作

TODO