Python入门桌面应用开发PyQT

举报
Gere 发表于 2022/07/16 21:39:54 2022/07/16
【摘要】 文章目录1. 概述2. PyQt的组织架构3. 快速体验3.1 桌面应用程序开发的一般流程3.2 Hello World4 控件布局4.1 分区布局4.2 栅格布局4.3 表单布局5. 事件和事件函数5.1 事件模型5.2 重写(Override)事件函数5.3 事件过滤器6. 信号和槽6.1 信号和槽的连接6.2 自定义信号7 桌面应用程序通用框架7.1 顶层主窗口类7.2 状态栏7.3 ...


1. 概述

Qt是一个跨平台的C++图形用户界面应用程序开发框架,目前已成为最强大,最受欢迎的跨平台GUI库之一。或许说Qt是一个GUI库并不恰当,因为Qt已经庞大到可以提供“一站式”服务了:既可开发GUI程序,也可开发非GUI程序,比如控制台工具和服务程序。

PyQt是Qt的Python封装,提供Qt类和函数的API。PyQt的最新版本是PyQt6,本文代码使用的就是这个版本。和上一个版本PyQt5相比,不考虑性能提升仅就使用习惯而言,PyQt6似乎只有以下3个改变:

  • QAction类从QtWidgets模块移到了QtGui模块
  • QApplication类exec_方法更名为exec,去掉了后面的下划线
  • 枚举和常量增加了分组限定,比如QtCore.Qt中的AlignCenter变成了AlignmentFlag.AlignCenter

新用户可以直接使用下面的命令安装最新版。如果此前已经安装了PyQt5也没有关系,PyQt6可以和PyQt5并存,甚至可以混合使用——尽管这不是一个值得推荐的做法。

pip install PyQt6

安装完成后,可以在Python的IDLE中检查Qt和PyQt的版本。

>>> from PyQt6.QtCore import QT_VERSION_STR, PYQT_VERSION_STR
>>> QT_VERSION_STR, PYQT_VERSION_STR
('6.3.0', '6.3.0')

2. PyQt的组织架构

在一个热爱生活的的程序员眼里,诸美皆可食。如果把wxPthon比作是珍馐玉馔,那么Tkinter就好比是肯德基套餐,而PyQt则是传说中的满汉全席了。据说一顿满汉全席要持续三天,倘若被邀赴宴,不事先了解一下宴席的礼仪规矩、菜系菜品和时序流程,恐怕要闹出不少笑话。同样的,既然把PyQt比作满汉全席,那就有必要在使用之前了解一下它的组织架构,以免被代码中导入的数量巨多的模块和类弄得晕头转向,从此产生心理阴影。

PyQt究竟有多么庞大呢?看看下面这张PytQt6的组织架构图就清楚了,PytQt5比这个还要臃肿一点点。

在这里插入图片描述

这张图只列出了PyQt6的常用模块,每个模块各自封装了大量的类和函数。其中QtWidgets模块、QtGui模块、QtCore模块是桌面程序开发中使用频率最高的模块,QtWidgets类中QApplication类、QWidget类和QMainWindow类又是使用频率最高的类。根据模块和类在桌面程序开发中使用频率的高低,我给它们标注了红黄绿蓝四种颜色。当然,这是非常主观的,完全是我个人的使用体验。


【小结:几乎每个应用程序都会用到的3个模块】

QtWidgets模块:包含应用程序类、窗口类、控件类和组件类
QtGui模块:包含和gui相关的功能,例如用于事件处理、图像处理、字体和颜色类等
QtCore模块:包含核心的非gui功能,例如线程、定时器、日期时间类、文件类等

3. 快速体验

3.1 桌面应用程序开发的一般流程

用PyQt6写一个桌面应用程序,通常分为五个步骤:

  1. 创建应用程序
  2. 创建窗口
  3. 把需要的控件放到窗口上,并告诉它们当有预期的事件发生时就执行预设的动作
  4. 显示窗口
  5. 应用程序进入事件处理主循环

除第3步之外的其它步骤,基本都是一行代码就可以完成,第3步的复杂程度取决于功能需求的多寡和业务逻辑的复杂度。下面这段代码就是这个一般流程的体现。

from PyQt6.QtWidgets import *

app = QApplication([]) # 第1步:创建应用程序
win = QWidget() # 第2步:创建窗口
lab = QLabel('Hello World', win) # 第3步:显示Hello World
win.show() # 第4步:显示窗口
app.exec() # 第5步:应用程序进入事件处理主循环

代码中导入了QtWidgets模块的全部类,但只用到了QApplication类(构建应用程序)、QWidget类(构建窗口)和QLabel类(实例化标签)等3个类。这段代码虽然运行起来没有问题,但不够美观,也没有遵循下面4个约定俗成的规则:

  • 由于PyQt太过庞大,应当尽量避免使用星号导入所有项,而是仅导入指定的项
  • 应用程序以sys.argv作为来自命令行的参数列表
  • 应用程序退出事件处理主循环后调用sys.exit函数清理现场
  • 面向对象编程,减少全局变量,将业务逻辑封装在派生的窗口类中

3.2 Hello World

下面这段代码遵循了上述的规则,加上了窗口标题和图标,同时设置了标签大小、文本的字体字号和对齐方式。

import sys
from PyQt6.QtWidgets import QApplication, QWidget, QLabel
from PyQt6.QtGui import QIcon, QFont
from PyQt6.QtCore import Qt

class MyWindow(QWidget):
    """从QWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__() # 调用基类的构造函数
        
        self.setWindowTitle('Hello World') # 设置标题
        self.setWindowIcon(QIcon('res/qt.png')) # 设置图标

        lab = QLabel('Hello World', self) # 实例化标签
        lab.resize(320,160) # 设置标签大小
        lab.setFont(QFont('Arial', 32, QFont.Weight.Bold)) # 设置字体字号
        lab.setAlignment(Qt.AlignmentFlag.AlignCenter) # 文本在标签内居中

        self.show() # 显示窗口

if __name__ == '__main__':
    app = QApplication(sys.argv) # 创建应用程序,接收来自命令行的参数列表
    win = MyWindow() # 创建窗口
    sys.exit(app.exec()) # 应用程序主循环结束后,调用sys.exit()方法清理现场

代码中用到了一个.png格式的图像文件文件,想要运行这段代码的话,请先替换成本地文件。至于文件格式,setWindowIcon方法没有任何限制,常见的包括.ico在内的图像格式都支持。运行界面如下图所示。

在这里插入图片描述


【小结:使用频率最高的类】

QApplication类:每个应用程序都是该类的实例
QWidget类:所有窗口和控件的基类,每个应用程序的窗口都是该类或其派生类的实例
QLabel:出镜率最高的控件之一

4 控件布局

在Hello World例子中设置了标签的大小,窗口自动适应标签。通常的应用场景正好与之相反:控件需要根据窗口大小自动适应,并且多个控件之间的相对关系也需要自动适应。这就是控件布局要实现的功能。

4.1 分区布局

所谓分区布局,就是将一个矩形区域沿水平或垂直方向分割成多个矩形区域,并可嵌套分区。QtWidgets模块提供了QBoxLayout类作为分区布局管理器,其派生的QHBoxLayout类和QVBoxLayout类分别实现水平分区布局和垂直分区布局。

分区布局的诸多方法中有两个极为重要的参数,需要详细说明。

  • stretch:拉伸因子,用来设置控件或子项在布局方向上占用剩余空间的额度。例如,有三个控件水平布局,假定三个控件的拉伸因子分别为0、1、2,那么第1个控件除了自身宽度外,不占用剩余空间,第2个控件除了自身宽度外,占用剩余空间的1/3,第3个控件除了自身宽度外,占用剩余空间的2/3。
  • alignment:对齐方式,用来设置控件在其可用空间内的放置方式。默认控件填充分配给自身的全部空间,可以选项由QtCore.Qt.AlignmentFlag枚举,包括AlignLeft、AlignRight、AlignTop、AlignBottom、AlignCenter、AlignHCenter、AlignVCenter、AlignBaseline、AlignJustify等。

分区布局QBoxLayout类的方法很多,不过最重要最常用的只有下述这几个:

  • addWidget(QWidget, stretch=0, alignment=0) - 添加部件
  • addLayout(QLayout, stretch=0) - 添加布局管理器
  • addStretch(stretch) - 添加拉伸因子
  • addSpacing(spacing) - 添加以像素为单位的空间

下面是一个水平和垂直混合分区布局的例子。

import sys
from PyQt6.QtWidgets import QApplication, QWidget, QLabel, QLineEdit, QPushButton, QHBoxLayout, QVBoxLayout
from PyQt6.QtGui import QIcon
from PyQt6.QtCore import Qt

class MyWindow(QWidget):
    """从QWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('分区布局')
        self.setWindowIcon(QIcon('res/qt.png'))
        self.setGeometry(400, 300, 320, 160) # 设置窗位置和大小
        
        lab_acc = QLabel('账号:')
        account = QLineEdit()
        account.setAlignment(Qt.AlignmentFlag.AlignCenter)

        lab_pw = QLabel('密码:')
        passwd = QLineEdit()
        passwd.setAlignment(Qt.AlignmentFlag.AlignCenter)
        passwd.setEchoMode(QLineEdit.EchoMode.Password) # 不显示密码

        btn_ok = QPushButton('确定')
        btn_cancel = QPushButton('取消')

        # 使用水平布局管理器布局lab_acc控件和account控件,左右留白10像素
        hbox_acc = QHBoxLayout()
        hbox_acc.addSpacing(10)
        hbox_acc.addWidget(lab_acc)
        hbox_acc.addWidget(account)
        hbox_acc.addSpacing(10)
        
        # 使用水平布局管理器布局lab_pw控件和passwd控件,左右留白10像素
        hbox_pw = QHBoxLayout()
        hbox_pw.addSpacing(10)
        hbox_pw.addWidget(lab_pw)
        hbox_pw.addWidget(passwd)
        hbox_pw.addSpacing(10)

        # 使用水平布局管理器布局btn_ok控件和btn_cancel控件
        hbox_btn = QHBoxLayout() # 水平布局管理器
        hbox_btn.addStretch(5) # 设置左侧拉伸因子
        hbox_btn.addWidget(btn_ok) # 添加btn_ok控件
        hbox_btn.addWidget(btn_cancel) # 添加btn_cancel控件
        hbox_btn.addStretch(1) # 设置右侧拉伸因子

        # 使用垂直布局管理器布局上面3个水平布局管理器
        vbox = QVBoxLayout() 
        vbox.addSpacing(10)
        vbox.addLayout(hbox_acc)
        vbox.addSpacing(5)
        vbox.addLayout(hbox_pw)
        vbox.addStretch(1)
        vbox.addLayout(hbox_btn)
        vbox.addSpacing(10)

        # 将垂直布局管理器应用到窗口
        self.setLayout(vbox)

        self.show() # 显示窗口

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MyWindow()
    sys.exit(app.exec())

