开发

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

如何实现 "Reveal in Finder" 的功能

Sublime、VSCode 这些编辑器都有一个 "Reveal in Finder" 的功能(macOS 是 Finder, Windows 下是 "Reveal in Explorer", Linux 下是 "Open Containing Folder"),就是针对某个文件,使用系统默认的文件管理器打开其所在目录,并选中该文件。 大部分文件管理器的命令都有打开目录并选中指定文件的参数。所以要想在自己的程序中实现类似功能,只需要: 找到对应系统的文件管理器命令。macOS 是 "open", Windows 是 "explorer.exe"。 Linux 则比较复杂了,Ubuntu 默认是 "nautilus", Kali 默认是 "thunar" 确定文件管理器命令是否支持选中文件?如果支持,命令行参数是什么? macOS open -R file_full_path -R, --reveal Windows explorer.exe /select, file_full_path 注意是 /select, Ubuntu nautilus -s file_full_path -s, --select Kali thunar file_full_path 不需要额外参数,默认就会帮忙选中指定文件 在代码中启动子进程来调用命令。比如 python 中就是 subprocess.run(['open', '-R', 'file_full_path'])   另外,可以参考一个开源库:https://github.com/damonlynch/showinfilemanager

使用 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):…

macOS 下 pyinstaller 报错 "Error while signing the bundle ... ... .DS_Store files cannot be a symlink"

今天在 macOS 下使用 pyinstaller 打包程序的时候,遇到一个 codesign 签名的错误。 Error while signing the bundle。我一开始还以为是签名证书的问题,还按照 https://stackoverflow.com/a/68937623/5777080 这里的答案新建了一个自签名的 code signing 证书。但是依然报同样的错误。 其实这个报错的原因是在第二句话:.DS_Store files cannot be a symlink。 因为 pyinstaller 拷贝的静态文件目录中,出现了 .DS_Store 文件。只要将 .DS_Store 文件删除即可。 find ./ -name .DS_Store -delete 签名成功后,使用 codesign 命令检查 app 包,可以看到 Identifier 已经正确变成了指定的 bundle id。另外 Signature 签名方式显示的是 adhoc,这个大概可以理解为 “特定的,开发测试版本,无需认证”。

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  

Python 创建线程安全的单例

之前在文章《深入理解 Python 中的类与元类》中讲了使用 __new__() 方法可以很方便地实现单例模式。但其实那段代码是线程不安全的。 验证代码如下: import threading import time class Singleton(object): _instance = None def __new__(cls, *args, **kwargs): if cls._instance is None: time.sleep(1) cls._instance = object.__new__(cls) return cls._instance def create(): obj = Singleton() print(obj) threads = [] for i in range(10): t = threading.Thread(target=create) threads.append(t) for t in threads: t.start() 输出结果如下图: 正确的线程安全的实现方法是: import logging, threading class Singleton(object): _instance = None _lock = threading.Lock() _initialized = False def __new__(cls, *args, **kwargs): if cls._instance is None: with cls._lock: if cls._instance is None: cls._instance = object.__new__(cls) return cls._instance def __init__(self): cls = self.__class__ if not cls._initialized: with cls._lock: if not cls._initialized: cls._initialized = True # 在此处进行成员变量的声明与初始化 self.logger = logging.getLogger(cls.__name__) self.logger.debug('Init %s singleton', cls.__name__) pass 为什么要嵌套两次判断 cls._instance is None 呢? 其实不加第一层判断也是可以的,但由于获取线程锁是一个“费时”的操作,所以加第一层判断其实就是为了避免每次调用 __new__() 方法都要去获取线程锁。 with lock: 的用法,参考官方文档。

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