跳至主要內容

PyQt6文档

大约 64 分钟约 19341 字

PyQt6中文手册

一、PyQt6 简介

本教程是 PyQt6 的入门教程。本教程的目的是让您开始使用 PyQt6 库。

关于 PyQt6

PyQt6 Digia 公司的 Qt 程序的 Python 中间件。Qt库是最强大的GUI库之一。PyQt6的官网:www.riverbankcomputing.co.uk/newsopen in new window。PyQt6是由Riverbank Computing公司开发的。Digia和RiverBank的关系不太清楚,大家多提意见

PyQt6 是基于 Python 的一系列模块。它是一个多平台的工具包,可以在包括Unix、Windows和Mac OS在内的大部分主要操作系统上运行。PyQt6 有两个许可证,开发人员可以在 GPL 和商业许可之间进行选择。

安装 PyQt6

$ pip install PyQt6

我们可以使用 pip 工具安装 PyQt6。

PyQt6 模块

PyQt6 类是由一系列模块组成的,包括如下的模块:

  • QtCore
  • QtGui
  • QtWidgets
  • QtDBus
  • QtNetwork
  • QtHelp
  • QtXml
  • QtSvg
  • QtSql
  • QtTest

QtCore 模块是非 GUI 的核心库。这个模块用来处理时间、文件、目录、各种类型的数据、流(stream)、URLs,mime 类型、线程和进程。 QtGui 有窗口系统集成、事件处理、2D图形,基本图像、字体、文本的类。 QtWidgets 有创建经典风格的用户界面的类。

QtDBus 是使用 D-Bus 处理 IPC 通讯的类。QtNetwork 是网络变成类,这些类使网络编程变得更容易,可移植性也更好,方便了 TCP/IP 和 UDP 服务端和客户端编程。 QtHelp 包含了创建、查看和搜索文档的类。

QtXml 包含了处理 XML 文件的类,实现了 SAX 和 DOM API。QtSvg 提供了显示 SVG 的类,可缩放矢量图形(SVG)是一种描述二维图像和图像应用的 XML 语言。QtSql 模块提供了数据库的类,QtTest 提供了可以对 PyQt6 应用进行单元测试的工具。

Python

Python 是一种通用的、动态的、面向对象的编程语言。Python语言的设计目的强调程序员的生产力和代码的可读性。它于1991年首次发行。Python 的灵感来自于 ABC、Haskell、Java、Lisp、Icon 和 Perl 编程语言。Python 是一种高级的、通用的、多平台的解释性语言,是一种极简语言,由世界各地的一大群志愿者维护。

官方网站是 https://python.orgopen in new window

PyQt6 version

QT_VERSION_STR 可以显示 Qt 的版本信息,PYQT_VERSION_STR 可以显示 PyQt 的版本信息

from PyQt6.QtCore import QT_VERSION_STR
from PyQt6.QtCore import PYQT_VERSION_STR

print(QT_VERSION_STR)
print(PYQT_VERSION_STR)

运行这个脚本可以显示 QT 和 PyQt 的版本。

$ ./version.py 
6.4.2
6.4.2

这个章节介绍的是 PyQt 的工具类库。

二、PyQt6 日期和时间

QDate, QTime, QDateTime

PyQt6 有 QDate, QDateTime, QTime 类处理日期和时间。QDate 是用于处理公历中的日期的类。它有获取、比较或操作日期的方法。QTime 类用于处理时间。它提供了比较时间、确定时间和其他各种时间操作方法。QDateTimeQDateQTime 的组合。

PyQt 当前日期和时间

PyQt6 有 currentDatecurrentTimecurrentDateTime 方法获取当前的日期或时间。

from PyQt6.QtCore import QDate, QTime, QDateTime, Qt

now = QDate.currentDate()

print(now.toString(Qt.DateFormat.ISODate))
print(now.toString(Qt.DateFormat.RFC2822Date))

datetime = QDateTime.currentDateTime()

print(datetime.toString())

time = QTime.currentTime()
print(time.toString(Qt.DateFormat.ISODate))

上面的代码打印出了当前日期,当前日期和时间,不同格式的时间。

now = QDate.currentDate()

currentDate 方法返回当前的日期。

print(now.toString(Qt.DateFormat.ISODate))
print(now.toString(Qt.DateFormat.RFC2822Date))

toString 传入不同的参数: Qt.DateFormat.ISODateQt.DateFormat.RFC2822Date 获取不同格式的日期。

datetime = QDateTime.currentDateTime()

currentDateTime 方法返回当前的日期和时间。

time = QTime.currentTime()

currentTime 方法返回了当前时间。

$ ./current_date_time.py 
2023-12-09
09 Dec 2023
Sat Dec 9 17:44:52 2023
17:44:52

PyQt6 UTC 时间

我们的星球是一个球体,绕着它自己的轴旋转。地球向东旋转,所以太阳在不同的时间在不同的地点升起。地球大约每24小时自转一次。因此,世界被划分为24个时区。在每个时区,都有不同的当地时间。当地时间通常会被夏时制进一步修改。

实际上也需要一个标准时间。一个标准时间有助于避免时区和夏令时的混淆。选择UTC(通用协调时间)作为主要的时间标准。UTC时间用于航空、天气预报、飞行计划、空中交通管制许可和地图。与当地时间不同,UTC时间不随季节变化而变化。

from PyQt6.QtCore import QDateTime, Qt

now = QDateTime.currentDateTime()

print('Local datetime: ', now.toString(Qt.DateFormat.ISODate))
print('Universal datetime: ', now.toUTC().toString(Qt.DateFormat.ISODate))

print(f'The offset from UTC is: {now.offsetFromUtc()} seconds')

print('Local datetime: ', now.toString(Qt.DateFormat.ISODate))

currentDateTime 方法返回了本地时间的当前时间。我们可以使用 toLocalTime 方法把标准时间转换成本地时间。

print('Universal datetime: ', now.toUTC().toString(Qt.DateFormat.ISODate))

我们使用 toUTC 方法从时间对象里获取了标准时间。

print(f'The offset from UTC is: {now.offsetFromUtc()} seconds')

offsetFromUtc 方法给出了本地时间与标准时间的差,以秒为单位。

$ ./utc_local.py 
Local datetime:  2023-12-09T17:47:48
Universal datetime:  2023-12-09T09:47:48Z
The offset from UTC is: 28800 seconds
Local datetime:  2023-12-09T17:47:48

PyQt6 天数

daysInMonth 方法返回了指定月份的天数,daysInYear 方法返回了指定年份的天数。

from PyQt6.QtCore import QDate

now = QDate.currentDate()

d = QDate(1945, 5, 7)

print(f'Days in month: {d.daysInMonth()}')
print(f'Days in year: {d.daysInYear()}')

本例打印了指定年份和月份的天数。

$ ./n_of_days.py 
Days in month: 31
Days in year: 365

PyQt6 天数差

daysTo 方法返回了一个日期到另外一个日期的差。

from PyQt6.QtCore import QDate, Qt

now = QDate.currentDate()
y = now.year()

print(f'today is {now.toString(Qt.DateFormat.ISODate)}')

xmas1 = QDate(y-1, 12, 25)
xmas2 = QDate(y, 12, 25)

dayspassed = xmas1.daysTo(now)
print(f'{dayspassed} days have passed since last XMas')

nofdays = now.daysTo(xmas2)
print(f'There are {nofdays} days until next XMas')

该示例计算出了从上一个圣诞节到下一个圣诞节的天数。

$ ./xmas.py
today is 2023-12-09
349 days have passed since last XMas
There are 16 days until next XMas

PyQt6 时间的计算

我们经常需要对天,秒或者年进行加减等计算。

from PyQt6.QtCore import QDateTime, Qt

now = QDateTime.currentDateTime()

print(f'Today: {now.toString(Qt.DateFormat.ISODate)}')
print(f'Adding 12 days: {now.addDays(12).toString(Qt.DateFormat.ISODate)}')
print(f'Subtracting 22 days: {now.addDays(-22).toString(Qt.DateFormat.ISODate)}')

print(f'Adding 50 seconds: {now.addSecs(50).toString(Qt.DateFormat.ISODate)}')
print(f'Adding 3 months: {now.addMonths(3).toString(Qt.DateFormat.ISODate)}')
print(f'Adding 12 years: {now.addYears(12).toString(Qt.DateFormat.ISODate)}')

该示例展示了对当前日期时间添加或减去天、秒、月或年。

$ ./arithmetic.py 
Today: 2023-12-09T17:49:32
Adding 12 days: 2023-12-21T17:49:32
Subtracting 22 days: 2023-11-17T17:49:32
Adding 50 seconds: 2023-12-09T17:50:22
Adding 3 months: 2024-03-09T17:49:32
Adding 12 years: 2035-12-09T17:49:32

PyQt6 夏令时

夏令时 (DST) 是在夏季调快时间,使晚上变得更长。 初春时调前调一小时,秋季时调后调至标准时间。

from PyQt6.QtCore import QDateTime, QTimeZone, Qt

now = QDateTime.currentDateTime()

print(f'Time zone: {now.timeZoneAbbreviation()}')

if now.isDaylightTime():
    print('The current date falls into DST time')
else:
    print('The current date does not fall into DST time')

该例判断一个时间是不是夏令时。

print(f'Time zone: {now.timeZoneAbbreviation()}')

timeZoneAbbreviation 方法返回了时区的缩写。

if now.isDaylightTime():
...

isDaylightTime 判断日期是不是夏令时。

$ ./daylight_saving.py 
Time zone: CEST
The current date falls into DST time

当前日期属于夏令时时间,中欧城市布拉迪斯拉发在夏季执行。 中欧夏令时 (CEST) 比世界时间早 2 小时。 该时区是夏令时时区,用于欧洲和南极洲。

PyQt6 unix 纪元

纪元是被选为特定纪元起源的时间瞬间。 例如,在西方基督教国家,时间纪元从耶稣诞生的第 0 天开始。 另一个例子是使用了十二年的法国共和历。 这个时代是共和时代的开始,1792 年 9 月 22 日宣布第一共和国成立,君主制也被废除。

计算机也有它的时代。 最受欢迎的时代之一是 Unix 时代。 Unix 纪元是 1970 年 1 月 1 日 UTC 时间 00:00:00(或 1970-01-01T00:00:00Z ISO 8601)。 计算机中的日期和时间是根据自该计算机或平台定义的纪元以来经过的秒数或时钟滴答数确定的。

Unix 时间是自 Unix 纪元以来经过的秒数。

$ date +%s

Unix date 命令可用于获取 Unix 时间。 在这个特殊的时刻,自 Unix 时代以来已经过去了 1619172620 秒。

from PyQt6.QtCore import QDateTime, Qt

now = QDateTime.currentDateTime()

unix_time = now.toSecsSinceEpoch() 
print(unix_time)

d = QDateTime.fromSecsSinceEpoch(unix_time)
print(d.toString(Qt.DateFormat.ISODate))

该例展示了 Unix 时间,并把它转换成了 QDateTime

now = QDateTime.currentDateTime()

首先获取当前的日期和时间。

unix_time = now.toSecsSinceEpoch()

toSecsSinceEpoch 返回了 Unix 时间。

d = QDateTime.fromSecsSinceEpoch(unix_time)

使用 fromSecsSinceEpoch 方法把 Unix 时间转换成 QDateTime

$ ./unix_time.py 
1702115458
2023-12-09T17:50:58

PyQt6 儒略日

儒略日是指自儒略时期开始以来的连续天数。 它主要由天文学家使用。 它不应与儒略历混淆。 它开始于公元前 4713 年。第 0 天是公元前 4713 年 1 月 1 日中午的那天。

儒略日数 (JDN) 是自此期间开始以来经过的天数。 任何时刻的儒略日期 (JD) 是前一个中午的儒略日数加上自该时刻起当天的分数。 (Qt 不计算这个分数。)除了天文学,儒略日期经常被军事和大型机程序使用。

from PyQt6.QtCore import QDate, Qt

now = QDate.currentDate()

print('Gregorian date for today:', now.toString(Qt.DateFormat.ISODate))
print('Julian day for today:', now.toJulianDay())

该例中,我们得到了今天在公历中的表达和在儒略日的表达。

print('Julian day for today:', now.toJulianDay())

toJulianDay() 返回了儒略日的日期。

$ ./julian_day.py 
Gregorian date for today: 2023-12-09
Julian day for today: 2460288

历史战役

使用儒略日可以进行跨越几个世纪的计算。

from PyQt6.QtCore import QDate, Qt

borodino_battle = QDate(1812, 9, 7)
slavkov_battle = QDate(1805, 12, 2)

now = QDate.currentDate()

j_today = now.toJulianDay()
j_borodino = borodino_battle.toJulianDay()
j_slavkov = slavkov_battle.toJulianDay()

d1 = j_today - j_slavkov
d2 = j_today - j_borodino

print(f'Days since Slavkov battle: {d1}')
print(f'Days since Borodino battle: {d2}')

该示例计算自两个历史事件以来经过的天数。

borodino_battle = QDate(1812, 9, 7)
slavkov_battle = QDate(1805, 12, 2)

这是两个拿破仑战斗日期。

j_today = now.toJulianDay()
j_borodino = borodino_battle.toJulianDay()
j_slavkov = slavkov_battle.toJulianDay()

这是今天以及斯拉夫科夫和博罗季诺战役的儒略日。

d1 = j_today - j_slavkov
d2 = j_today - j_borodino

这是两次大战的天数差。

$ ./battles.py 
Days since Slavkov battle: 78670
Days since Borodino battle: 76199

当我们运行这个脚本时,距离斯拉夫科夫战役已经过去了 78670 天,距离博罗季诺战役已经过去了 76199 天。