这段代码设计了一个登录界面,演示了水平布局和垂直布局的方法,同时介绍了单行文本编辑框控件QLineEdit和按钮控件QPushButton的用法。运行界面如下图所示。

在这里插入图片描述


【小结:除了布局管理类,按钮和单行文本编辑框也是最常用的控件】

QHBoxLayout:水平布局管理器
QVBoxLayout:垂直布局管理器
QPushButton:按钮
QLineEdit:单行文本编辑框

4.2 栅格布局

顾名思义,栅格布局就是将布局空间划分成网格,将控件放置到不同的网格内。栅格布局比较简单,用起来非常方便。栅格布局QGridLayout类的主要方法有:

  • addWidget(QWidget, row, col, alignment) - 在row行col列添加控件,并设置对齐方式
  • addWidget(QWidget, row, col, r, c, alignment) - 在row行col列添加控件,占r行c列,并设置对齐方式
  • addLayout(QLayout, row, col, alignment) - 在row行col列添加布局,并设置对齐方式
  • addLayout(QLayout, row, col, r, c, alignment) - 在row行col列添加布局,占r行c列,并设置对齐方式
  • setRowStretch(row, stretch) - 设置row行的拉伸因子
  • setColumnStretch(col, stretch) - 设置col列的拉伸因子

下面是一个栅格布局的例子。

import sys
from PyQt6.QtWidgets import QApplication, QWidget, QPushButton, QGridLayout
from PyQt6.QtGui import QIcon

class MyWindow(QWidget):
    """从QWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('栅格布局')
        self.setWindowIcon(QIcon('res/qt.png'))
        
        self.initUI() # 初始化界面
        self.show() # 显示窗口
    
    def initUI(self):
        """初始化界面"""
        
        keys = [
            ['(', ')', 'Back', 'Clear'],
            ['7',  '8',  '9',  '/'], 
            ['4',  '5',  '6',  '*'], 
            ['1',  '2',  '3',  '-'], 
            ['0',  '.',  '=',  '+']
        ]
        
        grid = QGridLayout() # 创建网格布局管理器
        self.setLayout(grid) # 将网格布局管理器应用到窗口
        
        for i in range(5):
            for j in range(4):
                button = QPushButton(keys[i][j])
                grid.addWidget(button, i, j)

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MyWindow()
    sys.exit(app.exec())

这段代码实现了一个计算器界面,5行4列20个按钮放置在5行4列的网格上。代码运行界面如下图所示。

在这里插入图片描述


【小结:栅格布局管理器】

QGridLayout:使用频率和分区布局管理器不相上下

4.3 表单布局

分区布局的例子是一个登录界面,界面上每一行都是一个标签和一个单行文本编辑器。针对此种情况,PyQt推出了表单布局,专用于登录、注册等标签和单行文本编辑器成对使用的情况。通过下面的代码,很容易掌握表单布局管理器QFormLayout类的用法。

import sys
from PyQt6.QtWidgets import QApplication, QWidget, QLabel, QLineEdit, QPushButton, QVBoxLayout, QFormLayout
from PyQt6.QtGui import QIcon
from PyQt6.QtCore import Qt

class MyWindow(QWidget):
    """从QWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('表单布局')
        self.setWindowIcon(QIcon('res/qt.png'))
        self.setGeometry(400, 300, 320, 200) # 设置窗位置和大小
        
        form = QFormLayout() # 创建表单布局管理器
        form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) #设置标签右对齐(默认左对齐)
        
        name = QLineEdit()
        mobile = QLineEdit()
        passwd = QLineEdit()
        addr = QLineEdit()
        
        form.addRow('姓名', name)
        form.addRow('移动电话', mobile)
        form.addRow('密码', passwd)
        form.addRow('通讯地址', addr)
        
        name.setPlaceholderText("请输入姓名")
        mobile.setPlaceholderText("请输入移动电话")
        passwd.setPlaceholderText("请输入密码")
        addr.setPlaceholderText("请输入通讯地址")
        
        name.setEchoMode(QLineEdit.EchoMode.Normal)
        mobile.setEchoMode(QLineEdit.EchoMode.NoEcho)
        passwd.setEchoMode(QLineEdit.EchoMode.Password)
        addr.setEchoMode(QLineEdit.EchoMode.PasswordEchoOnEdit)
        
        btn_ok = QPushButton('确定')
        
        vbox = QVBoxLayout()
        vbox.addSpacing(10)
        vbox.addLayout(form)
        vbox.addStretch(1)
        vbox.addWidget(btn_ok, alignment=Qt.AlignmentFlag.AlignCenter)
        vbox.addSpacing(10)
        
        # 将垂直布局管理器应用到窗口
        self.setLayout(vbox)

        self.show() # 显示窗口

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MyWindow()
    sys.exit(app.exec())

代码运行界面如下图所示。

在这里插入图片描述


【小结:表单布局管理器】

QFormLayout:适用于标签和单行文本编辑框成对使用的场合

5. 事件和事件函数

如果把窗体和控件比作是桌面程序的躯体,那么事件驱动机制就是它的灵魂。同样的,PyQt的GUI程序也是事件驱动的,但不同于Tkinter和wx那样任由用户绑定事件和事件函数,事件在PyQt中是底层的,每个事件都有与之对应的事件函数——也许只是个空函数,用户无法改变它们之间的绑定关系。

5.1 事件模型

PyQt事件大致有键盘事件、鼠标事件、拖放事件、滚轮事件、定时事件、焦点事件、进入和离开事件、窗口关闭事件、窗口移动事件、窗口显示和隐藏事件,窗口选中事件,以及Socket事件、剪贴板事件、文字改变事件,布局改变事件等。

事件有三个要素:事件源、事件对象和事件目标。事件源指向事件的制造者,事件目标指向事件的处理者,事件对象封装了事件的信息,比如事件源的状态变化等。

针对每个事件,窗口和控件的基类QWidget提供与之对应的事件函数。有些事件函数是有内容的,比如和窗口关闭事件对应的closeEvent;有些事件函数则是空的,比如和键盘事件对应的keyPressEvent,需要用户根据业务逻辑重写(Override)。所有的事件函数都以事件对象为参数,事件对象提供了事件的详细信息,比如键盘按下事件的事件对象就包含了被按下的键的信息。

