geekdoc-python-zh/docs/pythonlibrary/creating-a-text-search-gui-...

23 KiB
Raw Permalink Blame History

用 wxPython 创建文本搜索 GUI

原文:https://www.blog.pythonlibrary.org/2021/09/04/creating-a-text-search-gui-with-wxpython/

在前面的教程中,您学习了如何使用 wxPython 创建文件搜索 GUI。在本文中您将学习如何使用 wxPython 创建一个文本搜索实用程序。

如果你想了解更多关于创建 GUI 应用程序的知识,你应该看看我的书用 wxPythonLeanpubGumroadAmazon 上创建 GUI 应用程序。

你可以在 GitHub 上下载这篇文章的源代码。注意:本文依赖于来自的用 wxPython 创建文件搜索 GUI 的一些代码。

现在,让我们开始吧!

文本搜索实用程序

文本搜索工具是一种可以在其他文件中搜索单词或短语的工具,比如流行的 GNU grep 工具。有一些工具也可以搜索 Microsoft Word、PDF 文件内容等等。您将只专注于搜索文本文件。除了常规文本文件之外,这些文件还包括 XML、HTML、Python 文件和其他代码文件。

有一个很好的 Python 包为我们做文本搜索,名为grin。因为这本书使用的是 Python 3你会想要使用grin3,因为这是与 Python 3 兼容的grin的版本。

您可以在此阅读关于该套餐的所有信息:

您将在这个包的顶部添加一个轻量级用户界面,允许您使用它来搜索文本文件。

安装依赖项

您可以使用pip安装grin3:

pip install grin3

一旦安装完毕,你将能够在 Mac 或 Linux 上从命令行运行gringrind。如果您在 Windows 上,您可能需要将它添加到您的路径中。

警告:grin3之前的版本是grin。如果你把它安装到 Python 3 中并试图运行它,你会看到错误,因为grinPython 3 不兼容。你需要卸载grin,然后安装grin3

现在你可以设计你的用户界面了!

设计文本搜索工具

您可以从本章前面的文件搜索实用程序中获取代码,并修改用户界面以用于文本搜索。您现在不关心搜索词是否区分大小写,所以您可以删除这个小部件。你也可以去掉子目录复选框,因为默认情况下grin会搜索子目录,这也是你想要的。

您仍然可以按文件类型过滤,但是为了简单起见,让我们也删除它。但是,您需要一种方法来显示找到的文件以及包含找到的文本的行。为此,除了ObjectListView小部件之外,您还需要添加一个多行文本控件。

记住所有这些,这里是模型:

Text Search Mockup

是时候开始编码了!

创建文本搜索实用程序

新的文本搜索工具将分为三个模块:

  • 主模块
  • 搜索线程模块
  • 偏好模块

模块将包含主用户界面的代码。 search_thread 模块将包含使用grin搜索文本的逻辑。最后,首选项将用于创建一个对话框,您可以用它来保存grin可执行文件的位置。

现在您可以开始创建模块。

主模块

模块不仅保存用户界面,它还会检查以确保你已经安装了grin以便它能够工作。它还将启动首选项对话框,并向用户显示搜索结果(如果有)。

下面是前几行代码:

# main.py

import os
import sys
import subprocess
import time
import wx

from configparser import ConfigParser, NoSectionError
from ObjectListView import ObjectListView, ColumnDefn
from preferences import PreferencesDialog
from pubsub import pub
from search_thread import SearchThread

这个模块与之前版本的模块有许多相同的导入。然而在这一个中,你将使用 Python 的configparser模块以及创建一个PreferencesDialog和一个SearchThread。其余的导入应该是不言自明的。

您需要复制SearchResult类,并像这样修改它:

class SearchResult:

    def __init__(self, path, modified_time, data):
        self.path = path
        self.modified = time.strftime('%D %H:%M:%S',
                                      time.gmtime(modified_time))
        self.data = data

该类现在接受一个新的参数data,它保存一个字符串,该字符串包含在文件中找到搜索词的所有位置的引用。当用户选择一个搜索结果时,您将向用户显示该信息。

但是首先,您需要创建 UI:

