qt

PySide6 实现 Qt 程序的多语言

1 在源码中事先准备好字符串 对于要进行多语言翻译的字符串,需要在 coding 的时候使用 QCoreApplication.translate() 或者 QObject.tr() 这两个静态方法预埋好“翻译入口”。 如下图所示: 当程序运行时,这两个方法会自动调用当前 QApplication 的 translator 对字符串进行翻译。(如果没有主动设置过 translator,那么就是不翻译。) 对于使用 .ui 文件生成的 .py 界面代码,pyside6-uic 命令会自动对 UI 中的字符串包裹上 translate() 方法。 对于不在 .ui 设计阶段定义的字符串,需要主动在代码中调用 translate() 或者 tr() 方法。 2 生成 ts 文件 使用 pyside6-lupdate 命令生成 ts 文件。 pyside6-lupdate designer/* transfer_dog/view/main_window.py -ts i18n/zh_cn.ts 执行上述语句,pyside6-lupdate 会遍历 designer/ 目录下的所有文件,以及 transfer_dog/view/main_windows.py 文件,扫描其中的 translate() 与 tr() 语句,然后生成一个 zh_cn.ts 文件放在 i18n 目录下。 ts 文件本质是一个 xml 文件,你可以直接使用文本编辑器打开,并进行编辑。 更方便地是使用 Qt 提供的 Qt Linguist 工具打开 ts 文件进行编辑。 3 生成 qm 文件 对于编辑好的 ts 文件。可以使用 pyside6-lrelease 命令生成 .qm 后缀的二进制文件。这个 qm 文件才是 Qt 程序使用的翻译文件。 pyside6-lrelease i18n/zh_cn.ts -qm i18n/zh_cn.qm 也可以使用 Qt Linguist 工具的 File > Release 菜单直接生成。 4 在源码中加载 QTranslator 在你的 Qt 主程序代码中,为 QApplication 实例加载 translator,这个 translator 负责读取 qm 文件并进行字符串翻译。 q_app = QApplication(sys.argv) translator = QTranslator() if translator.load('zh_cn.qm', directory='i18n'): q_app.installTranslator(translator) 需要注意的是,installTranslator() 并不会让 QCoreApplication 保存对这个 translator 对象的引用。所以要小心,不要某个函数中 installTranslator() 之后又把 translator 释放了,最好的做法就是将 translator 保存在某个全局变量中。 Note: QCoreApplication does not take ownership of translationFile. from: https://doc.qt.io/qt-6/qcoreapplication.html#installTranslator

使用 QLocalSocket 与 QLocalServer 实现程序的单实例运行。

之前在 这篇文章 中,曾用 QSharedMemory 实现了 QT 程序的单实例运行。 除此之外,还有一种方式是使用 QLocalSocket 与 QLocalServer 实现。 与 QSharedMemory 相比,QLocalSocket 更麻烦一点,因为需要第一个运行的进程启动 QLocalServer 来监听后续进程的连接。 但也因为如此,它比 QSharedMemory 更容易进行进程间通信(IPC)。可以在第二次、第三次... 启动程序的时候,告知首次运行的程序实例,让它做一些适当操作,比如显示到桌面前端。 #!/usr/bin/env python # -*- coding: utf-8 -*- # Author: funway.wang # Created: 2023/06/29 23:59:24 import logging, sys, time, os from PySide6.QtWidgets import QApplication, QMainWindow, QLabel from PySide6.QtNetwork import QLocalServer, QLocalSocket from PySide6.QtCore import QSystemSemaphore, QByteArray, QCoreApplication, Qt class SingleAppGuard(object): """docstring for SingleAppGuard. QLocalSocket 在 Unix 类系统使用 Unix domain socket 作为底层实现,在 Windows 系统使用命名管道作为底层实现 由于并不是真实的网络连接,所以 QLocalSocket 并不需要等待 “握手” 完成。 只要服务端已经开始 listen(), 那么在 QLocalSocket.connectToServer() 之后,这个连接其实就已经建立了。 客户端可以不需要 waitForConnected() 就直接开始 write() 数据了。 服务端可以不需要 nextPendingConnection() 来获取该连接, 如果它并不想知道客户端发了什么消息过来,也没打算回消息的话。 如果不存在 server, 客户端 error() 将返回 ServerNotFoundError 错误 如果存在一个残留的已崩溃的 server, 客户端 error() 将返回 LocalSocketError.ConnectionRefusedError 错误 """ def __init__(self, app_id, raise_error = True): super(SingleAppGuard, self).__init__() self.logger = logging.getLogger(self.__class__.__name__) self.logger.debug('Init a %s instance', self.__class__.__name__) if QCoreApplication.instance() is None: warning = """A QObject instance (like {}.server) created before QCoreApplication (or QGuiApplication/QApplication) initialized, its signal/slot will not be handled by the main event loop! """.format(self.__class__.__name__) self.logger.warning(warning) self.app_id = app_id self.server = None self._is_another_running = False # 获取互斥信号量,同时只允许有一个进程进入下面步骤 _sema = QSystemSemaphore(app_id, 1, mode=QSystemSemaphore.AccessMode.Open) _sema.acquire() self.logger.debug('已取得系统信号量: %s', _sema) _client = QLocalSocket() self.logger.debug('创建本地 socket: %s [%s]', _client, _client.state()) # 尝试连接服务器(第一个启动的程序实例会创建一个 QLocalServer 服务器) _client.connectToServer(app_id) self._is_another_running = _client.waitForConnected() if self._is_another_running: self.logger.warning('另一个程序实例已启动') # _client.write(QByteArray('Hello! From PID[%s]' % os.getpid())) # _client.flush() if raise_error: _sema.release() raise Exception('另一个程序实例已启动') else: self.logger.debug('无法建立连接: %s [%s]', _client.error(), _client.state()) self.server = QLocalServer() # unix 类系统的在程序崩溃的时候不会自动释放 unix socket # 所以这里需要尝试 remove 一下,把上次奔溃残留的 unix socket 删除(如果有的话) self.server.removeServer(app_id) # 启动监听 ret = self.server.listen(app_id) if ret: self.logger.debug('QLocalServer 开始监听: %s', self.server.fullServerName()) else: self.logger.warning('QLocalServer 无法启动监听. %s', self.server.serverError()) _sema.release() raise Exception('QLocalServer 无法启动监听') # 释放信号量 _sema.release() self.logger.debug('已释放系统信号量') pass def is_another_running(self):…

Qt: border-radius of page in QToolBox are broken —— QToolBox 中 page 的圆角边框无法闭合问题排查

1 异常现象 今天用到了 QToolBox,然后想给每个 page 添加圆角边框,于是使用了如下 stylesheet QToolBox > QWidget { border: 1px solid silver; border-radius: 6px; } 却发现圆角边框无法闭合。 2 原因 然后查了半天,看了 Qt 源码。才发现原来我们直观以为的这个 page QWidget,并不是 QToolBox 的直接 child。而是 QToolBox > QScrollArea > QWidget ( QScrollArea.viewport ) > QWidget。 (注意,包括 QScrollArea、QTreeView 等这些控件内部的 viewport,并不是 QViewport 对象,而是一个隐藏的 QWidget 对象。QViewport 是 3D 显示用的。) 关键代码在 qtoolbox.cpp 中 这些控件的父子链如下图所示: 我还写了一个测试代码,对 page 对象不断往上调用 parent(),得到其父子链结果如下: 3 解决办法 所以,我们在上面的 qss 中用的 QToolBox > QWidget 其实指向的是 QScrollArea,并不是我们要的 page。 而圆角边框无法闭合的问题,是 QScrollArea 固有的 BUG。我猜应该是跟 scrollbar 有关吧,先不管它了。 所以要解决 QToolBox 中 page 圆角边框的问题,只需要将我们的 qss 改成: QToolBox > QScrollArea > QWidget > .QWidget { border: 1px solid silver; border-radius: 6px; } 4 其他问题 4.1 page 中的滚动条变样了 为什么上述解决办法中的选择器要用 QToolBox > QScrollArea > QWidget > .QWidget 而不是 QToolBox > QScrollArea > QWidget > QWidget? 首先我们要知道最后一个 QWidget 前面加个点表示这项只匹配 QWidget 类型,不匹配 QWidget 的派生类。如果不加这个点,就是匹配 QWidget 以及其派生类。 然后,由于 QScrollArea 下面其实还有其他 QWidget 对象,hcontainer 与 vcontainer。这两个 container 中又包含滚动条 QScrollBar(派生自 QWidget)。所以如果使用 QScrollArea > QWidget > QWidget 来匹配的话,其样式会影响到滚动条的显示效果。 4.2 关于 tab 的高度 QToolBoxButton { min-height: 40px; } /* 这是无效的 */ QToolBox::tab { min-height: 40px; } 因为 tab 的本质是一个 QToolBoxButton (继承自 QAbstractButton)。但为什么使用 ::tab 时候无效呢?我暂时也没去考证它的内部逻辑。 4.3 关于tab 与 page 之间的间隙 QToolBox::tab { background-color: #bbccdd; } QToolBox > QScrollArea > QWidget > .QWidget { border: 1px solid silver; border-radius: 6px; } 发现 tab 与 page 之间的间隙过大,想要调整。但这个用 qss 是无法实现的。 因为 QToolBox 的绘图盒模型是这样子的: tab 与 page 以及 page 与下一个 tab 之间的间隙,其实是 QToolBox.layout().spacing 决定的。 5 更深入的问题! 5.1 QScrollArea 的圆角边框问题 使用 QToolBox > QScrollArea > QWidget > .QWidget 虽然找到了 page 本体,也给它设置了边框。但是由于这是 QScrollArea 内部控件的边框啊,所以当整个窗口被收缩,QScrollArea 出现滚动条后,这个 page 的上下边框可能就被滚动掉了!!!   这其实并不是我们的本意。 我们希望边框是一直都在的,不管你滚动条怎么滚动。所以其实,我们本来就应该对这个 QScrollArea 设置边框。 但 QScrollArea 的圆角边框无法正常显示圆角的现象,似乎就是一个 BUG。我找了一个相似问题(QTextArea 也是这样)的帖子 qt.io。并咨询了其中的大牛,得到的答复是: Chris Kawa: It's a bit different issue. Setting border radius does…

Qt: QTreeView 可视区域内节点判断与滚动事件

这两天遇到两个问题: 1、对于一个 QTreeView,如何判断一个节点的 QModelIndex 是否处于 QTreeView 可视区域内。当 QTreeView 中有太多节点,必然有些节点是处于可视区域外部的,不显示的。QTreeView 也不会对这些不显示的节点调用 itemdelegate.paint() 方法。 2、如何获取 QTreeView 内容的滚动事件。 不只是对 QTreeView,所有的 QAbstractItemView 派生类,包括 QListView, QTableView 也都如此。 一、visualRect(index) QAbstractItemView.visualRect(index: QModelIndex) -> Qrect 返回 index 节点相对于可视区域 QAbstractItemView.viewport() 的位置矩形,即使节点在可视区域之外。(官方文档) 比如 viewport 是 (x=0, y=0, width=200, height=300),如果节点的 visualRect(index) 返回的是 (0, -50, 200, 30),那么这个节点就是在 viewport 之上的不可见区域。 所以,可以根据 visualRect.y 来判断节点是否处于可视区域。y < -height 或者 y > viewport.rect.height 的,都是在超出的不可见区域。 更简单的,可以使用 QRect.intersects(otherRect) 方法来判断 visualRect 与 viewport.rect 这两个矩形是否有交叉。如果有,就说明节点(部分/全部)处于可视区域。 intersect = treeView.visualRect(idx).intersects(treeView.viewport().rect()) logging.debug('%s 节点是否处于可视区域: %s', idx.data(), intersect) 二、indexAt(point), indexAbove(index), indexBelow(index) QAbstractItemView.indexAt(point: QPoint) -> QModelIndex 返回 viewport 中处于 point 位置的节点 index。(官方文档) 初次之外,QTreeView 还提供了两个特有的方法: indexAbove(index) -> QModelIndex 和 indexBelow(index) -> QModelIndex 用来返回位置紧邻着 index 之上,或之下的节点。如果 index 本来就是最顶端节点,没有 above,或者 index 本来就是最底部节点,没有 below。那么就会返回一个无效的 QModelIndex 实例 idx,用 idx.isValid() 判断返回 False,用 idx.data() 返回 None。 idx_topleft = self.treeView.indexAt(self.treeView.viewport().rect().topLeft()) # 取可视区域顶部位置的节点 idx_bottomright = self.treeView.indexAt(self.treeView.viewport().rect().bottomRight()) # 取可视区域底部位置的节点,如果没有,则返回一个无效 QModelIndex self.logger.info('左上节点: %s, 右下节点: %s', idx_topleft.data(), idx_bottomright.data()) self.logger.info('上上: %s, 下下: %s', self.treeView.indexAbove(idx_topleft).isValid(), self.treeView.indexBelow(idx_bottomright).isValid()) 三、verticalScrollBar(), horizontalScrollBar() 我一开始在 QTreeView, QAbstractitemView, QAbstractScrollArea 的官方文档里找了半天,也没有找到任何关于可视区域滚动的信号。后来也不知道怎么搜的,才发现原来 QAbstractitemView 是通过 verticalScrollBar / horizontalScrollBar 来触发滚动条滚动信号!!!😤 (ScrollBar 信号) self.treeView.verticalScrollBar().valueChanged.connect(self._on_v_scroll_changed) def _on_v_scroll_changed(self, value): self.logger.debug('V scroll bar value changed to: %s', value) pass  

Qt: 自定义 QWidget 响应 StyleSheet 样式

1. 必须重写 paintEvent() 根据官方文档的说明,如果希望自定义的 QWidget 派生类能够响应 StyleSheet 中定义的样式,就必须用如下代码重写 paintEvent() 方法。(其实应该叫做实现 paintEvent(),因为 QWidget::paintEvent() 本来是个空函数,啥都没做) 换成 Python + PySide6 语法就是: class CustomWidget(QWidget): def __init__(self, parent: QWidget = None): super().__init__(parent) ... def paintEvent(self, event): opt = QStyleOption() opt.initFrom(self) painter = QPainter(self) self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, painter, self) pass 2. paintEvent() 的作用 首先需要知道,所有 QWidget 派生类需要绘制的时候,都是调用 paintEvent() 方法进行绘制!(但 QWidget::paintEvent() 是空函数,具体实现都由派生类自己来做,比如 QLabel::paintEvent()) ① 如果继承自某个内建 widget(比如 QLabel, QPushButton 这些),那么重写该方法将会覆盖父类的绘制行为。 ② 如果该 widget 还有 child widgets 的话,在执行完自己的 paintEvent() 之后,还会接着自动调用所有 child widgets 的 paintEvent()! (其实并不是所有,只需要调用与该 event.rect() 有交集的 child widgets 的 paintEvent() 方法。) ③ 对于由内建 widget 组合而成的自定义 widget。比如说我新建了一个 CustomWidget,它其实是由 QLabel + QPushButton 组合起来的。 那么根据 ② 中所述,是可以不需要重写 paintEvent() 方法的。child widgets 会自行绘制自己。 ④ 但是!如果不重写 paintEvent() 的话,自定义的 widget 就没办法响应 StyleSheet 中与其相匹配的样式。即在 qss 文件中,或者直接通过 setStyleSheet() 设置的 CustomWidget { background-color: #acdbb7 } 就无法生效。 3. 响应 StyleSheet 属性选择器的样式 CustomWidget[clicked="true"] { background-color: #dc5f5f } 这是 StyleSheet 属性选择器的例子。 要想让自定义控件 CustomWidget 的实例响应这个 [clicked="true"] 属性选择器。除了 CustomWidget 必须重写 paintEvent() 外,还必须在代码中给 CutomWidget 实例设置 clicked “属性”,还必须调用 QStyle.polish() 方法来重新加载 StyleSheet。 widget.setProperty('clicked', True) widget.style().polish(widget) 4. 示例代码 QSS = """ CustomWidget { background-color: #acdbb7 } CustomWidget[clicked="true"] { background-color: #dc5f5f } """ class CustomWidget(QWidget): def __init__(self, title: str, parent: QWidget = None): super().__init__(parent) # 1. 图标 self.label_icon = QLabel(self) self.label_icon.setPixmap(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarMenuButton).pixmap(32, 32)) # 2. title self.label_title = QLabel(self) self.label_title.setText(title) self.horizontalLayout = QHBoxLayout(self) self.horizontalLayout.addWidget(self.label_icon) self.horizontalLayout.addWidget(self.label_title) pass def paintEvent(self, event): opt = QStyleOption() opt.initFrom(self) painter = QPainter(self) self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, painter, self) pass def mousePressEvent(self, ev): self.setProperty('clicked', True) self.style().polish(self) pass if __name__ == '__main__': app = QApplication(sys.argv) app.setStyleSheet(QSS) main_window = QMainWindow() main_window.setCentralWidget(CustomWidget('测试')) main_window.show() sys.exit(app.exec()) pass  

QWidget 鼠标事件穿透

假设现在有两个重叠显示的 QWidget 部件,widget_1 与 widget_2。二者无父子关系,只是位置重叠,widget_1 在 widget_2 之上,如下图所示。 这个时候如果鼠标点击在 widget_1 上,widget_2 是不会响应鼠标事件的。因为事件被位于上层的 widget_1 捕获了。 如果我们想让鼠标事件穿透 widget_1,由 widget_2 捕获。就可以给 widget_1 设置 WA_TransparentForMouseEvents 属性,让它对所有鼠标事件“透明”。 widget_1.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents) QLabel 显示富文本时会吞掉鼠标事件 今天还遇到一个问题,当 QLabel 作为子控件的时候,如果是 PlainText 模式,那么在 QLabel 上触发的鼠标事件会自动传递给父控件。但如果 QLabel 处于 RichText 模式,那么它会响应并且吞掉鼠标事件!事件无法传递给父控件! BUG 复现代码如下: class Window(QMainWindow): def __init__(self): super().__init__() self.label = MyLabel(self) self.label.resize(200, 50) self.label.setStyleSheet("border: 1px solid black;") text = "Test mouse events on here" self.label.setText(text) self.label.setText("<p style='color:red;'>{}</p>".format(text)) # self.label.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents) def mouseReleaseEvent(self, e): if e.button() == QtCore.Qt.LeftButton: print("Left") if e.button() == QtCore.Qt.RightButton: print("Right") pass App = QApplication(sys.argv) window = Window() window.show() sys.exit(App.exec()) 有两种解决办法: (1)给 label 设置 setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents) ,这样 label 自己就不响应鼠标事件,也不会吞掉事件而是直接传递给父节点。 (2)派生 QLabel,重写 mouseReleaseEvent() 方法,在该方法中,判断如果可能是富文本,就手动将鼠标事件传递给父节点。 def mouseReleaseEvent(self, event): if QtGui.Qt.mightBeRichText(self.text()): self.parent().mouseReleaseEvent(event) super().mouseReleaseEvent(event) pass  

