naritoブログ

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

Tkinterで、コード補完

プログラミング関連 Python Tkinter 約29日前
2017年10月23日20:45
エディタでよくあるコード補完機能を作ります。
今回はコード補完のために作りますが、この手の処理は使えると便利です。

まずは順番に実装していきましょう。タブキーを押下で、あるメソッドが呼ばれるようにしてみます。
import tkinter as tk
import tkinter.ttk as ttk


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.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))

        # Tab押下時(インデント、又はコード補完)
        self.text.bind('<Tab>', self.tab)

        self.text.columnconfigure(0, weight=1)
        self.text.rowconfigure(0, weight=1)

    def tab(self, event=None):
        """タブ押下時の処理"""
        pass


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()




一般的なエディタの場合、タブ押下でインデントも行えるはずです。
なので、そのままインデントか、補完かを状況によって使い分ける必要があります。
具体的には、
・文字選択時のタブ押下ではなく
・前の文字がタブ、スペース、空文字、改行でない
場合に、補完リストを作成することになるでしょう。

これは案外簡単に書けます。
    def tab(self, event=None):
        """タブ押下時の処理"""
        # 文字を選択しておらず...
        sel_range = self.text.tag_ranges('sel')
        if not sel_range:
            # インサートカーソルの前の文字がタブ、スペース、空文字、改行じゃない
            before_insert_text = self.text.get('insert-1c', 'insert')
            if before_insert_text not in (' ', '\t', '\n', ''):
                return self.auto_complete()


auto_completeメソッドは以下のようになります。
内容は大体コメントの通りです。
エンターを押すとその候補をtk.Textに挿入し、Esc, Tab, 他の場所をクリックで補完リストを削除します。
    def auto_complete(self):
        """補完リストの作成"""
        auto_complete_list = tk.Listbox(self.text)

        # エンターでそのキーワードを選択
        auto_complete_list.bind('<Return>', self.selection)

        # エスケープ、タブ、他の場所をクリックで補完リスト削除
        auto_complete_list.bind('<Escape>', self.remove_list)
        auto_complete_list.bind('<Tab>', self.remove_list)
        auto_complete_list.bind('<FocusOut>', self.remove_list)

        # (x,y,width,height,baseline)
        x, y, _, height, _ = self.text.dlineinfo(
            'insert')
        # 現在のカーソル位置のすぐ下に補完リストを貼る
        auto_complete_list.place(x=x, y=y+height)

        # 補完リストの候補を作成
        for word in self.get_keywords():
            auto_complete_list.insert(tk.END, word)

        # 補完リストをフォーカスし、0番目を選択している状態に
        auto_complete_list.focus_set()
        auto_complete_list.selection_set(0)
        self.auto_complete_list = auto_complete_list  # self.でアクセスできるように
        return 'break'



補完リストの中身を返すメソッドですが、とりあえずは簡単なコードにしておきます...
    def get_keywords(self):
        """コード補完リストの候補キーワードを作成する."""
        return ['Python', 'Ruby', 'PHP', 'Perl']


補完リストを削除するremove_listも簡単に書けます。
    def remove_list(self, event=None):
        """コード補完リストの削除処理."""
        self.auto_complete_list.destroy()
        self.text.focus()  # テキストウィジェットにフォーカスを戻す



そして、候補をクリックした際のselectionメソッド。これはちょっと複雑です。
    def selection(self, event=None):
        """コード補完リストでの選択後の処理."""
        # リストの選択位置を取得
        select_index = self.auto_complete_list.curselection()
        if select_index:
            # リストの表示名を取得
            value = self.auto_complete_list.get(select_index)

            # 現在入力中の単語位置の取得
            _, start, end = self.get_current_insert_word()
            self.text.delete(start, end)
            self.text.insert('insert', value)
            self.remove_list()



もし以下のような状態で、「Wo」を入力しタブを押したとします。そして、候補を選択したとします。
そうなると、「Wo」の文字をけしつつ、選んだ候補の単語を挿入する必要があります。
Woの文字を消すには、現在入力中の単語の開始位置と終了位置...つまり、「W」の位置と「o」の位置を調べなくてはなりません...
Hello Wo
↓
Hello World!



それを調べるのが、get_current_insert_wordメソッドです。
不恰好ですが、個人的にはよく出来ていると思います。
    def get_current_insert_word(self):
        """現在入力中の単語と位置を取得する."""
        text = ''
        start_i = 1
        end_i = 0
        while True:
            start = 'insert-{0}c'.format(start_i)
            end = 'insert-{0}c'.format(end_i)
            text = self.text.get(start, end)

            # 1文字ずつ見て、スペース、改行、タブ、空文字にぶつかったら終わり
            if text in (' ', '\t', '\n', ''):
                text = self.text.get(end, 'insert')
                return text, end, 'insert'

            start_i += 1
            end_i += 1




動かしてみると、ちゃんと動作しますね。




補完リストの中身を作成するget_keywordsも、もう少し便利にしたいものです。
組み込みの関数やクラスの名前であれば、割と簡単に取得ができます。
# 組み込みの関数や例外の名前リスト
import __main__
builtins = __main__.__builtins__
BUILTIN_KEYWORD = [x for x in dir(builtins) if not x.startswith('__')]


get_keywordsを書き直します。
現在入力中の単語で始まるものだけ、抽出するようにしています。
    def get_keywords(self):
        """コード補完リストの候補キーワードを作成する."""
        # 現在入力中の単語を取得
        text, _, _ = self.get_current_insert_word()
        # 組み込みの関数、例外クラス
        return [x for x in BUILTIN_KEYWORD if x.startswith(text) or x.startswith(text.title())]


よく動作しているようです。


更に一歩、自分で定義したクラスや関数も補完リストに表示できれば万歳です。

Tkinterで、Pythonコードをハイライトする
https://torina.top/detail/415/
ではpygmentsを使いましたが、このpygmentsを上手く利用することができます。
from pygments import lex
from pygments.lexers import PythonLexer
...
...
    def get_keywords(self):
        """コード補完リストの候補キーワードを作成する."""
        # 現在入力中の単語を取得
        text, _, _ = self.get_current_insert_word()

        # 全てのテキスト
        src = self.text.get('1.0', 'end - 1c')

        # 自分で定義した関数、クラスを取得する
        my_func_and_class = set()
        for token, content in lex(src, PythonLexer()):
            # import名、関数名、クラス名ならば変数に格納
            if str(token) in ('Token.Name.Namespace', 'Token.Name.Class', 'Token.Name.Function'):
                my_func_and_class.add(content)

        # 自分で定義した関数、クラス + 組み込みのクラスや関数
        result = list(my_func_and_class) + BUILTIN_KEYWORD
        return [x for x in result if x.startswith(text) or x.startswith(text.title())]



ちゃんと動作していますね。