在 PyQt6 教程的这一部分中,我们使用了日期和时间。

三、PyQt6 的第一个程序

在 PyQt6 教程的这一部分中,我们将学习一些基本功能。这些示例显示工具提示和图标、关闭窗口、显示消息框以及在桌面上居中显示窗口。

PyQt6 简单示例

这是一个显示小窗口的简单示例。但我们可以用这个窗口做很多事情。我们可以调整它的大小、最大化或最小化它。 实现这些功能通常需要写大量的代码,但这些功能很常见,所以这部分功能已经封装好了,我们直接使用即可。PyQt6 是一个高级工具包,如果使用低级的工具包,下面的代码示例很可能需要数百行才可以实现。

import sys
from PyQt6.QtWidgets import QApplication, QWidget

def main():
    app = QApplication(sys.argv)
    w = QWidget()
    w.resize(250, 200)
    w.move(300, 300)
    w.setWindowTitle('Simple')
    w.show()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

上面的代码会再屏幕上显示一个小窗口。

import sys
from PyQt6.QtWidgets import QApplication, QWidget

这里引入了必要的包,基础小组件位于 PyQt6.QtWidgets 模块。

app = QApplication(sys.argv)

每个 PyQt6 应用程序都必须创建一个应用程序对象。sys.argv 参数是来自命令行的参数列表。Python 脚本可以从 shell 运行,这是应用启动的一种方式。

w = QWidget()

QWidget 小部件是 PyQt6 中所有用户界面对象的基类。我们为 QWidget 提供了默认构造函数。默认构造函数没有父级。没有父级的小部件称为窗口。

w.resize(250, 150)

resize 方法改变了小部件的尺寸,现在它250像素宽,150像素高。

w.move(300, 300)

move 方法把小部件移动到屏幕的指定坐标(300, 300)。

w.setWindowTitle('Simple')

使用 setWindowTitle 给窗口设置标题,标题显示在标题栏。

w.show()

show 方法是在屏幕上显示小部件的方法。显示一个部件的步骤是首先在内存里创建,然后在屏幕上显示。

sys.exit(app.exec())

最后,我们进入应用程序的主循环。事件处理从这里开始。主循环从窗口系统接收事件并将它们分派给应用程序小部件。 如果我们调用 exit 方法或主小部件被销毁,则主循环结束。sys.exit 方法确保一个干净的退出。环境将被告知应用程序如何结束。

运行结果
运行结果

PyQt6 tooltip

我们可以为程序创建一个气泡提示。

import sys
from PyQt6.QtWidgets import (QWidget, QToolTip,
    QPushButton, QApplication)
from PyQt6.QtGui import QFont

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        QToolTip.setFont(QFont('SansSerif', 10))
        self.setToolTip('This is a <b>QWidget</b> widget')
        btn = QPushButton('Button', self)
        btn.setToolTip('This is a <b>QPushButton</b> widget')
        btn.resize(btn.sizeHint())
        btn.move(50, 50)
        self.setGeometry(300, 300, 300, 200)
        self.setWindowTitle('Tooltips')
        self.show()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

该示例中,我们给两个小部件创建了一个气泡提示框。

QToolTip.setFont(QFont('SansSerif', 10))

这个静态方法给气泡提示框设置了字体,这里使用了10pt 的 SansSerif 字体。

self.setToolTip('This is a <b>QWidget</b> widget')

调用 setTooltip 方法创建气泡提示框,可以使用富文本内容。

btn = QPushButton('Button', self)
btn.setToolTip('This is a <b>QPushButton</b> widget')

在气泡提示框上添加了一个按钮部件。

btn.resize(btn.sizeHint())
btn.move(50, 50)

sizeHint 方法是给按钮一个系统建议的尺寸,然后使用 move 方法移动这个按钮的位置。

运行结果
运行结果

PyQt6 退出按钮

关闭窗口的明显方法是单击标题栏上的 x 标记。在下一个示例中,我们将展示如何以编程方式关闭窗口。 我们将简要介绍信号和插槽。

本例中使用 QPushButton 部件完成这个功能。

QPushButton(text, parent = None)

参数 text 是将显示在按钮上的文本。parent 是我们放置按钮的小部件。在我们的例子中,它将是一个QWidget。应用程序的小部件形成层次结构,在这个层次结构中,大多数小部件都有父级。没有父级的小部件的父级是顶级窗口。

import sys
from PyQt6.QtWidgets import QWidget, QPushButton, QApplication

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        qbtn = QPushButton('Quit', self)
        qbtn.clicked.connect(QApplication.instance().quit)
        qbtn.resize(qbtn.sizeHint())
        qbtn.move(50, 50)
        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('Quit button')
        self.show()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

本例创建了一个退出按钮,点击此按钮退出程序。

qbtn = QPushButton('Quit', self)

我们创建了一个按钮,它是 QPushButton 类的一个实例。构造函数的第一个参数是按钮的标签。 第二个参数是父级小部件。父小部件是 Example 小部件,它继承自 QWidget

qbtn.clicked.connect(QApplication.instance().quit)

PyQt6 的事件处理系统是由信号和插槽机制构成的,点击按钮(事件),会发出点击信号。事件处理插槽可以是 Qt 自带的插槽,也可以是普通 Python 函数

使用 QApplication.instance 获取的 QCoreApplication 对象包含主事件循环————它处理和分派所有事件。 单击的信号连接到终止应用程序的退出方法。 通信是在两个对象之间完成的:发送者和接收者。 发送者是按钮,接收者是应用程序对象。

运行结果
运行结果

PyQt6 弹窗

默认情况下,如果我们点击标题栏上的 x 按钮,QWidget 会被关闭。有时我们想修改这个默认行为。 例如,如果在编辑器中打开了一个文件,修改了部分内容,我们需要显示一个消息框来确认退出程序的操作。

import sys
from PyQt6.QtWidgets import QWidget, QMessageBox, QApplication

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.setGeometry(300, 300, 350, 200)
        self.setWindowTitle('Message box')
        self.show()

    def closeEvent(self, event):
        reply = QMessageBox.question(self, 'Message',
                    "Are you sure to quit?", 
                    MessageBox.StandardButton.Yes |
                    QMessageBox.StandardButton.No, 
                    MessageBox.StandardButton.No)
        if reply == QMessageBox.StandardButton.Yes:
            event.accept()
        else:
            event.ignore()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

关闭 QWidget 操作会产生 QCloseEvent 事件。重新实现 closeEvent 事件处理,替换部件的默认行为。

reply = QMessageBox.question(self, 'Message',
                             "Are you sure to quit?", 
                             MessageBox.Yes |
                             QMessageBox.No, QMessageBox.No)

这里创建了一个带有两个按钮的消息框:是和否。第一个参数是标题栏,第二个参数是对话框显示的消息文本,第三个参数是对话框中的按钮组合,最后一个参数是默认选中的按钮。返回值存储在变量 reply 中。

if reply == QtGui.QMessageBox.Yes:
    event.accept()
else:
    event.ignore()

对返回值进行判断,如果单击 Yes 按钮,执行小部件关闭和终止应用程序,否则我们忽略关闭事件。

运行结果
运行结果

PyQt6 窗口居中

下面的脚本会在屏幕上显示一个居中的窗口。

import sys
from PyQt6.QtWidgets import QWidget, QApplication

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.resize(350, 250)
        self.center()
        self.setWindowTitle('Center')
        self.show()

    def center(self):
        qr = self.frameGeometry()
        cp = self.screen().availableGeometry().center()
        qr.moveCenter(cp)
        self.move(qr.topLeft())

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

QScreen 类可以查询屏幕属性。

self.center()

使用自定义 center 方法居中显示窗口。

qr = self.frameGeometry()

这样就可以得到一个矩形的窗口,这里可以放置所有类型的窗口。

cp = self.screen().availableGeometry().center()

从屏幕属性里计算出分辨率,然后计算出中心点位置。

qr.moveCenter(cp)

我们已经知道矩形窗口的宽高,只需要把矩形窗口的中心点放置到屏幕窗口的中心点即可。这不会修改矩形窗口的大小。

self.move(qr.topLeft())

把应用窗口的左上方点坐标移动到矩形窗口的左上方,这样就可以居中显示了。

本章教程,我们创建了一个简单的 PyQt6 应用。

四、PyQt6 的菜单和工具栏

在这部分教程中,我们创建了一个状态栏、菜单栏和工具栏。菜单是位于菜单栏中的一组命令。工具栏有一些按钮和应用程序中的一些常用命令。状态栏显示状态信息,通常位于应用程序窗口的底部。

PyQt6 QMainWindow

QMainWindow 类提供了主程序窗口。在这里可以创建一个具有状态栏、工具栏和菜单栏的经典应用程序框架。

PyQt6 状态栏

状态栏是显示状态信息的小部件。

import sys
from PyQt6.QtWidgets import QMainWindow, QApplication

class Example(QMainWindow):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.statusBar().showMessage('Ready')
        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('Statusbar')
        self.show()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

使用 QMainWindow 创建状态栏。

self.statusBar().showMessage('Ready')

使用 QtGui.QMainWindow 方法创建状态栏,该方法的创建了一个状态栏,并返回statusbar对象,再调用 showMessage 方法在状态栏上显示一条消息。

运行结果
运行结果

PyQt6 简单菜单

