PythonのTKinterを使ったGUI構築について

##きっかけ

Pythonを使ってExcelのGrep検索をしたく、色々と試行錯誤しておりました。
技術的に「ExcelGrepy」というWindowsアプリケーションを作り上げたのはいいのだが、どうもUIがうまく作れない。。。。

なんというか、レイアウトデザインがめんどくさい。

UIを構成するに当たり、いくつかの選択肢はありました。

今回採用したTKinterとは別に、Kivy、wxPython、PySimpleGUIなど。

それぞれを比較するサイトはいくつもあるので、見てみて下さい。


##ExcelGrepyのレイアウトイメージ

ざっくりイメージはこんな感じ：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249830/b8df85c1-f467-d7e6-a0d4-849f81a2aee0.png)


さて、これをPythonでどうやって表現するか。



##TKinterのレイアウト構成の手段

まぁいろいろと手段はあるのですが、以下サイトを参考に・・・。
https://www.delftstack.com/ja/tutorial/tkinter-tutorial/tkinter-geometry-managers/


###1.pack()
Formを1枚の画面と捉えて、4方向に配置する感じです。
まぁよくわからん。


###2.grid()
Formを上下左右に分割して、Gridを設定していくような感じで、HTMLのレイアウト構成に近い？
いや、VB上がりの人間にはキツイ。
そして、HTMLより癖がすごい。
まぁよくわからん。


###3.place()
これが一番分かりやすかった。
が、めんどい。
なんで2020年にもなって、全部 X Y Width Height全部指定しないといけないんだ。


##検討結果(grid)
一応、Gridが一番簡単そうだったので、やってみました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249830/5c2d827f-b23e-42a7-0a63-be151a9c71cd.png)



はい、ガタガタ。



##検討結果(pack)
次にPackでお試し。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249830/5784e2b2-908c-b845-3b42-2d162e20ef2a.png)

んー、イメージ通りには行かない。



##ということで、Frameworkを作ることにした。

これらをやってみて、いい感じに組み合わせることが出来れば、Pythonでの画面構成がいい感じになるんじゃないか？
構想はこんな感じ。

・１画面をあらかじめ分割して、row,colのイメージでレイアウトを構成する。
・レイアウトを10x10に分割して、レイアウトを感覚的に置けるようにする
・レイアウトの指定方法は、X  ,Y  ,Col_SPAN   ,ROW_SPAN
・画面のスケーリングにも対応できるように

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249830/e5542026-fffc-ce1f-7e34-7404f00c2dd7.png)


これを実装するにあたり使ったのが、「3.place()」です。
色々と調べてみると、画面の幅といい感じに比で設定できるプロパティがありました。

・relx　： X座標の画面幅の比
・rely　： Y座標の画面高さの比
・relwidth　： オブジェクト幅の画面幅との比
・relheight　： オブジェクト高さの画面高さとの比

それぞれ、MAX 1.0 になっており、0.1 であれば、1/10サイズ。
・画面サイズ：width=1000px   height= 600px
この場合、0.1だと100px、0.05だと、50pxになると。

これプロパティのいいところが、Scaling対応してるんですよね。
画面の幅変えると、各オブジェクトも変わるんです。


結果：こんな感じになりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249830/82b209ce-2245-1b3c-06dc-1f1322f1d60f.png)


全画面化：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249830/b8e30fe3-729a-cef3-b6f9-d0fc64321e56.png)



いいね！


##ソースはこんな感じになりました。
もうちょっとグレードアップすれば、商品化できないかぁ・・・。

まずは、FrameWork側：


