naritoブログ

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

Tkinterで、Pythonコードをハイライトする

プログラミング関連 Python Tkinter 約29日前
2017年10月23日15:51
今回はTkinterを使い、tk.Textに書いたコードをハイライトしていきます。
そのために、「pygments」を使うので、インストールしておきましょう。
pip install pygments


まず、大雑把に実装してみます。
tk.Textウィジェットを持つttk.Frameを作り、Ctrl+Lでhighlightメソッドが呼ばれるようにします。
import tkinter as tk
import tkinter.ttk as ttk

from pygments import lex
from pygments.lexers import PythonLexer


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-l>', self.highlight)
        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)

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




コードをハイライトするためには、入力された文字を解析する必要があります。
自力でやるのは大変なので、pygmentsにやってもらいましょう。
動作を説明するためにも、まずは以下のように書きます。
    def highlight(self, event=None):
        """ハイライト"""
        text = self.text.get('1.0', 'end - 1c')  # 入力されたコードの取得
        for token, content in lex(text, PythonLexer()):
            print(content, token)


このように入力し、ctrl+Lを押してみます。


すると、こんな感じで出力されます。
for Token.Keyword
  Token.Text
i Token.Name
  Token.Text
in Token.Operator.Word
  Token.Text
range Token.Name.Builtin
( Token.Punctuation
10 Token.Literal.Number.Integer
) Token.Punctuation
: Token.Punctuation

 Token.Text
     Token.Text
print Token.Keyword
( Token.Punctuation
i Token.Name
) Token.Punctuation

 Token.Text



重要なところを抜粋すると、こうです。
for はToken.Keyword、inはToken.Operator.Word、のように、プログラムの構成要素をきちんと取得してくれています。
for Token.Keyword
i Token.Name
in Token.Operator.Word
range Token.Name.Builtin
( Token.Punctuation
10 Token.Literal.Number.Integer
) Token.Punctuation
: Token.Punctuation
print Token.Keyword
( Token.Punctuation
i Token.Name
) Token.Punctuation



簡単にまとめると、Pythonコードならば以下のように分類されています。
Token.Keyword → def class if for else return pass with try except finally print 等
Token.Keyword.Namespace → from import
Token.Name.Decorator → @spam
Token.Operator.Word → and in 等

Token.Name.Namespace → import a のa
Token.Name.Class → クラス名
Token.Name.Function → 関数名
Token.Name.Exception → エラー名
Token.Name.Function.Magic → __init__ 等
Token.Name.Builtin → len range input enumerate dir 等の組み込み関数
Token.Name.Builtin.Pseudo → self cls 等

Token.Literal.String.Doc → ドックストリング
Token.Literal.String.Double → "文字列"
Token.Literal.String.Single → '文字列'

Token.Comment.Single → #コメント部分
Token.Literal.Number.Integer → 数値リテラル
Token.Literal.String.Escape → \n  \t 等
Token.Operator → . + - / * == =

Token.Punctuation → : [] () {} ,
Token.Name → 変数名等



せっかく名前がついてるわけですから、これをtk.Textのタグ名として使ってしまえばよさそうです。
highlightメソッドを以下のように変更します。
    def highlight(self, event=None):
        """ハイライト"""
        text = self.text.get('1.0', 'end - 1c')
        self.text.mark_set('range_start', '1.0')
        for token, content in lex(text, PythonLexer()):
            self.text.mark_set(
                'range_end', 'range_start+{0}c'.format(len(content))
            )
            self.text.tag_add(str(token), 'range_start', 'range_end')  # ここで、token名をタグとして利用する
            self.text.mark_set('range_start', 'range_end')



どういった動作になるかというと、まず全文を取得し、mark_setで、1.0...つまり一番始めを選択します。
「for i in range」という文字列ならば、最初はtokenに「Token.Keyword」、contentに「for」が入ります。
range_endに、range_startから文字数分入れ、本文中のforの文字列まで選択し、taf_addでタグの付与を行います。
forという文字列部分がおわり、range_startに最後の文字をの位置を設定し、次のtoken, contentとループします。
(この方法だと、ハイライトしない文字にもタグが付与されています。tokenによっては、tag_addしない、等をするとパフォーマンスが上がるでしょう)


