naritoブログ

このブログはDjangoとBootstrap4で作成されました
ソースコード

Tkinterで、検索ボックス

プログラミング関連 Python Tkinter 約30日前
2017年10月22日16:31
今回は、tk.Text内の文字列を検索する機能を作ります。

このような、文字が入ったTextウィジェットがあり...


検索ボックスに入力し、エンターを押すと文字が選択されます。


まずはmain.py
Textウィジェットを持ったttk.Frameを作成し、表示しています。今回の検索ボックスを確認するための記述が殆どで、検索処理にはほとんど関わりません。
import tkinter as tk
import tkinter.ttk as ttk
import search


class MainFrame(ttk.Frame):
    def __init__(self, master=None, **kwargs):
        super().__init__(master, **kwargs)
        self.create_widgets()

    def create_widgets(self):
        self.text = tk.Text(self)
        self.text.bind(
            '<Control-f>',
            lambda event: search.create_search_box(text_widget=self.text),
        )
        self.text.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
        self.text.columnconfigure(0, weight=1)
        self.text.rowconfigure(0, weight=1)


if __name__ == '__main__':
    root = tk.Tk()
    app = MainFrame(root)
    app.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
    root.columnconfigure(0, weight=1)
    root.rowconfigure(0, weight=1)
    root.mainloop()




検索機能は、search.pyにまとめています。
"""テキスト検索ボックスを提供するモジュール

create_search_box関数に、tk.Textウィジェットを渡せば利用できます。

"""
import tkinter as tk
import tkinter.ttk as ttk


class SearchBox(ttk.Frame):
    """テキスト検索ボックス."""

    def __init__(self, master, text_widget, *args, **kwargs):
        """初期化

        text_widget引数に、tk.Textウィジェットを渡してください
        """
        super().__init__(master, *args, **kwargs)
        self.target_text = text_widget  # 検索する対象となるTextウィジェット
        self.create_widgets()
        self.last_text = ''
        self.all_pos = []
        self.next_pos_index = 0
        self.text.focus()  # 入力欄にフォーカスしとく

    def create_widgets(self):
        """ウィジェットの作成."""
        # 検索文字の入力欄と、紐付けるStringVar
        self.text_var = tk.StringVar()
        self.text = ttk.Entry(self, textvariable=self.text_var)
        self.text.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))

        # エンターキーで検索実行される
        self.text.bind('<Return>', self.search)

    def search_start(self, text):
        """検索の初回処理."""
        # 各変数の初期化
        self.next_pos_index = 0
        self.all_pos = []

        # はじめは1.0から検索し、見つかれば、それの最後+1文字の時点から再検索
        # all_posには、['1.7', '3,1', '5.1'...]のような検索文字が見つかった地点の最初のインデックスが入っていく
        start_index = '1.0'
        while True:
            pos = self.target_text.search(text, start_index, stopindex='end')
            if not pos:  # 検索文字がもう見つからければbreak
                break
            self.all_pos.append(pos)
            start_index = '{0} + 1c'.format(pos)  # 最後から+1文字を起点に、再検索

        # 最初のマッチ部分、all_pos[0]を選択させておく
        self.search_next(text)

    def search_next(self, text):
        """検索の続きの処理."""
        try:
            # 今回のマッチ部分の取得を試みる
            pos = self.all_pos[self.next_pos_index]
        except IndexError:
            # all_posが空でなくIndexErrorならば、全てのマッチを見た、ということ
            # なのでnext_post_indexを0にし、最初からまたマッチを見せる
            if self.all_pos:
                self.next_pos_index = 0
                self.search_next(text)
        else:
            # 次のマッチ部分を取得できればここ
            start = pos
            end = '{0} + {1}c'.format(pos, len(text))

            # マッチ部分〜マッチ部分+文字数分 の範囲を選択する
            self.target_text.tag_add('sel', start, end)

            # インサートカーソルをマッチした部分に入れ、スクロールもしておく
            self.target_text.mark_set('insert', start)
            self.target_text.see('insert')

            # 次回取得分のために+1
            self.next_pos_index += 1

    def search(self, event=None):
        """文字の検索を行う."""
        # 現在選択中の部分を解除
        self.target_text.tag_remove('sel', '1.0', 'end')

        # 現在検索ボックスに入力されてる文字
        now_text = self.text_var.get()

        if not now_text:
            # 空欄だったら処理しない
            pass
        elif now_text != self.last_text:
            # 前回の入力と違う文字なら、検索を最初から行う
            self.search_start(now_text)
        else:
            # 前回の入力と同じなら、検索の続きを行う
            self.search_next(now_text)

        # 今回の入力を、「前回入力文字」にする
        self.last_text = now_text


