今天写个如何用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_())
-