__init__内で、タグの作成メソッドを呼び出します。
    def __init__(self, master=None, **kwargs):
        super().__init__(master, **kwargs)
        self.create_widgets()
        self.create_tags()  # タグの作成メソッド


タグの作成メソッドは、以下のような感じで。
    def create_tags(self):
        """タグの作成"""
        # 黄色く表示する
        self.text.tag_configure(
            'Token.Keyword', foreground='#CC7A00'
        )  # def class if for else return pass with try except finally print
        self.text.tag_configure(
            'Token.Keyword.Namespace', foreground='#CC7A00')  # from import
        self.text.tag_configure(
            'Token.Name.Decorator', foreground='#CC7A00')  # @deco
        self.text.tag_configure(
            'Token.Operator.Word', foreground='#CC7A00')  # and, in

        # 青く表示する
        self.text.tag_configure(
            'Token.Name.Namespace', foreground='#003D99'
        )  # import a のa
        self.text.tag_configure(
            'Token.Name.Class', foreground='#003D99')  # クラス名
        self.text.tag_configure(
            'Token.Name.Exception', foreground='#003D99')  # エラー名
        self.text.tag_configure(
            'Token.Name.Function', foreground='#003D99')  # 関数名
        self.text.tag_configure(
            'Token.Name.Function.Magic', foreground='#003D99')  # __init__
        self.text.tag_configure(
            'Token.Name.Builtin', foreground='#003D99'
        )  # len range input enumerate dir
        self.text.tag_configure(
            'Token.Name.Builtin.Pseudo', foreground='#003D99')  # self cls

        # 緑表示する
        self.text.tag_configure(
            'Token.Literal.String.Doc', foreground='#248F24'  # """docstring"""
        )
        self.text.tag_configure(
            'Token.Literal.String.Double', foreground='#248F24')  # "文字"
        self.text.tag_configure(
            'Token.Literal.String.Single', foreground='#248F24')  # '文字'

        # 赤表示する
        self.text.tag_configure(
            'Token.Comment.Single', foreground='#dc143c')  # #コメント
        self.text.tag_configure(
            'Token.Literal.Number.Integer', foreground='#dc143c')  # 1 2 数字
        self.text.tag_configure(
            'Token.Literal.String.Escape', foreground='#dc143c')  # \t \n
        self.text.tag_configure(
            'Token.Operator', foreground='#dc143c')  # . + - / * == =

        # 黒く表示
        self.text.tag_configure(
            'Token.Punctuation', foreground='#000000')  # : [] () {} ,
        self.text.tag_configure(
            'Token.Name', foreground='#000000')  # 変数名など


ちゃんとハイライトされるようになりましたね。



Ctrl+Lを複数押すなどの場合は、前のハイライトを一度解除しなければいけません。
これはtag_removeで設定したタグを解除すれば大丈夫です。
ハイライト処理の最初にでも入れておきましょう。
        # 全てのハイライトを一度解除する
        for tag in self.text.tag_names():
            self.text.tag_remove(tag, '1.0', 'end')



もし他の言語をハイライトするならば、PythonLexerの部分を変更しましょう。
from pygments.lexers.html import HtmlLexer
...
...
    for token, content in lex(text, HtmlLexer()):



Ctrl+Lではなく、文字を入力するたびにハイライト処理が行いたい、ということもあるでしょう。

Tkinterで、Textウィジェットにon_change、on_scrollイベントを作る
https://torina.top/detail/406/

を使えば、これも解決できます。
ただ、入力毎に全部ハイライトし直すのはパフォーマンス的に不安です。
そこで、Ctrl+Lで全文を、入力中はその行だけハイライトするようにしてみます。

長くなりましたが、それらを実装した全コードです。
import tkinter as tk
import tkinter.ttk as ttk

from pygments import lex
from pygments.lexers import PythonLexer


class CustomText(tk.Text):
    """Textの、イベントを拡張したウィジェット."""

    def __init__(self, master, **kwargs):
        super().__init__(master, **kwargs)
        self.tk.eval('''
            proc widget_proxy {widget widget_command args} {
                set result [uplevel [linsert $args 0 $widget_command]]
                if {([lrange $args 0 1] == {xview moveto}) ||
                    ([lrange $args 0 1] == {xview scroll}) ||
                    ([lrange $args 0 1] == {yview moveto}) ||
                    ([lrange $args 0 1] == {yview scroll})} {
                    event generate  $widget <<Scroll>> -when tail
                }
                if {([lindex $args 0] in {insert replace delete})} {
                    event generate  $widget <<Change>> -when tail
                }
                # return the result from the real widget command
                return $result
            }
            ''')
        self.tk.eval('''
            rename {widget} _{widget}
            interp alias {{}} ::{widget} {{}} widget_proxy {widget} _{widget}
        '''.format(widget=str(self)))