Qt:QWidget 的绘制逻辑(源码分析)

一、QWidget QWidget 既是 QObject 的子类,也是 QPaintDevice 的子类! 而 QPainter 类实例,都要有一个目标 device! TLW(Top Level Widget)顶层窗口,可以看作是 “没有 parent 的 widget” 或者 “widget.window() == self 的 widget”。当调用 widget.show() 的时候,tlw 作为一个独立窗口显示在屏幕上,non top level widget 则作为一个内部控件显示在某个 tlw 中。 1. QWidget.update(rect) 底层逻辑(源码分析) QWidget.update(rect) 调用私有类的方法 QWidgetPrivate.update(rect) QWidgetPrivate.update(rect) 首先判断 widget 是否需要绘制。如果不需要,则直接返回。比如 widget 不可见、update 被禁用、参数 rect 与 widget.rect 无交集,这些都不需要绘制。如果已经正在绘制过程中,就向程序全局 QCoreApplication 发布一个 UpdateLaterEvent 事件并返回。如果需要绘制,则在顶层窗口(TLW: Top Level Widget),将该 widget 需要绘制的 clipped 区域标记为脏。tlwExtra→repaintManager→markDirty(clipped, q) QWidgetRepaintManager::markDirty() 中。 首先需要判断该 widget 是否在顶层窗口 tlw 中显示,如果 widget 与当前 tlw 没有联系,那么连标脏都不需要了,直接丢弃。 然后将 widget 以及需要重绘的区域加入 tlw 的 dirtyWidgets 列表( addDirtyWidget(widget, r) )。顶层窗口 tlw 发布重绘请求 sendUpdateRequest(tlw)。 在 sendUpdateRequest() 中,实际是向程序全局 QCoreApplication 发布一个 QEvent::UpdateRequest 事件。 接着就进入了 Qt 的事件响应机制,由 QApplicationPrivate::notify_helper(QObject *receiver, QEvent * e) 负责将 QEvent::UpdateRequest 事件交给顶层窗口 tlw (我猜的)的 QWidget.event() 分发处理。 QWidget.event() 调用 QWidgetPrivate::syncBackingStore() 处理 QEvent::UpdateRequest 事件。 QWidgetPrivate::syncBackingStore() >> QWidgetRepaintManager::sync() >> QWidgetRepaintManager::paintAndFlush() 负责最终的 paint(QWidgetPrivate::drawWidget()) 与 flush(QWidgetRepaintManager::flush())。我理解的是,paint 是指在“Qt 画布”上绘制出图像,flush 是指将内存画布上的图像冲洗到“显卡画布”上(flush 就跟各操作系统的显示驱动有关了)。 在 QWidgetPrivate::drawWidget() 方法中,主要会执行三个操作:① 绘制背景 paintBackground(painter, region, flags);② 调用 paintEvent() (通过发布 QEvent::Paint 事件);③ 调用 paintSiblingsRecursive() 递归绘制 child widgets。 最后, QWidgetRepaintManager::flush() 调用 QtSrc/plugins/platforms/xxxos/xxxBackingStore::flush() 将内存画布冲印到显卡。   参考:https://www.cnblogs.com/appsucc/p/14528310.html 3、QWidget.paintEvent() 所有 QWidget 绘制的时候,都是调用 paintEvent() 方法进行绘制。不管是 QWidget.upadte() 还是 QWidget.repaint(),都是要调用 paintEvent() 。 如果继承自某个内建 widget(比如 QLabel, QPushButton 这些),那么重写该方法将会覆盖父类的绘制行为。 此外!!!如果该 widget 还有 child widgets 的话,在执行完自己的 paintEvent() 之后,还会接着自动调用所有 child widgets 的 paintEvent()! (其实并不是所有,只需要调用与该 event.rect() 有交集的 child widgets 的 paintEvent() 方法。) 4、QWidget.render(painter, targetOffset, sourceRegion, renderFlags) 先在临时画布上绘制 widget,截取出 sourceRegion 区域。然后在 target painter 上的 targetOffset 位置开始绘制。 但是实际上 QWidget.render() 一直有个官方 BUG 未解决!(https://bugreports.qt.io/browse/QTBUG-26694) 它实际绘制时候的 targetOffset,可能会变成是窗口(top-level widget)坐标系,而不是 painter.device() 指向的父 widget 坐标系。 5、先清除,再绘制! QWidget.update(rect) 会将 rect 区域先清除,再绘制。(paintBackground() >> paintEvent()) Qt normally erases the widget's area before the paintEvent() call. 二、QPainter 1、QPainter 内部必须关联一个 QPaintDevice 对象(所有的 QWidget 都属于 QPaintDevice)。 2、要使用 QPainter 对象,必须先激活它。QPainter 必须以 begin(paintDevice) 方法激活,或者由带 paintDevice 参数的构造函数自动激活。然后由 end() 方法注销,或者是由析构函数自动注销。 3、我认为,可以将 QPainter 看作内存画布。Qt 对 widgets 的绘制,是先从顶层窗口(TLW,Top Level Widget)进行绘制,然后一级一级往下绘制 child…