```python:TKinterK.py

# -*- coding: utf-8 -*-

#### インポート
import tkinter as tk
import tkinter.ttk as ttk


class FormK(tk.Tk):
    pass

    def __init__(self, p_max_row, p_max_col, p_padding):
        super(FormK, self).__init__()
        
        ## レイアウト用プロパティ
        self.MAX_ROW = p_max_row
        self.MAX_COL = p_max_col
        self.PAD_OUT = p_padding
        self.PAD_IN  = p_padding

        # 定数設定
        self.CONST_MSG_ICON_INFO = 1
        self.CONST_MSG_ICON_ALERT = 2
        self.CONST_MSG_ICON_ERROR = 3
         
        self.CONST_MSG_QUES_YES_NO = 1
        self.CONST_MSG_QUES_OK_CANCEL = 2
        self.CONST_MSG_QUES_RETRY_CANCEL = 4

    ## 定義時の画面サイズ設定
    def geometry(self,newGeometry=None):
        super(FormK, self).geometry(newGeometry)
        sp = newGeometry.split("x")
        self.WIDTH  = int(sp[0])
        self.HEIGHT = int(sp[1])


    ## メッセージボックス
    def MsgBox(self,p_msg,p_title,p_icon,p_ques):

        # 返却値初期値
        o_res = None

        if (p_ques == None):
            if (p_icon == self.CONST_MSG_ICON_INFO):
                messagebox.showinfo(p_title,p_msg)
            if (p_icon == self.CONST_MSG_ICON_ALERT):
                messagebox.showwarning(p_title,p_msg)
            if (p_icon == self.CONST_MSG_ICON_ERROR):
                messagebox.showerror(p_title,p_msg)
        if (p_ques == self.CONST_MSG_QUES_YES_NO):
            if (p_icon == self.CONST_MSG_ICON_INFO):
                o_res = messagebox.askyesno(p_title,p_msg)
            if (p_icon == self.CONST_MSG_ICON_ALERT):
                o_res = messagebox.askyesno(p_title,p_msg)
            if (p_icon == self.CONST_MSG_ICON_ERROR):
                o_res = messagebox.askyesno(p_title,p_msg)
        if (p_ques == self.CONST_MSG_QUES_OK_CANCEL):
            if (p_icon == self.CONST_MSG_ICON_INFO):
                o_res = messagebox.askokcancel(p_title,p_msg)
            if (p_icon == self.CONST_MSG_ICON_ALERT):
                o_res = messagebox.askokcancel(p_title,p_msg)
            if (p_icon == self.CONST_MSG_ICON_ERROR):
                o_res = messagebox.askokcancel(p_title,p_msg)
        if (p_ques == self.CONST_MSG_QUES_RETRY_CANCEL):
            if (p_icon == self.CONST_MSG_ICON_INFO):
                o_res = messagebox.askretrycancel(p_title,p_msg)
            if (p_icon == self.CONST_MSG_ICON_ALERT):
                o_res = messagebox.askretrycancel(p_title,p_msg)
            if (p_icon == self.CONST_MSG_ICON_ERROR):
                o_res = messagebox.askretrycancel(p_title,p_msg)
        return o_res

    ## オブジェクトを配置する
    def set_layout(self):

        n_height_in = self.HEIGHT - (self.PAD_OUT * 2)
        n_height_one = (n_height_in - ((self.MAX_ROW - 1) * self.PAD_IN)) / self.MAX_ROW

        n_width_in = self.WIDTH - (self.PAD_OUT * 2)
        n_width_one = (n_width_in  - ((self.MAX_COL - 1) * self.PAD_IN)) / self.MAX_COL
        for v in self.children:
            try:
                if self.children[v].layout != None:
                    sp = self.children[v].layout.split(",")

                    self.children[v].place_configure(
                        relx     =round((float(self.PAD_OUT) + ((int(sp[0])-1) * n_width_one)  + ((int(sp[0]) - 1) * self.PAD_IN)) / self.WIDTH ,4)
                       ,rely     =round((float(self.PAD_OUT) + ((int(sp[1])-1) * n_height_one) + ((int(sp[1]) - 1) * self.PAD_IN)) / self.HEIGHT ,4)
                       ,relwidth =round(((int(sp[2]) * n_width_one)  + ((int(sp[2]) - 1) * self.PAD_IN)) / self.WIDTH ,4)
                       ,relheight=round(((int(sp[3]) * n_height_one) + ((int(sp[3]) - 1) * self.PAD_IN)) / self.HEIGHT ,4)
                    )
            except:
                print("No TkinterK Object(" + v +").")
                pass


        pass

class ButtonK(tk.Button):
    pass

    def __init__(self):
        super(ButtonK, self).__init__()
        self.layout = None


class EntryK(tk.Entry):
    pass

    def __init__(self):
        super(EntryK, self).__init__()
        self.layout = None
        self["highlightthickness"] = 1
        self.config(highlightcolor= "red")

class ProgressbarK(ttk.Progressbar):
    pass

    def __init__(self):
        super(ProgressbarK, self).__init__()
        self.layout = None

class LabelK(tk.Label):
    pass

    def __init__(self):
        super(LabelK, self).__init__()
        self.layout = None

class TreeviewK(ttk.Treeview):
    pass

    def __init__(self):
        super(TreeviewK, self).__init__()
        self.layout = None

```


次にExcelGrepy：