class MainPanel(wx.Panel):

    def __init__(self, parent):
        super().__init__(parent)
        self.search_results = []
        self.main_sizer = wx.BoxSizer(wx.VERTICAL)
        self.create_ui()
        self.SetSizer(self.main_sizer)
        pub.subscribe(self.update_search_results, 'update')

        module_path = os.path.dirname(os.path.abspath( __file__ ))
        self.config = os.path.join(module_path, 'config.ini')
        if not os.path.exists(self.config):
            message = 'Unable to find grin3 for text searches. ' \
                       'Install grin3 and open preferences to ' \
                       'configure it:  pip install grin3'
            self.show_error(message)

像以前一样,MainPanel建立一个空的search_results列表。它还通过调用create_ui()创建了 UI并添加了一个pubsub订阅。但是添加了一些新代码来获取脚本的路径并检查配置文件。如果配置文件不存在,你向用户显示一条消息,告诉他们需要安装grin3并使用首选项菜单配置应用程序。

现在让我们看看用户界面代码是如何变化的:

def create_ui(self):
    # Create a widgets for the search path
    row_sizer = wx.BoxSizer()
    lbl = wx.StaticText(self, label='Location:')
    row_sizer.Add(lbl, 0, wx.ALL | wx.CENTER, 5)
    self.directory = wx.TextCtrl(self, style=wx.TE_READONLY)
    row_sizer.Add(self.directory, 1, wx.ALL | wx.EXPAND, 5)
    open_dir_btn = wx.Button(self, label='Choose Folder')
    open_dir_btn.Bind(wx.EVT_BUTTON, self.on_choose_folder)
    row_sizer.Add(open_dir_btn, 0, wx.ALL, 5)
    self.main_sizer.Add(row_sizer, 0, wx.EXPAND)

这段代码将创建一个水平的row_sizer并添加三个小部件:一个标签、一个保存要搜索的文件夹的文本控件和一个选择该文件夹的按钮。这一系列小部件与其他代码示例中的小部件相同。

事实上,下面的搜索控制代码也是如此:

# Add search bar
self.search_ctrl = wx.SearchCtrl(
    self, style=wx.TE_PROCESS_ENTER, size=(-1, 25))
self.search_ctrl.Bind(wx.EVT_SEARCHCTRL_SEARCH_BTN, self.on_search)
self.search_ctrl.Bind(wx.EVT_TEXT_ENTER, self.on_search)
self.main_sizer.Add(self.search_ctrl, 0, wx.ALL | wx.EXPAND, 5)

同样,您创建了一个wx.SearchCtrl实例,并将其绑定到相同的事件和相同的事件处理程序。事件处理程序的代码会有所不同,但是您很快就会看到变化。

让我们先完成小部件代码:

# Search results widget
self.search_results_olv = ObjectListView(
    self, style=wx.LC_REPORT | wx.SUNKEN_BORDER)
self.search_results_olv.SetEmptyListMsg("No Results Found")
self.search_results_olv.Bind(wx.EVT_LIST_ITEM_SELECTED,
                             self.on_selection)
self.main_sizer.Add(self.search_results_olv, 1, wx.ALL | wx.EXPAND, 5)
self.update_ui()

self.results_txt = wx.TextCtrl(
    self, style=wx.TE_MULTILINE | wx.TE_READONLY)
self.main_sizer.Add(self.results_txt, 1, wx.ALL | wx.EXPAND, 5)

show_result_btn = wx.Button(self, label='Open Containing Folder')
show_result_btn.Bind(wx.EVT_BUTTON, self.on_show_result)
self.main_sizer.Add(show_result_btn, 0, wx.ALL | wx.CENTER, 5)

当用户在ObjectListView小部件中选择一个搜索结果时,触发on_selection事件处理程序。您获取选择,然后将文本控件值设置为data属性。data属性是字符串的list,因此您需要使用字符串的join()方法,通过换行符\n将所有这些行连接在一起。您希望每一行都在自己的行上,以便于阅读结果。

您可以将文件搜索实用程序中的on_show_result()方法复制到这个程序中,因为该方法不需要任何更改。

下一个要写的新代码是on_search()方法:

def on_search(self, event):
    search_term = self.search_ctrl.GetValue()
    self.search(search_term)

这次的on_search()方法要简单得多,因为您只需要获取search_term。在这个版本的应用程序中没有任何过滤器,这无疑减少了代码混乱。一旦你有了要搜索的术语,你就打电话给search()

说到这里,这是下一个要创建的方法:

def search(self, search_term):
    """
    Search for the specified term in the directory and its
    sub-directories
    """
    folder = self.directory.GetValue()
    config = ConfigParser()
    config.read(self.config)
    try:
        grin = config.get("Settings", "grin")
    except NoSectionError:
        self.show_error('Settings or grin section not found')
        return

    if not os.path.exists(grin):
        self.show_error(f'Grin location does not exist {grin}')
        return
    if folder:
        self.search_results = []
        SearchThread(folder, search_term)

search()代码将获得folder路径并创建一个config对象。然后它将尝试打开配置文件。如果配置文件不存在或无法读取“设置”部分您将显示一条错误消息。如果“Settings”部分存在grin可执行文件的路径不存在,您将显示不同的错误消息。但是如果你通过了这两个关卡,文件夹本身也设置好了,那么你就可以开始SearchThread了。该代码保存在另一个模块中,所以您必须等待了解这一点。

现在,让我们看看show_error()方法中发生了什么:

def show_error(self, message):
    with wx.MessageDialog(None, message=message,
                          caption='Error',
                          style= wx.ICON_ERROR) as dlg:
        dlg.ShowModal()

这个方法将创建一个wx.MessageDialog并向用户显示一个错误,并向用户传递一个message。该函数对于显示错误非常方便。如果你想显示其他类型的消息,你可以稍微更新一下。

当搜索完成时,它将发出一条pubsub消息,这将导致以下代码执行:

def update_search_results(self, results):
    """
    Called by pubsub from thread
    """
    for key in results:
        if os.path.exists(key):
            stat = os.stat(key)
            modified_time = stat.st_mtime
            result = SearchResult(key, modified_time, results[key])
            self.search_results.append(result)

    if results:
        self.update_ui()
    else:
        search_term = self.search_ctrl.GetValue()
        self.search_results_olv.ClearAll()
        msg = f'No Results Found for: "{search_term}"'
        self.search_results_olv.SetEmptyListMsg(msg)

这个方法接受一个dict搜索结果。然后,它遍历dict中的键,并验证路径是否存在。如果是的话,那么您使用os.stat()来获取关于文件的信息并创建一个SearchResult对象,然后将它append()到您的search_results

当搜索没有返回结果时,您将希望清除搜索结果小部件,并通知用户他们的搜索没有找到任何结果。

update_ui()代码与前面的代码几乎完全相同:

def update_ui(self):
    self.search_results_olv.SetColumns([
        ColumnDefn("File Path", "left", 800, "path"),
        ColumnDefn("Modified Time", "left", 150, "modified")
    ])
    self.search_results_olv.SetObjects(self.search_results)

这里唯一的区别是列比文件搜索工具中的要宽一些。这是因为在测试过程中发现的许多结果往往是相当长的字符串。

wx.Frame的代码也发生了变化,因为您现在可以添加一个菜单:

class Search(wx.Frame):

    def __init__(self):
        super().__init__(None, title='Text Search Utility',
                         size=(1200, 800))
        pub.subscribe(self.update_status, 'status')
        panel = MainPanel(self)
        self.create_menu()
        self.statusbar = self.CreateStatusBar(1)
        self.Show()

    def update_status(self, search_time):
        msg = f'Search finished in {search_time:5.4} seconds'
        self.SetStatusText(msg)

这里你创建了一个Search框架,并设置了一个比其他工具更宽的尺寸。您还将创建面板、订户和菜单。update_status()方法和上次一样。

真正新的一点是对create_menu()的调用,这也是接下来要做的:

def create_menu(self):
    menu_bar = wx.MenuBar()

    # Create file menu
    file_menu = wx.Menu()

    preferences = file_menu.Append(
        wx.ID_ANY, "Preferences",
        "Open Preferences Dialog")
    self.Bind(wx.EVT_MENU, self.on_preferences,
              preferences)

    exit_menu_item = file_menu.Append(
        wx.ID_ANY, "Exit",
        "Exit the application")
    menu_bar.Append(file_menu, '&File')
    self.Bind(wx.EVT_MENU, self.on_exit,
              exit_menu_item)

    self.SetMenuBar(menu_bar)

在这段代码中,您创建了MenuBar并添加了一个file_menu。在该菜单中,添加两个菜单项;一个用于preferences,一个用于退出应用程序。

您可以首先创建退出代码:

def on_exit(self, event):
    self.Close()

如果用户进入文件菜单并选择“退出”,该代码将会执行。当他们这样做时,你的应用程序将Close()。由于框架是最顶层的窗口,当它关闭时,它也会自我毁灭。