class MainFrame(ttk.Frame):

    def __init__(self, master=None, **kwargs):
        super().__init__(master, **kwargs)
        self.create_widgets()
        self.create_events()
        self.create_tags()

    def create_tags(self):
        """タグの作成"""
        # 黄色く表示する
        self.text.tag_configure(
            'Token.Keyword', foreground='#CC7A00'
        )  # def class if for else return pass with try except finally print
        self.text.tag_configure(
            'Token.Keyword.Namespace', foreground='#CC7A00')  # from import
        self.text.tag_configure(
            'Token.Name.Decorator', foreground='#CC7A00')  # @deco
        self.text.tag_configure(
            'Token.Operator.Word', foreground='#CC7A00')  # and, in

        # 青く表示する
        self.text.tag_configure(
            'Token.Name.Namespace', foreground='#003D99'
        )  # import a のa
        self.text.tag_configure(
            'Token.Name.Class', foreground='#003D99')  # クラス名
        self.text.tag_configure(
            'Token.Name.Exception', foreground='#003D99')  # エラー名
        self.text.tag_configure(
            'Token.Name.Function', foreground='#003D99')  # 関数名
        self.text.tag_configure(
            'Token.Name.Function.Magic', foreground='#003D99')  # __init__
        self.text.tag_configure(
            'Token.Name.Builtin', foreground='#003D99'
        )  # len range input enumerate dir
        self.text.tag_configure(
            'Token.Name.Builtin.Pseudo', foreground='#003D99')  # self cls

        # 緑表示する
        self.text.tag_configure(
            'Token.Literal.String.Doc', foreground='#248F24'  # """docstring"""
        )
        self.text.tag_configure(
            'Token.Literal.String.Double', foreground='#248F24')  # "文字"
        self.text.tag_configure(
            'Token.Literal.String.Single', foreground='#248F24')  # '文字'

        # 赤表示する
        self.text.tag_configure(
            'Token.Comment.Single', foreground='#dc143c')  # #コメント
        self.text.tag_configure(
            'Token.Literal.Number.Integer', foreground='#dc143c')  # 1 2 数字
        self.text.tag_configure(
            'Token.Literal.String.Escape', foreground='#dc143c')  # \t \n
        self.text.tag_configure(
            'Token.Operator', foreground='#dc143c')  # . + - / * == =

        # 黒く表示
        self.text.tag_configure(
            'Token.Punctuation', foreground='#000000')  # : [] () {} ,
        self.text.tag_configure(
            'Token.Name', foreground='#000000')  # 変数名など

    def create_widgets(self):
        """ウィジェットの作成、配置"""
        self.text = CustomText(self)
        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)

    def create_events(self):
        """イベントの設定"""
        self.text.bind('<Control-l>', self.all_highlight)
        self.text.bind('<<Change>>', self.line_highlight)

    def all_highlight(self, event=None):
        """全てハイライト"""
        # 全てのテキストを取得
        src = self.text.get('1.0', 'end - 1c')

        # 全てのハイライトを一度解除する
        for tag in self.text.tag_names():
            self.text.tag_remove(tag, '1.0', 'end')

        # ハイライトする
        self._highlight('1.0', src)

    def line_highlight(self, event=None):
        """現在行だけハイライト"""
        start = 'insert linestart'
        end = 'insert lineend'

        # 現在行のテキストを取得
        src = self.text.get(start, end)

        # その行のハイライトを一度解除する
        for tag in self.text.tag_names():
            self.text.tag_remove(tag, start, end)

        # ハイライトする
        self._highlight(start, src)

    def _highlight(self, start, src):
        """ハイライトの共通処理"""
        self.text.mark_set('range_start', start)
        for token, content in lex(src, PythonLexer()):
            self.text.mark_set(
                'range_end', 'range_start+{0}c'.format(len(content))
            )
            self.text.tag_add(str(token), 'range_start', 'range_end')
            self.text.mark_set('range_start', 'range_end')


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