菜单栏在GUI应用程序中很常见,它是位于各种菜单中的一组命令。(Mac OS 对菜单栏的处理是不同的,要得到类似的结果,我们可以添加下面这行: menubar.setNativeMenuBar(False)

import sys
from PyQt6.QtWidgets import QMainWindow, QApplication
from PyQt6.QtGui import QIcon, QAction

class Example(QMainWindow):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        exitAct = QAction(QIcon('exit.png'), '&Exit', self)
        exitAct.setShortcut('Ctrl+Q')
        exitAct.setStatusTip('Exit application')
        exitAct.triggered.connect(QApplication.instance().quit)
        self.statusBar()
        menubar = self.menuBar()
        fileMenu = menubar.addMenu('&File')
        fileMenu.addAction(exitAct)
        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('Simple menu')
        self.show()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

上门的示例中,创建了有一个菜单的菜单栏。这个菜单命令是终止应用,也绑定了快捷键 Ctrl+Q。示例中也创建了一个状态栏。

exitAct = QAction(QIcon('exit.png'), '&Exit', self)
exitAct.setShortcut('Ctrl+Q')
exitAct.setStatusTip('Exit application')

QAction 是行为抽象类,包括菜单栏,工具栏,或自定义键盘快捷方式。在上面的三行中,创建了一个带有特定图标和 ‘Exit’ 标签的行为。此外,还为该行为定义了一个快捷方式。第三行创建一个状态提示,当我们将鼠标指针悬停在菜单项上时,状态栏中就会显示这个提示。

exitAct.triggered.connect(QApplication.instance().quit)

当选择指定的行为时,触发了一个信号,这个信号连接了 QApplication 组件的退出操作,这会终止这个应用程序。

menubar = self.menuBar()
fileMenu = menubar.addMenu('&File')
fileMenu.addAction(exitAct)

menuBar 方法创建了一个菜单栏,然后使用 addMenu 创建一个文件菜单,使用 addAction 创建一个行为。

运行结果
运行结果

PyQt6 子菜单

子菜单是位于菜单里的菜单。

import sys
from PyQt6.QtWidgets import QMainWindow, QMenu, QApplication
from PyQt6.QtGui import QAction

class Example(QMainWindow):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        menubar = self.menuBar()
        fileMenu = menubar.addMenu('File')
        impMenu = QMenu('Import', self)
        impAct = QAction('Import mail', self)
        impMenu.addAction(impAct)
        newAct = QAction('New', self)
        fileMenu.addAction(newAct)
        fileMenu.addMenu(impMenu)
        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('Submenu')
        self.show()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

本例中,有两个菜单项,一个在 File 菜单里,一个在 File 的 Import 子菜单里。

impMenu = QMenu('Import', self)

使用 QMenu 创建一个新菜单。

impAct = QAction('Import mail', self)
impMenu.addAction(impAct)

使用addAction 给子菜单添加行为。

运行结果
运行结果

PyQt6 勾选菜单

下面的示例中,创建了一个可以勾选的菜单。

import sys
from PyQt6.QtWidgets import QMainWindow, QApplication
from PyQt6.QtGui import QAction

class Example(QMainWindow):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.statusbar = self.statusBar()
        self.statusbar.showMessage('Ready')
        menubar = self.menuBar()
        viewMenu = menubar.addMenu('View')
        viewStatAct = QAction('View statusbar', self, checkable=True)
        viewStatAct.setStatusTip('View statusbar')
        viewStatAct.setChecked(True)
        viewStatAct.triggered.connect(self.toggleMenu)
        viewMenu.addAction(viewStatAct)
        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('Check menu')
        self.show()

    def toggleMenu(self, state):
        if state:
            self.statusbar.show()
        else:
            self.statusbar.hide()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

创建只有一个行为的 View 菜单。这个行为用来展现或者隐藏状态栏,如果状态栏可见,菜单是勾选的状态。

viewStatAct = QAction('View statusbar', self, checkable=True)

使用 checkable 参数创建一个可以勾选的菜单。

viewStatAct.setChecked(True)

因为状态栏默认可见,所以使用 setChecked 方法勾选菜单。

def toggleMenu(self, state):
    if state:
        self.statusbar.show()
    else:
        self.statusbar.hide()

根据行为的状态,设置状态栏的状态。

运行结果
运行结果

PyQt6 上下文菜单

上下文菜单,也称为弹出菜单,是在某些上下文下出现的命令列表。例如,在 Opera 浏览器中,在网页上按下鼠标右键,我们会得到一个上下文菜单,这个菜单上,可以重新加载页面、返回或查看页面源代码。如果右击工具栏,会得到另一个用于管理工具栏的上下文菜单。

import sys
from PyQt6.QtWidgets import QMainWindow, QMenu, QApplication

class Example(QMainWindow):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('Context menu')
        self.show()

    def contextMenuEvent(self, event):
        cmenu = QMenu(self)
        newAct = cmenu.addAction("New")
        openAct = cmenu.addAction("Open")
        quitAct = cmenu.addAction("Quit")
        action = cmenu.exec(self.mapToGlobal(event.pos()))
        if action == quitAct:
            QApplication.instance().quit()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

重新实现 contextMenuEvent 方法,调出一个上下文菜单。

action = cmenu.exec(self.mapToGlobal(event.pos()))

使用 exec 方法调出上下文菜单,通过鼠标事件对象获得鼠标坐标点,再调用 mapToGlobal 方法把组件的坐标设置成全局的屏幕坐标。

if action == quitAct:
    QApplication.instance().quit()

如果上下文菜单触发的动作是退出动作,就终止程序。

运行结果
运行结果

PyQt6 工具栏

菜单包含了一个应用程序里所有需要使用到的命令,工具栏则是放置常用命令的地方。

import sys
from PyQt6.QtWidgets import QMainWindow,  QApplication
from PyQt6.QtGui import QIcon, QAction

class Example(QMainWindow):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        exitAct = QAction(QIcon('exit24.png'), 'Exit', self)
        exitAct.setShortcut('Ctrl+Q')
        exitAct.triggered.connect(QApplication.instance().quit)
        self.toolbar = self.addToolBar('Exit')
        self.toolbar.addAction(exitAct)
        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('Toolbar')
        self.show()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

上面的示例中创建了一个简单的状态栏,只有一个行为,关闭应用。

exitAct = QAction(QIcon('exit24.png'), 'Exit', self)
exitAct.setShortcut('Ctrl+Q')
exitAct.triggered.connect(QApplication.instance().quit)

和上面菜单栏示例相似,创建一个行为对象,对象有标签,图标和快捷键。然后把 QApplication 的退出方法和行为发出的信号绑定。

self.toolbar = self.addToolBar('Exit')
self.toolbar.addAction(exitAction)

使用 addToolBar 方法创建工具栏,然后使用 addAction 方法添加行为。

运行结果
运行结果

PyQt6 主窗口

这是本章最后一个示例,这里创建一个菜单栏,一个工具栏和一个状态栏,并增加一个中心布局组件。

import sys
from PyQt6.QtWidgets import QMainWindow, QTextEdit, QApplication
from PyQt6.QtGui import QIcon, QAction

class Example(QMainWindow):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        textEdit = QTextEdit()
        self.setCentralWidget(textEdit)
        exitAct = QAction(QIcon('exit24.png'), 'Exit', self)
        exitAct.setShortcut('Ctrl+Q')
        exitAct.setStatusTip('Exit application')
        exitAct.triggered.connect(self.close)
        self.statusBar()
        menubar = self.menuBar()
        fileMenu = menubar.addMenu('&File')
        fileMenu.addAction(exitAct)
        toolbar = self.addToolBar('Exit')
        toolbar.addAction(exitAct)
        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('Main window')
        self.show()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

示例展示了一个经典的 GUI 应用的布局,有一个菜单栏,一个工具栏和一个状态栏。

textEdit = QTextEdit()
self.setCentralWidget(textEdit)

这里创建了一个文本编辑器组件,并把它设置到 QMainWindow 的中央。居中布局组件撑满了所有空白的部分。

运行结果
运行结果

本部分 PyQt6 教程,我们展示了菜单,工具栏,状态栏和主程序窗口。

五、PyQt6 的布局管理

布局管理是我们在应用程序窗口中放置小部件的方式。我们可以使用绝对定位或布局类来放置小部件。使用布局管理器管理布局是组织小部件的首选方法。

绝对定位

以像素为单位指定每个小部件的位置和大小。在使用绝对定位时,我们必须了解以下局限性:

  • 如果我们调整窗口大小,窗口小部件的大小和位置不会改变
  • 应用程序在不同的平台上看起来可能不同,改变应用程序的字体可能会破坏布局
  • 如果要改变布局,我们必须完全重做我们的布局,这很繁琐耗时

下面的示例以绝对坐标来定位小部件。

import sys
from PyQt6.QtWidgets import QWidget, QLabel, QApplication

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        lbl1 = QLabel('ZetCode', self)
        lbl1.move(15, 10)
        lbl2 = QLabel('tutorials', self)
        lbl2.move(35, 40)
        lbl3 = QLabel('for programmers', self)
        lbl3.move(55, 70)
        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('Absolute')
        self.show()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

我们使用 move 方法来定位小部件。在本例中也就是标签。我们通过提供x和y坐标来定位。坐标系的起始点在左上角,x值从左到右递增。y值从上到下递增。

lbl1 = QLabel('ZetCode', self)
lbl1.move(15, 10)
The label widget is positioned at x=15 and y=10.
运行结果
运行结果

PyQt6 QHBoxLayout

QHBoxLayoutQVBoxLayout 是基本的布局类,用于水平和垂直地排列小部件。

假设我们想在右下角放置两个按钮。为了创建这样的布局,我们使用一个水平框和一个垂直框。为了创造必要的空间,我们添加了一个“拉伸因子”。

import sys
from PyQt6.QtWidgets import (QWidget, QPushButton,
        QHBoxLayout, QVBoxLayout, QApplication)

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        okButton = QPushButton("OK")
        cancelButton = QPushButton("Cancel")
        hbox = QHBoxLayout()
        hbox.addStretch(1)
        hbox.addWidget(okButton)
        hbox.addWidget(cancelButton)
        vbox = QVBoxLayout()
        vbox.addStretch(1)
        vbox.addLayout(hbox)
        self.setLayout(vbox)
        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('Buttons')
        self.show()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

窗口的右下角有两个按钮。当我们调整应用程序窗口的大小时,它们仍然在那里。这里使用了 HBoxLayoutQVBoxLayout

okButton = QPushButton("OK")
cancelButton = QPushButton("Cancel")

这里创建两个按钮。

hbox = QHBoxLayout()
hbox.addStretch(1)
hbox.addWidget(okButton)
hbox.addWidget(cancelButton)

创建一个水平框布局,并添加一个拉伸因子和两个按钮。拉伸在两个按钮之前增加了一个可拉伸的空间,这将把他们推到窗口的右边。

vbox = QVBoxLayout()
vbox.addStretch(1)
vbox.addLayout(hbox)

水平布局被放入垂直布局中。垂直框中的拉伸因子将把带有按钮的水平框推到窗口的底部。

self.setLayout(vbox)

最后,把布局放到窗口中里。

运行结果
运行结果

PyQt6 QGridLayout

QGridLayout 是最常用的布局类,它能把空间分为多行多列。

import sys
from PyQt6.QtWidgets import (QWidget, QGridLayout,
        QPushButton, QApplication)

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        grid = QGridLayout()
        self.setLayout(grid)
        names = ['Cls', 'Bck', '', 'Close',
                 '7', '8', '9', '/',
                 '4', '5', '6', '*',
                 '1', '2', '3', '-',
                 '0', '.', '=', '+']
        positions = [(i, j) for i in range(5) for j in range(4)]
        for position, name in zip(positions, names):
            if name == '':
                continue
            button = QPushButton(name)
            grid.addWidget(button, *position)
        self.move(300, 150)
        self.setWindowTitle('Calculator')
        self.show()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

这里创建了一组按钮。

grid = QGridLayout()
self.setLayout(grid)

QGridLayout 实例创建了并把布局设置到窗口中。

names = ['Cls', 'Bck', '', 'Close',
            '7', '8', '9', '/',
        '4', '5', '6', '*',
            '1', '2', '3', '-',
        '0', '.', '=', '+']

这些是随后要用到的按钮上的标签。

positions = [(i,j) for i in range(5) for j in range(4)]

创建了给格栅用到的位置。

for position, name in zip(positions, names):
    if name == '':
        continue
    button = QPushButton(name)
    grid.addWidget(button, *position)

创建了按钮,并用 addWidget 方法添加到布局里。

运行结果
运行结果

示例:回复

组件可以跨越多个行和列,下面的示例来演示这个。

import sys
from PyQt6.QtWidgets import (QWidget, QLabel, QLineEdit,
        QTextEdit, QGridLayout, QApplication)

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        title = QLabel('Title')
        author = QLabel('Author')
        review = QLabel('Review')
        titleEdit = QLineEdit()
        authorEdit = QLineEdit()
        reviewEdit = QTextEdit()
        grid = QGridLayout()
        grid.setSpacing(10)
        grid.addWidget(title, 1, 0)
        grid.addWidget(titleEdit, 1, 1)
        grid.addWidget(author, 2, 0)
        grid.addWidget(authorEdit, 2, 1)
        grid.addWidget(review, 3, 0)
        grid.addWidget(reviewEdit, 3, 1, 5, 1)
        self.setLayout(grid)
        self.setGeometry(300, 300, 350, 300)
        self.setWindowTitle('Review')
        self.show()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

窗口里有三个标签,两个行编辑器和一个文本编辑组件,布局使用了 QGridLayout

grid = QGridLayout()
grid.setSpacing(10)

创建一个格栅布局,并设置了组件间的分割空间。

grid.addWidget(reviewEdit, 3, 1, 5, 1)

如果需要往格栅里添加组件,指定组件的跨行和跨列的个数。本例,reviewEdit 组件占用了5行。

运行结果
运行结果

本例展示了 PyQt6 的布局管理。

六、PyQt6 事件和信号

这部分教程,我们探索 PyQt6 程序中的事件和信号。

PyQt6 中的事件

GUI 应用程序是事件驱动的。事件主要由应用程序的用户触发,但也可以通过其他方式生成,例如 Internet 连接、窗口管理器或定时器。当我们调用应用程序的 exec() 方法时,应用程序进入主循环。 主循环获取事件并将它们发送到对象。

在事件模型里,有三个要素:

  • 事件源 event source
  • 事件对象 event object
  • 事件目标 event target

事件源是状态改变的对象,它会产生事件。event object(事件)封装了事件源中的状态变化。 event target 是要被通知的对象。事件源对象将处理事件的任务委托给事件目标。

PyQt6 有独特的信号和插槽机制来处理事件,用于对象之间的通信,当特定事件发生时触发。插槽可以是任意可调用的 Python 脚本。当发出连接的信号时,调用插槽脚本

译注:可以理解成钩子 (hooks) 或回调函数(callback)。

PyQt6 信号和插槽

下面的示例展示了 PyQt6 的信号和插槽。

import sys
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (QWidget, QLCDNumber, QSlider,
        QVBoxLayout, QApplication)

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        lcd = QLCDNumber(self)
        sld = QSlider(Qt.Orientation.Horizontal, self)
        vbox = QVBoxLayout()
        vbox.addWidget(lcd)
        vbox.addWidget(sld)
        self.setLayout(vbox)
        sld.valueChanged.connect(lcd.display)
        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('Signal and slot')
        self.show()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

本例中,展示了 QtGui.QLCDNumberQtGui.QSlider。我们可以通过拖动滑块改变显示器里的数字。

sld.valueChanged.connect(lcd.display)

把滑块的 valueChanged 事件和 显示器 display 插槽绑定到一起。

sender 是触发信号的对象, receiver 是接收信号的对象,slot 是对信号做出反应的方法。

运行结果
运行结果

PyQt6 重新实现事件处理器

PyQt6里,事件的处理器一般都会重新实现。

译注:所有的事件处理器都有默认的实现,也就是默认事件。默认事件可能有自己的逻辑,比如拖选,点击,有的可能只是一个空函数。空函数都需要重新覆盖原来的实现,达到事件处理的目的。有默认事件处理函数的,也有可能被覆盖实现,比如禁用自带的拖选,或者重写拖选的效果等。

import sys
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QWidget, QApplication

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('Event handler')
        self.show()

    def keyPressEvent(self, e):
        if e.key() == Qt.Key.Key_Escape.value:
            self.close()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

本例中,我们重新实现了 keyPressEvent 的事件处理器

def keyPressEvent(self, e):
    if e.key() == Qt.Key.Key_Escape.value:
        self.close()

按下 Escape 按钮,应用会退出。

PyQt6 事件对象

事件对象是一个 Python object,包含了一系列描述这个事件的属性,具体内容要看触发的事件。

import sys
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QWidget, QApplication, QGridLayout, QLabel

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        grid = QGridLayout()
        x = 0
        y = 0
        self.text = f'x: {x},  y: {y}'
        self.label = QLabel(self.text, self)
        grid.addWidget(self.label, 0, 0, Qt.AlignmentFlag.AlignTop)
        self.setMouseTracking(True)
        self.setLayout(grid)
        self.setGeometry(300, 300, 450, 300)
        self.setWindowTitle('Event object')
        self.show()

    def mouseMoveEvent(self, e):
        x = int(e.position().x())
        y = int(e.position().y())
        text = f'x: {x},  y: {y}'
        self.label.setText(text)

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

本例中,在标签组件里,展示了鼠标的坐标。

self.setMouseTracking(True)

鼠标跟踪默认是关闭的,鼠标移动时,组件只能在鼠标按下的时候接收到事件。开启鼠标跟踪,只移动鼠标不按下鼠标按钮,也能接收到事件。

def mouseMoveEvent(self, e):
    x = int(e.position().x())
    y = int(e.position().y())
    ...

e 是事件对象,它包含了事件触发时候的数据。通过 position().x()e.position().y() 方法,能获取到鼠标的坐标值。

self.text = f'x: {x},  y: {y}'
self.label = QLabel(self.text, self)

坐标值 x 和 y 显示在 QLabel 组件里。

运行结果
运行结果

PyQt6 事件触发者

某些时候,需要知道事件的触发者是谁,PyQt6 有获取事件触发者的方法。

import sys
from PyQt6.QtWidgets import QMainWindow, QPushButton, QApplication


class Example(QMainWindow):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        btn1 = QPushButton("Button 1", self)
        btn1.move(30, 50)
        btn2 = QPushButton("Button 2", self)
        btn2.move(150, 50)
        btn1.clicked.connect(self.buttonClicked)
        btn2.clicked.connect(self.buttonClicked)
        self.statusBar()
        self.setGeometry(300, 300, 450, 350)
        self.setWindowTitle('Event sender')
        self.show()

    def buttonClicked(self):
        sender = self.sender()
        msg = f'{sender.text()} was pressed'
        self.statusBar().showMessage(msg)

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

本例中有两个按钮。 buttonClicked 调用触发者方法确定了是哪个按钮触发的事件。

btn1.clicked.connect(self.buttonClicked)
btn2.clicked.connect(self.buttonClicked)

两个按钮绑定了同一个插槽。

def buttonClicked(self):
    sender = self.sender()
    msg = f'{sender.text()} was pressed'
    self.statusBar().showMessage(msg)

在应用的状态栏里,显示了是哪个按钮被按下。

运行结果
运行结果

PyQt6 触发信号

QObject 可以主动触发信号。下面的示例显示了如果触发自定义信号。

import sys
from PyQt6.QtCore import pyqtSignal, QObject
from PyQt6.QtWidgets import QMainWindow, QApplication

class Communicate(QObject):
    closeApp = pyqtSignal()

class Example(QMainWindow):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.c = Communicate()
        self.c.closeApp.connect(self.close)
        self.setGeometry(300, 300, 450, 350)
        self.setWindowTitle('Emit signal')
        self.show()

    def mousePressEvent(self, e):
        self.c.closeApp.emit()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

创建了一个叫 closeApp 的信号,在鼠标按下的时候触发,和关闭插槽 QMainWindow 绑定。

class Communicate(QObject):
    closeApp = pyqtSignal()

外部 Communicate 类的属性 pyqtSignal 创建信号。

self.c = Communicate()
self.c.closeApp.connect(self.close)

自定义信号 closeApp 绑定到 QMainWindow 的关闭插槽上。

def mousePressEvent(self, event):
    self.c.closeApp.emit()

在窗口上点击鼠标按钮的时候,触发 closeApp 信号,程序终止。

本章教程,我们讲述了信号和插槽。

七、PyQt6 的对话框

对话是两个或更多人之间的交谈。在计算机程序中,对话框是用于与应用程序“交谈”的窗口,用于诸如从用户那里获取数据或更改应用程序设置之类的事情。

PyQt6 QInputDialog

QInputDialog 提供了一个简单方便的对话框来从用户那里获取输入。输入值可以是字符串、数字或列表中的项目。

from PyQt6.QtWidgets import (QWidget, QPushButton, QLineEdit,
        QInputDialog, QApplication)
import sys

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.btn = QPushButton('Dialog', self)
        self.btn.move(20, 20)
        self.btn.clicked.connect(self.showDialog)
        self.le = QLineEdit(self)
        self.le.move(130, 22)
        self.setGeometry(300, 300, 450, 350)
        self.setWindowTitle('Input dialog')
        self.show()

    def showDialog(self):
        text, ok = QInputDialog.getText(self, 'Input Dialog',
                                        'Enter your name:')
        if ok:
            self.le.setText(str(text))

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

这个示例有一个按钮和行内编辑部件,按钮打开输入一个对话框,对话框里有一个文本输入框,用户输入的文本会显示在行内编辑部件里。

text, ok = QInputDialog.getText(self, 'Input Dialog',
    'Enter your name:')

这行代码打开了输入对话框,第一个参数是对话框标题,第二个参数是对话框里的提示信息。对话框会返回输入的文本和一个布尔值。如果点击 OK 按钮,这个布尔值是 true

if ok:
    self.le.setText(str(text))

使用 setText() 从对话框里获取输入的文本。

运行结果
运行结果

PyQt6 QColorDialog

QColorDialog 是可以选择颜色对话框。

from PyQt6.QtWidgets import (QWidget, QPushButton, QFrame,
        QColorDialog, QApplication)
from PyQt6.QtGui import QColor
import sys

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        col = QColor(0, 0, 0)
        self.btn = QPushButton('Dialog', self)
        self.btn.move(20, 20)
        self.btn.clicked.connect(self.showDialog)
        self.frm = QFrame(self)
        self.frm.setStyleSheet("QWidget { background-color: %s }"
                               % col.name())
        self.frm.setGeometry(130, 22, 200, 200)
        self.setGeometry(300, 300, 450, 350)
        self.setWindowTitle('Color dialog')
        self.show()

    def showDialog(self):
        col = QColorDialog.getColor()
        if col.isValid():
            self.frm.setStyleSheet("QWidget { background-color: %s }" 
                                   % col.name())

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

示例里有个按钮和一个 QFrame。部件的背景颜色是默认颜色,可以使用 QColorDialog 修改部件的背景颜色。

col = QColor(0, 0, 0)

这是 QFrame 的初始背景色。

col = QColorDialog.getColor()

这一行弹出 QColorDialog

if col.isValid():
    self.frm.setStyleSheet("QWidget { background-color: %s }" 
                           % col.name())

这里检查了颜色是不是有效的。如果点击取消按钮,没有返回可用的颜色值。如果返回的颜色是有效值,就使用样式表修改背景颜色。

运行结果
运行结果

PyQt6 QFontDialog

QFontDialog 是选择字体的对话框。

from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QPushButton,
        QSizePolicy, QLabel, QFontDialog, QApplication)