以下是PyQt的全部事件函数。

  • actionEvent
  • changeEvent
  • childEvent
  • closeEvent
  • contextMenuEvent
  • customEvent
  • dragEnterEvent
  • dragLeaveEvent
  • dragMoveEvent
  • dropEvent
  • enterEvent
  • focusInEvent
  • focusOutEvent
  • hideEvent
  • inputMethodEvent
  • installEventFilter
  • keyPressEvent
  • keyReleaseEvent
  • leaveEvent
  • mouseDoubleClickEvent
  • mouseMoveEvent
  • mousePressEvent
  • mouseReleaseEvent
  • moveEvent
  • nativeEvent
  • paintEvent
  • removeEventFilter
  • resizeEvent
  • showEvent
  • tabletEvent
  • timerEvent
  • wheelEvent

5.2 重写(Override)事件函数

既然事件和事件函数之间的已经建立了对应关系,用户就无需考虑事件和事件函数之间的绑定,只在有必要的时候重写(Override)事件函数即可。

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

class MyWindow(QWidget):
    """从QWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('事件和事件函数')
        self.setGeometry(400, 300, 320, 80)
        
        self.initUI() # 初始化界面
        self.show()
    
    def initUI(self):
        """初始化界面"""
        
        lab = QLabel('按Esc键关闭窗口')
        box = QHBoxLayout()
        box.addStretch(1)
        box.addWidget(lab)
        box.addStretch(1)
        self.setLayout(box)
    
    def keyPressEvent(self, evt):
        """重写按键事件函数"""
        
        if evt.key() == Qt.Key.Key_Escape.value:
            self.close()

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MyWindow()
    sys.exit(app.exec())

这段代码重写了键盘按下事件函数keyPressEvent,实现了Esc键关闭窗口的功能。代码运行界面如下图所示。

在这里插入图片描述
下面这段代码在控制台打印了鼠标进入窗口事件对象的全部属性和方法,名字通俗易懂,望文生义基本都不会错。

import sys
from PyQt6.QtWidgets import QApplication, QWidget, QLabel, QHBoxLayout
from PyQt6.QtGui import QIcon, QFont
from PyQt6.QtCore import Qt

class MyWindow(QWidget):
    """从QWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('事件对象')
        self.setWindowIcon(QIcon('res/qt.png'))
        self.setGeometry(400, 300, 320, 80)
        
        self.initUI() # 初始化界面
        self.show()
    
    def initUI(self):
        """初始化界面"""
        
        self.lab = QLabel('')
        self.lab.setFont(QFont('Arial', 32, QFont.Weight.Bold))
        self.lab.setAlignment(Qt.AlignmentFlag.AlignCenter)
        
        box = QHBoxLayout()
        box.addWidget(self.lab)
        self.setLayout(box)
    
    def enterEvent(self, evt):
        """重写进入事件函数"""
        
        for item in dir(evt):
            print(item)
        
        pos = evt.position()
        self.lab.setText('x=%d, y=%d'%(pos.x(), pos.y()))

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MyWindow()
    sys.exit(app.exec())

代码运行界面如下图所示。

在这里插入图片描述
鼠标进入窗口时,控制台同时打印了事件对象的属性和方法。

accept
allPointsAccepted
button
buttons
clone
device
deviceType
exclusivePointGrabber
globalPosition
ignore
isAccepted
isBeginEvent
isEndEvent
isInputEvent
isPointerEvent
isSinglePointEvent
isUpdateEvent
modifiers
point
pointById
pointCount
pointerType
pointingDevice
points
position
registerEventType
scenePosition
setAccepted
setExclusivePointGrabber
spontaneous
timestamp
type

【小结:重写(Override)】

重写是子类对父类允许访问的方法的实现过程重新编写代码, 返回值和形参都不能改变,即外壳不变,核心重写。重写不同于与重载(Overload),重载是方法的参数个数或种类或顺序不同,方法名相同。

5.3 事件过滤器

在某些应用特定场景中,需要拦截屏蔽某些事件,或者在某些事件被处理前插入其他操作,此时就需要事件过滤器出场了。事件过滤器的使用有几个要点:一是要重写QObject.eventFilter过滤器,二是要在调用QApplication.installEventFilter安装过滤器。

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

class MyWindow(QWidget):
    """从QWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('事件过滤器')
        self.setGeometry(400, 300, 320, 80)
        
        self.initUI() # 初始化界面
        self.show()
    
    def initUI(self):
        """初始化界面"""
        
        lab = QLabel('按键事件被过滤,按Esc键不能关闭窗口')
        box = QHBoxLayout()
        box.addStretch(1)
        box.addWidget(lab)
        box.addStretch(1)
        self.setLayout(box)
    
    def keyPressEvent(self, evt):
        """重写按键事件函数"""
        
        if evt.key() == Qt.Key.Key_Escape.value:
            self.close()
    
    def eventFilter(self, objwatched, evt):
        """事件过滤器"""
        
        if evt.type() == QEvent.Type.KeyPress.value:
            print('忽略按键事件')
            return True
                
        return super().eventFilter(objwatched, evt)

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MyWindow()
    app.installEventFilter(win)
    sys.exit(app.exec())

尽管这段代码里也同样包含了按Esc关闭窗口的代码,但因为使用了事件过滤器过滤按键事件,导致不能像上一个例子那样按Esc键关闭窗口。代码运行界面如下图所示。

在这里插入图片描述


【小结:拦截屏蔽事件的关键】

在事件过滤器eventFilter中,在需要拦截屏蔽的事件分支中返回True,在插入操作后需要继续向后传递的事件分支中返回False。

6. 信号和槽

PyQt中提供了两种事件处理的机制:一种是事件和事件函数,另一种就是信号和槽。事件和事件函数是比较底层的机制,信号和槽可以说是对这种底层事件处理机制的高级封装,也是PyQt的特色。

6.1 信号和槽的连接

在PyQt体系中,点击按钮就会发射clicked信号,改变文本编辑框内容就会发射textChanged信号。信号(Signals)在本质上也是事件,只是PyQt没有提供与之对应事件函数,而是使用槽(Slots)来接收信号。槽可以是内置的,也可以由用户自行定义。信号和槽的连接,类似于Tkinter和wx的事件绑定。

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

class MyWindow(QWidget):
    """从QWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('信号和槽')
        self.setGeometry(400, 300, 240, 150)
        
        self.initUI()
        self.show()
    
    def initUI(self):
        """初始化界面"""
        
        lcd = QLCDNumber(self)
        sld = QSlider(Qt.Orientation.Horizontal, self)

        vbox = QVBoxLayout()
        vbox.addWidget(lcd)
        vbox.addSpacing(5)
        vbox.addWidget(sld)

        self.setLayout(vbox)
        
        sld.valueChanged.connect(lcd.display)
        sld.sliderReleased.connect(self.on_slider)
    
    def on_slider(self):
        """自定义槽函数,被连接到释放滑块的信号"""
        
        answer = QMessageBox.question(self, '操作提示', '确定关闭窗口?', 
            QMessageBox.StandardButton.Yes|QMessageBox.StandardButton.No, QMessageBox.StandardButton.No)
        
        if answer == QMessageBox.StandardButton.Yes:
            self.close()

if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = MyWindow()
    sys.exit(app.exec())

这段代码将滑块改变发出的信号valueChanged连接到了液晶显示屏控件QLCDNumber的display方法,将滑块被释放的信号sliderReleased连接到自定义的on_slider方法上,该方法弹窗询问是否关闭窗口。代码运行界面如下图所示。

在这里插入图片描述


【小结:新控件】

QLCDNumber:液晶显示屏控件
QSlider:滑块控件

6.2 自定义信号

如果仅仅把信号和槽视为事件和事件函数的别名,那就小看了PyQt这个独步天下的消息机制。PyQt的创世主QObject——所有类的基类——允许用户通过实例化信号基类pyqtSignal来自定义信号。

import sys, time
from PyQt6.QtWidgets import QApplication, QWidget, QLCDNumber, QVBoxLayout
from PyQt6.QtGui import QIcon
from PyQt6.QtCore import QObject, pyqtSignal, QThread, QTime

class ClockServer(QObject):
    """计时服务"""
    
    update_time = pyqtSignal(str) # 更新时间信号
    
    def run(self):
        """启动服务"""
        
        while True:
            ct = QTime.currentTime()
            self.update_time.emit(ct.toString('hh:mm:ss'))
            time.sleep(0.2)