Qt: 自定义 QTreeView(2)- 显示自定义 widget 以及 GIF

本文所涉及代码:https://github.com/funway/TestQTreeView 一、自定义 Widget 由于我们的自定义 widget 是由几种内建 widget 组合而来,所以不需要在 paintEvent() 方法中进行手工绘制。parent widget 会自动调用 child widgets 的绘制方法 paintEvent()。 注意,在 QLabel 中加载要缩放的图片时,最好用 QLabel.setPixmap( QIcon().pixmap(w, h) ),而不是 QLabel.setPixmap( QPixmap.scaled(w, h) ) 。 「代码-1」 import logging, sys, random from PySide6 import QtCore from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, \ QLabel, QVBoxLayout, QHBoxLayout from PySide6.QtGui import QIcon, QMovie, QFont class MyLabel(QLabel): """docstring for MyLabel.""" def __init__(self, arg): super(MyLabel, self).__init__(arg) self.logger = logging.getLogger(self.__class__.__name__) self.logger.debug('Init a %s instance' % self.__class__.__name__) def paintEvent(self, event): self.logger.debug('my label paintevent: %s', event.rect()) # 调用父类方法进行绘制 super().paintEvent(event) pass class TaskInfoWidget(QWidget): """自定义 Widget。包含 icon,title, description 三个部分""" def __init__(self, title: str, description: str = '任务描述...', icon: QIcon = None, parent: QWidget = None): super(TaskInfoWidget, self).__init__(parent) self.logger = logging.getLogger(self.__class__.__name__) self.logger.debug('Init a %s instance' % self.__class__.__name__) # 1. 图标 self.label_icon = MyLabel(self) self.label_icon.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) self.label_icon.setFixedWidth(40) # 1.1 QLabel 加载图片 ## 使用 QIcon 来获得缩放后的 QPixmap # self.label_icon.setPixmap(QIcon("/Users/funway/project/python/TransferDog/transfer_dog/ui/resources/icons/dog.png").pixmap(32, 32)) ### 注意!不要像下面这样直接使用 QPixmap.scaled() 来缩放图标,scaled 方法的效果并不理想!(好像) ## p = QtGui.QPixmap("/Users/funway/project/python/TransferDog/transfer_dog/ui/resources/icons/dog.png") ## self.label_icon.setPixmap(p.scaled(32, 32, transformMode=QtCore.Qt.TransformationMode.SmoothTransformation)) # 1.2 QLabel 加载 gif self.movie = QMovie("/Users/funway/project/python/TransferDog/transfer_dog/ui/resources/icons/loading.gif") self.movie.setScaledSize(QtCore.QSize(32, 32)) self.movie.setCacheMode(QMovie.CacheMode.CacheAll) self.label_icon.setMovie(self.movie) self.movie.start() # 2. title self.label_title = QLabel(self) font = QFont() font.setPointSize(24) self.label_title.setFont(font) self.label_title.setText(title) # 3. description self.label_description = QLabel(self) self.label_description.setText(description) # QLabel.setText() 是支持富文本的 self.verticalLayout = QVBoxLayout() self.verticalLayout.addWidget(self.label_title) self.verticalLayout.addWidget(self.label_description) self.horizontalLayout = QHBoxLayout(self) self.horizontalLayout.addWidget(self.label_icon) self.horizontalLayout.addLayout(self.verticalLayout) self.label_icon.setStyleSheet('background-color: #{:06x}'.format(random.randint(0, 0xFFFFFF))) self.label_title.setStyleSheet('background-color: #{:06x}'.format(random.randint(0, 0xFFFFFF))) self.label_description.setStyleSheet('background-color: #{:06x}'.format(random.randint(0, 0xFFFFFF))) self.verticalLayout.setSpacing(0) self.verticalLayout.setContentsMargins(0, 0, 0, 0) self.horizontalLayout.setSpacing(0) self.horizontalLayout.setContentsMargins(0, 0, 0, 0) pass def paintEvent(self, event): """所有 QWidget 绘制的时候,都是调用 paintEvent() 方法进行绘制。 如果继承自某个内建 widget(比如 QLabel, QPushButton 这些),那么重写该方法将会覆盖父类的绘制行为。 此外!!!如果该 widget 还有 child widgets 的话,在执行完自己的 paintEvent() 之后, 还会接着自动调用所有 child widgets 的 paintEvent()! (其实并不是所有,只需要调用与该 event.rect() 有交集的 child widgets 的 paintEvent() 方法。) 对于 TaskInfoWidget,我们不需要在此手工绘制什么,只需由其 child widgets 自行绘制即可。…

