今天写个如何用Python+PyQt5模块,单个python代码文件运行,实现Windows窗口应用工具的例子。
代码不长,去除注释可能都不到200行,很适合新人入门。
本文代码最终实现结果如下,
窗口界面参考Windows端鼎鼎大名文本查找程序SearchAndReplace,
顺便一提如有Windows单机海量文本或文件查找需求的,可以试试AnyTXT Searcher和Everything,这种可以自建索引实现高速查找文本或文件的工具。
直接上菜
复制代码保存到单个py文件,执行即可。
后续如果需要将py文件打包exe的,可以搜索nuitka,知乎上有精通这方面的人,专门发过帖子。
# -*- coding: UTF8 -*-
import sys
import re
import os
import time
import traceback
from PyQt5.QtGui import QIcon, QBrush, QColor
from PyQt5.QtWidgets import QDialog, QPushButton, QTreeWidget, QMessageBox, QApplication, QVBoxLayout, QHBoxLayout, QTreeWidgetItem, QLabel, QLineEdit, QFileDialog
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSize
# @Created by CSDN-watfe
class AvoidJam(QThread):
"""
耗时会导致QTUI无响应的内容,放到这里执行。
引入信号槽函数,是为了不卡死的同时,保持QTUI控件的交互数据更新(如果直接刷新主界面控件时,比如表格控件,并不会立即刷新,需要点击一下才可以刷新,则需要使用引入槽函数)。
"""
signal = pyqtSignal(tuple) # 创建信号
def __init__(self, *args, **kwargs):
super(AvoidJam, self).__init__() # 子类调用父类,继承了父类QThread的属性和方法,并用父类初始化方法来初始化继承的属性
self.main_win = kwargs.get('main_win') # 主界面
self.signal.connect(self.refresh) # 信号绑定到函数
self.folder = self.main_win.windowTitle().split(':', 1)[1]
self.filetype = '(py|ipynb|txt|md)' # 限定文档类型
self.repat = self.main_win.lineEdit.text()
def run(self):
"""
IO开销耗时,导致界面卡死的部分,放到这里执行;
有需要传递给界面控件的,通过signal.emit发送tuple类型的信号sigtuple:(信号id,值1,值2,...)
"""
try:
# 搜索文件,限制文件类型,返回结果
filepathArr = fileSearch(path=self.folder, repat=r'.*\.'+self.filetype, ignorecase=True, fullpath=True, children=True)
for index,filepath in enumerate(filepathArr):
# 当前执行序号
sigtuple = (2, index+1,len(filepathArr))
self.signal.emit(sigtuple) # 发送信号执行函数
# 打开文件,正则查找
with open(filepath,'r',encoding='utf-8') as f:
text = f.read()
textlen = len(text)
# 找到结果输出到界面
reIndexArr = [m.span() for m in re.finditer(self.repat, text)] # 正则文本位于全文字符序号位置
if reIndexArr:
reRowArr = [text[:start].count('\n')+1 for start,end in reIndexArr] # 正则文本位于全文的第几行
text = text.replace('\r\n',' \n').replace('\n',' ')
reArr = [(text[start-30 if start-30>0 else 0:start].replace('<', '<').replace('>', '>'),text[start:end].replace('<', '<').replace('>', '>'),text[end:end+30 if end+30<=textlen+1 else textlen+1].replace('<', '<').replace('>', '>')) for start,end in reIndexArr]
for i in reArr:
if '\n' in i[2]:
print(i)
sigtuple = (1, filepath, reArr, reIndexArr, reRowArr)
self.signal.emit(sigtuple) # 发送信号执行函数
# 结束
sigtuple = (3, len(filepathArr))
self.signal.emit(sigtuple) # 发送信号执行函数
except Exception as e:
print(e)
print(traceback.format_exc())
def refresh(self, sigtuple):
"""
这里完成QTUI控件交互
"""
# sigtuple信号拆成:(信号id,值1,值2...)
sigid, *values = sigtuple
# 执行界面变动
if sigid == 1:
# 根节点(文件名)
filepath = values[0]
filename = os.path.split(filepath)[1]
root = QTreeWidgetItem(self.main_win.tree) # 设置根节点
root.setText(0, filename) # 文件名
root.setText(1, filepath) # 路径
root.setForeground(0, QBrush(Qt.blue))
# 子节点(匹配值)
reArr = values[1]
reIndexArr = values[2]
reRowArr = values[3]
for matchstr,startend,rowcount in zip(reArr,reIndexArr,reRowArr):
# 多个子节点逐个插入
child = QTreeWidgetItem(root)
label = QLabel() # 引入QLabel用彩色标记关键字
label.setText(f"{matchstr[0]}<font color=red>{matchstr[1]}</font>{matchstr[2]}")
child.setText(1,f'{rowcount:>5} 行,{startend[0]}-{startend[1]}')
self.main_win.tree.setItemWidget(child,0,label)
# # 展开全部
# self.main_win.tree.expandAll()
elif sigid == 2:
# 输出当前进度
index = values[0] # 当前执行到第几个文件
total = values[1] # 总计文件数
self.main_win.label.setText(f'{index}/{total}')
elif sigid == 3:
# 结束,展开全部,按钮变回开始
total = values[0]
self.main_win.label.setText(f'{total}项查完')
self.main_win.pushButton.setText('开始')
self.main_win.tree.expandAll()
class MainWidgetUI(QDialog):
def __init__(self):
super().__init__()
# 窗口标题、图标、大小
# folder应用程序当前位置(代替folder = os.getcwd(),否则建立快捷方式,等其他情况执行的时候,folder会返回C:\Windows\system32,而非运行目录
if getattr(sys, 'frozen', False): # 判断当前是脚本,还是exe文件
folder = os.path.dirname(sys.executable) # 脚本目录
elif __file__:
folder = os.path.dirname(__file__) # exe文件
self.setWindowTitle('文本查找:'+folder)
self.setWindowIcon(QIcon("favicon.ico"))
self.resize(1000, 700) # 禁止调整窗口大小
self.setWindowFlags(Qt.WindowCloseButtonHint) # 隐藏右上角问号
'''以下控件也可以通过QTUI设计去画,然后从转化出来的ui_Dialog.py中拷贝出来'''
# 窗口控件
self.pushButton = QPushButton("开始")
self.pushButton_folder = QPushButton("选择文件夹")
self.pushButton_folder.setFocusPolicy(Qt.NoFocus)
self.tree = QTreeWidget()
self.label = QLabel()
self.lineEdit = QLineEdit()
# 初始化label
self.label.setMinimumSize(QSize(100, 0))
self.label.setText('0/0')
# 初始化tree
self.tree.setColumnCount(2) # 设置列数
self.tree.setHeaderLabels(['文件名', '路径']) # 设置头的标题
self.tree.setColumnWidth(0, 800) # 设置列宽
self.tree.setColumnWidth(1, 900) # 设置列宽
self.tree.itemDoubleClicked.connect(self.doubleClickOpenFile) # 关联双击动作,打开文件
# 布局
# 创建水平布局,并嵌入到垂直中
LayoutH = QHBoxLayout()
LayoutH.addWidget(self.pushButton_folder)
LayoutH.addWidget(self.label)
LayoutH.addWidget(self.lineEdit)
LayoutH.addWidget(self.pushButton)
# 按照方向、添加顺序,绘制布局
Layout = QVBoxLayout() # QVBoxLayout()垂直、QHBoxLayout()水平, 查看布局请移步CURL-api.py
Layout.addLayout(LayoutH)
Layout.addSpacing(8) # 添加一个8px的空间距离 且不带弹性
Layout.addWidget(self.tree)
# 应用布局
self.setLayout(Layout)
# 按钮连接到函数
self.pushButton_folder.clicked.connect(self.BTfolder) # 选择文件夹
self.pushButton.clicked.connect(self.BTclick) # 执行查找
def doubleClickOpenFile(self, item, column_no):
# 双击子节点,调用系统方法打开父节点文件
if item.parent(): # 父目录是None的跳过
# 打开文件
filepath = item.parent().text(1)
os.startfile(filepath)
def BTfolder(self):
# 选择文件夹
folder = QFileDialog.getExistingDirectory(self, "选择要查找的文件夹(搜索将包含子文件夹)", "./")
# 当窗口非继承QtWidgets.QDialog时,self可替换成 None
self.setWindowTitle('文本查找:'+folder)
def BTclick(self):
# 点击按钮,执行查找
# AvoidJam的写法,支持IO开销大,需避免界面卡,支持随时停止
if self.pushButton.text() == '开始':
patStr = self.lineEdit.text()
# 尚未输入任何要找文本
if patStr=='':
QMessageBox.information(self, '注意', '请先输入要找(支持正则)文本', QMessageBox.Yes)
return
patStrStrip = patStr.lstrip('(').rstrip(')')
# 要找文本长度小于2位
if len(patStrStrip)<2:
QMessageBox.information(self, '注意', '至少输入2位要找字符', QMessageBox.Yes)
return
# 要找内容首尾包含不定长度匹配
if patStrStrip[1] in ['*','+'] and patStrStrip[0]!='\\' or patStrStrip[-1] in ['*','+'] and patStrStrip[-1]!='\\':
QMessageBox.information(self, '注意', '请勿在正则首尾,使用*或+等不定长度查找,将严重影响查询速度', QMessageBox.Yes)
return
self.tree.clear()
self.pushButton.setText('停止')
self.thread = AvoidJam(main_win=self)
self.thread.start()
else:
self.pushButton.setText('开始')
self.thread.terminate()
self.thread.quit()
def fileSearch(path='.', repat= r'.*', ignorecase=True, fullpath=True, children=True):
"""
文件查找:
文件夹及子文件夹下,所有匹配文件,返回list文件列表,绝对路径形式
Args:
path: 文件路径(默认当前路径)
repat: 文件名正则匹配,不区分大小写(默认匹配所有文件)比如:r'\d+\.jpg',匹配所有数字名称的.jpg文件
ignorecase: True忽略大小写,否则区分大小写
fullpath: True返回文件的绝对路径,否则只返回文件名
children: True获取子目录下的文件,否则忽略子目录下的文件
Returns:
files_match: 文件列表
"""
if not os.path.exists(path):
raise '路径不存在'
# 获取文件夹,及子文件夹下所有文件,并转为绝对路径
files = []
repat = '^'+repat+'$'
oswalk = os.walk(path)
# walk结果形式 [(folder:文件夹,[childrenFolderList:该文件夹下的子文件夹],[fileList:该文件夹下的文件]),(子文件夹1,[子子文件夹],[]),(子文件夹2,[],[])...]
# 该遍历会走遍所有子文件夹,返回上述形式的结果信息。
oswalk = oswalk if children else [next(oswalk)] # 是否遍历子目录
ignorecase = re.IGNORECASE if ignorecase else 0 # 是否忽略大小写
# 找出符合条件的文件名,合并绝对路径后,加入到返回列表
for folder, childrenFolderList, fileList in oswalk:
for file in fileList:
# 文件名是否符合要求
if re.match(pattern=repat, string=file, flags=ignorecase):
# 返回绝对路径或只是文件名
filepath = os.path.abspath(os.path.join(folder, file)).replace('\\', '/') if fullpath else file
files.append(filepath)
# print('找到{0}个文件'.format(len(files)))
# 返回满足要求的
return files
if __name__ == "__main__":
app = QApplication(sys.argv)
main_widget = MainWidgetUI()
main_widget.show()
sys.exit(app.exec_())