import sys

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        vbox = QVBoxLayout()
        btn = QPushButton('Dialog', self)
        btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
        btn.move(20, 20)
        vbox.addWidget(btn)
        btn.clicked.connect(self.showDialog)
        self.lbl = QLabel('Knowledge only matters', self)
        self.lbl.move(130, 20)
        vbox.addWidget(self.lbl)
        self.setLayout(vbox)
        self.setGeometry(300, 300, 450, 350)
        self.setWindowTitle('Font dialog')
        self.show()

    def showDialog(self):
        font, ok = QFontDialog.getFont()
        if ok:
            self.lbl.setFont(font)

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

本例中,有个有文本的按钮。使用 QFontDialog 可以修改按钮文本的字体。

font, ok = QFontDialog.getFont()

这里弹出了字体选择对话框。getFont 方法返回了选择的字体名称和 ok 参数,如果点击 Ok 按钮,ok 的值是 True,反则是 False

if ok:
    self.label.setFont(font)

如果点击 Ok 按钮,setFont 方法会修改文本的字体。

运行结果
运行结果

PyQt6 QFileDialog

QFileDialog 是选择文件或者文件夹的对话框,可以用作选择或者保存操作。

from PyQt6.QtWidgets import (QMainWindow, QTextEdit,
        QFileDialog, QApplication)
from PyQt6.QtGui import QIcon, QAction
from pathlib import Path
import sys

class Example(QMainWindow):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.textEdit = QTextEdit()
        self.setCentralWidget(self.textEdit)
        self.statusBar()
        openFile = QAction(QIcon('open.png'), 'Open', self)
        openFile.setShortcut('Ctrl+O')
        openFile.setStatusTip('Open new File')
        openFile.triggered.connect(self.showDialog)
        menubar = self.menuBar()
        fileMenu = menubar.addMenu('&File')
        fileMenu.addAction(openFile)
        self.setGeometry(300, 300, 550, 450)
        self.setWindowTitle('File dialog')
        self.show()

    def showDialog(self):
        home_dir = str(Path.home())
        fname = QFileDialog.getOpenFileName(self, 'Open file', home_dir)
        if fname[0]:
            f = open(fname[0], 'r')
            with f:
                data = f.read()
                self.textEdit.setText(data)

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

该示例有一个有居中显示的文本编辑部件的菜单栏和一个状态栏。菜单项显示了用于选择文件的 QFileDialog。文件的内容被加载到文本编辑小部件中。

class Example(QMainWindow):
    def __init__(self):
        super().__init__()
        self.initUI()

示例是基于 QMainWindow 部件,是因为需要把文本编辑部件居中显示。

home_dir = str(Path.home())
fname = QFileDialog.getOpenFileName(self, 'Open file', home_dir)

这里弹出 QFileDialoggetOpenFileName 的第一个参数字符串是标题,第二个字符串指定对话框工作目录。我们使用 path 模块来确定用户的主目录。默认情况下,文件过滤器设置为所有文件 (*)。

if fname[0]:
    f = open(fname[0], 'r')
    with f:
        data = f.read()
        self.textEdit.setText(data)

读取选择文件并把内容放置到文本编辑部件里。

本章教程,我们学习了对话框。

八、PyQt6 组件(一)

组件是应用程序的基础组成部分。PyQt6 具有各种各样的小部件,包括按钮、复选框、滑块或列表框。在本节教程中,我们将介绍几个有用的小部件:QCheckBox、QPushButton、QSlider、QProgressBar和QCalendarWidget。

PyQt6 QCheckBox

QCheckBox 组件有两个状态:选中和非选中。由个选框和文字组成,主要用于表示某个属性时开启还是关闭。

from PyQt6.QtWidgets import QWidget, QCheckBox, QApplication
from PyQt6.QtCore import Qt
import sys

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        cb = QCheckBox('Show title', self)
        cb.move(20, 20)
        cb.toggle()
        cb.stateChanged.connect(self.changeTitle)
        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('QCheckBox')
        self.show()

    def changeTitle(self, state):
        if state == Qt.CheckState.Checked.value:
            self.setWindowTitle('QCheckBox')
        else:
            self.setWindowTitle(' ')

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

创建了一个切换窗口标题的复选框。

cb = QCheckBox('Show title', self)

这是 QCheckBox 构造器。

cb.toggle()

设置了窗口标题,所以这里勾选上复选框。

cb.stateChanged.connect(self.changeTitle)

把用户定义的 changeTitle 方法和 stateChanged 信号连接起来。changeTitle 方法用来切换窗口标题。

if state == Qt.CheckState.Checked.value:
    self.setWindowTitle('QCheckBox')
else:
    self.setWindowTitle(' ')

组件的状态是 changeTitle 方法改变变量得到的。如果选中组件,就设置窗口的标题。否则,标题栏是一个空字符串。

运行结果
运行结果

切换按钮

切换按钮是 QPushButton 的一个特殊情况。它又两个状态:按下与否,鼠标点击触发。

from PyQt6.QtWidgets import (QWidget, QPushButton,
        QFrame, QApplication)
from PyQt6.QtGui import QColor
import sys

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.col = QColor(0, 0, 0)
        redb = QPushButton('Red', self)
        redb.setCheckable(True)
        redb.move(10, 10)
        redb.clicked[bool].connect(self.setColor)
        greenb = QPushButton('Green', self)
        greenb.setCheckable(True)
        greenb.move(10, 60)
        greenb.clicked[bool].connect(self.setColor)
        blueb = QPushButton('Blue', self)
        blueb.setCheckable(True)
        blueb.move(10, 110)
        blueb.clicked[bool].connect(self.setColor)
        self.square = QFrame(self)
        self.square.setGeometry(150, 20, 100, 100)
        self.square.setStyleSheet("QWidget { background-color: %s }" %
                                  self.col.name())
        self.setGeometry(300, 300, 300, 250)
        self.setWindowTitle('Toggle button')
        self.show()

    def setColor(self, pressed):
        source = self.sender()
        if pressed:
            val = 255
        else:
            val = 0
        if source.text() == "Red":
            self.col.setRed(val)
        elif source.text() == "Green":
            self.col.setGreen(val)
        else:
            self.col.setBlue(val)
        self.square.setStyleSheet("QFrame { background-color: %s }" %
                                  self.col.name())

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

示例中,创建了三个切换按钮和一个 QWidget。并将 QWidget 的背景颜色设置为黑色。切换按钮用于切换颜色值为红色、绿色和蓝色。组件背景颜色取决于按下的按钮。

self.col = QColor(0, 0, 0)

初始化颜色为黑色。

redb = QPushButton('Red', self)
redb.setCheckable(True)
redb.move(10, 10)

创建一个 QPushButton 并调用 setCheckable 方法设为为可选,就创建了一个切换按钮。

redb.clicked[bool].connect(self.setColor)

把用户自定义的方法和点击信号绑定,用点击信号改变一个布尔值。

source = self.sender()

获取到点击的按钮。

if source.text() == "Red":
    self.col.setRed(val)

如果点击的是红色按钮,就相应的把颜色改成红色。

self.square.setStyleSheet("QFrame { background-color: %s }" %
    self.col.name())

使用样式表修改背景颜色,修改样式需要调用 setStyleSheet 方法。

运行结果
运行结果

图示:切换按钮

PyQt6 QSlider

QSlider是一个有简单手柄的小部件,这个手柄可以前后拖动。通过这种方式,我们可以为特定的任务选择一个值。有时使用滑块比输入数字或使用旋转框更自然。

在我们的示例中,我们显示了一个滑块和一个标签。标签显示一个图像。滑块控制标签。

from PyQt6.QtWidgets import (QWidget, QSlider,
        QLabel, QApplication)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPixmap
import sys

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        sld = QSlider(Qt.Orientation.Horizontal, self)
        sld.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        sld.setGeometry(30, 40, 200, 30)
        sld.valueChanged[int].connect(self.changeValue)
        self.label = QLabel(self)
        self.label.setPixmap(QPixmap('mute.png'))
        self.label.setGeometry(250, 40, 80, 30)
        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('QSlider')
        self.show()

    def changeValue(self, value):
        if value == 0:
            self.label.setPixmap(QPixmap('mute.png'))
        elif 0 < value <= 30:
            self.label.setPixmap(QPixmap('min.png'))
        elif 30 < value < 80:
            self.label.setPixmap(QPixmap('med.png'))
        else:
            self.label.setPixmap(QPixmap('max.png'))

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