def create_search_box(text_widget, title='Search Box'):
    """検索ボックスを作成する関数

    args:
        text_widget: tk.Textウィジェット
        title: 検索ボックスのタイトル

    """
    window = tk.Toplevel()
    window.title(title)
    box = SearchBox(window, text_widget)
    box.pack()


if __name__ == '__main__':
    root = tk.Tk()
    root.title('Search Test')
    text = tk.Text(root)
    text.pack()
    root.bind(
        '<Control-f>',
        lambda event: create_search_box(text_widget=text),
    )
    root.mainloop()




create_search_box関数にtk.Textウィジェットを渡すだけで済みます。
やっているのは簡単で、tk.Toplevel()で別ウィンドウを作成し、その中に検索ボックスウィジェットを配置しているだけ!
def create_search_box(text_widget, title='Search Box'):
    """検索ボックスを作成する関数

    args:
        text_widget: tk.Textウィジェット
        title: 検索ボックスのタイトル

    """
    window = tk.Toplevel()
    window.title(title)
    box = SearchBox(window, text_widget)
    box.pack()


クラスを利用しているならmain.pyを、そうでなければsearch.pyのif __name__ == '__main__'部分のように利用してください。
ラムダ関数が嫌ならば、関数を定義しその関数からcreate_search_box(text_widget=text) のようにします。
if __name__ == '__main__':
    root = tk.Tk()
    root.title('Search Test')
    text = tk.Text(root)
    text.pack()
    root.bind(
        '<Control-f>',
        lambda event: create_search_box(text_widget=text),
    )
    root.mainloop()




中身の処理を見ていきます。
class SearchBox(ttk.Frame):
    """テキスト検索ボックス."""




まずself.target_textに、対象のtk.Textウィジェットを保存しておきます。
create_widgetsでウィジェットの作成と配置を行います。
検索処理で使う変数がいくつかあります。
last_textは、前回入力された検索文字列を保持します。
all_posは、['1.7', '3,1', '5.1'...]のような、対象Textにおける検索文字が見つかった最初の位置を全て格納しています。
next_pos_indexは、all_posのインデックスです。最初は0番目の位置を見せて、次の検索の際には1番目、2番目...と増えていきます。
    def __init__(self, master, text_widget, *args, **kwargs):
        """初期化

        text_widget引数に、tk.Textウィジェットを渡してください
        """
        super().__init__(master, *args, **kwargs)
        self.target_text = text_widget  # 検索する対象となるTextウィジェット
        self.create_widgets()
        self.last_text = ''
        self.all_pos = []
        self.next_pos_index = 0
        self.text.focus()  # 入力欄にフォーカスしとく



ウィジェットの作成と配置は、シンプルなものです。
入力文字列を取得するため、tk.StringVarを使います。
入力欄でエンターを押すと、検索開始ということでsearchメソッドを呼びます。
    def create_widgets(self):
        """ウィジェットの作成."""
        # 検索文字の入力欄と、紐付けるStringVar
        self.text_var = tk.StringVar()
        self.text = ttk.Entry(self, textvariable=self.text_var)
        self.text.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))

        # エンターキーで検索実行される
        self.text.bind('<Return>', self.search)



searchメソッドでは、まず現在選択中の部分を全て解除します。これは前回の検索マッチ部分を消すためです。
検索ボックスに入力された文字を取得し
・空欄なら処理なし
・前回の入力と違う文字でエンターされたら、search_startで検索処理を開始
・前回の入力と同じ文字でエンターされたら、search_nextでマッチ文字列の続きを表示
となります。
    def search(self, event=None):
        """文字の検索を行う."""
        # 現在選択中の部分を解除
        self.target_text.tag_remove('sel', '1.0', 'end')

        # 現在検索ボックスに入力されてる文字
        now_text = self.text_var.get()

        if not now_text:
            # 空欄だったら処理しない
            pass
        elif now_text != self.last_text:
            # 前回の入力と違う文字なら、検索を最初から行う
            self.search_start(now_text)
        else:
            # 前回の入力と同じなら、検索の続きを行う
            self.search_next(now_text)

        # 今回の入力を、「前回入力文字」にする
        self.last_text = now_text



