Python デバッグ用ViewをTkinterで作成（Windows用）

# 目的
デバッグ中に何らかの内部データを見るために、デバッガでbreakpointを設定して、逐次止めてみたり、流れていくログテキストを追うのは効率が悪いです。
MultiProcess環境ですと、breakpointで止めてしまうと正常な再開ができなかったりします。
それに、ユーザーからの入力も受け付けてほしいとき、ターミナルのキー入力を使って行うと、ログのテキストが流れて、訳が分からなくなります。

# 作成物
Windows用でいたってシンプルです。

機能
・インスタンス生成すると、自動的に別スレッドで動き出します。(mainloopで返ってこないということはありません。）
・インスタンス生成時にダイアログタイトルと初期メッセージを渡して表示できます。
・テキストの最終行でEnterを押すとその前の行を切り出して、Queueで返信してくれます。
→メッセージルールを作っておけば、受信したテキストに応じてアプリケーションの挙動を制御できますね。
例えば、#command が先頭にあったら何等かの動作をするなど。
・テキストを表示させる場合、Queueでテキストを送るか、text_insert関数を使用します。
・ダイアログ終了時に、'#END'がQueueに送出されますので、受信したら親スレッドでdelete関数を呼び出してください。

## 機能追加の修正履歴
2021/03/29　TabおよびShift＋Tabキーによるコマンド履歴入力を追加しました。同じ内容を毎回入力するのも億劫なので、Tab（遡り）、Shift＋Tab（進み）で過去の入力が表示されます。全く同じ入力は過去のものが更新されて、新しい履歴に再入されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542103/203e8d8e-aeca-355a-9d20-0c363a02831c.png)


# コード