class MyWindow(QWidget):
    """从QWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('自定义信号')
        self.setGeometry(400, 300, 420, 120)
        
        self.initUI()
        self.show()
    
    def initUI(self):
        """初始化界面"""
        
        lcd = QLCDNumber()
        lcd.setDigitCount(8)
        
        vbox = QVBoxLayout()
        vbox.addWidget(lcd)
        self.setLayout(vbox)
        
        
        self.ts = ClockServer() # 创建计时服务
        self.ts.update_time.connect(lcd.display) # 连接update_time信号到液晶显示屏的显示函数
        
        self.thread = QThread() # 创建线程
        self.ts.moveToThread(self.thread) # 将计时服务加到线程
        self.thread.started.connect(self.ts.run) # 将计时服务的启动方法设置为线程函数
        self.thread.start() # 启动线程

这段代码定义了一个计时服务,并以线程方式运行。计时服务每隔200毫秒就会发射一次update_time信号,该信号被连接到液晶显示屏控件QLCDNumber的display方法上,最终实现的是一个数字时钟。代码运行界面如下图所示。

在这里插入图片描述


【小结:在PyQt中使用线程】

PyQt不允许从其主线程外部访问任何类型的对象,这意味着外部线程无法更新、设置任何控件或部件。因此,在PyQt中使用线程的话最好使用用QThread,QThread基于QObject,QObject的好处是享受PyQt的信号槽机制。在QObject中自定义信号,通过信号发射和接收实现与主线程通讯。该方式需要重写QThread类中的run方法,就像本例这样。

7 桌面应用程序通用框架

7.1 顶层主窗口类

在前面所有例子中,创建窗口时无一例外地都使用了QWidget这个窗口基类。其实,QWidget还有两个派生类QDialog和QMainWindow,这三个类都可以用来创建窗口。那么,它们有什么区别,又各自适用于什么场合呢?

QWidget作为基类,自然可以应用在任何场合,但一切都需要自己动手,白手起家,唯一的好处是想怎么干就怎么干。

QDialog是对话框窗口的基类,可用来执行短期任务,或者与用户进行互动。对话框窗口可以是模态的,也可以是非模态的,没有菜单栏、工具栏、状态栏等。

QMainWindow类是GUI程序的主窗口类,提供了构建用户应用程序界面的框架,包括MenuBar、ToolBar、StatusBar、DockWidget、CentralWidget等,如下图所示。如果应用程序需要菜单栏、工具栏、状态栏等组件,应该首选QMainWindow类来创建窗口。
在这里插入图片描述

因为QMainWindow类有自己的布局,不能像前面的例子那样调用窗口的setLayout方法布局,因此也就不能嵌入到其他窗口,只能作为顶层窗口。如果要对QMainWindow类创建的窗口内的部件布局,应该调用中央部件区的setLayout方法。下面的代码演示了QmainWindow类的布局方法。

import sys
from PyQt6.QtWidgets import QApplication, QMainWindow, QWidget, QLabel, QHBoxLayout
from PyQt6.QtGui import QIcon, QFont
from PyQt6.QtCore import Qt

class MainWindow(QMainWindow):
    """从QMainWindow类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('主窗口布局')
        self.setWindowIcon(QIcon('res/qt.png'))
        self.resize(320, 160) # 设置窗大小
        self.center() # 窗口在屏幕上居中
        
        self.initUI() # 初始化界面
        self.show() # 显示窗口
    
    def center(self):
        """窗口在屏幕上居中"""
        
        win_rect = self.frameGeometry() # 窗口矩形(QRect类)
        scr_center = self.screen().availableGeometry().center() # 屏幕中心
        win_rect.moveCenter(scr_center) # 窗口矩形中心移动到屏幕中心
        self.move(win_rect.topLeft()) # 移动窗口和窗口矩形重合
    
    def initUI(self):
        """初始化界面"""
        
        lab = QLabel('Hello World')
        lab.setFont(QFont('Arial', 32, QFont.Weight.Bold))
        lab.setAlignment(Qt.AlignmentFlag.AlignCenter)
        
        box = QHBoxLayout()
        box.addWidget(lab)
        
        self.main_widget = QWidget()
        self.main_widget.setLayout(box)
        self.setCentralWidget(self.main_widget)

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MainWindow()
    sys.exit(app.exec())

这段代码还定义了一个令窗口在屏幕上居中的方法,其中隐约可见QRect对象的使用技巧。类似QRect这样的小玩意儿,在PyQt中随处可见,喜欢的则拍案叫绝,不喜欢的则嗤之以鼻,正所谓萝卜青菜各有所爱。代码运行界面如下图所示。

在这里插入图片描述


【小结:新控件】

QMainWindow:顶层窗口类,重量级控件。出场虽晚,但绝对是大腕儿。

7.2 状态栏

实例化QStatusBar类可以得到一个状态栏,再调用QMainWindow.setStatusBar方法就可以将其添加到主窗口。不过,QMainWindow提供了更简单的方式:调用其statusBar方法可以返回一个QStatusBar对象,调用该对象的showMessage方法,就可以将信息显示在状态栏上了。例如,在一个QMainWindow的派生类中,随时可以像下面这样在状态栏上显示信息。

self.statusBar().showMessage('准备就绪')

不过,若想像wxWidgets那样将状态栏分栏显示多组状态,又该如何实现呢?下面的例子展示了思路。

import sys
from PyQt6.QtWidgets import QApplication, QMainWindow, QWidget, QLabel, QHBoxLayout
from PyQt6.QtGui import QIcon, QPixmap
from PyQt6.QtCore import Qt

class MainWindow(QMainWindow):
    """从QMainWindow类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('状态栏')
        self.setWindowIcon(QIcon('res/qt.png'))
        
        self.initUI() # 初始化界面
        self.center() # 窗口在屏幕上居中
        self.show() # 显示窗口
    
    def center(self):
        """窗口在屏幕上居中"""
        
        win_rect = self.frameGeometry() # 窗口矩形(QRect类)
        scr_center = self.screen().availableGeometry().center() # 屏幕中心
        win_rect.moveCenter(scr_center) # 窗口矩形中心移动到屏幕中心
        self.move(win_rect.topLeft()) # 移动窗口和窗口矩形重合
    
    def initUI(self):
        """初始化界面"""
        
        lab = QLabel()
        lab.setPixmap(QPixmap('res/forever.png'))
        
        box = QHBoxLayout()
        box.addWidget(lab)
        
        self.main_widget = QWidget()
        self.main_widget.setLayout(box)
        self.setCentralWidget(self.main_widget)
        
        # 对于需要处理鼠标移动事件的部件,开启感知鼠标移动轨迹
        lab.setMouseTracking(True)
        self.main_widget.setMouseTracking(True)
        self.setMouseTracking(True)
        
        self.sbar = self.statusBar() # 返回窗口状态栏
        self.mouth_info = QLabel() # 显示鼠标坐标的标签
        self.sbar.addPermanentWidget(self.mouth_info) # 将显示鼠标坐标的标签添加到状态栏
        self.sbar.addPermanentWidget(QLabel('版权所有 ')) # 将版权声明添加到状态栏
        self.sbar.showMessage('准备就绪', 3000) # 显示消息,3秒钟后消失
    
    def enterEvent(self, evt):
        """响应鼠标进入窗口事件"""
        
        self.sbar.showMessage('鼠标进入', 3000)

    
    def leaveEvent(self, evt):
        """响应鼠标离开窗口事件"""
        
        self.sbar.showMessage('鼠标离开', 3000)
        self.mouth_info.setText('')
    
    def mouseMoveEvent(self, evt):
        """响应鼠标移动事件"""
        
        pos = evt.position()
        self.mouth_info.setText('x=%d, y=%d '%(pos.x(), pos.y()))

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MainWindow()
    sys.exit(app.exec())

这段代码使用状态栏的addPermanentWidget方法在状态栏里加了两个QLabel,一个用来显示版权信息,一个用来显示鼠标位置。鼠标进入或离开窗口、鼠标在窗口内移动,都会更新状态栏上相应位置的信息。代码运行界面如下图所示。

在这里插入图片描述


【小结:状态栏信息显示小技巧】

QStatusBar.showMessage(str, msecs=0):显示str消息持续msecs毫秒。如果msecs为0(默认),则消息将一直显示,直到调用clearMessage()或再次showMessage以更改消息。

7.3 菜单栏

和状态栏类似,调用QMainWindow类的menuBar方法就得到一个QMenuBar对象——菜单栏,调用菜单栏的addMenu就生成一个QMenu实例,也就是一个主菜单。主菜单可以添加菜单项(QAction实例),也可以添加子菜单(QMenu实例)。

设计菜单,一般会用到QMenu类的以下几个方法:

  • QMenu.addAction(QAction) - 添加菜单项
  • QMenu.addSeparator() - 添加分割线
  • QMenu.addMenu(QMenu) - 添加子菜单
  • QMenu.addMenu(QIcon, str) - 添加带图标的子菜单

下面是一个带有二级菜单的完整的例子,要运行这段代码的话,请自备图标文件。

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

class MainWindow(QMainWindow):
    """从QMainWindow类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('菜单栏')
        self.setWindowIcon(QIcon('res/qt.png'))
        self.resize(320, 160)
        
        self.initUI() # 初始化界面
        self.center() # 窗口在屏幕上居中
        self.show() # 显示窗口
    
    def center(self):
        """窗口在屏幕上居中"""
        
        win_rect = self.frameGeometry() # 窗口矩形(QRect类)
        scr_center = self.screen().availableGeometry().center() # 屏幕中心
        win_rect.moveCenter(scr_center) # 窗口矩形中心移动到屏幕中心
        self.move(win_rect.topLeft()) # 移动窗口和窗口矩形重合
    
    def initUI(self):
        """初始化界面"""
        
        self.sbar = self.statusBar() # 返回窗口状态栏
        
        openAction = QAction(QIcon('res/open_mso.png'), '&打开', self)
        openAction.setShortcut('Ctrl+O')
        openAction.setStatusTip('打开文件')
        openAction.triggered.connect(lambda : print('此处弹出打开文件的对话框'))
        
        saveAction = QAction(QIcon('res/save_mso.png'), '&保存', self)
        saveAction.setShortcut('Ctrl+S')
        saveAction.setStatusTip('保存文件')
        saveAction.triggered.connect(lambda : print('此处弹出保存文件的对话框'))
        
        quitAction = QAction(QIcon('res/close_mso.png'), '&退出', self)
        quitAction.setShortcut('Ctrl+Q')
        quitAction.setStatusTip('退出程序')
        quitAction.triggered.connect(self.close)
        
        singleAction = QAction(QIcon('res/single_mso.png'), '&个人权限', self)
        singleAction.setStatusTip('个人权限')
        singleAction.triggered.connect(lambda : print('此处响应个人权限操作'))
        
        groupAction = QAction(QIcon('res/group_mso.png'), '&组权限', self)
        groupAction.setStatusTip('组权限')
        groupAction.triggered.connect(lambda : print('此处响应组权限操作'))
 
        mb = self.menuBar()
        
        fm = mb.addMenu('&文件')
        fm.addAction(openAction)
        fm.addAction(saveAction)
        
        fm.addSeparator()
        subMenu = fm.addMenu(QIcon('res/admin_mso.png'), '权限')
        subMenu.addAction(singleAction)
        subMenu.addAction(groupAction)
        
        fm.addSeparator()
        fm.addAction(quitAction)

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MainWindow()
    sys.exit(app.exec())