Qt: 自定义 QTreeView(1)

一、QTreeView 基本使用 下面的代码通过 QTreeView 实现一个任务列表的树形展示,任务列表包含两级结构:任务组与任务。 import logging, sys from PyQt6 import QtCore from PyQt6.QtWidgets import QApplication, QMainWindow, QTreeView from PyQt6.QtGui import QStandardItemModel, QStandardItem, QBrush, QColor, QIcon class MainWindow(QMainWindow): def __init__(self): super(MainWindow, self).__init__() self.logger = logging.getLogger(self.__class__.__name__) self.logger.debug('Init a %s instance' % self.__class__.__name__) self.treeview = QTreeView() self.treeview.setHeaderHidden(True) # 定义数据 self.treemodel = QStandardItemModel() # 根节点 rootItem = self.treemodel.invisibleRootItem() # 一级节点 gp1 = QStandardItem('TG_Default') gp2 = QStandardItem('TG_Test') # 二级节点 tk11 = QStandardItem('t_任务1') tk11.setData('UserRole 可以用来保存附加数据', role=QtCore.Qt.ItemDataRole.UserRole) tk12 = QStandardItem('t_<span style="color:red;"><b>任务</b></span>task2') # 不支持富文本 tk13 = QStandardItem('t_资料收集333') tk13.setData(QBrush(QColor("#f7c948")), role=QtCore.Qt.ItemDataRole.BackgroundRole) # 设置背景色 tk21 = QStandardItem('t_发送测试') tk22 = QStandardItem('t_collection 1') tk22.setIcon(QIcon('/Users/funway/project/python/TransferDog/transfer_dog/ui/resources/icons/dog.png')) # 设置图标 rootItem.appendRow(gp1) rootItem.appendRow(gp2) gp1.appendRow(tk11) gp1.appendRow(tk12) gp1.appendRow(tk13) gp2.appendRow(tk21) gp2.appendRow(tk22) # 设置数据 self.treeview.setModel(self.treemodel) # 展开所有节点 self.treeview.expandAll() # 连接 信号-槽函数 self.treeview.doubleClicked.connect(self.on_doubleclicked) self.setCentralWidget(self.treeview) def on_doubleclicked(self, index:QtCore.QModelIndex): """槽函数,响应 QTreeView 的 doubleClicked() 信号, 该信号会向槽函数传递 QModelIndex 对象,代表被双击的节点 Args: index (QtCore.QModelIndex): 被双击的节点 """ self.logger.debug('double clicked on [%s, %s]', index.row(), index.column()) self.logger.debug('data(Default): %s', index.data()) self.logger.debug('data(UserRole): %s', index.data(role=QtCore.Qt.ItemDataRole.UserRole)) def main(): # 生成QApplication主程序 app = QApplication(sys.argv) # 生成窗口类实例 main_window = MainWindow() # 设置窗口标题 main_window.setWindowTitle('QTreeView Test') # 设置窗口大小 main_window.resize(400, 500) # 显示窗口 main_window.show() # 进入QApplication的事件循环 sys.exit(app.exec()) pass if __name__ == '__main__': log_format = '%(asctime)s pid[%(process)d] %(levelname)7s %(name)s.%(funcName)s - %(message)s' logging.basicConfig(level=logging.DEBUG, format=log_format) main() 结果如下图: 二、使用 QSortFilterProxyModel 添加搜索功能 关键点: QSortFilterProxyModel.filterAcceptsRow() QSortFilterProxyModel.setSourceModel() 代码: import logging, sys from PyQt6 import QtCore from PyQt6.QtWidgets import QApplication, QMainWindow, QTreeView, QLineEdit, QVBoxLayout, QWidget from PyQt6.QtGui import QStandardItemModel, QStandardItem, QBrush, QColor, QIcon class SearchProxyModel(QtCore.QSortFilterProxyModel): def __init__(self): super().__init__() self.logger = logging.getLogger(self.__class__.__name__) self.logger.debug('Init a %s instance' % self.__class__.__name__) pass def __accept_index(self, idx:QtCore.QModelIndex) -> bool: """判断 idx 节点(包括子节点)是否匹配 self.filterRegularExpression。节点(包括子节点)只要有一个能匹配到,就返回 True。 Args: idx (QtCore.QModelIndex): 节点的 QModelIndex 对象 Returns: bool: 匹配返回 True,否则返回 False """ if idx.isValid(): text =…