该类中的最后一段代码用于创建首选项对话框:

def on_preferences(self, event):
    with PreferencesDialog() as dlg:
        dlg.ShowModal()

在这里,您实例化了PreferencesDialog并显示给用户。当用户关闭对话框时,它将被自动销毁。

您需要将以下代码添加到文件的末尾,以便代码能够运行:

if __name__ == '__main__':
    app = wx.App(False)
    frame = Search()
    app.MainLoop()

当您完成了该应用程序的其余部分的编码后,它将如下所示:

Text Search Utility

注意,当您进行搜索时,grin允许正则表达式,所以您也可以在 GUI 中输入它们。

下一步是创建线程代码!

搜索线程模块

search_thread 模块包含使用grin3可执行文件在文件中搜索文本的逻辑。在这个模块中,您只需要一个Thread的子类,因为您将总是搜索子目录。

第一步是创建导入:

# search_thread.py

import os
import subprocess
import time
import wx

from configparser import ConfigParser
from pubsub import pub
from threading import Thread

对于搜索线程模块,您需要访问ossubprocesstime模块。新的模块是subprocess模块,因为您将启动一个外部应用程序。这里的另一个新增功能是ConfigParser,它用于从配置文件中获取可执行文件的路径。

让我们继续创建SearchThread本身:

class SearchThread(Thread):

    def __init__(self, folder, search_term):
        super().__init__()
        self.folder = folder
        self.search_term = search_term
        module_path = os.path.dirname(os.path.abspath( __file__ ))
        self.config = os.path.join(module_path, 'config.ini')
        self.start()

__init__()方法接受目标foldersearch_term来寻找。它还重新创建了module_path来导出config文件的位置。

最后一步是start()线程。当这个方法被调用时,它调用了run()方法。

让我们忽略下一个:

def run(self):
    start = time.time()
    config = ConfigParser()
    config.read(self.config)
    grin = config.get("Settings", "grin")
    cmd = [grin, self.search_term, self.folder]
    output = subprocess.check_output(cmd, encoding='UTF-8')
    current_key = ''
    results = {}
    for line in output.split('\n'):
        if self.folder in line:
            # Remove the colon off the end of the line
            current_key = line[:-1]
            results[current_key] = []
        elif not current_key:
            # key not set, so skip it
            continue
        else:
            results[current_key].append(line)
    end = time.time()
    wx.CallAfter(pub.sendMessage,
                 'update',
                 results=results)
    wx.CallAfter(pub.sendMessage, 'status', search_time=end-start)

在这里,您添加一个start时间,并获得应该在此时创建的config。接下来,您创建一个命令的listgrin实用程序将搜索词和要搜索的目录作为其主要参数。实际上,您可以添加其他参数来使搜索更有针对性,但这需要额外的 UI 元素,并且您的目标是保持该应用程序美观简单。

下一步是调用获取命令列表的subprocess.check_output()。你还把encoding设置为 UTF-8。这告诉subprocess模块返回一个字符串,而不是字节串,它还验证返回值是否为零。

现在需要对返回的结果进行解析。您可以通过在换行符上拆分来循环遍历每一行。每个文件路径应该是唯一的,所以它们将成为您的results字典的关键字。请注意,您需要删除该行的最后一个字符,因为键的末尾有一个冒号。这会使路径无效,所以删除它是个好主意。然后,对于路径后面的每一行数据,将它附加到字典中特定键的值上。

完成后,您通过pubsub发送两条消息来更新 UI 和状态栏。

现在是时候创建最后一个模块了!

首选项模块

首选项模块包含创建PreferencesDialog所需的代码,这将允许您配置grin可执行文件在您机器上的位置。

让我们从进口开始:

# preferences.py

import os
import wx

from configparser import ConfigParser

幸运的是,模块的导入部分很短。你只需要oswxconfigparser模块就能完成这项工作。

既然你已经弄清楚了那部分,你可以通过进入文件- >首选项菜单来创建对话框本身:

class PreferencesDialog(wx.Dialog):

    def __init__(self):
        super().__init__(None, title='Preferences')
        module_path = os.path.dirname(os.path.abspath( __file__ ))
        self.config = os.path.join(module_path, 'config.ini')
        if not os.path.exists(self.config):
            self.create_config()

        config = ConfigParser()
        config.read(self.config)
        self.grin = config.get("Settings", "grin")

        self.main_sizer = wx.BoxSizer(wx.VERTICAL)
        self.create_ui()
        self.SetSizer(self.main_sizer)