代码运行界面如下图所示。

在这里插入图片描述


【小结:QAction是统一菜单栏和工具栏内在逻辑的利器】

QAction.setIcon(QIcon):设置图标
QAction.setShortcut(str):设置快捷键
QAction.setToolTip(str):设置提示文字
QAction.setStatusTip(str):设置状态提示文字

7.4 工具栏

每个主窗口的菜单栏和状态栏都是唯一的,且位置是固定的,工具栏则不同,不但可以有多个,位置也有多种选择。工具栏的位置选项在枚举常量子模块中,可选项有:

  • QtCore.Qt.ToolBarArea.LeftToolBarArea
  • QtCore.Qt.ToolBarArea.RightToolBarArea
  • QtCore.Qt.ToolBarArea.TopToolBarArea
  • QtCore.Qt.ToolBarArea.BottomToolBarArea
  • QtCore.Qt.ToolBarArea.AllToolBarAreas
  • QtCore.Qt.ToolBarArea.NoToolBarArea

主窗口QMainWindow类的addToolBar是重载方法:传入字符串参数,则在主窗口的TopToolBarArea位置的最后添加一个工具栏,传入一个位置参数和一个工具栏QToolBar实例,则在主窗口的指定位置的最后添加一个工具栏。工具栏中的每一项和菜单栏的菜单项一样,都是QAction实例,都需要调用addAction方法加入到菜单或工具栏中。

下面的例子演示了两种添加工具栏的方法。

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

class MainWindow(QMainWindow):
    """从QMainWindow类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('工具栏')
        self.setWindowIcon(QIcon('res/qt.png'))
        self.resize(320, 160)
        
        self.initUI() # 初始化界面
        self.center() # 窗口在屏幕上居中
        self.show() # 显示窗口
    
    def center(self):
        """窗口在屏幕上居中"""
        
        win_rect = self.frameGeometry() # 窗口矩形(QRect类)
        scr_center = self.screen().availableGeometry().center() # 屏幕中心
        win_rect.moveCenter(scr_center) # 窗口矩形中心移动到屏幕中心
        self.move(win_rect.topLeft()) # 移动窗口和窗口矩形重合
    
    def initUI(self):
        """初始化界面"""
        
        self.sbar = self.statusBar() # 返回窗口状态栏
        
        openAction = QAction(QIcon('res/open_mso.png'), '&打开', self)
        openAction.setShortcut('Ctrl+O')
        openAction.setStatusTip('打开文件')
        openAction.triggered.connect(lambda : print('此处弹出打开文件的对话框'))
        
        saveAction = QAction(QIcon('res/save_mso.png'), '&保存', self)
        saveAction.setShortcut('Ctrl+S')
        saveAction.setStatusTip('保存文件')
        saveAction.triggered.connect(lambda : print('此处弹出保存文件的对话框'))
        
        quitAction = QAction(QIcon('res/close_mso.png'), '&退出', self)
        quitAction.setShortcut('Ctrl+Q')
        quitAction.setStatusTip('退出程序')
        quitAction.triggered.connect(self.close)
        
        singleAction = QAction(QIcon('res/single_mso.png'), '&个人权限', self)
        singleAction.setStatusTip('个人权限')
        singleAction.triggered.connect(lambda : print('此处响应个人权限操作'))
        
        groupAction = QAction(QIcon('res/group_mso.png'), '&组权限', self)
        groupAction.setStatusTip('组权限')
        groupAction.triggered.connect(lambda : print('此处响应组权限操作'))
 
        mb = self.menuBar()
        
        fm = mb.addMenu('&文件')
        fm.addAction(openAction)
        fm.addAction(saveAction)
        
        fm.addSeparator()
        subMenu = fm.addMenu(QIcon('res/admin_mso.png'), '权限')
        subMenu.addAction(singleAction)
        subMenu.addAction(groupAction)
        
        fm.addSeparator()
        fm.addAction(quitAction)
        
        tb = self.addToolBar('文件')
        tb.addAction(openAction)
        tb.addAction(saveAction)
        
        tb = self.addToolBar('退出')
        tb.addAction(quitAction)
        
        tb = QToolBar(self)
        tb.setObjectName('权限')
        self.addToolBar(Qt.ToolBarArea.RightToolBarArea, tb)
        tb.addAction(singleAction)
        tb.addAction(groupAction)

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MainWindow()
    sys.exit(app.exec())

这段代码生成了三个工具栏,两个在窗口上部,一个在窗口右侧。代码运行界面如下图所示。

在这里插入图片描述


【小结:小技巧】

**QToolBar**在布局上属于主窗口的悬停部件区(DockWidgets),故可以拖动一组工具栏至其他悬停位置。

7.5 对话框

PyQt提供了消息对话框,输入对话框、文件对话框、颜色对话框等多种类型的对话框,其中消息对话框和文件对话框又有适应不同场景的样式。下面的代码演示了最常用的10种对话框。

import sys, os
from PyQt6.QtWidgets import QApplication, QMainWindow, QWidget, QPushButton, QGridLayout, QMessageBox, QInputDialog, QColorDialog, QFileDialog
from PyQt6.QtGui import QIcon
from PyQt6.QtCore import Qt

class MainWindow(QMainWindow):
    """从QMainWindow类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('对话框')
        self.setWindowIcon(QIcon('res/qt.png'))
        
        self.initUI()
        self.show()
    
    def initUI(self):
        """初始化界面"""
        
        grid = QGridLayout()
        btns = [
            ['通知消息', '警告消息', '错误消息', '确认消息', '关于'],
            ['颜色选择', '保存文件', '打开文件', '选择路径', '输入']
        ]
        
        for i in (0, 1):
            for j in range(5):
                btn = QPushButton(btns[i][j])
                btn.clicked.connect(self.on_button)
                grid.addWidget(btn, i, j)
        
        self.main_widget = QWidget()
        self.main_widget.setLayout(grid)
        self.setCentralWidget(self.main_widget)
        
        self.sbar = self.statusBar()
        self.sbar.showMessage('Ready', 5000)
    
    def on_button(self):
        """响应按键"""
        
        key = self.sender().text()
        self.sbar.showMessage(key, 1000)
        
        if key == '通知消息':
            reply = QMessageBox.information(self, '提示', '对手认负,比赛结束。', 
                QMessageBox.StandardButton.Ok,
                QMessageBox.StandardButton.Ok,                
            )
            self.sbar.showMessage(reply.name, 2000)
        elif key == '警告消息':
            reply = QMessageBox.warning(self, '警告', '不能连续提和!', 
                QMessageBox.StandardButton.Ok, 
                QMessageBox.StandardButton.Ok
            )
            self.sbar.showMessage(reply.name, 2000)
        elif key == '错误消息':
            reply = QMessageBox.critical(self, '错误', '着法错误!', 
                QMessageBox.StandardButton.Retry | QMessageBox.StandardButton.Ignore | QMessageBox.StandardButton.Abort, 
                QMessageBox.StandardButton.Retry
            )
            self.sbar.showMessage(reply.name, 2000)
        elif key == '确认消息':
            reply = QMessageBox.question(self, '请选择', '对手提和,接受吗?', 
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
                QMessageBox.StandardButton.No
            )
            self.sbar.showMessage(reply.name, 2000)
        elif key == '关于':
            QMessageBox.about(self, '关于', '奥棋网络对弈 V2.0')
        elif key == '颜色选择':
            color = QColorDialog.getColor()
            if color.isValid():
                self.sbar.showMessage(color.name(), 2000)
        elif key == '保存文件':
            dase_dir = os.getcwd()
            file_type = 'Python Files (*.py);;Text Files (*.txt);;All Files (*)'
            fname, fext = QFileDialog.getSaveFileName(self, '保存文件', directory=dase_dir, filter=file_type)
            if fname:
                self.sbar.showMessage(fname, 2000)
        elif key == '打开文件':
            dase_dir = os.getcwd()
            fname = QFileDialog.getOpenFileName(self, '选择文件', directory=dase_dir, filter=file_type)
            if fname[0]:
                self.sbar.showMessage(fname[0], 2000)
        elif key == '选择路径':
            dase_dir = os.getcwd()
            folder = QFileDialog.getExistingDirectory(self, '选择文件夹', directory=dase_dir)
            self.sbar.showMessage(folder, 2000)
        elif key == '输入':
            text, ok = QInputDialog.getText(self, '输入对话框', '请输入您的房间号码:')
            if ok:                    
                self.sbar.showMessage(text, 2000)

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MainWindow()
    sys.exit(app.exec())