```python
import threading
import time

from queue import Empty, Queue

import tkinter as tk
from tkinter import ttk

class class_DebugBufferView(threading.Thread):
    def __init__(self,message,title=None):
        threading.Thread.__init__(self)

        if title is not None:
            self.title = title
        else:
            self.title = 'Debug View'

        self.message = message

        self.text_index = 1.0
        self.after_interval = 1000

        self.sendQueue, self.recvQueu = None, None
        self.isLoop = True

        self.command_list = list()
        self.tab_repeat_cnt = 0
        self.isTab = False

        self.start()

        return

    def CreateDialog(self):
        self.Text = tk.Text(
            self.dialog,
            wrap = tk.NONE,
        )

        self.v_scroll = ttk.Scrollbar(
            self.dialog,
            orient = tk.VERTICAL,
            command = self.Text.yview
            )
        self.Text['yscrollcommand'] = self.v_scroll.set

        self.Text.bind('<Key-Return>',self.key_interrupt)
        self.Text.bind('<Key-Tab>',self.key_repeat)
        self.Text.bind('<Shift-Key-Tab>',self.key_back_repeat)
        self.Text.bind('<KeyRelease-Tab>',self.tab_release)

        self.h_scroll = ttk.Scrollbar(
            self.dialog,
            orient = tk.HORIZONTAL,
            command = self.Text.xview
            )
        self.Text['xscrollcommand'] = self.h_scroll.set

        self.v_scroll.pack(side=tk.RIGHT, fill=tk.Y)
        self.h_scroll.pack(side=tk.BOTTOM, fill=tk.X)
        self.Text.pack(side=tk.LEFT, fill=tk.BOTH,expand=True)

        return

    def run(self):
        self.dialog = tk.Tk()

        self.dialog.geometry('{}x{}+{}+{}'.format(800,1000,0,0))
        self.dialog.title(self.title)

        self.CreateDialog()

        self.text_insert(self.message)

        self.dialog.protocol("WM_DELETE_WINDOW", self.quit)

        self.dialog.after(self.after_interval,self.loop)

        self.dialog.mainloop()

    def setQueue(self, send, recv):
        if self.sendQueue is None and self.recvQueu is None:
            self.sendQueue, self.recvQueu = recv, send
            return True
        else:
            raise Exception('すでにQueueは設定されています。')

    def text_insert(self,text):
        self.Text.insert(str(self.text_index),text)
        self.text_index += float(len(text))
        self.Text.see('end')

    def loop(self):
        # dialogの処理ループ
        if self.sendQueue is not None and self.recvQueu is not None:
            while(not self.recvQueu.empty()):
                item = self.recvQueu.get()
                self.text_insert(item)

        if self.isLoop:
            self.id_after = self.dialog.after(self.after_interval,self.loop)

    def key_interrupt(self, event):

        # Enterキーが押されたら
        if event.keysym == 'Return':
            # Textboxの最終indexを取得
            end_index = float(self.Text.index('end'))
            # 1行戻した行頭のindex
            start_index = end_index - 1.0
            # Enterを押された改行された最終行の1行前を取得
            command = self.Text.get(f'{start_index}','end-1c')
            if '\t' in command:
                command = command.split('\t')
                command = ''.join(command)
            if command:
                if command in self.command_list:
                    index = self.command_list.index(command)
                    self.command_list.pop(index)
                self.command_list.append(command)
                # Queueで返送する
                if self.sendQueue is not None:
                    self.sendQueue.put(command)
            self.tab_repeat_cnt = 0

        return

    def key_repeat(self, event):
        # Tabキーが押されたら
        if event.keysym == 'Tab':
            self.isTab = True
            #Keyイベントの後に文字が挿入されるので、Modify部でInertする。
            pass

    def key_back_repeat(self, event):
        # Shift+Tabキーが押されたら
        if event.keysym == 'Tab':
            end_index = float(self.Text.index('end-1c'))
            end_index = float(int(end_index))
            self.Text.delete(f"{end_index}","end")
            self.Text.insert("end",'\n')

            self.tab_repeat_cnt -= 1
            index = len(self.command_list) -1 - self.tab_repeat_cnt
            if len(self.command_list) > index and index >= 0:
                end_index = float(self.Text.index('end-1c'))
                end_index = float(int(end_index))
                rep_command = self.command_list[index]
                self.Text.insert(f"{end_index}",rep_command)
            else:
                self.tab_repeat_cnt += 1
            pass

    def tab_release(self, event):
        if self.isTab:
            self.isTab = False
            end_index = float(self.Text.index('end-1c'))
            end_index = float(int(end_index))
            self.Text.delete(f"{end_index}","end")
            self.Text.insert("end",'\n')

            index = len(self.command_list) -1 - self.tab_repeat_cnt

            if end_index >= 1.0:
                if len(self.command_list) > index and index >= 0:
                    rep_command = self.command_list[index]
                    # Textboxの最終indexを取得
                    self.Text.insert(f"{end_index}",rep_command)
                    self.tab_repeat_cnt += 1

        return

    def quit(self):
        if self.sendQueue is not None:
            # 終了したことをメインスレッドに伝達するためにQueueで通知
            self.sendQueue.put('#END')
        else:
            self.delete()

    def delete(self):
        # Tcl_AsyncDelete例外対策のため、メインスレッドから
        # class_DebugBufferViewのTkinterスレッドを終了させている。
        self.isLoop = False
        self.dialog.after_cancel(self.id_after)
        self.dialog.quit()

if __name__ == '__main__':
    import json
    import msvcrt as mskey

    sendQueue, recvQueue = Queue(),Queue()

    # タイトルと最初のメッセージを付けてインスタンス作成
    instView = class_DebugBufferView(message='Start...\n',title='Input View')
    # 送受信するQueueをセット
    instView.setQueue(send=sendQueue,recv=recvQueue)

    # 試しに、JSONデータを表示させてみる
    message = {
        'message':'TEST Message',
    }
    sendQueue.put(json.dumps(message,indent=2)+'\n')

    isLoop = True

    while(isLoop):
        time.sleep(0.03) #0.03くらいのインターバルがキー入力にはちょうど良い。

        # キー入力したものを表示させる
        if mskey.kbhit():
            key = mskey.getwche()
            sendQueue.put(key)

        while(not recvQueue.empty()):
            item = recvQueue.get()
            print(item)

            if item == '#END':
                # 終了通知を受け取ったら、スレッドを破棄させる
                instView.delete()
                isLoop = False

    print('プログラムの終了')


```