naritoブログ

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

Tkinterで、行番号付きText

プログラミング関連 Python Tkinter 約39日前
2017年10月13日14:40
今回は行番号付きのTextウィジェットを作成します。

これもスタックオーバーフローのが基です。
https://stackoverflow.com/questions/16369470/tkinter-adding-line-number-to-text-widget/16375233#16375233


まずは順を追って実装していきましょう。
F1キー押下でupdate_line_numbersというメソッドが呼ばれるようにしてみます。
import tkinter as tk
import tkinter.ttk as ttk


class LineFrame(ttk.Frame):
    """行番号付きのフレーム"""

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

    def create_widgets(self):
        pass

    def update_line_numbers(self, event=None):
        pass


root = tk.Tk()

app = LineFrame(root)
app.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))

# F1キーで行番号表示
root.bind('<F1>', app.update_line_numbers)
root.columnconfigure(0, weight=1)
root.rowconfigure(0, weight=1)
root.mainloop()




Textウィジェットそのものに行番号をつけるのではなく、行番号部分は別のウィジェットに任せます。
柔軟なtk.Canvasを使いましょう。
    def create_widgets(self):
        # 入力欄、行番号欄を作成
        self.text = tk.Text(self)
        self.line_numbers = tk.Canvas(self, width=40)

        # 左から行番号、入力欄
        self.line_numbers.grid(row=0, column=0, sticky=(tk.N, tk.S))
        self.text.grid(row=0, column=1, sticky=(tk.N, tk.S, tk.W, tk.E))

        # テキスト入力欄のみ拡大されるように
        self.columnconfigure(1, weight=1)
        self.rowconfigure(0, weight=1)


見た目



Textウィジェットはスクロールがデフォルトで可能ですが、見た目的にもスクロールバーが欲しい、ということであれば以下のようにしておきましょう。
    def create_widgets(self):
        # 入力欄、行番号欄、スクロール部分を作成
        self.text = tk.Text(self)
        self.line_numbers = tk.Canvas(self, width=40)
        self.ysb = ttk.Scrollbar(
            self, orient=tk.VERTICAL, command=self.text.yview)

        # 入力欄にスクロールを紐付け
        self.text.configure(yscrollcommand=self.ysb.set)

        # 左から行番号、入力欄、スクロールウィジェット
        self.line_numbers.grid(row=0, column=0, sticky=(tk.N, tk.S))
        self.text.grid(row=0, column=1, sticky=(tk.N, tk.S, tk.W, tk.E))
        self.ysb.grid(row=0, column=2, sticky=(tk.N, tk.S))

        # テキスト入力欄のみ拡大されるように
        self.columnconfigure(1, weight=1)
        self.rowconfigure(0, weight=1)



見た目。スクロールバーがついてて良いですね。今回はこちらで進めていきます。他に部分に影響はないので、スクロールバーなしのサンプルでも大丈夫です。



この部分のwidth=40ですが、これは幅の指定です。これを消すとCanvasの行番号部分が大きくなり、不恰好になります。
好みに応じて数値の部分は変えてください。
self.line_numbers = tk.Canvas(self, width=40)



では、行番号の描画処理です。
    def update_line_numbers(self, event=None):
        """行番号の描画"""
        # 現在の行番号を全て消す
        self.line_numbers.delete(tk.ALL)

        # Textの0, 0座標、つまり一番左上が何行目にあたるかを取得
        first_row = self.text.index('@0,0')
        first_row_number = int(first_row.split('.')[0])

        current = first_row_number
        while True:
            # dlineinfoは、その行がどの位置にあり、どんなサイズか、を返す
            # (3, 705, 197, 13, 18) のように帰る(x,y,width,height,baseline)
            dline = self.text.dlineinfo('{0}.0'.format(current))

            # dlineinfoに、存在しない行や、スクロールしないと見えない行を渡すとNoneが帰る
            if dline is None:
                break
            else:
                y = dline[1]  # y座標を取得

            # (x座標, y座標, 方向, 表示テキスト)を渡して行番号のテキストを作成
            self.line_numbers.create_text(3, y, anchor=tk.NW, text=current)
            current += 1



文字を入力し、F1キーを押すとちゃんと行番号が表示されますね。