代码运行界面如下图所示。

在这里插入图片描述

常用消息对话框的样式如下图所示。

在这里插入图片描述

文件选择对话框如下图所示。

在这里插入图片描述


【小结:谁发出了信号?】

**QWidget.sender**在槽函数种调用该方法可以返回信号的发出者。

8. 示例和技巧

8.1 相册

前文在状态栏的例子中已经展示了QLabel控件作为图像容器的例子,下面的例子用QLabel制作了一个相册,点击前翻后翻按钮可在多张照片之间循环切换。

import sys
from PyQt6.QtWidgets import QApplication, QWidget, QLabel, QPushButton, QHBoxLayout, QVBoxLayout
from PyQt6.QtGui import QIcon, QPixmap

class MyWindow(QWidget):
    """从QWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('相册')
        self.setWindowIcon(QIcon('res/qt.png'))
        
        self.initUI()
        self.show()
        self.center()
    
    def center(self):
        """窗口在屏幕上居中"""
        
        win_rect = self.frameGeometry()
        scr_center = self.screen().availableGeometry().center()
        win_rect.moveCenter(scr_center)
        self.move(win_rect.topLeft())
    
    def initUI(self):
        """初始化界面"""
        
        self.curr = 0
        self.photos = ('res/DSC03363.jpg', 'res/DSC03394.jpg', 'res/DSC03402.jpg')
        
        self.lab = QLabel()
        self.lab.setPixmap(QPixmap(self.photos[self.curr]))
        
        btn_prev = QPushButton('<')
        btn_next = QPushButton('>')
        
        btn_prev.clicked.connect(self.on_btn)
        btn_next.clicked.connect(self.on_btn)
        
        hbox = QHBoxLayout()
        hbox.addStretch(1)
        hbox.addWidget(btn_prev)
        hbox.addSpacing(50)
        hbox.addWidget(btn_next)
        hbox.addStretch(1)
        
        vbox = QVBoxLayout()
        vbox.addWidget(self.lab)
        vbox.addLayout(hbox)
        
        self.setLayout(vbox)
    
    def on_btn(self):
        """响应按键"""
        
        key = self.sender().text()
        
        if key == '<':
            self.curr = (self.curr-1)%len(self.photos)
        else:
            self.curr = (self.curr+1)%len(self.photos)
        
        self.lab.setPixmap(QPixmap(self.photos[self.curr]))

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MyWindow()
    sys.exit(app.exec())

代码运行界面如下图所示。

在这里插入图片描述

8.2 计算器

几乎所有的GUI课程都会用计算器作为例子,PyQt怎能缺席呢?这个例子除了演示如何使用grid方法布局外,还演示了多个控件的信号连接到同一个槽函数时,如何判断信号由哪一个控件发射的。

import sys
from PyQt6.QtWidgets import QApplication, QWidget, QLabel, QPushButton, QGridLayout, QVBoxLayout
from PyQt6.QtGui import QIcon, QFont
from PyQt6.QtCore import Qt

class MyWindow(QWidget):
    """从QWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('计算器')
        self.setWindowIcon(QIcon('res/qt.png'))
        
        self.initUI()
        self.show()
        self.center()
    
    def center(self):
        """窗口在屏幕上居中"""
        
        win_rect = self.frameGeometry()
        scr_center = self.screen().availableGeometry().center()
        win_rect.moveCenter(scr_center)
        self.move(win_rect.topLeft())
    
    def initUI(self):
        """初始化界面"""
        
        self.lcd = QLabel('3.1415926')
        self.lcd.setFont(QFont('Arial', 24, QFont.Weight.Bold))
        self.lcd.setAlignment(Qt.AlignmentFlag.AlignRight)
        self.lcd.setStyleSheet('background-color:#000030; color:#30ff30')
        
        keys = [
            ['(', ')', 'Back', 'Clear'],
            ['7',  '8',  '9',  '/'], 
            ['4',  '5',  '6',  '*'], 
            ['1',  '2',  '3',  '-'], 
            ['0',  '.',  '=',  '+']
        ]
        
        grid = QGridLayout()
        box = QVBoxLayout()
        
        for i in range(5):
            for j in range(4):
                btn = QPushButton(keys[i][j])
                btn.clicked.connect(self.on_btn)
                
                if i == 0 and j in (2, 3):
                    btn.setStyleSheet('background-color:#f0e0d0')
                elif i > 0 and j == 3:
                    btn.setStyleSheet('background-color:#a0f0e0')
                elif i == 4 and j == 2:
                    btn.setStyleSheet('background-color:#f0e0a0')
                else:
                    btn.setStyleSheet('background-color:#d9e4f1')
                
                grid.addWidget(btn, i, j)
        
        box.addWidget(self.lcd)
        box.addSpacing(10)
        box.addLayout(grid)
        
        self.setLayout(box)
    
    def on_btn(self):
        """响应按键"""
        
        if self.lcd.text() == 'Error':
            self.lcd.setText('')
        
        key = self.sender().text()
        
        if key == 'Clear':
            self.lcd.setText('')
        elif key == 'Back':
            self.lcd.setText(self.lcd.text()[:-1])
        elif key == '=':
            try:
                result = str(eval(self.lcd.text()))
            except:
                result = 'Error'
            self.lcd.setText(result)
        else:
            self.lcd.setText(self.lcd.text() + key)

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MyWindow()
    sys.exit(app.exec())

代码运行界面如下图所示。

在这里插入图片描述

8.3 秒表

以百分之一秒的频率刷新显示,对于任何一款GUI库来说,都是不容小觑的负担。不过,由于PyQt禁止其他线程更新GUI线程,反倒是从根本上避免了界面刷新的重负荷。由于采用了独特的信号和槽机制,计时线程和GUI线程保持了各组的独立性,计时线程以每秒一百次的频率连续发出信号,GUI线程负责接收信号并处理,二者均不受对方工作状态的影响,保持了较高的隔离度。

import sys, time
from PyQt6.QtWidgets import QApplication, QWidget, QLCDNumber, QPushButton, QVBoxLayout
from PyQt6.QtGui import QIcon
from PyQt6.QtCore import QObject, pyqtSignal, QThread

