[Python]タイマーを作ってみた[GUI編]

Python

前回はCUI(CLI)のタイマーを作りましたが、やっぱりGUIを作りたいと思い、作成してみました。

前提として、標準ライブラリ「Pathlib」、外部パッケージ「PySimpleGUI」を使用します。
どちらもPython3.4以上からサポートがされていますが、外部パッケージはインストールが必要です。

PySimpleGUIの使い方など、基本的なことはこちらで扱いました。

タイマーを作る手順として、

  1. カウントダウンの中身
  2. タイマーセット機能
  3. スタート・ストップ機能
  4. リセット・セット機能
  5. タイムアップ

の順番でやっていきます。

完成したコードは「まとめ」にあります。

作成時の環境
Windows10
Python 3.10.0
PySimpleGUI 4.56.0

 Androidアプリを作成しました。
 感情用のメモ帳です。

スポンサーリンク
スポンサーリンク

カウントダウンの中身

タイマーを再現するためにどういう方法がいいのか迷っていたところ、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()」は挿入したところから下の行をアプリ下部に押し下げます。

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ボタンを押すと、セットタイマー画面が現れた。

「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だと出来上がったものが目の前に現れるし、使い勝手はいいですね。

正直これが正解かどうかわかりませんが、自分で動くものが作れてちょっと感動しています。

このページが少しでもお役に立てたのなら幸いです。

タイトルとURLをコピーしました