在这里,您创建了__init__()方法并获得了module_path,这样您就可以找到config。然后验证config是否存在。如果没有,那么创建配置文件,但是不要设置可执行文件的位置。

您确实试图通过config.get()获得它的位置,但是如果它在文件中是空白的,那么您将得到一个空字符串。

最后三行设置了一个 sizer 并调用create_ui()

接下来您应该编写最后一个方法:

def create_ui(self):
    row_sizer = wx.BoxSizer()
    lbl = wx.StaticText(self, label='Grin3 Location:')
    row_sizer.Add(lbl, 0, wx.ALL | wx.CENTER, 5)
    self.grin_location = wx.TextCtrl(self, value=self.grin)
    row_sizer.Add(self.grin_location, 1, wx.ALL | wx.EXPAND, 5)
    browse_button = wx.Button(self, label='Browse')
    browse_button.Bind(wx.EVT_BUTTON, self.on_browse)
    row_sizer.Add(browse_button, 0, wx.ALL, 5)
    self.main_sizer.Add(row_sizer, 0, wx.EXPAND)

    save_btn = wx.Button(self, label='Save')
    save_btn.Bind(wx.EVT_BUTTON, self.save)
    self.main_sizer.Add(save_btn, 0, wx.ALL | wx.CENTER, 5)

在这段代码中,您创建了一行小部件。一个标签、一个保存可执行文件路径的文本控件和一个浏览该路径的按钮。您将所有这些添加到 sizer 中,然后 sizer 嵌套在main_sizer中。然后在对话框底部添加一个“保存”按钮。

下面是从头开始创建配置的代码:

def create_config(self):
    config = ConfigParser()
    config.add_section("Settings")
    config.set("Settings", 'grin', '')

    with open(self.config, 'w') as config_file:
        config.write(config_file)

当配置不存在时,将调用该代码。它实例化一个ConfigParser对象,然后向其添加适当的部分和设置。然后将它写到磁盘的适当位置。

save()方法可能是下一段最重要的代码:

def save(self, event):
    grin_location = self.grin_location.GetValue()
    if not grin_location:
        self.show_error('Grin location not set!')
        return
    if not os.path.exists(grin_location):
        self.show_error(f'Grin location does not exist {grin_location}')
        return

    config = ConfigParser()
    config.read(self.config)
    config.set("Settings", "grin", grin_location)
    with open(self.config, 'w') as config_file:
        config.write(config_file)
    self.Close()

在这里,您从文本控件中获得了grin应用程序的位置,如果没有设置,就会显示一个错误。如果该位置不存在,也会显示错误。但是,如果设置了它并且它确实存在,那么您打开配置文件备份并保存配置文件的路径,供主应用程序使用。一旦保存完成,你Close()对话框。

最后一个常规方法用于显示错误:

def show_error(self, message):
    with wx.MessageDialog(None, message=message,
                          caption='Error',
                          style= wx.ICON_ERROR) as dlg:
        dlg.ShowModal()

这段代码实际上和你在模块中的show_error()方法完全一样。每当你在代码中看到这样的事情,你知道你应该重构它。这个方法可能应该进入它自己的模块,然后导入到首选项模块。不过你可以自己想办法做到这一点。

最后,您需要为这个类创建唯一的事件处理程序:

def on_browse(self, event):
    """
    Browse for the grin file
    """
    wildcard = "All files (*.*)|*.*"
    with wx.FileDialog(None, "Choose a file",
                       wildcard=wildcard,
                       style=wx.ID_OPEN) as dialog:
        if dialog.ShowModal() == wx.ID_OK:
            self.grin_location.SetValue(dialog.GetPath())

当用户按下“浏览”按钮去寻找可执行文件 grin 时,这个事件处理器被调用。当他们找到文件时,他们可以选择它,文本控件将被设置到它的位置。

现在你已经把对话框全部编码好了,下面是它的样子:

Preferences Dialog for Text Search

包扎

现在您知道了如何使用 Python 和 wxPython GUI 工具包创建文本搜索实用程序。

以下是您可以添加的一些增强功能:

  • 添加停止搜索的功能
  • 防止多个搜索同时发生
  • 添加其他过滤器

您还可以通过添加对更多 grin 命令行选项的支持来增强它。查看 grin 的文档以获得关于该主题的更多信息。