class ClockServer(QObject):
    """计时服务"""
    
    update_time = pyqtSignal(str) # 更新时间信号
    isRun = False
    
    def run(self):
        """启动服务"""
        
        while True:
            t0 = time.time()
            while self.isRun:
                self.update_time.emit('%.2f'%(time.time()-t0))
                time.sleep(0.01)

class MyWindow(QWidget):
    """从QWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('秒表')
        self.setWindowIcon(QIcon('res/qt.png'))
        self.setGeometry(400, 300, 240, 140)
        
        self.initUI()
        self.show()
    
    def initUI(self):
        """初始化界面"""
        
        self.lcd = QLCDNumber()
        self.lcd.setDigitCount(6)
        self.lcd.display('0:00')
        
        self.btn = QPushButton('开始')
        self.btn.setStyleSheet('background-color:#f0d090')
        self.btn.clicked.connect(self.on_btn)
        
        vbox = QVBoxLayout()
        vbox.addWidget(self.lcd)
        vbox.addSpacing(5)
        vbox.addWidget(self.btn)
        self.setLayout(vbox)
        
        
        self.ts = ClockServer() # 创建计时服务
        self.ts.update_time.connect(self.lcd.display) # 连接update_time信号到液晶显示屏的显示函数
        
        self.thread = QThread() # 创建线程
        self.ts.moveToThread(self.thread) # 将计时服务加到线程
        self.thread.started.connect(self.ts.run) # 将计时服务的启动方法设置为线程函数
        self.thread.start() # 启动线程
    
    def on_btn(self):
        """响应按键"""
        
        if self.btn.text() == '开始':
            self.ts.isRun = True
            self.btn.setText('停止')
        else:
            self.ts.isRun = False
            self.btn.setText('开始')

if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = MyWindow()
    sys.exit(app.exec())

点击开始按钮,秒表自动清零并启动计时,计时精度高达百分之一秒。代码运行界面如下图所示。

在这里插入图片描述

8.4 画板

PyQt的QPainter类似于wxWidgets的PaintDC,响应窗口的重绘事件,因此画板设计的重点是要有一个数据结构,用来保存绘制的内容,重绘事件发生时遍历这个数据结构,将所有图元绘制一遍。调用窗口的update方法,可引发重绘事件。

import sys
from PyQt6.QtWidgets import QApplication, QWidget, QComboBox, QPushButton, QHBoxLayout, QVBoxLayout, QColorDialog
from PyQt6.QtGui import QIcon, QPainter, QPen, QColor, QPolygon
from PyQt6.QtCore import Qt, QPoint

class MyWindow(QWidget):
    """从QWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('画板')
        self.setWindowIcon(QIcon('res/qt.png'))
        self.resize(480, 320)
        
        self.initUI()
        self.show()
        self.center()
    
    def center(self):
        """窗口在屏幕上居中"""
        
        win_rect = self.frameGeometry()
        scr_center = self.screen().availableGeometry().center()
        win_rect.moveCenter(scr_center)
        self.move(win_rect.topLeft())
    
    def initUI(self):
        """初始化界面"""
        
        self.pen_c = '#90f010' # 当前颜色
        self.pen_w = 5 # 当前画笔宽度
        self.isDrag = False # 鼠标键按下标志
        
        self.contents = list() # 图元
        self.setMouseTracking(True) # 开启感知鼠标移动轨迹
        
        btn_color = QPushButton('')
        btn_color.setStyleSheet('background-color:%s'%self.pen_c)
        btn_color.clicked.connect(self.on_color)
        
        cb = QComboBox()
        cb.addItems(['1pix','3pix','5pix','7pix','9pix'])
        cb.setCurrentText('%dpix'%self.pen_w)
        cb.currentIndexChanged.connect(self.on_combobox)
        
        vbox = QVBoxLayout()
        vbox.addSpacing(20)
        vbox.addWidget(btn_color)
        vbox.addSpacing(10)
        vbox.addWidget(cb)
        vbox.addStretch(1)
        
        hbox = QHBoxLayout()
        hbox.addStretch(1)
        hbox.addLayout(vbox)
        
        self.setLayout(hbox)
    
    def on_combobox(self, evt):
        """选择画笔宽度"""
        
        self.pen_w = int(self.sender().currentText()[:-3])
    
    def on_color(self):
        """选择颜色"""
        
        color = QColorDialog.getColor()
        if color.isValid():
            self.pen_c = color.name()
            self.sender().setStyleSheet('background-color:%s'%self.pen_c)
    
    def mousePressEvent(self, evt):
        """按下鼠标按键"""
        
        self.isDrag = True
        pos = evt.position()
        self.contents.append(dict({'pen_c':self.pen_c, 'pen_w':self.pen_w, 'points':[QPoint(pos.x(), pos.y())]}))
    
    def mouseReleaseEvent(self, evt):
        """释放鼠标按键"""
        
        self.isDrag = False
    
    def mouseMoveEvent(self, evt):
        """鼠标移动事件"""
        
        if self.isDrag:
            pos = evt.position()
            self.contents[-1]['points'].append(QPoint(pos.x(), pos.y()))
            self.update()
    
    def paintEvent(self, evt):
        """响应重绘事件"""
        
        painter = QPainter(self)
        for item in self.contents:
            painter.setPen(QPen(QPen(QColor(item['pen_c']), item['pen_w'], Qt.PenStyle.SolidLine)))
            painter.drawPolyline(QPolygon(item['points']))

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MyWindow()
    sys.exit(app.exec())

代码运行界面如下图所示。如果再有一个擦除按钮就更好用了。如有兴趣,不妨一试。

在这里插入图片描述

8.5 嵌入浏览器

QAxWidget类是包装ActiveX控件的QWidget。
QAxWidget可以实例化为空对象,带有它应该包装的ActiveX控件的名称,或者带有指向ActiveX控件的现有接口指针。

import sys
from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout
from PyQt6.QtGui import QIcon
from PyQt6.QAxContainer import QAxWidget

class MyWindow(QWidget):
    """从QWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('内嵌浏览器')
        self.setWindowIcon(QIcon('res/qt.png'))
        self.setFixedSize(960, 640)
        
        browser = QAxWidget(self)
        browser.setControl("{8856F961-340A-11D0-A96B-00C04FD705A2}")
        browser.dynamicCall('Navigate(const QString&)', 'https://cn.bing.com')
        
        box = QVBoxLayout(self)
        box.addWidget(browser)
        
        self.center()
        self.show()
    
    def center(self):
        """窗口在屏幕上居中"""
        
        win_rect = self.frameGeometry()
        scr_center = self.screen().availableGeometry().center()
        win_rect.moveCenter(scr_center)
        self.move(win_rect.topLeft())

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MyWindow()
    sys.exit(app.exec())

代码运行界面如下图所示。

在这里插入图片描述

关闭窗口时,控制台会显示如下所示的错误信息,我一直没有找到解决方案。如果你有更好的方法,请通知我,谢谢。

QWindowsNativeInterface::nativeResourceForWindow: 'handle' requested for null window or window without handle.

9. 集成应用

9.1 在PyQt6中使用Matplotlib

大概厌倦了PyQt不断地更新吧,Matplotlib干脆在matplotlib.backends.qt_compat中推出了一个几乎可以替代PyQt.QtWidgets的模块,于是在PyQt6中使用Matplotlib变得易如反掌了。为此,我重新安装了最新版的Matplotlib,版本号3.5.1,并顺手写下了这段代码。

import numpy as np
import matplotlib
from matplotlib.backends.qt_compat import QtWidgets
from matplotlib.backends.backend_qtagg import FigureCanvas
from matplotlib.figure import Figure

matplotlib.use('TkAgg')
matplotlib.rcParams['font.sans-serif'] = ['FangSong']
matplotlib.rcParams['axes.unicode_minus'] = False

import sys
from PyQt6.QtGui import QIcon, QPixmap

class MyWindow(QtWidgets.QWidget):
    """从QWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('集成Matplotlib')
        self.setWindowIcon(QIcon('res/qt.png'))
        
        self.initUI()
        self.show()
        self.center()
    
    def center(self):
        """窗口在屏幕上居中"""
        
        win_rect = self.frameGeometry()
        scr_center = self.screen().availableGeometry().center()
        win_rect.moveCenter(scr_center)
        self.move(win_rect.topLeft())
    
    def initUI(self):
        """初始化界面"""
        
        self.fig = Figure(dpi=150)
        self.canvas = FigureCanvas(self.fig)
        #self.cv.get_tk_widget().pack(fill=BOTH, expand=1, padx=5, pady=5)
        
        btn_1 = QtWidgets.QPushButton('散点图')
        btn_2 = QtWidgets.QPushButton('等值线图')
        
        btn_1.clicked.connect(self.on_scatter)
        btn_2.clicked.connect(self.on_contour)
        
        hbox = QtWidgets.QHBoxLayout()
        hbox.addStretch(1)
        hbox.addWidget(btn_1)
        hbox.addSpacing(50)
        hbox.addWidget(btn_2)
        hbox.addStretch(1)
        
        vbox = QtWidgets.QVBoxLayout()
        vbox.addWidget(self.canvas)
        vbox.addLayout(hbox)
        
        self.setLayout(vbox)
    
    def on_scatter(self):
        """散点图"""
        
        x = np.random.randn(50) # 随机生成50个符合标准正态分布的点(x坐标)
        y = np.random.randn(50) # 随机生成50个符合标准正态分布的点(y坐标)
        color = 10 * np.random.rand(50) # 随即数,用于映射颜色
        area = np.square(30*np.random.rand(50)) # 随机数表示点的面积
        
        self.fig.clear()
        ax = self.fig.add_subplot(111)
        ax.scatter(x, y, c=color, s=area, cmap='hsv', marker='o', edgecolor='r', alpha=0.5)
        self.canvas.draw()
    
    def on_contour(self):
        """等值线图"""
        
        y, x = np.mgrid[-3:3:60j, -4:4:80j]
        z = (1-y**5+x**5)*np.exp(-x**2-y**2)
        
        self.fig.clear()
        ax = self.fig.add_subplot(111)
        ax.set_title('有填充的等值线图')
        c = ax.contourf(x, y, z, levels=8, cmap='jet')
        self.fig.colorbar(c, ax=ax)
        self.canvas.draw()