示例中,模拟了音量控制。通过拖动滑块的手柄,我们可以改变标签上的图像。

sld = QSlider(Qt.Orientation.Horizontal, self)

创建一个水平的 QSlider

self.label = QLabel(self)
self.label.setPixmap(QPixmap('mute.png'))

创建一个 QLabel 组件,并给它初始化一个静音的图标。

sld.valueChanged[int].connect(self.changeValue)

valueChanged 信号和用户定义的 changeValue 方法绑定。

if value == 0:
    self.label.setPixmap(QPixmap('mute.png'))
...

根据滑块的值,修改标签的图像。在上面的代码中,如果滑块等于零,把标签改为mute.png图像。

运行结果
运行结果

PyQt6 QProgressBar

进度条是一个用于处理冗长任务的小部件。它是动态的,以便用户知道任务正在进行中。QProgressBar 小部件在 PyQt6 工具包中提供了一个水平或垂直的进度条。可以设置进度条的最小值和最大值,默认值为0和99。

from PyQt6.QtWidgets import (QWidget, QProgressBar,
        QPushButton, QApplication)
from PyQt6.QtCore import QBasicTimer
import sys

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.pbar = QProgressBar(self)
        self.pbar.setGeometry(30, 40, 200, 25)
        self.btn = QPushButton('Start', self)
        self.btn.move(40, 80)
        self.btn.clicked.connect(self.doAction)
        self.timer = QBasicTimer()
        self.step = 0
        self.setGeometry(300, 300, 280, 170)
        self.setWindowTitle('QProgressBar')
        self.show()

    def timerEvent(self, e):
        if self.step >= 100:
            self.timer.stop()
            self.btn.setText('Finished')
            return
        self.step = self.step + 1
        self.pbar.setValue(self.step)
        
    def doAction(self):
        if self.timer.isActive():
            self.timer.stop()
            self.btn.setText('Start')
        else:
            self.timer.start(100, self)
            self.btn.setText('Stop')

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

示例中,有一个水平进度条和一个按钮,点击按钮可以启动和停止进度条。

self.pbar = QProgressBar(self)

这是 QProgressBar 的构造器。

self.timer = QBasicTimer()

使用定时器对象启动进度条。

self.timer.start(100, self)

调用定时器的开始方法,触发定时器事件。方法有两个参数,超时时间和接收事件的对象。

def timerEvent(self, e):
    if self.step >= 100:
        self.timer.stop()
        self.btn.setText('Finished')
        return
    self.step = self.step + 1
    self.pbar.setValue(self.step)

每个QObject 和它的后代都有一个 timerEvent 事件处理器,这里实现一些函数处理这些事件。

def doAction(self):
    if self.timer.isActive():
        self.timer.stop()
        self.btn.setText('Start')
    else:
        self.timer.start(100, self)
        self.btn.setText('Stop')

doAction 方法里,处理定时器的开启和暂停。

运行结果
运行结果

PyQt6 QCalendarWidget

QCalendarWidget 提供了一个月视图的日历组件,它能让用户简单直观的选择日期。

from PyQt6.QtWidgets import (QWidget, QCalendarWidget,
        QLabel, QApplication, QVBoxLayout)
from PyQt6.QtCore import QDate
import sys

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        vbox = QVBoxLayout(self)
        cal = QCalendarWidget(self)
        cal.setGridVisible(True)
        cal.clicked[QDate].connect(self.showDate)
        vbox.addWidget(cal)
        self.lbl = QLabel(self)
        date = cal.selectedDate()
        self.lbl.setText(date.toString())
        vbox.addWidget(self.lbl)
        self.setLayout(vbox)
        self.setGeometry(300, 300, 350, 300)
        self.setWindowTitle('Calendar')
        self.show()

    def showDate(self, date):
        self.lbl.setText(date.toString())

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

该示例有一个日历组件和一个标签组件,选择的日期显示在标签组件里。

cal = QCalendarWidget(self)

创建了一个 QCalendarWidget

cal.clicked[QDate].connect(self.showDate)

选中一个日期,会触发 clicked[QDate] 信号,信号是和用户定义的 showDate 方法绑定。

def showDate(self, date):

    self.lbl.setText(date.toString())

调用 selectedDate 方法获取到选中的日期,再把日期转换成字符串,设置到标签组件里。

运行结果
运行结果

八、PyQt6 组件(二)

本章继续介绍 PyQt6 组件,包含了QPixmap, QLineEdit, QSplitterQComboBox

PyQt6 QPixmap

QPixmap 是用于处理图像的小组件,为显示图像进行了优化。下面是使用 QPixmap 渲染图像的示例。

from PyQt6.QtWidgets import (QWidget, QHBoxLayout,
        QLabel, QApplication)
from PyQt6.QtGui import QPixmap
import sys

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        hbox = QHBoxLayout(self)
        pixmap = QPixmap('sid.jpg')
        lbl = QLabel(self)
        lbl.setPixmap(pixmap)
        hbox.addWidget(lbl)
        self.setLayout(hbox)
        self.move(300, 200)
        self.setWindowTitle('Sid')
        self.show()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

在窗口里展示了一个图片。

pixmap = QPixmap('sid.jpg')

用文件名作为参数创建一个 QPixmap 对象。

lbl = QLabel(self)
lbl.setPixmap(pixmap)

然后把 pixmap 放到 QLabel 组件里。

PyQt6 QLineEdit

QLineEdit是一个可以输入单行文本的组件,它有撤消和重做、剪切和粘贴以及拖放功能。

import sys
from PyQt6.QtWidgets import (QWidget, QLabel,
        QLineEdit, QApplication)

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.lbl = QLabel(self)
        qle = QLineEdit(self)
        qle.move(60, 100)
        self.lbl.move(60, 40)
        qle.textChanged[str].connect(self.onChanged)
        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('QLineEdit')
        self.show()

    def onChanged(self, text):
        self.lbl.setText(text)
        self.lbl.adjustSize()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

本例中有一个 QLineEdit 组件和一个标签,在编辑器里输入的文本会立即显示在标签里。

qle = QLineEdit(self)

创建一个 QLineEdit 组件。

qle.textChanged[str].connect(self.onChanged)

如果编辑器的文本发生了变化,就调用 onChanged 方法。

def onChanged(self, text):
    self.lbl.setText(text)
    self.lbl.adjustSize()

onChanged 里,把输入的文本设置到标签组件里,同时使用 adjustSize 方法调整文字显示。

运行结果
运行结果

PyQt6 QSplitter

QSplitter 允许用户通过拖动子部件之间的边界来控制子部件的大小。下面是被两个分割条分开的三个QFrame 组件的示例。

import sys

from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (QWidget, QHBoxLayout, QFrame,
        QSplitter, QApplication)

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        hbox = QHBoxLayout(self)
        topleft = QFrame(self)
        topleft.setFrameShape(QFrame.Shape.StyledPanel)
        topright = QFrame(self)
        topright.setFrameShape(QFrame.Shape.StyledPanel)
        bottom = QFrame(self)
        bottom.setFrameShape(QFrame.Shape.StyledPanel)
        splitter1 = QSplitter(Qt.Orientation.Horizontal)
        splitter1.addWidget(topleft)
        splitter1.addWidget(topright)
        splitter2 = QSplitter(Qt.Orientation.Vertical)
        splitter2.addWidget(splitter1)
        splitter2.addWidget(bottom)
        hbox.addWidget(splitter2)
        self.setLayout(hbox)
        self.setGeometry(300, 300, 450, 400)
        self.setWindowTitle('QSplitter')
        self.show()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

这里有三个 QFrame 组件和连个 QSplitter 组件,注意,在某些主题里,分割条可能不容易看到。

topleft = QFrame(self)
topleft.setFrameShape(QFrame.Shape.StyledPanel)

给框架组件设置一些样式,这样更容易看清楚边界。

splitter1 = QSplitter(Qt.Orientation.Horizontal)
splitter1.addWidget(topleft)
splitter1.addWidget(topright)

创建一个有俩框架组件的 QSplitter 组件。

splitter2 = QSplitter(Qt.Orientation.Vertical)
splitter2.addWidget(splitter1)

再添加一个分割条和一个框架组件。

运行结果
运行结果

PyQt6 QComboBox

QComboBox 是下拉选框组件,能让用户在一系列选项中进行选择。

import sys
from PyQt6.QtWidgets import (QWidget, QLabel,
        QComboBox, QApplication)

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.lbl = QLabel('Ubuntu', self)
        combo = QComboBox(self)
        combo.addItem('Ubuntu')
        combo.addItem('Mandriva')
        combo.addItem('Fedora')
        combo.addItem('Arch')
        combo.addItem('Gentoo')
        combo.move(50, 50)
        self.lbl.move(50, 150)
        combo.textActivated[str].connect(self.onActivated)
        self.setGeometry(300, 300, 450, 400)
        self.setWindowTitle('QComboBox')
        self.show()

    def onActivated(self, text):
        self.lbl.setText(text)
        self.lbl.adjustSize()
        
def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

示例中有一个 QComboBox 和一个 QLabel。下拉选框有一个包含五个选项的列表,是Linux发行版的名称。标签组件显示组合框中选择的选项。

combo = QComboBox(self)

combo.addItem('Ubuntu')
combo.addItem('Mandriva')
combo.addItem('Fedora')
combo.addItem('Arch')
combo.addItem('Gentoo')

创建有五个选项的 QComboBox 组件。

combo.textActivated[str].connect(self.onActivated)

如果选择了一个选项,就调用 onActivated 方法。

def onActivated(self, text):
    self.lbl.setText(text)
    self.lbl.adjustSize()

在这个方法里,设置选中的文本到标签组件里,然后调整标签组件大小。

运行结果
运行结果

九、PyQt6 中的拖拽操作

本章教程,讲的是 PyQt6 中的拖拽操作。

计算机图形界面中,拖拽操作是点击一个对象不放,把它放在另外一个地方或者另外一个对象上面的操作。一般来说,这会触发很多类型的行为,或者在两个对象上建立多种关系。

在计算机图形用户界面中,拖放是(或支持)点击虚拟对象并将其拖到不同位置或另一个虚拟对象上的动作。 一般来说,它可以用来调用多种动作,或者在两个抽象对象之间创建各种类型的关联。

拖放是图形界面的一部分,使用户能够直观地做复杂的事情。

通常,我们可以拖放两个东西:数据或图形对象。将图像从一个应用程序拖到另一个应用程序,操作的是二进制数据。如果在 Firefox 中拖动一个选项卡并将其移动到另一个位置,操作的是一个图形组件。

QDrag

QDrag 提供对基于 MIME 的拖放数据传输的支持。它处理拖放操作的大部分细节。传输的数据包含在 QMimeData 对象中

简单拖拽操作

示例中,有一个 QLineEditQPushButton 部件,我们将纯文本从行编辑小部件拖放到按钮小部件上,以改变按钮的标签。

import sys
from PyQt6.QtWidgets import (QPushButton, QWidget,
        QLineEdit, QApplication)

class Button(QPushButton):
    def __init__(self, title, parent):
        super().__init__(title, parent)
        self.setAcceptDrops(True)

    def dragEnterEvent(self, e):
        if e.mimeData().hasFormat('text/plain'):
            e.accept()
        else:
            e.ignore()
            
    def dropEvent(self, e):
        self.setText(e.mimeData().text())

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        edit = QLineEdit('', self)
        edit.setDragEnabled(True)
        edit.move(30, 65)
        button = Button("Button", self)
        button.move(190, 65)
        self.setWindowTitle('Simple drag and drop')
        self.setGeometry(300, 300, 300, 150)

def main():
    app = QApplication(sys.argv)
    ex = Example()
    ex.show()
    app.exec()

if __name__ == '__main__':
    main()

示例展示了简单的拖拽操作。

class Button(QPushButton):
    def __init__(self, title, parent):
        super().__init__(title, parent)

        ...

为了完成把文本拖到 QPushButton 部件上,我们必须实现某些方法才可以,所以这里创建了一个继承自 QPushButtonButton 类。

self.setAcceptDrops(True)

使用 setAcceptDrops 方法处理部件的释放事件。

def dragEnterEvent(self, e):
    if e.mimeData().hasFormat('text/plain'):
        e.accept()
    else:
        e.ignore()

dragEnterEvent 方法,定义了我们接受的数据类型————纯文本。

def dropEvent(self, e):
    self.setText(e.mimeData().text())

dropEvent 方法,处理释放事件————修改按钮组件的文本。

edit = QLineEdit('', self)
edit.setDragEnabled(True)

QLineEdit 部件支持拖放操作,这里只需要调用 setDragEnabled 方法激活它。

运行结果
运行结果

拖放按钮组件

接下来的示例演示了如何拖放按钮组件。

import sys
from PyQt6.QtCore import Qt, QMimeData
from PyQt6.QtGui import QDrag
from PyQt6.QtWidgets import QPushButton, QWidget, QApplication

