pysimplegui + pyinstallerで簡単なツールを配布する

# 内容

pythonで自分用に作っていた簡単なデバッグツールを、内輪限定で配布することになった。
pythonを入れていない人やCLIに慣れていない人もいたため適当にGUIをつけ、.exe化したので、その手順や過程で覚えたことを書きます。


## 最小限のGUIをつけて配布する
以下のコードのparameter/somefuncを書き換えます
```python
import PySimpleGUI as sg

# parameter
Param1 = 3

# 自作処理
def somefunc():
    import time
    time.sleep(Param1)
    return 'End'


def main():
    # GUIのコード
    # レイアウト定義
    layout = [[sg.Button('Run'), sg.Button('Exit')]]

    # Window生成
    window = sg.Window('ツール名', layout)

    # Event Loop to process "events" and get the "values" of the inputs
    while True:
        event, values = window.read()
        if event == sg.WIN_CLOSED or event == 'Exit':  # if user closes window or clicks exit
            break
        elif event == 'Run':            # Runボタン実行時
            somefunc()

    window.close()


if __name__ == '__main__':
    main()
```

pyinstallerを使って、上記.pyファイルを.exe化します。
カレントディレクトリにqiita.pyという名前で保存したとして、以下のコマンドを実行します
```bash
pip install pyinstaller
pyinstaller -wF ./qiita.py
```
distというフォルダに、qiita.exeファイルが生成されます。ダブルクリックで実行できます。


## 過程や結果を出力する
レイアウトのコードに、マルチライン（テキストエリア）を追加します
レイアウトのコードは2次元配列で記述します
```python
    layout = [[sg.Button('Run'), sg.Button('Exit')],
              # 下３行を追加
              [sg.HSeparator()],
              [sg.Multiline(size=(65, 20), key='MultiLine', autoscroll=True,
                            reroute_stdout=True, write_only=True],]
```
GUIにマルチラインを追加した状態でコード中でprint()すると、マルチライン上に表示されて確認することができます。


## パラメータを可変にする
レイアウト上にインプットエリアやチェックボックスを追加して、設定値を読み取ります。
設定値は、イベントループの変数valueに設定され、keyを使って読み取ることができます。
### レイアウトのコード
```python
    layout = [
        # 下２行を追加
        [sg.InputText('初期値１', key='Param1'), sg.Checkbox(
            'チェックボックス', default=True, key='Check1')],
        [sg.Button('Run'), sg.Button('Exit')],
        [sg.HSeparator()],
        [sg.Multiline(size=(65, 20), key='MultiLine', autoscroll=True,
                      reroute_stdout=True, write_only=True, reroute_cprint=True)],]
```
### 入力値読み取り個所のコード（Eventloop内）
```python
    # valuesの読み取り（抜粋）
    event, values = window.read()
    if event == 'Run':            # Runボタン実行時
        somefunc(values)
```
### 関数（引数で値を読み取り）
```python
    def somefunc(values):
        print(values['Param1'])
        if values['Check1']:
            print("チェックされています。")
```



## 時間がかかる処理をスレッド化する
Runボタンを押した後、自作の処理を実行している間GUIは反応しなくなります。
Webへのリクエストなどをするときは時間がかかるので、ボタンを押したときにスレッドを生成して、そのスレッドで実行することでGUIを止めないようにできます。Runボタンを連打させない対策も必要です。

```python3
# threadingのインポートを追加
import threading
```
# ボタンを押したときのコード（Eventloop内）
```python
        elif event == 'Run':            # Runボタン実行時
            # ボタンを無効化する（連打対策）
            window['Run'].update(disabled=True)
            # スレッドで実行（この時、args=にてパラメータを設定できる
            threading.Thread(target=somefunc, args=(
                window, values), daemon=True).start()
```
### 自作処理の例
```python
def somefunc(window, values):
    import time
    for i in range(1, 6):
        sg.cprint(f"{i}... ", end="", text_color="green")
        time.sleep(1)
    sg.cprint("Finish!", text_color="blue")
    # 完了後、window上のボタンを有効化
    window['Run'].update(disabled=False)

```