```python:ExcelGrepy.py

# -*- coding: utf-8 -*-

#### インポート
import os
import tkinter as tk
import tkinter.ttk as ttk
import openpyxl as px
import subprocess

import TKinterK as tkk


from tkinter import messagebox
from tkinter import filedialog
from pathlib import Path





#### rootフレームの設定
root = tkk.FormK(20,10,10)
root.title("Excel Grepy")
root.geometry("1000x800")

# 背景色
root.bg = '#B7E899'

root.configure(background=root.bg)

root.result = tk.StringVar()


# スタイル設定
style = ttk.Style() 
style.configure('TButton', font = 
               ('calibri', 20, 'bold'),
                    borderwidth = '4') 
  
# Changes will be reflected 
# by the movement of mouse. 
style.map('Button'
         , foreground = [('active', '!disabled', 'green')]
         , background = [('active', 'black')]
         )





#### 画面イベント関数

# フォルダ選択ダイアログ
def btnFolderDir_click():
    root = tk.Tk()
    root.withdraw()
    iDir = ""
    file = filedialog.askdirectory(initialdir = iDir)
    
    # 処理ファイル名の出力
    if file != "":
        txtPath.delete(0, tk.END)
        txtPath.insert(0, file)

# クリアボタン
def btnClear_click():

    # メッセージ初期化
    root.result.set("")

    # 各種パス初期化
    txtPath.delete(0, tk.END)
    txtStr.delete(0, tk.END)

    # grid初期化
    x = tree.get_children()
    for item in x:
        tree.delete(item)

    # プログレスバー更新（初期化）
    progress.configure(value=0, maximum=100)
    progress.update()


# 精査ボタン
def btnCheck_click():

    # メッセージ初期化
    root.result.set("")

    # grid初期化
    x = tree.get_children()
    for item in x:
        tree.delete(item)

    # パラメータ取得
    p_temp = Path(txtPath.get())

    # エラーチェック
    if txtPath.get() == "":
        messagebox.showerror("エラー", "検索するフォルダを選択してください。")
        return

    cnt = 0
    for i in p_temp.glob('**/*.xlsx'):
        row_data =[i.name, '-', i]
        tree.insert("","end",tags=cnt,values=row_data)
        cnt += 1

    if cnt == 0:
        root.result.set(str(cnt) + "該当のフォルダにはxlsxファイルは存在しませんでした。")
    else:
        root.result.set(str(cnt) + "個のファイルが見つかりました！")


# Grepボタン
def btnGrep_click():

    # メッセージ初期化
    root.result.set("")

    # grid初期化
    x = tree.get_children()
    for item in x:
        tree.delete(item)

    # パラメータ取得
    p_temp = Path(txtPath.get())
    s_str = txtStr.get()

    # エラーチェック
    if txtPath.get() == "":
        messagebox.showerror("エラー", "検索するフォルダを選択してください。")
        return
    if s_str == "":
        messagebox.showerror("エラー", "検索する文字列を入力してください。")
        return

    cnt = 0
    prg_cnt = 0
    max_cnt = 0

    # 検索結果数をカウント
    for i in p_temp.glob('**/*.xlsx'):
        max_cnt += 1

    # プログレスバーを設定
    progress.configure(value=prg_cnt, maximum=max_cnt)

    for i in p_temp.glob('**/*.xlsx'):
        # 引数のExcelファイルを開く
        wb = px.load_workbook(i, data_only=True)
        for nm in wb.get_sheet_names():
            ws = wb[nm]
            value_matrix = str(list(ws.values))
            value_matrix = value_matrix.replace('(None','')
            value_matrix = value_matrix.replace('None), ','')
            value_matrix = value_matrix.replace(', None','')
            if (s_str in str(value_matrix)):
                row_data =[i.name, nm, i]
                tree.insert("","end",tags=cnt,values=row_data)
                cnt += 1
        
        # プログレスバー更新
        prg_cnt += 1
        progress.configure(value=prg_cnt)
        progress.update()

    # プログレスバー更新（END）
    progress.configure(value=max_cnt, maximum=max_cnt)
    progress.update()

    if cnt == 0:
        root.result.set("該当のフォルダには検索文字列は存在しませんでした。")
    else:
        root.result.set(str(cnt) + "個のファイルが見つかりました！")

# treeviewダブルクリック
def tree_row_dclick(self):
    # 行データの取得
    selected_items = tree.selection()
    row_data = tree.item(selected_items[0])
    # パスの取得
    row_value = row_data['values']
    file_path = row_value[2]
    # ファイルを開く
    #print (file_path)
    subprocess.Popen(['start', file_path], shell=True)




#### 画面オブジェクト作成

# 1.メニューの設定
btnQuit = tkk.ButtonK()
btnQuit["text"] = "終了"
btnQuit["command"] = root.destroy
btnQuit.layout = "10,1,1,1"


# ラベルの生成
lblProg = tkk.LabelK()
lblProg["text"] = "進捗"
lblProg["bg"] = root.bg
lblProg["anchor"] = "e"
lblProg.layout = "1,1,1,1"

progress = tkk.ProgressbarK()
progress.configure( value=0
                  , mode='determinate'
                  , maximum=1000
                  , length=600)
progress.layout = "2,1,8,1"


## 2 row ################################################
# ラベルの生成
lblFilePath = tkk.LabelK()
lblFilePath["text"] = "フォルダパス"
lblFilePath["bg"] = root.bg
lblFilePath["anchor"] = "e"
lblFilePath.layout = "1,2,1,1"

# 入力ボックス(FilePath)
txtPath = tkk.EntryK()
txtPath.layout = "2,2,8,1"

# 参照ボタン
btnFolderDir = tkk.ButtonK()
btnFolderDir["text"] = "参照"
btnFolderDir["command"] = btnFolderDir_click
btnFolderDir.layout = "10,2,1,1"


## 3 row ################################################
# ラベルの生成
lblFilePath = tkk.LabelK()
lblFilePath["text"] = "検索文字列"
lblFilePath["bg"] = root.bg
lblFilePath["anchor"] = "e"
lblFilePath.layout = "1,3,1,1"

# 検索文字
txtStr = tkk.EntryK()
txtStr.layout = "2,3,8,1"

## 4 row ################################################
# ラベルの生成
lblCond = tkk.LabelK()
lblCond["text"] = "検索結果"
lblCond["bg"] = root.bg
lblCond["anchor"] = "e"
lblCond.layout = "1,4,1,1"

lblCondResult = tkk.LabelK()
lblCondResult["textvariable"] = root.result
lblCondResult["anchor"] = "w"
lblCondResult.layout = "2,4,8,1"


## 5 row ################################################

# 検索処理
btnGrep = tkk.ButtonK()
btnGrep["text"] = "Grep"
btnGrep["command"] = btnGrep_click
btnGrep.layout = "10,5,1,1"

btnCheck = tkk.ButtonK()
btnCheck["text"] = "精 査"
btnCheck["command"] = btnCheck_click
btnCheck.layout = "9,5,1,1"

btnCheck = tkk.ButtonK()
btnCheck["text"] = "クリア"
btnCheck["command"] = btnClear_click
btnCheck.layout = "1,5,1,1"


## 6-20 row ################################################
# ツリービューの作成
tree = tkk.TreeviewK()

tree["columns"] = (1,2,3)
tree["show"] = "headings"
tree.column(1,width=100)
tree.column(2,width=75)
tree.column(3,width=100)
tree.heading(1,text="ファイル名")
tree.heading(2,text="シート名")
tree.heading(3,text="ファイルパス")
tree.bind('<Double-1>', tree_row_dclick)
tree.layout = "1,6,10,15"

#オブジェクトをレイアウト通りに配置する
root.set_layout()

# メインループ
root.mainloop()

```