中身の処理をみていきます。大まかな流れは
1.Textウィジェットの、今見えている一番上の行が本来の何行目かを取得
2.行のy座標を取得する
3.Canvasに、上で得たy座標部分に行番号を挿入する
4.次の行番号を取得
5. 2〜4をループ
という感じです。
def update_line_numbers(self, event=None):



まず最初に、現在の行番号を全て消しておきます。これをしないと行番号の文字が重なってしまいます。
        # 現在の行番号を全て消す
        self.line_numbers.delete(tk.ALL)



indexメソッドに「@x,y」と渡すと、その座標に近い行番号を返します。今回は行番号だけでいいので、1.5 のような文字列から1を取り出します。
        # Textの0, 0座標、つまり一番左上が何行目にあたるかを取得
        first_row = self.text.index('@0,0')
        first_row_number = int(first_row.split('.')[0])



上で得た行番号を基に、その行のy座標を取得します。dlineinfoです。
そのy座標を基に、Canvasに行番号を挿入していきます。
        current = first_row_number
        while True:
            # dlineinfoは、その行がどの位置にあり、どんなサイズか、を返す
            # (3, 705, 197, 13, 18) のように帰る(x,y,width,height,baseline)
            dline = self.text.dlineinfo('{0}.0'.format(current))

            # dlineinfoに、存在しない行や、スクロールしないと見えない行を渡すとNoneが帰る
            if dline is None:
                break
            else:
                y = dline[1]  # y座標を取得

            # (x座標, y座標, 方向, 表示テキスト)を渡して行番号のテキストを作成
            self.line_numbers.create_text(3, y, anchor=tk.NW, text=current)
            current += 1



現在はF1キーで行番号が描画されますが、これは自動で行われるべきです。

Tkinterで、Textウィジェットにon_change、on_scrollイベントを作る
https://torina.top/detail/406/
で作った、on_change, on_scrollイベント付きのTextウィジェットを使えば簡単に実装可能です。

それを実装したコードは、以下のようになります。
import tkinter as tk
import tkinter.ttk as ttk


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 LineFrame(ttk.Frame):
    """行番号付きのフレーム"""

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

    def create_event(self):
        """イベントの設定"""
        # テキスト内でのスクロール時
        self.text.bind('<<Scroll>>', self.update_line_numbers)

        # テキストの変更時
        self.text.bind('<<Change>>', self.update_line_numbers)

        # ウィジェットのサイズが変わった際。行番号の描画を行う
        self.text.bind('<Configure>', self.update_line_numbers)

    def create_widgets(self):
        # 入力欄、行番号欄、スクロール部分を作成
        self.text = CustomText(self)
        self.line_numbers = tk.Canvas(self, width=40)
        self.ysb = ttk.Scrollbar(
            self, orient=tk.VERTICAL, command=self.text.yview)

        # 入力欄にスクロールを紐付け
        self.text.configure(yscrollcommand=self.ysb.set)

        # 左から行番号、入力欄、スクロールウィジェット
        self.line_numbers.grid(row=0, column=0, sticky=(tk.N, tk.S))
        self.text.grid(row=0, column=1, sticky=(tk.N, tk.S, tk.W, tk.E))
        self.ysb.grid(row=0, column=2, sticky=(tk.N, tk.S))

        # テキスト入力欄のみ拡大されるように
        self.columnconfigure(1, weight=1)
        self.rowconfigure(0, weight=1)

    def update_line_numbers(self, event=None):
        """行番号の描画."""
        # 現在の行番号を全て消す
        self.line_numbers.delete(tk.ALL)

        # Textの0, 0座標、つまり一番左上が何行目にあたるかを取得
        first_row = self.text.index('@0,0')
        first_row_number = int(first_row.split('.')[0])

        current = first_row_number
        while True:
            # dlineinfoは、その行がどの位置にあり、どんなサイズか、を返す
            # (3, 705, 197, 13, 18) のように帰る(x,y,width,height,baseline)
            dline = self.text.dlineinfo('{0}.0'.format(current))

            # dlineinfoに、存在しない行や、スクロールしないと見えない行を渡すとNoneが帰る
            if dline is None:
                break
            else:
                y = dline[1]  # y座標を取得

            # (x座標, y座標, 方向, 表示テキスト)を渡して行番号のテキストを作成
            self.line_numbers.create_text(3, y, anchor=tk.NW, text=current)
            current += 1


root = tk.Tk()

app = LineFrame(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()