if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv) 
    win = MyWindow()
    sys.exit(app.exec())

代码运行界面如下图所示。

在这里插入图片描述

9.2 在PyQt6中使用OpenGL

PyQt对于OpenGL的支持非常全面,甚至将各个版本的OpenGL的API集成到了QOpenGLFunctions中,如此一来用户就可以不用加载GL库了。不过,由于PyQt版本众多,使用方式并不一致,反倒让用户更加迷茫。

本文推荐使用QOpenGLWidget作为窗口类,配合原生的GL库绘制三维模型,无需了解PyQt6对于OpenGL所作的封装,直接照搬OpenGL的使用经验,就可以轻松实现在PyQt6中集成OpenGL。

继承QOpenGLWidget类派生应用程序窗口时,需要重写以下三个方法。

  • resizeGL - 窗口改变的槽函数
  • initializeGL - 初始化GL的槽函数
  • paintGL - 模型重绘的槽函数

下面的代码绘制了一个棱长为2的六面体,其中心位于三维坐标系原点。相机位于z轴正方向(0,0,5)点处,采用透视投影。拖拽鼠标可以改变相机的方位角和高度角,滚动滚轮可以相机与原点之间的距离。

import sys
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from PyQt6.QtWidgets import QApplication
from PyQt6.QtGui import QIcon
from PyQt6.QtCore import Qt, QPoint

import numpy as np
from OpenGL.GL import *
from OpenGL.GLU import *

class MyGLWidget(QOpenGLWidget):
    """从QOpenGLWidget类派生的桌面应用程序窗口类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.setWindowTitle('集成OpenGL')
        self.setWindowIcon(QIcon('res/qt.png'))
        self.resize(800, 600)
        self.show()
        
    def initializeGL(self):
        """重写GL初始化函数"""
        
        glClearColor(0.0, 0.0, 0.2, 1.0) # 设置画布背景色
        glEnable(GL_DEPTH_TEST) # 开启深度测试,实现遮挡关系        
        glDepthFunc(GL_LEQUAL) # 设置深度测试函数
        
        self.oecs = [0.0, 0.0, 0.0] # 视点坐标系原点
        self.cam_pos = [0.0, 0.0, 5.0] # 相机位置
        self.cam_up = [0.0, 1.0, 0.0] # 指向上方的向量
        self.dist = 5.0 # 相机距离视点坐标系原点的距离
        self.azim = 0.0 # 初始方位角
        self.elev = 0.0 # 初始高度角
        
        self.leftdown = False # 鼠标左键按下
        self.mpos = QPoint(0, 0) # 鼠标位置
        
    def paintGL(self):
        """重写绘制函数"""
        
        # 根据窗口宽高计算视锥体
        k = self.csize[0]/self.csize[1]
        view = (-k, k, -1, 1, 3, 10) if k > 1 else (-1, 1, -1/k,1/k, 3, 10)
        
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) # 清除屏幕及深度缓存
        glViewport(0, 0, *self.csize) # 设置视口
        
        # 设置投影(透视投影)
        glMatrixMode(GL_PROJECTION)
        glLoadIdentity()
        glFrustum(*view)
        
        # 设置视点
        gluLookAt(*self.cam_pos, *self.oecs, *self.cam_up)
        
        # 六面体顶点编号示意图
        #    v4----- v7
        #   /|      /|
        #  v0------v3|
        #  | |     | |
        #  | v5----|-v6
        #  |/      |/
        #  v1------v2

        # 六面体顶点集
        vertices = [
            [-1,1,1],  [-1,-1,1],  [1,-1,1],  [1,1,1], # v0-v1-v2-v3
            [-1,1,-1], [-1,-1,-1], [1,-1,-1], [1,1,-1] # v4-v5-v6-v7
        ]
        
        # 顶点集对用的颜色
        colors = [
            [0.8,0,0], [0,0.8,0], [0,0,0.8], [1,1,0.2], # v0-v1-v2-v3
            [1,0.2,1], [0.2,1,1], [0.5,1,0], [0,1,0.5]  # v4-v5-v6-v7
        ]

        # 顶点构成四边形的索引集
        indices = [
            0, 1, 2, 3, # v0-v1-v2-v3 (front)
            4, 0, 3, 7, # v4-v0-v3-v7 (top)
            1, 5, 6, 2, # v1-v5-v6-v2 (bottom)
            7, 6, 5, 4, # v7-v6-v5-v4 (back)
            3, 2, 6, 7, # v3-v2-v6-v7 (right)
            4, 5, 1, 0  # v4-v5-v1-v0 (left)
        ]
        
        glBegin(GL_QUADS) # 开始绘制四角面
        for i in indices:
            glColor3f(*colors[i])
            glVertex3f(*vertices[i])
        glEnd() # 结束绘制四角面
        
    def resizeGL(self, width, height):
        """重写改变窗口事件函数"""
 
        self.csize = (width, height)
    
    def mousePressEvent(self, evt):
        """重写鼠标按键被按下事件函数"""
        
        if evt.buttons() == Qt.MouseButton.LeftButton:
            self.leftdown = True
            self.mpos = evt.position()
    
    def mouseReleaseEvent(self, evt):
        """重写鼠标按键被释放事件函数"""
        
        if evt.buttons() == Qt.MouseButton.LeftButton:
            self.leftdown = False
        
    def mouseMoveEvent(self, evt):
        """重写鼠标移动事件函数"""
        
        if self.leftdown:
            pos = evt.position()
            dx, dy = pos.x()-self.mpos.x(), pos.y()-self.mpos.y()
            self.mpos = pos
            
            azim = self.azim - self.cam_up[1]*(180*dx/self.csize[0])
            elev = self.elev + 90*dy/self.csize[1]
            self.update_cam_and_up(azim=azim, elev=elev)
            self.update()
        
    def wheelEvent(self, evt):
        """重写鼠标滚轮事件函数"""
        
        if evt.angleDelta().y() < 0:
            dist = self.dist * 1.02
        else:
            dist = self.dist * 0.98
        
        self.update_cam_and_up(dist=dist)
        self.update()
    
    def update_cam_and_up(self, dist=None, azim=None, elev=None):
        """根据相机与ECS原点的距离、方位角、仰角等参数,重新计算相机位置和up向量"""
        
        if not dist is None:
            self.dist = dist
        
        if not azim is None:
            self.azim = (azim+180)%360 - 180
        
        if not elev is None:
            self.elev = (elev+180)%360 - 180
        
        azim, elev  = np.radians(self.azim), np.radians(self.elev)
        d = self.dist * np.cos(elev)
        
        self.cam_pos[1] = self.dist * np.sin(elev) + self.oecs[1]
        self.cam_pos[2] = d * np.cos(azim) + self.oecs[2]
        self.cam_pos[0] = d * np.sin(azim) + self.oecs[0]
        self.cam_up[1] = 1.0 if -90 <= self.elev <= 90 else -1.0
            

if __name__ == '__main__':
    app = QApplication(sys.argv) 
    win = MyGLWidget()
    sys.exit(app.exec())

代码运行界面如下图所示。

在这里插入图片描述



【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。