##ソース解説
やっていることはシンプル：
####1.フォーム(root)を定義
root = tkk.FormK(20,10,10)
この引数は、（MAX_ROW,MAX_COL,PADDING）です。

####2.フォームの画面サイズを設定
root.geometry("1000x800")
これはそのまま使用してますが、Frameworkで画面のサイズとしてWidth、Heightを保持してます。

####3.各オブジェクトに、「layout」プロパティを作成+設定
'#1.メニューの設定
btnQuit = tkk.ButtonK()
btnQuit["text"] = "終了"
btnQuit["command"] = root.destroy

終了ボタンの配置をしてます。
本来のButton定義時には、定義時の引数にプロパティを設定することができますが、
このFrameworkでは、うまく出来ません。
オブジェクトを作成後に、それぞれ設定して下さい。

btnQuit.layout = "10,1,1,1"

これは、10列目、1行目、1/10幅、1/10高さ　のボタンを作成してます。


####4.各オブジェクトの定義後に、root.set_layout関数を呼び出して、レイアウトを設定

root.set_layout()

これを書かないと、何も表示されない画面になります。


####5.4の中身

まぁコード見たほうが早いので、細かい説明は省きますが。
・画面サイズ、FormK定義時の分割数から、1列のサイズ、1行のサイズを算出
・各オブジェクトのlayoutプロパティを','で分割して、表示したい幅を算出
・算出結果を元に、Object.place_configure(...)で、再定義でOK

これだけです。
ちょっと計算式に苦戦しましたが、これだけです。



##今後の開発について
ちょっといろんな機能をつけて、現行でWindowsアプリケーションを作っている人、及び、Pythonアプリケーションを作っている人用にフレームワークを開発していこうか考え中です。

なんならGitに上げて、みんあで開発的なやりかたもありだとは思うけど、同じ意見を持っている人がどれだけいるか。

ニーズがないものを作り込むモチベーションはないｗ
まだPythonのポテンシャルを図りきれてないものあるし・・・。
まぁ直感的に分かりやすいレイアウト構成なので、いつか主流になれば私も技術の幅が増えるのですが。。。
そもそも、GUIツールをフレームワーク化してライセンス的にいいのか！？ｗ

まぁ乞うご期待ｗ


##追記

GitHubへ公開しました。
今後のPythonプロジェクトは、こちらにて更新いたします。
https://github.com/kinjoarata/python_projects

どうぞよろしく。