naritoブログ

【お知らせ】
新ブログができました。今後そちらで更新し、このサイトは更新されません(ウェブサイト自体は残しておきます)
このブログの内容に関してコメントしたい場合は、新ブログのフリースペースに書き込んでください

このブログの内容を新ブログに移行中です。このブログで見つからない記事は、新ブログにありま

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

約414日前 2017年10月23日15:51
プログラミング関連
Python Tkinter
今回は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()