search_startメソッドは検索処理の開始、具体的には現在の状態の初期化と、全てのマッチ部分の取得を行います。
全てのマッチ部分を取得したら、一度search_nextを呼び出し、最初のマッチ文字列を表示(ただ選択状態にして目立たせるだけ)します。
こうしてみると、メソッド名がよくないですね。
    def search_start(self, text):
        """検索の初回処理."""
        # 各変数の初期化
        self.next_pos_index = 0
        self.all_pos = []

        # はじめは1.0から検索し、見つかれば、それの最後+1文字の時点から再検索
        # all_posには、['1.7', '3,1', '5.1'...]のような検索文字が見つかった地点の最初のインデックスが入っていく
        start_index = '1.0'
        while True:
            pos = self.target_text.search(text, start_index, stopindex='end')
            if not pos:  # 検索文字がもう見つからければbreak
                break
            self.all_pos.append(pos)
            start_index = '{0} + 1c'.format(pos)  # 最後から+1文字を起点に、再検索

        # 最初のマッチ部分、all_pos[0]を選択させておく
        self.search_next(text)


tk.Textには、searchという文字列検索用のメソッドがあります。
マッチ部分を全て取得しているのは、以下の部分です。
        start_index = '1.0'
        while True:
            pos = self.target_text.search(text, start_index, stopindex='end')
            if not pos:  # 検索文字がもう見つからければbreak
                break
            self.all_pos.append(pos)
            start_index = '{0} + 1c'.format(pos)  # 最後から+1文字を起点に、再検索



そして、マッチ文字列の表示処理です。
    def search_next(self, text):
        """検索の続きの処理."""
        try:
            # 今回のマッチ部分の取得を試みる
            pos = self.all_pos[self.next_pos_index]
        except IndexError:
            # all_posが空でなくIndexErrorならば、全てのマッチを見た、ということ
            # なのでnext_post_indexを0にし、最初からまたマッチを見せる
            if self.all_pos:
                self.next_pos_index = 0
                self.search_next(text)
        else:
            # 次のマッチ部分を取得できればここ
            start = pos
            end = '{0} + {1}c'.format(pos, len(text))

            # マッチ部分〜マッチ部分+文字数分 の範囲を選択する
            self.target_text.tag_add('sel', start, end)

            # インサートカーソルをマッチした部分に入れ、スクロールもしておく
            self.target_text.mark_set('insert', start)
            self.target_text.see('insert')

            # 次回取得分のために+1
            self.next_pos_index += 1



all_posは、['1.7', '3,1', '5.1'...]のように入っており、どの位置を今回は表示するのか、だけを考えれば良さそうです。
self.next_pos_indexは最初は0なので、0番目の位置を初回は表示します。このメソッドの最後で、self.next_pos_index += 1 のようにして一つづつ増やします。
        try:
            # 今回のマッチ部分の取得を試みる
            pos = self.all_pos[self.next_pos_index]



IndexErrorは2通りの状況があります。all_posが空(マッチした文字列がない場合)と、all_posはあるけど要素外を参照した場合...つまり、全てのマッチ文字列を見終わった後です。
全て見終わった後ならば、self.next_pos_index = 0 とするだけでまた最初から表示できますね。
        except IndexError:
            # all_posが空でなくIndexErrorならば、全てのマッチを見た、ということ
            # なのでnext_post_indexを0にし、最初からまたマッチを見せる
            if self.all_pos:
                self.next_pos_index = 0
                self.search_next(text)



posはそのまま開始位置として使えます。終了位置も、文字の数だけ増やせばおわりです。
        else:
            # 次のマッチ部分を取得できればここ
            start = pos
            end = '{0} + {1}c'.format(pos, len(text))



tag_addで、選択状態にします。
そして、その部分にインサートカーソルを入れておきます。mart_set('insert', start)の部分です。
.see('insert')で、インサートカーソルの位置が見えなければスクロールします。
そして、次回取得文のためにnext_pos_indexを+1します。
            # マッチ部分〜マッチ部分+文字数分 の範囲を選択する
            self.target_text.tag_add('sel', start, end)

            # インサートカーソルをマッチした部分に入れ、スクロールもしておく
            self.target_text.mark_set('insert', start)
            self.target_text.see('insert')

            # 次回取得分のために+1
            self.next_pos_index += 1