class Button(QPushButton):
    def __init__(self, title, parent):
        super().__init__(title, parent)

    def mouseMoveEvent(self, e):
        if e.buttons() != Qt.MouseButton.RightButton:
            return
        mimeData = QMimeData()
        drag = QDrag(self)
        drag.setMimeData(mimeData)
        drag.setHotSpot(e.position().toPoint() - self.rect().topLeft())
        dropAction = drag.exec(Qt.DropAction.MoveAction)

    def mousePressEvent(self, e):
        super().mousePressEvent(e)
        if e.button() == Qt.MouseButton.LeftButton:
            print('press')

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.setAcceptDrops(True)
        self.button = Button('Button', self)
        self.button.move(100, 65)
        self.setWindowTitle('Click or Move')
        self.setGeometry(300, 300, 550, 450)

    def dragEnterEvent(self, e):
        e.accept()

    def dropEvent(self, e):
        position = e.position()
        self.button.move(position.toPoint())
        e.setDropAction(Qt.DropAction.MoveAction)
        e.accept()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    ex.show()
    app.exec()

if __name__ == '__main__':
    main()

本例中,窗口里有个 QPushButton,鼠标左键点击它,会在控制台打印 'press’消息,鼠标右键可以点击拖拽它。

class Button(QPushButton):
    def __init__(self, title, parent):
        super().__init__(title, parent)

基于 QPushButton 创建了一个 Button 类,并实现了两个 QPushButton 方法:mouseMoveEventmousePressEventmouseMoveEvent 方法是处理拖放操作开始的地方。

if e.buttons() != Qt.MouseButton.RightButton:
    return

定义鼠标右键为触发拖拽操作的按钮,鼠标左键只会触发点击事件。

drag = QDrag(self)
drag.setMimeData(mimeData)
drag.setHotSpot(e.position().toPoint() - self.rect().topLeft())

创建 QDrag 对象,以提供基于 MIME 数据类型的拖拽操作。

dropAction = drag.exec(Qt.DropAction.MoveAction)

drag 对象的 exec 方法执行拖拽操作。

def mousePressEvent(self, e):
    super().mousePressEvent(e)
    if e.button() == Qt.MouseButton.LeftButton:
        print('press')

如果鼠标左键点击按钮,会在控制台打印 ‘press’ 消息,注意,这里在父级上也调用了 mousePressEvent 方法,不然按钮按下的动作不会展现出来。

position = e.pos()
self.button.move(position)

dropEvent 方法处理鼠标释放按钮后的操作————把组件的位置修改为鼠标当前坐标。

e.setDropAction(Qt.MoveAction)
e.accept()

使用 setDropActon 指定拖放操作的类型————鼠标移动。

本章讲述了 PyQt6 中的拖拽操作。

十、PyQt6 的绘制

PyQt6绘画系统能够呈现矢量图形、图像和基于字体的文本轮廓。想要更改或增强现有的小部件,或者从头创建自定义小部件时,需要 PyQt6 工具包提供的绘图 API 进行绘制。

QPainter

QPainter 在小部件和其他可绘制单元上执行底层绘制。从简单的线条到复杂的形状,它可以画任何东西。

paintEvent 方法

绘制时由 paintEvent 方法完成的。绘制代码位于 QPainter 对象的开始和结束方法之间。它在小部件和其他绘制单元上执行底层绘制。

PyQt6 绘制文本

从绘制一些 Unicode 文本开始。

import sys
from PyQt6.QtWidgets import QWidget, QApplication
from PyQt6.QtGui import QPainter, QColor, QFont
from PyQt6.QtCore import Qt

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.text = "Лев Николаевич Толстой\nАнна Каренина"
        self.setGeometry(300, 300, 350, 300)
        self.setWindowTitle('Drawing text')
        self.show()

    def paintEvent(self, event):
        qp = QPainter()
        qp.begin(self)
        self.drawText(event, qp)
        qp.end()

    def drawText(self, event, qp):
        qp.setPen(QColor(168, 34, 3))
        qp.setFont(QFont('Decorative', 10))
        qp.drawText(event.rect(), Qt.AlignmentFlag.AlignCenter, self.text)

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()main()

本例中,绘制了一些西里尔字母,并水平和垂直对齐了文本。

def paintEvent(self, event):
...

使用 paintEvent 完成绘画。

qp = QPainter()
qp.begin(self)
self.drawText(event, qp)
qp.end()

QPainter 类负责所有的底层绘制。所有的绘制都在开始和结束方法之间。实际的绘制被委托给 drawText 方法。

qp.setPen(QColor(168, 34, 3))
qp.setFont(QFont('Decorative', 10))

这里定义了绘制文本的笔触和字体。

qp.drawText(event.rect(), Qt.AlignmentFlag.AlignCenter, self.text)

drawText 方法在窗口上绘制文本。paintEvent 的rect方法返回需要更新的矩形。用 Qt.AlignmentFlag.AlignCenter 在两个维度上对齐文本。

运行结果
运行结果

PyQt6 绘制点

点是绘制里最简单的图形对象。

from PyQt6.QtWidgets import QWidget, QApplication
from PyQt6.QtGui import QPainter
from PyQt6.QtCore import Qt
import sys, random

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.setMinimumSize(50, 50)
        self.setGeometry(300, 300, 350, 300)
        self.setWindowTitle('Points')
        self.show()

    def paintEvent(self, e):
        qp = QPainter()
        qp.begin(self)
        self.drawPoints(qp)
        qp.end()

    def drawPoints(self, qp):
        qp.setPen(Qt.GlobalColor.red)
        size = self.size()
        for i in range(1000):
            x = random.randint(1, size.width() - 1)
            y = random.randint(1, size.height() - 1)
            qp.drawPoint(x, y)

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

上例中,在窗口里绘制了1000个随机的红点。

qp.setPen(Qt.GlobalColor.red)

用预定义 Qt.GlobalColor.red 常量常量把笔触设置为红色。

size = self.size()

每次改变窗口大小,都会产生一个绘制事件。获得当前窗口大小,根据这个大小把点分布到窗口上的各个位置。

qp.drawPoint(x, y)

使用 drawPoint 方法绘制点。

运行结果
运行结果

PyQt6 颜色

颜色是表示红色、绿色和蓝色 (RGB) 强度值组合的对象。有效的 RGB 值的范围是0到255。可以用不同的方法定义一种颜色。最常见的是RGB十进制值或十六进制值。还可以使用 RGBA 值,它代表红色、绿色、蓝色和 Alpha 通道,添加了透明度信息。Alpha 值为255定义完全不透明,0表示完全透明,也就是颜色不可见。

from PyQt6.QtWidgets import QWidget, QApplication
from PyQt6.QtGui import QPainter, QColor
import sys

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.setGeometry(300, 300, 350, 100)
        self.setWindowTitle('Colours')
        self.show()

    def paintEvent(self, e):
        qp = QPainter()
        qp.begin(self)
        self.drawRectangles(qp)
        qp.end()

    def drawRectangles(self, qp):
        col = QColor(0, 0, 0)
        col.setNamedColor('#d4d4d4')
        qp.setPen(col)
        qp.setBrush(QColor(200, 0, 0))
        qp.drawRect(10, 15, 90, 60)
        qp.setBrush(QColor(255, 80, 0, 160))
        qp.drawRect(130, 15, 90, 60)
        qp.setBrush(QColor(25, 0, 90, 200))
        qp.drawRect(250, 15, 90, 60)

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

上例中,绘制了三个不同颜色的矩形。

color = QColor(0, 0, 0)
color.setNamedColor('#d4d4d4')

使用16进制定义颜色。

qp.setBrush(QColor(200, 0, 0))
qp.drawRect(10, 15, 90, 60)

这里定义一个笔刷并绘制一个矩形。画笔是一种基本的图形对象,用于绘制形状的背景。drawRect 方法接受四个参数,前两个是轴上的x和y值,第三和第四个参数是矩形的宽度和高度,使用选择的笔触和笔刷绘制矩形。

运行结果
运行结果

PyQt6 QPen

QPen 是一个基本图形对象,可以绘制线条,曲线和矩形,椭圆,多边形等形状的轮廓。

from PyQt6.QtWidgets import QWidget, QApplication
from PyQt6.QtGui import QPainter, QPen
from PyQt6.QtCore import Qt
import sys

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.setGeometry(300, 300, 280, 270)
        self.setWindowTitle('Pen styles')
        self.show()

    def paintEvent(self, e):
        qp = QPainter()
        qp.begin(self)
        self.drawLines(qp)
        qp.end()

    def drawLines(self, qp):
        pen = QPen(Qt.GlobalColor.black, 2, Qt.PenStyle.SolidLine)
        qp.setPen(pen)
        qp.drawLine(20, 40, 250, 40)
        pen.setStyle(Qt.PenStyle.DashLine)
        qp.setPen(pen)
        qp.drawLine(20, 80, 250, 80)
        pen.setStyle(Qt.PenStyle.DashDotLine)
        qp.setPen(pen)
        qp.drawLine(20, 120, 250, 120)
        pen.setStyle(Qt.PenStyle.DotLine)
        qp.setPen(pen)
        qp.drawLine(20, 160, 250, 160)
        pen.setStyle(Qt.PenStyle.DashDotDotLine)
        qp.setPen(pen)
        qp.drawLine(20, 200, 250, 200)
        pen.setStyle(Qt.PenStyle.CustomDashLine)
        pen.setDashPattern([1, 4, 5, 4])
        qp.setPen(pen)
        qp.drawLine(20, 240, 250, 240)

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

示例中,我们画了6条线。线条是用六种不同的笔触风格样式的。有五种预定义的笔触。我们也可以创建自定义笔触样式。最后一条线是使用自定义笔触风格样式的。

pen = QPen(Qt.GlobalColor.black, 2, Qt.PenStyle.SolidLine)

这里创建了一个 QPen 对象,颜色是黑色,宽度2像素,这样就能区别不同的笔触。Qt.SolidLine 是一个预定义的笔触。

pen.setStyle(Qt.PenStyle.CustomDashLine)
pen.setDashPattern([1, 4, 5, 4])
qp.setPen(pen)

这里我们自定义了一个笔触。样式设置为 Qt.PenStyle。CustomDashLine,用 setDashPattern 方法设置具体样式,参数一定是偶数个,奇数定义破折号,偶数定义空格。数字越大,空格或破折号就越大。这里设置的是1px横线,4px空格,5px横线,4px空格等等。

运行结果
运行结果

PyQt6 QBrush

QBrush 是一个基本图形对象。它用于绘制矩形、椭圆等形状的背景。笔刷有三种类型:预定义的笔刷、渐变或纹理模式。

from PyQt6.QtWidgets import QWidget, QApplication
from PyQt6.QtGui import QPainter, QBrush
from PyQt6.QtCore import Qt
import sys

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.setGeometry(300, 300, 355, 280)
        self.setWindowTitle('Brushes')
        self.show()

    def paintEvent(self, e):
        qp = QPainter()
        qp.begin(self)
        self.drawBrushes(qp)
        qp.end()

    def drawBrushes(self, qp):
        brush = QBrush(Qt.BrushStyle.SolidPattern)
        qp.setBrush(brush)
        qp.drawRect(10, 15, 90, 60)
        brush.setStyle(Qt.BrushStyle.Dense1Pattern)
        qp.setBrush(brush)
        qp.drawRect(130, 15, 90, 60)
        brush.setStyle(Qt.BrushStyle.Dense2Pattern)
        qp.setBrush(brush)
        qp.drawRect(250, 15, 90, 60)
        brush.setStyle(Qt.BrushStyle.DiagCrossPattern)
        qp.setBrush(brush)
        qp.drawRect(10, 105, 90, 60)
        brush.setStyle(Qt.BrushStyle.Dense5Pattern)
        qp.setBrush(brush)
        qp.drawRect(130, 105, 90, 60)
        brush.setStyle(Qt.BrushStyle.Dense6Pattern)
        qp.setBrush(brush)
        qp.drawRect(250, 105, 90, 60)
        brush.setStyle(Qt.BrushStyle.HorPattern)
        qp.setBrush(brush)
        qp.drawRect(10, 195, 90, 60)
        brush.setStyle(Qt.BrushStyle.VerPattern)
        qp.setBrush(brush)
        qp.drawRect(130, 195, 90, 60)
        brush.setStyle(Qt.BrushStyle.BDiagPattern)
        qp.setBrush(brush)
        qp.drawRect(250, 195, 90, 60)

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

示例中绘制了9个不同的矩形。

brush = QBrush(Qt.BrushStyle.SolidPattern)
qp.setBrush(brush)
qp.drawRect(10, 15, 90, 60)
123

这里定义了一个笔刷对象,调用 drawRect 方法绘制矩形。

运行结果
运行结果

贝塞尔曲线

贝塞尔曲线是三次方曲线。PyQt6 中的贝塞尔曲线可以用 QPainterPath 创建。画线路径是由许多图形构建块(如矩形、椭圆、直线和曲线)组成的对象。

import sys
from PyQt6.QtGui import QPainter, QPainterPath
from PyQt6.QtWidgets import QWidget, QApplication

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.setGeometry(300, 300, 380, 250)
        self.setWindowTitle('Bézier curve')
        self.show()

    def paintEvent(self, e):
        qp = QPainter()
        qp.begin(self)
        qp.setRenderHint(QPainter.RenderHint.Antialiasing)
        self.drawBezierCurve(qp)
        qp.end()

    def drawBezierCurve(self, qp):
        path = QPainterPath()
        path.moveTo(30, 30)
        path.cubicTo(30, 30, 200, 350, 350, 30)
        qp.drawPath(path)

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

This example draws a Bézier curve.

path = QPainterPath()
path.moveTo(30, 30)
path.cubicTo(30, 30, 200, 350, 350, 30)

使用 QPainterPath 创建贝塞尔曲线路径。使用 cubicTo 方法绘制曲线,该方法需要三个点:起始点,控制点,结束点。

qp.drawPath(path)

使用 drawPath 方法绘制最终的路径。

运行结果
运行结果

本章讲解了基本的绘画。

十一、PyQt6 自定义部件

