前回はCUI(CLI)のタイマーを作りましたが、やっぱりGUIを作りたいと思い、作成してみました。
前提として、標準ライブラリ「Pathlib」、外部パッケージ「PySimpleGUI」を使用します。
どちらもPython3.4以上からサポートがされていますが、外部パッケージはインストールが必要です。
PySimpleGUIの使い方など、基本的なことはこちらで扱いました。
タイマーを作る手順として、
- カウントダウンの中身
- タイマーセット機能
- スタート・ストップ機能
- リセット・セット機能
- タイムアップ
の順番でやっていきます。
完成したコードは「まとめ」にあります。
作成時の環境
Windows10
Python 3.10.0
PySimpleGUI 4.56.0
カウントダウンの中身
タイマーを再現するためにどういう方法がいいのか迷っていたところ、PySimpleGUIのCookbook「Desktop Floating Widget - Timer」を見て、なるほどなあと思ったので参考にしました。
そのページでは「time.time()」を使って時間の計測を行っています。
time.timeはエポック(各OSごとに異なる日時を起点としたもの)からの経過時間(秒)を返すメソッドで、このメソッドを実行するたびに経過時間が増えていきます。
timeをインポートして、実行してみましょう。
$ python3 Python 3.10.0 (default, Oct 12 2021, 16:02:08) [GCC 9.3.0] on linux Type "help", "copyright", "credits" or "license" for more information. >>> import time >>> time.time() 1644220683.7099214 >>> time.time() 1644220738.7302358 >>> time.time() 1644220899.9755356
桁が大きいのでちょっとわかりにくいですが、秒数が増えていますね。
スタートした時間を変数に入れ、実行した「time.time()」から引くと、スタートからの経過時間を出すことができます。
>>> start_time = time.time() >>> time.time() - start_time 79.74693536758423 >>> time.time() - start_time 139.61748456954956
今回作りたいのはタイマーなので、終了時間を最初に変数に入れて、「time.time()」で出した時間をそのつど引けば、カウントダウンが実現できそうです。
>>> end_time = time.time() + 60
>>> end_time - time.time()
35.19264507293701
>>> end_time - time.time()
17.355372190475464
>>> end_time - time.time()
1.7945432662963867
>>> end_time - time.time()
-215.18259978294373
ひとまず終了時間は60秒後にして、end_timeという変数に入れました。
time.time()を実行するたびにend_timeに近づいていき、やがて追い抜いています。
この「end_time - time.time()」の結果をGUIで表示し、時間が0になったらポップアップなどで知らせるようにすれば、タイマーとして使えそうです。
タイマーのセット
いつも同じ時間のタイマーしか使えないのは不便なので、ユーザーから希望する時間を受け取りたいと思います。
まずはセット機能を関数にまとめ、呼び出し元に返せるようにします。
先にコードを掲載。
timer_gui.py
import pathlib import time import PySimpleGUI as sg sg.theme('DarkBrown1') path = pathlib.Path('user_set.txt') def set_time(): layout = [[sg.Text('タイマーをセットします', size=(None, 2))], [sg.Spin([m for m in range(61)], size=2, font=(None, 15)), sg.T('分'), sg.Spin([s for s in range(60)], size=2, font=(None, 15)), sg.T('秒')], [sg.VPush()], [sg.Checkbox('デフォルトとして設定する', key='-DEFAULT-')], [sg.OK(), sg.Cancel()]] window = sg.Window('セットタイマー', layout, size=(250, 160) ,element_justification='center') event, values = window.read() if event == sg.WIN_CLOSED or event == 'Cancel': user = (0, 0) elif event == 'OK': user = (values[0], values[1]) if values['-DEFAULT-']: with open(path, 'w', encoding='utf-8') as f: f.write(f'{values[0]} {values[1]}') window.close() return user set_time()
上記モジュールを実行すると、次のようなアプリが起動します。
$ python timer_gui.py
では、コードの補足説明を。
ユーザーから受け取った数値をファイル(user_set.txt)に書き込むため、pathlibをインポートしています。
セットする機能は「set_time」という関数にまとめました。
レイアウトでは、数値の入力に余計なものが入らないように「sg.Spin」を使っています。
[sg.Spin([m for m in range(61)], size=2, font=(None, 15)), sg.T('分'), sg.Spin([s for s in range(60)], size=2, font=(None, 15)), sg.T('秒')],
「sg.Spin」の引数にはリストを渡しますが、分の方には、0から60までの数値、秒の方は、0から59までにしています。
「sg.VPush()」は挿入したところから下の行をアプリ下部に押し下げます。
イベントループの方で、もし「Cancel」や「×」ボタンが押された場合、ユーザーからの入力がなかったものと判断し(0, 0)を呼び出し元に返すようにしました。
「OK」ならSpinで選択した分と秒を返します。
合わせて、もし「デフォルトとして設定する」にチェックがあった場合、「user_set.txt」に書き込むようにしました。
スタート・ストップ
※次のコードは、前項のコードから実行文「set_time()」を削除し、追記したものです。
def main():
started = False
if path.exists():
with open(path, encoding='utf-8') as f:
user_m, user_s = map(int, f.read().split())
else:
user_m, user_s = (3, 0)
current_m, current_s = user_m, user_s
layout = [[sg.T(f'{current_m} : {current_s}', pad=(None, 10), font=(None, 35), key='-DISPLAY-')],
[sg.VPush()],
[sg.B('Start'), sg.B('Stop'), sg.B('Reset')],
[sg.B('Set'), sg.Quit()]]
window = sg.Window('タイマー', layout, size=(280, 150), element_justification='center')
def update_display():
window['-DISPLAY-'].update(f'{int(current_m)} : {int(current_s)}')
while True:
event, _ = window.read(timeout=50)
if event == sg.WIN_CLOSED or event == 'Quit':
break
elif event == 'Start':
if not started:
started = True
end_time = time.time() + current_m*60 + current_s
elif event == 'Stop':
started = False
elif event == 'Reset':
pass
elif event == 'Set':
pass
if started:
remaining_time = end_time - time.time()
current_m, current_s = divmod(remaining_time, 60)
update_display()
window.close()
main()
「path.exists()」でファイル(user_set.txt)の存在確認をし、存在していたら「user_m, user_s」にユーザーがセットした数値をそれぞれ代入します。
まだセット機能の連携と調整をしていないため、ファイルが存在しなかったら、とりあえず動作確認をするために(3, 0)を代入しておきます。
※動作確認後には(0, 0)に変更します。
「user_m, user_s」の値を「current_m, current_s」に入れているのは、初期化用とカウントダウン用で分けるためです。
read()の引数には、タイムアウト(ミリ秒)を設定しています。
event, _ = window.read(timeout=50)
50ミリ秒(0.05秒)ぐらいならタイマーとして精度を損なわないかな、と結構アバウトに決めました。
タイムアウトを設定しなかった場合、ユーザーからの入力待ちで画面の表示が変わりません。
肝心のスタート・ストップ機能に関してはスイッチとして「started」という変数を用意しました。
「Start」ボタンが押されたときには、スイッチがオン(started=True)になり、「current_m, s」を使って終了時間(end_time)が決定されます。
elif event == 'Start': if not started: started = True end_time = time.time() + current_m*60 + current_s
スイッチがオンの間「end_time」と「time.time()」の差が縮まってきます(残り時間が少なくなってきます)。
このとき残り時間を使って「current_m, s」を更新、update_display関数で画面のアップデートも行います。
if started: remaining_time = end_time - time.time() current_m, current_s = divmod(remaining_time, 60) update_display()
途中で「Stop」ボタンが押されると、スイッチがオフになり、残り時間は計算されません。
再び「Start」ボタンを押すと、更新された「current_m, s」を使って終了時間があらためて計算されます。
モジュールを実行すると、アプリが起動します。
「Start」を押すとカウントダウンがはじまり、「Stop」を押すと止まるようになりました。
リセット・セット
「Reset」ボタンと「Set」ボタンに機能を加えます。
該当コードは「pass」だけだったので、以下のように変更します。
elif event == 'Reset': started = False current_m, current_s = user_m, user_s update_display() elif event == 'Set': started = False user_m, user_s = set_time() current_m, current_s = user_m, user_s update_display()
それぞれ「started = False」にし、初期化用の「user_m, s」を使って「current_m, s」の値を初期化します。
「Reset」を押すと、カウントダウンが初期値に戻ります。
「Set」ボタンが押された場合、set_time関数が呼び出され、戻り値を使って「user_m, s」を改めて初期化します。
実行してみます。
「Set」を押すとセット画面が現れ、OKボタンを押すと、タイマー本体の初期値が更新されました。
「Reset」でカウントダウンが初期値に戻りました。
「デフォルトとして設定する」にチェックが入っていた場合、テキストファイルに数値が書き込まれ、次回以降のモジュールの起動時に読み込むようになります。
問題なさそうです。
タイムアップ
現在のままだと0を過ぎても、マイナスで時間が表示され続けてしまいます。
残り時間(remaining_time)が0より小さいかどうかで処理を分岐させ、時間が来たらポップアップを表示させます。
「if stated:」のネストした所を変更します。
if started:
remaining_time = end_time - time.time()
if remaining_time < 0:
sg.popup('時間になりました', keep_on_top=True)
started = False
current_m, current_s = user_m, user_s
update_display()
else:
current_m, current_s = divmod(remaining_time, 60)
update_display()
ポップアップが他の画面に隠れていたら気づくことができないので、「keep_on_top=Ture」で最前線に表示させるようにしています。
実行します。
カウントダウンが0になると、ポップアップが現れました。
まとめ
これまでのコードをひとつにまとめます。
import pathlib
import time
import PySimpleGUI as sg
sg.theme('DarkBrown1')
path = pathlib.Path('user_set.txt')
def set_time():
layout = [[sg.Text('タイマーをセットします', size=(None, 2))],
[sg.Spin([m for m in range(61)], size=2, font=(None, 15)), sg.T('分'),
sg.Spin([s for s in range(60)], size=2, font=(None, 15)), sg.T('秒')],
[sg.VPush()],
[sg.Checkbox('デフォルトとして設定する', key='-DEFAULT-')],
[sg.OK(), sg.Cancel()]]
window = sg.Window('セットタイマー', layout, size=(250, 160), element_justification='center')
event, values = window.read()
if event == sg.WIN_CLOSED or event == 'Cancel':
user = (0, 0)
elif event == 'OK':
user = (values[0], values[1])
if values['-DEFAULT-']:
with open(path, 'w', encoding='utf-8') as f:
f.write(f'{values[0]} {values[1]}')
window.close()
return user
def main():
started = False
if path.exists():
with open(path, encoding='utf-8') as f:
user_min, user_sec = map(int, f.read().split())
else:
user_min, user_sec = (0, 0)
current_min, current_sec = user_min, user_sec
layout = [[sg.T(f'{current_min} : {current_sec}', pad=(None, 10), font=(None, 35), key='-DISPLAY-')],
[sg.VPush()],
[sg.B('Start'), sg.B('Stop'), sg.B('Reset')],
[sg.B('Set'), sg.Quit()]]
window = sg.Window('タイマー', layout, size=(280, 150), element_justification='center')
def update_display():
window['-DISPLAY-'].update(f'{int(current_min)} : {int(current_sec)}')
while True:
event, _ = window.read(timeout=50)
if event == sg.WIN_CLOSED or event == 'Quit':
break
elif event == 'Start':
if not started:
started = True
end_time = time.time() + current_min*60 + current_sec
elif event == 'Stop':
started = False
elif event == 'Reset':
started = False
current_min, current_sec = user_min, user_sec
update_display()
elif event == 'Set':
started = False
user_min, user_sec = set_time()
current_min, current_sec = user_min, user_sec
update_display()
if started:
remaining_time = end_time - time.time()
if remaining_time < 0:
sg.popup('時間になりました', keep_on_top=True)
started = False
current_min, current_sec = user_min, user_sec
else:
current_min, current_sec = divmod(remaining_time, 60)
update_display()
window.close()
if __name__ == '__main__':
main()
ひとまず完成です。
GitHubで公開してみました。
GUIだと出来上がったものが目の前に現れるし、使い勝手はいいですね。
正直これが正解かどうかわかりませんが、自分で動くものが作れてちょっと感動しています。
このページが少しでもお役に立てたのなら幸いです。