Tkinterでデスクトップアプリを作ってみた

# 概要
Pythonの標準GUIライブラリ**Tkinter**を使って、デスクトップアプリを作ってみました。
その名も『**BedtimeClock**』。

簡単に言えば、自分の「就寝予定時刻」「起床予定時刻」「睡眠予定時間」を表示する時計です。

この記事では、このアプリの（主観的な）有用性と、開発時に得た学びを書いていこうと思います。

※詳しい使い方やソースコードなどは[GitHubのホームページ](https://github.com/GitHubYoshiharu/BedtimeClock)をご覧下さい。

# Q. 何の役に立つの？

#### A. 寝る前についパソコンをいじり続けてしまい、寝るのが遅くなってしまうのを防ぐ。

目先の欲求――得てして、「なんでこんなことしたんだろう……」と後悔することになるもの――を跳ね除けるには、「**現在の自分の行動が、将来の自分にどのような影響を与えるのか**」を認識する必要があります。

しかし、だらだらとPCをいじっているとき、「もうこんな時間か」とは考えられますが、「自分は何時に寝ることになるのか」「自分は何時間寝られるのか」「寝不足だと明日の自分はどうなるのか」という、将来のことに意識が向きません。

ならば、「就寝予定時刻や睡眠予定時間など、将来の自分に関わる情報を見せることで、それをきっかけに将来の自分の姿を想像させることができるのではないか」と考えました。

それが『BedtimeClock』を開発しようと思ったきっかけです。

# 学び

以下では、このアプリの開発時に得た学びなどを記していこうと思います。

間違いがありましたら、コメントで指摘して頂けると嬉しいです。

※以下に記載しているソースコードには、簡略化や説明のためにGitHubのソースコードと変えている部分があります。

## Tkinter

* **ウィンドウを常に最前面表示させる方法**

```Python
root = Tk()
root.attributes("-topmost", True)
```

* **ウィンドウを右上に表示する方法**

```Python
# WIN_WIDTH、WIN_HEIGHTはユーザ定義変数。
root = Tk()
win_x = root.winfo_screenwidth() - WIN_WIDTH
root.geometry(f"{WIN_WIDTH}x{WIN_HEIGHT}+{win_x}+0")
```

* **`Canvas`の文字列を更新するときは、`create_text`メソッドよりも、`itemconfigure`メソッドを使う**

BedtimeClockは時計なので、一定時間ごとに画面を更新する必要があります。
毎回`create_text`を呼んでテキストを生成し直していたときと比べて、`itemconfigure`を呼んでテキストのconfigを変更したときの方が、圧倒的に処理が軽くなりました。

```Python
# テキストを生成するときにidを取得する。
text_id = canvas.create_text(
    220, 40, text='hoge',
    font=('', 50), fill='blue'
)

# idを指定して、テキストの内容や色を変更する。
canvas.itemconfigure(text_id, text='piyo', fill='red')
```

* **処理を待機させたいときは、`after`メソッドを使う**

時計の更新は、精密さをそこまで求めないのであれば、１秒に１回くらいで十分です。
その場合、`while`ループで更新するのはCPUの無駄遣いです。

`after`メソッドを使えば、指定時間後に処理を行わせることができるため、更新する必要のない間は処理を停止させておくことができます。

定期的に更新する場合、更新関数の中で更新関数の呼び出しを予約し、再帰的に呼び出され続けるようにします。

以下の例では、画面を初期化する関数も作っています。

```Python
root = Tk()

def init_UI():
    # 略
    # １秒後に初回呼び出しを行う。
    root.after(1000, update_UI)

def update_UI():
    # 略
    # 1秒後にこの関数を呼び出す。
    roor.after(1000, update_UI)
```

* **`CheckButton`はデフォルトでは不確定状態になる**

`CheckButton`は、何も指定しないと不確定状態（`alternate`）になります。
これは、「ユーザによる入力が為されていない状態」を表すため、「チェックが入っていない状態」、すなわち「『チェックを入れない』という入力が為された状態」と区別されます。

なので、単にチェックが入っていない状態（非選択状態）を表したいなら、不確定状態を解除しなければなりません。

また、選択状態（`selected`）を表したいなら、一度不確定状態を解除してから選択状態にする必要があります。

```Python
# 非選択状態にする。
checkButton.state(['!alternate'])
```
```Python
# 選択状態にする。
checkButton.state(['!alternate'])
checkButton.state(['selected'])
```

* **`ListBox`の選択肢をリストで指定する方法**

例えば、0以上23以下の値から選択させる場合は、以下のように記述します。

```Python
listBox.insert( tkinter.END, *range(0,24) )
```

これで、`tkinter.END`（最終要素）の直前に要素を追加していく（つまり、追加した順番に要素が並ぶ）、という指定になります。

※`*range(0,24)`と書くことでレンジを展開し、可変長引数にまとめて渡すことができる。
このように、シーケンス（レンジやリストなど）を複数の変数に展開して代入することを「**アンパック代入**」という。

* **`ListBox`のデフォルト値を指定する方法**

```Python
# 指定要素が表示されるようにスクロールさせる。
listBox.see(default_value)
# 指定範囲の要素を選択させる（第２引数を省略すると、１要素のみ）。
listBox.select_set(default_value)
```

* **`CheckButton`の値を`Frame`オブジェクトから取得する方法**

`CheckButton`が配置されている`Frame`オブジェクトから値を取得するには、`CheckButton`にフレーム内で一意の名前（ここでは`cb`）をつけておいて、それで探します。
※以下の例では関数を定義していますが、しなくても大丈夫です。

```Python
def get_child_by_name(frame, name):
    for child in frame.winfo_children():
        if child.winfo_name()==name:
            return child

# 例：選択状態か否かを取得する。
is_selected = get_child_by_name(frame, 'cb').instate(['selected'])
```

* **`ListBox`の値を`Frame`オブジェクトから取得する方法**

前述の`CheckButton`の場合とほとんど同じです。
`curselection`メソッドは、選択要素番号（0～）のタプルを返します。

```Python
def get_child_by_name(frame, name):
    for child in frame.winfo_children():
        if child.winfo_name()==name:
            return child

# 選択されている値を取得する。
lb = get_child_by_name(frame, 'lb')
lb_idx = lb.curselection()[0]
lb_val = lb.get(lb_idx)
```

* **exeファイルを実行したときに起こり得るエラーについて**

`Pyinstaller`で生成したexeファイルを実行すると、以下のようなエラーが出ました。
`Can't find a usable init.tcl in the following directories: ... `

列挙されているディレクトリの中の一つに`init.tcl`を配置することで解決しました。

* **`auto-py-to-exe`について**

[auto-py-to-exe](https://pypi.org/project/auto-py-to-exe/)は、GUIで`Pyinstaller`の設定・実行ができるツールです。

後からspecファイルをいじったりしなくてもいいので、楽でした。

## Python

* **時刻の正規表現**

ここで言う時刻とは、「0以上23以下の数値と、0以上59以下の数値を`:`でつなげたもの」を指すこととします。

0以上23以下の数値は、それぞれ次のように書けます。

| 値の範囲 | 正規表現 |
|:-:|:-:|
| 0～9 | [0-9] |
| 10～19 | 1[0-9] |
| 20～23 | 2[0-3] |

0以上59以下の数値は、次のように書けます。

| 値の範囲 | 正規表現 |
|:-:|:-:|
| 0～59 | [1-5]?[0-9] |

※`?`は「直前の文字の０回または１回の繰り返し」を表します。
つまり、上記の正規表現は`([0-9]|[1-5][0-9])`を簡略化したものと言えます。

これらを合わせると、時刻の正規表現は次のように書けます。

```Python
pattern = '([0-9]|1[0-9]|2[0-3]):[1-5]?[0-9]'
```

また、`07:05`のように`0`で埋める場合は以下のように書けます。

```Python
pattern = '((0?|1)[0-9]|2[0-3]):[0-5][0-9]'
```

（参考：[『時刻』の正規表現つくってみた](https://qiita.com/ayuma/items/54374c00e3ea39d6be7c)）

* **Pythonからシェルコマンドを実行する方法**

同期処理の場合は、`subprocess`モジュールの`run`メソッドを使います。

```Python
subprocess.run(any_cmd)
```

非同期処理の場合は、`subprocess`モジュールの`Popen`メソッドを使います。

```Python
subprocess.Popen(any_cmd)
```

ここで、注意点が１つあります。
`subprocess.run`や`subprocess.Popen`を呼び出しているpyファイルを**exeファイル化して実行**すると、次のようなエラーが発生することがあります。

```
[WinError 6] ハンドルが無効です。
```

[このページ](https://stackoverrun.com/ja/q/11983700)によると、引数の`stdin`や`stderr`に何も指定されていないとハンドルが無効になり、エラーを吐くらしいです。

私の場合は、次のように`stdout`も指定しておかないとエラーを吐きました。

```Python
subprocess.Popen(
    any_cmd,
    stdin=subprocess.DEVNULL,
    stdout=subprocess.DEVNULL,
    stderr=subprocess.DEVNULL
)
```

* **実行中のexeファイル自身の絶対パスを取得する方法**

実行中のpyファイル自身の絶対パスなら、次のように取得できます。

```Python
import os
pyfile_path = os.path.abspath(__file__)
```

`__file__`には、`python3`（または`python`）コマンドの実行時に指定したパスが格納されます。

上記のコードでは、相対パスが指定されたときのために、`os.path.abspath`メソッドを使って、必ず絶対パスが返ってくるようにしています。

しかし、この方法はexeファイルの場合には使えません。
私の環境では、`C:\WINDOWS\TEMP\_MEI<数字列>\<exeファイル名>`というパスが返ってきました。

実行中のexeファイル自身の絶対パスを取得するには、`sys.executable`か、`sys.argv[0]`を参照します。

```Python
import sys
exefile_path1 = sys.executable
# or
exefile_path2 = sys.argv[0]
```

ちなみに、exeファイルのディレクトリを取得するには、次のように書きます。

```Python
import os, sys
exefile_dir = os.path.dirname(sys.executable)
```

## シェル
* **`schtasks`の使い方** 

`schtasks`は、Windowsのタスクスケジューラを操作するコマンドです（[詳しくはこちら](https://docs.microsoft.com/ja-jp/windows-server/administration/windows-commands/schtasks)）。

タスクスケジューラとは、決められた時間または一定間隔でプログラムやスクリプトを実行する機能です（引用元：[Wikipedia](https://ja.wikipedia.org/wiki/%E3%82%BF%E3%82%B9%E3%82%AF%E3%82%B9%E3%82%B1%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%A9)）。

タスクを作成するには、`/create`を使います。

例：毎日`7:00`に`C:\hoge\hoge.exe`を実行する`hoge-task`を作成する。

```
schtasks /create /tn hoge-task /tr C:\hoge\hoge.exe /sc daily /st 07:00 /f
```

* `/tn`は、システム上で一意である必要があります。
* 実行するファイルを指定する`tr`には、ファイルに渡す引数も指定できます。
* `/st`で指定する時刻は、`HH:mm`で指定します。
* `/f`は、指定したタスクが既に存在する場合でも警告を出さないようにします。つまり、自動的に上書きさせます。

タスクを削除するには、`/delete`を使います。

例：`hoge-task`を削除する。

```
schtasks /delete /tn hoge-task /f
```

* 指定したタスクが存在しない場合、エラーを吐きます。
* `/f`は、削除時の確認メッセージを出さないようにします。