PyQt6 已经有丰富的部件,但是没有任何工具包能提供开发者开发应用中需要的全部部件。工具包通常只提供最常见的小部件,如按钮、文本小部件或滑块。如果需要满足特定需求的小部件,我们必须自己创建。

自定义小部件是使用工具包提供的绘图工具创建的。基本上有两种方式:程序员可以修改或增强现有的小部件,或者他可以从头开始创建自定义小部件。

PyQt6 烧录部件

这个部件可以在 Nero、K3B 或其他的 CD/DVD 烧录软件里看到。

from PyQt6.QtWidgets import (QWidget, QSlider, QApplication,
        QHBoxLayout, QVBoxLayout)
from PyQt6.QtCore import QObject, Qt, pyqtSignal
from PyQt6.QtGui import QPainter, QFont, QColor, QPen
import sys

class Communicate(QObject):
    updateBW = pyqtSignal(int)

class BurningWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.setMinimumSize(1, 30)
        self.value = 75
        self.num = [75, 150, 225, 300, 375, 450, 525, 600, 675]

    def setValue(self, value):
        self.value = value

    def paintEvent(self, e):
        qp = QPainter()
        qp.begin(self)
        self.drawWidget(qp)
        qp.end()

    def drawWidget(self, qp):
        MAX_CAPACITY = 700
        OVER_CAPACITY = 750
        font = QFont('Serif', 7, QFont.Weight.Light)
        qp.setFont(font)
        size = self.size()
        w = size.width()
        h = size.height()
        step = int(round(w / 10))
        till = int(((w / OVER_CAPACITY) * self.value))
        full = int(((w / OVER_CAPACITY) * MAX_CAPACITY))
        if self.value >= MAX_CAPACITY:
            qp.setPen(QColor(255, 255, 255))
            qp.setBrush(QColor(255, 255, 184))
            qp.drawRect(0, 0, full, h)
            qp.setPen(QColor(255, 175, 175))
            qp.setBrush(QColor(255, 175, 175))
            qp.drawRect(full, 0, till - full, h)
        else:
            qp.setPen(QColor(255, 255, 255))
            qp.setBrush(QColor(255, 255, 184))
            qp.drawRect(0, 0, till, h)
        pen = QPen(QColor(20, 20, 20), 1,
                   Qt.PenStyle.SolidLine)
        qp.setPen(pen)
        qp.setBrush(Qt.BrushStyle.NoBrush)
        qp.drawRect(0, 0, w - 1, h - 1)
        j = 0
        for i in range(step, 10 * step, step):
            qp.drawLine(i, 0, i, 5)
            metrics = qp.fontMetrics()
            fw = metrics.horizontalAdvance(str(self.num[j]))
            x, y = int(i - fw/2), int(h / 2)
            qp.drawText(x, y, str(self.num[j]))
            j = j + 1