## 出力にこだわる
sg.Multiline()のオプション、reroute_xxxxには、stdout, stderr, cprintがあります。
rerout_xxxx=Trueに設定した場所に出力されます。（※複数設定した場合、一番最後の要素）
|  要素名  |  デフォルト  |
| ---- | ---- |
|  stdout(標準出力)  |  コンソール  |
|  stderr(エラー出力)  |  コンソール  |
|  sg.cprint()  |  コンソール（ただしWarningが出る）  |

sg.cprint()は文字色、背景色を設定できます。
```python
sg.cprint("表示メッセージ", text_color="blue", background_color="lightgray")
```


## 設定項目が増えてきたらタブを分ける
プロパティやボタンの数が増えてきた場合、タブを使って分けてしまうとすっきりするのでお勧めです。
タブごとにレイアウト定義（２次元配列）を作り、親のレイアウトで、sg.TabGroup()->sg.Tab()を設定します。
（TabGroupの中のいずれか一つのタブが前面に表示されるイメージ）

```python
    main_tab = [[sg.Text("main setting"), sg.InputText("default value")],
                [sg.Button("Run"), sg.Button("Exit")],
                [sg.HSep()],
                [sg.Multiline(size=(65, 20), key='-ML-', autoscroll=True,
                              reroute_stdout=True, write_only=True, reroute_cprint=True)],
                ]

    config_tab = [[sg.Text("param1"), sg.InputText("default param")],
                  [sg.Text("param2"), sg.InputText("default param")],
                  ]

    layout = [[sg.Text("Sample")],
              [sg.TabGroup([
                  [sg.Tab('Main', main_tab), sg.Tab('config', config_tab)]
              ])],
              ]
```


## 設定値の保存、読み込みを可能にして、設定ファイルとセットで配る
GUIの設定値をjsonファイルに出力できるようにします。また、jsonファイルから読み込んだ設定値を反映します。
環境固有の設定などを切り出して配ることができます

```python
    # レイアウト
    config_tab = [[sg.Text("param1"), sg.InputText("default param", key='_config1')],
                  [sg.Text("param2"), sg.InputText(
                      "default param", key='_config2')],
                    # 下2行を追加（ファイル選択・保存ボタン及びファイル選択・読み込みボタン）
                  [sg.Input(key="_savepath"), sg.FileSaveAs(button_text="保存先選択",
                                                            file_types=(("json", "*.json"),), default_extension="setting.json"), sg.Button("保存する")],
                  [sg.Input(key="_loadpath"), sg.FileBrowse(file_types=(
                      ("json config file", "*.json"),)), sg.Button("読み込む")]
                  ]

    # 設定名とkey='xxx'を紐づける辞書を用意しておく
########################################## Load/Save Settings File ##########################################
# "Map" from the settings dictionary keys to the window's element keys
SETTINGS_KEYS_TO_ELEMENT_KEYS = {
    'main1': '_main1', 'cfg1': '_config1', 'cfg2': '_config2'}


    # jsonファイル読み込み、GUIへの反映、jsonファイルへの保存関数
def load_settings(settings_file):
    try:
        with open(settings_file, 'r') as f:
            settings = jsonload(f)
    except Exception as e:
        sg.popup_quick_message(f'exception {e}', 'No settings file load...',
                               keep_on_top=True, background_color='red', text_color='white')
        return None
    return settings


def update_settings(settings, window):
    for key in SETTINGS_KEYS_TO_ELEMENT_KEYS:   # update window with the values read from settings file
        try:
            window[SETTINGS_KEYS_TO_ELEMENT_KEYS[key]].update(
                value=settings[key])
        except Exception as e:
            print(
                f'Problem updating PySimpleGUI window from settings. Key = {key}')


def save_settings(settings_file, settings, values):
    if values:      # if there are stuff specified by another window, fill in those values
        for key in SETTINGS_KEYS_TO_ELEMENT_KEYS:  # update window with the values read from settings file
            try:
                settings[key] = values[SETTINGS_KEYS_TO_ELEMENT_KEYS[key]]
            except Exception as e:
                print(
                    f'Problem updating settings from window values. Key = {key}')

    with open(settings_file, 'w') as f:
        jsondump(settings, f)

    sg.popup('Settings saved')
```

### ボタンクリック時の処理（Eventloop内）
```python
        elif event == '保存する':
            save_settings(values['_savepath'], {}, values)
        elif event == '読み込む':
            settings = load_settings(values['_loadpath'])
            update_settings(settings, window)


```