class Example(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        OVER_CAPACITY = 750
        sld = QSlider(Qt.Orientation.Horizontal, self)
        sld.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        sld.setRange(1, OVER_CAPACITY)
        sld.setValue(75)
        sld.setGeometry(30, 40, 150, 30)
        self.c = Communicate()
        self.wid = BurningWidget()
        self.c.updateBW[int].connect(self.wid.setValue)
        sld.valueChanged[int].connect(self.changeValue)
        hbox = QHBoxLayout()
        hbox.addWidget(self.wid)
        vbox = QVBoxLayout()
        vbox.addStretch(1)
        vbox.addLayout(hbox)
        self.setLayout(vbox)
        self.setGeometry(300, 300, 390, 210)
        self.setWindowTitle('Burning widget')
        self.show()

    def changeValue(self, value):
        self.c.updateBW.emit(value)
        self.wid.repaint()

def main():
    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

这个示例中,有一个 QSlider 和一个自定义小部件——滑块控制自定义小部件。此小部件以图形方式显示介质的总容量和可用的可用空间。自定义小部件的最小值为 1,最大值为 OVER_CAPACITY。 如果值达到 MAX_CAPACITY ,会变成红色,代表需要烧录的数据大于介质的容量。

烧录部件位于窗口底部。用 QHBoxLayoutQVBoxLayout 实现。

class BurningWidget(QWidget):

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

烧录组件基于 QWidget

self.setMinimumSize(1, 30)

设置部件的高,默认的高度有点小。

font = QFont('Serif', 7, QFont.Weight.Light)
qp.setFont(font)

这里使用了较小的字体大小,这样看起来更适合我们的需求。

size = self.size()
w = size.width()
h = size.height()

step = int(round(w / 10))

till = int(((w / OVER_CAPACITY) * self.value))
full = int(((w / OVER_CAPACITY) * MAX_CAPACITY))

部件是动态渲染的。窗口越大,部件就越大,反之亦然。所以我们需要动态计算部件的大小必须计算在其上绘制自定义小部件的小部件的大小。参数 till 决定了部件的总大小,这个值来自于滑块部件,它是相对整个区域的一个比例。参数 full 是红色色块的起点。

绘图包括三个步骤,先绘制有黄色或红色和黄色的矩形,然后绘制垂直线,将小部件分成几个部分,最后画出表示介质容量的数字。

metrics = qp.fontMetrics()
fw = metrics.horizontalAdvance(str(self.num[j]))

x, y = int(i - fw/2), int(h / 2)
qp.drawText(x, y, str(self.num[j]))

我们使用字体材料来绘制文本,所以必须知道文本的宽度才能使其垂直居中。

def changeValue(self, value):
    self.c.updateBW.emit(value)
    self.wid.repaint()

移动滑块时,调用 changeValue 方法。在方法内部,触发一个带有参数的自定义 updateBW 信号,参数是滑块的当前值,这个值也要用于计算要绘制的 Burning 小部件的容量,这样,这个部件就绘制出来了。

运行结果
运行结果

本章的 PyQt6 教程里,我们创建了一个自定义部件。

十二、俄罗斯方块

本章实现一个俄罗斯方块游戏。

简介

俄罗斯方块游戏是有史以来最受欢迎的电脑游戏之一。最初的游戏是由俄罗斯程序员 Alexey Pajitnov 在1985年设计并编写的。从那时起,《俄罗斯方块》便以多种形式出现在几乎所有平台上。

俄罗斯方块被称为掉落方块拼图游戏。在这款游戏中,我们有7种不同的形状,叫做砖块(tetrminoes):S形、Z形、T形、L形、线形、反向L形和方形。每个形状都是由四个正方形组成的。这些形状从顶部掉落。《俄罗斯方块》游戏的目标是移动和旋转形状,尽可能的拼到一起,如果拼成一行,这一行会消失,这样就能得分,直到方块堆叠到顶部,游戏结束。

PyQt6 目标是创建应用程序,有些其他的库的目标是创造电脑游戏。尽管如此,PyQt6 和其他库也可以用于创建简单的游戏。

制作一个电脑游戏是提高编程技能的好方法。

开发

因为没有游戏砖块的图片,所以这里使用 PyQt6 编程工具包中的绘图 API 来绘制砖块。每一款电脑游戏的背后都有一个数学模型,俄罗斯方块也是如此。

一些思路:

  • 使用 QtCore.QBasicTimer 创建游戏循环
  • 画出砖块
  • 砖块整体旋转或移动(不是分开操作)
  • 数学意义上,游戏面板是个简单的数字列表

代码包含四个类:TetrisBoardTetrominoeShapeTetris 类设置了游戏。Board 是编写游戏逻辑的地方。Tetrominoe 类包含所有俄罗斯方块的名称,Shape 类包含俄罗斯方块的代码。

import random
import sys

from PyQt6.QtCore import Qt, QBasicTimer, pyqtSignal
from PyQt6.QtGui import QPainter, QColor
from PyQt6.QtWidgets import QMainWindow, QFrame, QApplication


class Tetris(QMainWindow):

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

        self.initUI()


    def initUI(self):
        """initiates application UI"""

        self.tboard = Board(self)
        self.setCentralWidget(self.tboard)

        self.statusbar = self.statusBar()
        self.tboard.msg2Statusbar[str].connect(self.statusbar.showMessage)

        self.tboard.start()

        self.resize(180, 380)
        self.center()
        self.setWindowTitle('Tetris')
        self.show()


    def center(self):
        """centers the window on the screen"""

        qr = self.frameGeometry()
        cp = self.screen().availableGeometry().center()

        qr.moveCenter(cp)
        self.move(qr.topLeft())


class Board(QFrame):

    msg2Statusbar = pyqtSignal(str)

    BoardWidth = 10
    BoardHeight = 22
    Speed = 300


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

        self.initBoard()


    def initBoard(self):
        """initiates board"""

        self.timer = QBasicTimer()
        self.isWaitingAfterLine = False

        self.curX = 0
        self.curY = 0
        self.numLinesRemoved = 0
        self.board = []

        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
        self.isStarted = False
        self.isPaused = False
        self.clearBoard()


    def shapeAt(self, x, y):
        """determines shape at the board position"""

        return self.board[(y * Board.BoardWidth) + x]


    def setShapeAt(self, x, y, shape):
        """sets a shape at the board"""

        self.board[(y * Board.BoardWidth) + x] = shape


    def squareWidth(self):
        """returns the width of one square"""

        return self.contentsRect().width() // Board.BoardWidth


    def squareHeight(self):
        """returns the height of one square"""

        return self.contentsRect().height() // Board.BoardHeight


    def start(self):
        """starts game"""

        if self.isPaused:
            return

        self.isStarted = True
        self.isWaitingAfterLine = False
        self.numLinesRemoved = 0
        self.clearBoard()

        self.msg2Statusbar.emit(str(self.numLinesRemoved))

        self.newPiece()
        self.timer.start(Board.Speed, self)


    def pause(self):
        """pauses game"""

        if not self.isStarted:
            return

        self.isPaused = not self.isPaused

        if self.isPaused:
            self.timer.stop()
            self.msg2Statusbar.emit("paused")

        else:
            self.timer.start(Board.Speed, self)
            self.msg2Statusbar.emit(str(self.numLinesRemoved))

        self.update()


    def paintEvent(self, event):
        """paints all shapes of the game"""

        painter = QPainter(self)
        rect = self.contentsRect()

        boardTop = rect.bottom() - Board.BoardHeight * self.squareHeight()

        for i in range(Board.BoardHeight):
            for j in range(Board.BoardWidth):
                shape = self.shapeAt(j, Board.BoardHeight - i - 1)

                if shape != Tetrominoe.NoShape:
                    self.drawSquare(painter,
                                    rect.left() + j * self.squareWidth(),
                                    boardTop + i * self.squareHeight(), shape)

        if self.curPiece.shape() != Tetrominoe.NoShape:

            for i in range(4):
                x = self.curX + self.curPiece.x(i)
                y = self.curY - self.curPiece.y(i)
                self.drawSquare(painter, rect.left() + x * self.squareWidth(),
                            boardTop + (Board.BoardHeight - y - 1) * self.squareHeight(),
                            self.curPiece.shape())


    def keyPressEvent(self, event):
        """processes key press events"""

        if not self.isStarted or self.curPiece.shape() == Tetrominoe.NoShape:
            super(Board, self).keyPressEvent(event)
            return

        key = event.key()

        if key == Qt.Key.Key_P:
            self.pause()
            return

        if self.isPaused:
            return

        elif key == Qt.Key.Key_Left.value:
            self.tryMove(self.curPiece, self.curX - 1, self.curY)

        elif key == Qt.Key.Key_Right.value:
            self.tryMove(self.curPiece, self.curX + 1, self.curY)

        elif key == Qt.Key.Key_Down.value:
            self.tryMove(self.curPiece.rotateRight(), self.curX, self.curY)

        elif key == Qt.Key.Key_Up.value:
            self.tryMove(self.curPiece.rotateLeft(), self.curX, self.curY)

        elif key == Qt.Key.Key_Space.value:
            self.dropDown()

        elif key == Qt.Key.Key_D.value:
            self.oneLineDown()

        else:
            super(Board, self).keyPressEvent(event)


    def timerEvent(self, event):
        """handles timer event"""

        if event.timerId() == self.timer.timerId():

            if self.isWaitingAfterLine:
                self.isWaitingAfterLine = False
                self.newPiece()
            else:
                self.oneLineDown()

        else:
            super(Board, self).timerEvent(event)


    def clearBoard(self):
        """clears shapes from the board"""

        for i in range(Board.BoardHeight * Board.BoardWidth):
            self.board.append(Tetrominoe.NoShape)


    def dropDown(self):
        """drops down a shape"""

        newY = self.curY

        while newY > 0:

            if not self.tryMove(self.curPiece, self.curX, newY - 1):
                break

            newY -= 1

        self.pieceDropped()


    def oneLineDown(self):
        """goes one line down with a shape"""

        if not self.tryMove(self.curPiece, self.curX, self.curY - 1):
            self.pieceDropped()


    def pieceDropped(self):
        """after dropping shape, remove full lines and create new shape"""

        for i in range(4):
            x = self.curX + self.curPiece.x(i)
            y = self.curY - self.curPiece.y(i)
            self.setShapeAt(x, y, self.curPiece.shape())

        self.removeFullLines()

        if not self.isWaitingAfterLine:
            self.newPiece()


    def removeFullLines(self):
        """removes all full lines from the board"""

        numFullLines = 0
        rowsToRemove = []

        for i in range(Board.BoardHeight):

            n = 0
            for j in range(Board.BoardWidth):
                if not self.shapeAt(j, i) == Tetrominoe.NoShape:
                    n = n + 1

            if n == 10:
                rowsToRemove.append(i)

        rowsToRemove.reverse()

        for m in rowsToRemove:

            for k in range(m, Board.BoardHeight):
                for l in range(Board.BoardWidth):
                    self.setShapeAt(l, k, self.shapeAt(l, k + 1))

        numFullLines = numFullLines + len(rowsToRemove)

        if numFullLines > 0:
            self.numLinesRemoved = self.numLinesRemoved + numFullLines
            self.msg2Statusbar.emit(str(self.numLinesRemoved))

            self.isWaitingAfterLine = True
            self.curPiece.setShape(Tetrominoe.NoShape)
            self.update()


    def newPiece(self):
        """creates a new shape"""

        self.curPiece = Shape()
        self.curPiece.setRandomShape()
        self.curX = Board.BoardWidth // 2 + 1
        self.curY = Board.BoardHeight - 1 + self.curPiece.minY()

        if not self.tryMove(self.curPiece, self.curX, self.curY):

            self.curPiece.setShape(Tetrominoe.NoShape)
            self.timer.stop()
            self.isStarted = False
            self.msg2Statusbar.emit("Game over")


    def tryMove(self, newPiece, newX, newY):
        """tries to move a shape"""

        for i in range(4):

            x = newX + newPiece.x(i)
            y = newY - newPiece.y(i)

            if x < 0 or x >= Board.BoardWidth or y < 0 or y >= Board.BoardHeight:
                return False

            if self.shapeAt(x, y) != Tetrominoe.NoShape:
                return False

        self.curPiece = newPiece
        self.curX = newX
        self.curY = newY
        self.update()

        return True


    def drawSquare(self, painter, x, y, shape):
        """draws a square of a shape"""

        colorTable = [0x000000, 0xCC6666, 0x66CC66, 0x6666CC,
                      0xCCCC66, 0xCC66CC, 0x66CCCC, 0xDAAA00]

        color = QColor(colorTable[shape])
        painter.fillRect(x + 1, y + 1, self.squareWidth() - 2,
                         self.squareHeight() - 2, color)

        painter.setPen(color.lighter())
        painter.drawLine(x, y + self.squareHeight() - 1, x, y)
        painter.drawLine(x, y, x + self.squareWidth() - 1, y)

        painter.setPen(color.darker())
        painter.drawLine(x + 1, y + self.squareHeight() - 1,
                         x + self.squareWidth() - 1, y + self.squareHeight() - 1)
        painter.drawLine(x + self.squareWidth() - 1,
                         y + self.squareHeight() - 1, x + self.squareWidth() - 1, y + 1)


class Tetrominoe:

    NoShape = 0
    ZShape = 1
    SShape = 2
    LineShape = 3
    TShape = 4
    SquareShape = 5
    LShape = 6
    MirroredLShape = 7


class Shape:

    coordsTable = (
        ((0, 0), (0, 0), (0, 0), (0, 0)),
        ((0, -1), (0, 0), (-1, 0), (-1, 1)),
        ((0, -1), (0, 0), (1, 0), (1, 1)),
        ((0, -1), (0, 0), (0, 1), (0, 2)),
        ((-1, 0), (0, 0), (1, 0), (0, 1)),
        ((0, 0), (1, 0), (0, 1), (1, 1)),
        ((-1, -1), (0, -1), (0, 0), (0, 1)),
        ((1, -1), (0, -1), (0, 0), (0, 1))
    )

    def __init__(self):

        self.coords = [[0, 0] for i in range(4)]
        self.pieceShape = Tetrominoe.NoShape

        self.setShape(Tetrominoe.NoShape)


    def shape(self):
        """returns shape"""

        return self.pieceShape


    def setShape(self, shape):
        """sets a shape"""

        table = Shape.coordsTable[shape]

        for i in range(4):
            for j in range(2):
                self.coords[i][j] = table[i][j]

        self.pieceShape = shape


    def setRandomShape(self):
        """chooses a random shape"""

        self.setShape(random.randint(1, 7))


    def x(self, index):
        """returns x coordinate"""

        return self.coords[index][0]


    def y(self, index):
        """returns y coordinate"""

        return self.coords[index][1]


    def setX(self, index, x):
        """sets x coordinate"""

        self.coords[index][0] = x


    def setY(self, index, y):
        """sets y coordinate"""

        self.coords[index][1] = y
        

    def minX(self):
        """returns min x value"""

        m = self.coords[0][0]
        for i in range(4):
            m = min(m, self.coords[i][0])

        return m


    def maxX(self):
        """returns max x value"""

        m = self.coords[0][0]
        for i in range(4):
            m = max(m, self.coords[i][0])

        return m


    def minY(self):
        """returns min y value"""

        m = self.coords[0][1]
        for i in range(4):
            m = min(m, self.coords[i][1])

        return m


    def maxY(self):
        """returns max y value"""

        m = self.coords[0][1]
        for i in range(4):
            m = max(m, self.coords[i][1])

        return m


    def rotateLeft(self):
        """rotates shape to the left"""

        if self.pieceShape == Tetrominoe.SquareShape:
            return self

        result = Shape()
        result.pieceShape = self.pieceShape

        for i in range(4):
            result.setX(i, self.y(i))
            result.setY(i, -self.x(i))

        return result


    def rotateRight(self):
        """rotates shape to the right"""

        if self.pieceShape == Tetrominoe.SquareShape:
            return self

        result = Shape()
        result.pieceShape = self.pieceShape

        for i in range(4):
            result.setX(i, -self.y(i))
            result.setY(i, self.x(i))

        return result


def main():

    app = QApplication([])
    tetris = Tetris()
    sys.exit(app.exec())


if __name__ == '__main__':
    main()

游戏被简化了一点,这样更容易理解。游戏启动后立即开始。我们可以按 P 键暂停游戏。按空格键,方块会立即掉落到底部。游戏以固定的速度运行,没有任加速度。分数是消除的行数。

self.tboard = Board(self)
self.setCentralWidget(self.tboard)

实例化 Board 类,并将其设置为应用程序的中心部件。

self.statusbar = self.statusBar()
self.tboard.msg2Statusbar[str].connect(self.statusbar.showMessage)

这里创建一个状态栏,用于显示消息。我们将显示三种可能的消息:已经删除的行数,暂停消息,或游戏结束消息。msg2Statusbar 是在 Board 类中实现的自定义信号。showMessage 是一个内置方法,用于在状态栏上显示消息。

self.tboard.start()

初始化游戏。

class Board(QFrame):
    msg2Statusbar = pyqtSignal(str)
...

pyqtSignal 创建了一个自定义信号。想在状态栏上展示信息或者分数,触发 msg2Statusbar 信号即可。

BoardWidth = 10
BoardHeight = 22
Speed = 300

这些是 Board 的参数。BoardWidthBoardHeight 定义画板的宽高。 Speed 是游戏的速度,每300毫秒游戏循环进行一次。

...
self.curX = 0
self.curY = 0
self.numLinesRemoved = 0
self.board = []
...

initBoard 方法中,我们初始化一些变量。self.board 是一个从0到7的数字列表,代表了砖块的各种形状和位置信息。

def shapeAt(self, x, y):
    """determines shape at the board position"""

    return self.board[(y * Board.BoardWidth) + x]

shapeAt 方法决定了形状的位置。

def squareWidth(self):
    """returns the width of one square"""

    return self.contentsRect().width() // Board.BoardWidth

画板可以动态调整大小。squareWidth 计算并返回单个形状的像素宽度。Board.BoardWidth 是画板的大小。

def pause(self):
    """pauses game"""

    if not self.isStarted:
        return

    self.isPaused = not self.isPaused

    if self.isPaused:
        self.timer.stop()
        self.msg2Statusbar.emit("paused")

    else:
        self.timer.start(Board.Speed, self)
        self.msg2Statusbar.emit(str(self.numLinesRemoved))

    self.update()

pause 方法暂停游戏,停止计时并在状态栏上显示一个信息。

def paintEvent(self, event):
    """paints all shapes of the game"""

    painter = QPainter(self)
    rect = self.contentsRect()
...

paintEvent 方法内绘制游戏。QPainter 是 PyQt6 里执行底层绘制的方法。

for i in range(Board.BoardHeight):
    for j in range(Board.BoardWidth):
        shape = self.shapeAt(j, Board.BoardHeight - i - 1)

        if shape != Tetrominoe.NoShape:
            self.drawSquare(painter,
                rect.left() + j * self.squareWidth(),
                boardTop + i * self.squareHeight(), shape)

游戏绘制分为两个步骤。在第一步中,我们画出所有的形状,或者已经落在画板底部的形状。所有的方格都记录在 self.board 的变量列表。可以使用 shapeAt 方法访问该变量。

if self.curPiece.shape() != Tetrominoe.NoShape:

    for i in range(4):

        x = self.curX + self.curPiece.x(i)
        y = self.curY - self.curPiece.y(i)
        self.drawSquare(painter, rect.left() + x * self.squareWidth(),
            boardTop + (Board.BoardHeight - y - 1) * self.squareHeight(),
            self.curPiece.shape())

第二步画出正在下落的砖块。

elif key == Qt.Key.Key_Right.value:
    self.tryMove(self.curPiece, self.curX + 1, self.curY)

keyPressEvent 方法中,我们检查按下的键。如果按下右箭头键,将尝试部件向右移动。说“尝试”是因为它可能无法移动。

elif key == Qt.Key.Key_Up.value:
    self.tryMove(self.curPiece.rotateLeft(), self.curX, self.curY)

Up 键向左旋转砖块。

elif key == Qt.Key.Key_Space.value:
    self.dropDown()

Space 键会让砖块直接落到底部。

elif key == Qt.Key.Key_D.value:
    self.oneLineDown()

按下D键,则会让砖块加速下落一会。

def timerEvent(self, event):
    """handles timer event"""

    if event.timerId() == self.timer.timerId():

        if self.isWaitingAfterLine:
            self.isWaitingAfterLine = False
            self.newPiece()
        else:
            self.oneLineDown()

    else:
        super(Board, self).timerEvent(event)

在计时器事件中,要么在前一个砖块到底部之后创建一个新的,要么将一个砖块向下移动一行。

def clearBoard(self):
    """clears shapes from the board"""

    for i in range(Board.BoardHeight * Board.BoardWidth):
        self.board.append(Tetrominoe.NoShape)

clearBoard 方法用设置 Tetrominoe.NoShape 的方式做到清空画板上的全部砖块。

def removeFullLines(self):
    """removes all full lines from the board"""

    numFullLines = 0
    rowsToRemove = []

    for i in range(Board.BoardHeight):

        n = 0
        for j in range(Board.BoardWidth):
            if not self.shapeAt(j, i) == Tetrominoe.NoShape:
                n = n + 1

        if n == 10:
            rowsToRemove.append(i)

    rowsToRemove.reverse()


    for m in rowsToRemove:

        for k in range(m, Board.BoardHeight):
            for l in range(Board.BoardWidth):
                    self.setShapeAt(l, k, self.shapeAt(l, k + 1))

    numFullLines = numFullLines + len(rowsToRemove)
...

如果砖块落在底部,就调用 removeFullLines 方法,找出所有完整的一行并删除。将所有行移动到当前整行的位置上,做到删除的效果。注意,我们颠倒了要删除的行的顺序,不然会出现BUG,在我们的例子中,我们使用了 naive gravity,这意味着不是整行的砖块可能漂浮在空白的间隙之上。

def newPiece(self):
    """creates a new shape"""

    self.curPiece = Shape()
    self.curPiece.setRandomShape()
    self.curX = Board.BoardWidth // 2 + 1
    self.curY = Board.BoardHeight - 1 + self.curPiece.minY()

    if not self.tryMove(self.curPiece, self.curX, self.curY):

        self.curPiece.setShape(Tetrominoe.NoShape)
        self.timer.stop()
        self.isStarted = False
        self.msg2Statusbar.emit("Game over")

newPiece 方法创建随机的砖块,如果创建的砖块不能到达初始位置,游戏结束。

def tryMove(self, newPiece, newX, newY):
    """tries to move a shape"""

    for i in range(4):

        x = newX + newPiece.x(i)
        y = newY - newPiece.y(i)

        if x < 0 or x >= Board.BoardWidth or y < 0 or y >= Board.BoardHeight:
            return False

        if self.shapeAt(x, y) != Tetrominoe.NoShape:
            return False

    self.curPiece = newPiece
    self.curX = newX
    self.curY = newY
    self.update()

    return True

tryMove 方法尝试移动一个砖块,如果砖块在画板或者另外一个砖块的边缘,返回 False。否则就执行移动。

class Tetrominoe:

    NoShape = 0
    ZShape = 1
    SShape = 2
    LineShape = 3
    TShape = 4
    SquareShape = 5
    LShape = 6
    MirroredLShape = 7

Tetrominoe 类包含了所有形状的名称,这里也有一个叫 NoShape 的空形状。

Shape 类保存了砖块的信息。

class Shape(object):

    coordsTable = (
        ((0, 0),     (0, 0),     (0, 0),     (0, 0)),
        ((0, -1),    (0, 0),     (-1, 0),    (-1, 1)),
        ...
    )
...

coordsTable 元组里包括了所有砖块的组合坐标,可以从这个模板里拼出需要的砖块。

self.coords = [[0,0] for i in range(4)]

初始化时,创建一个空的坐标列表,用来存储砖块的坐标。

比如,(0, -1), (0, 0), (-1, 0), (-1, -1) 代表了Z字形的砖块。

def rotateLeft(self):
    """rotates shape to the left"""

    if self.pieceShape == Tetrominoe.SquareShape:
        return self

    result = Shape()
    result.pieceShape = self.pieceShape

    for i in range(4):

        result.setX(i, self.y(i))
        result.setY(i, -self.x(i))

    return result

rotateLeft 方法向左旋转砖块。方形没必要旋转,所以就直接返回当前的对象。当砖块发生旋转时,会产生一个新的对象代表这个旋转后的砖块。

运行结果
运行结果
上次编辑于:
贡献者: 棋.