マルチタイマー
複数タイマーを同時管理できるアプリ。カウントダウン・ストップウォッチ切替と完了通知音付き。
1. アプリ概要
複数タイマーを同時管理できるアプリ。カウントダウン・ストップウォッチ切替と完了通知音付き。
このアプリはutilカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter(標準ライブラリ)、難易度は ★★☆ です。
このアプリは「ユーティリティ」カテゴリです。小回りの効くユーティリティは Python の真骨頂で、汎用的なユーザー操作を組み合わせる発想が他のアプリにもそのまま流用できます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。
完成形を見てから細部の解説に進む流れが効果的です。実行→気になる箇所を解説で確認→自分の手で改造、というサイクルで定着が早まります。
カスタマイズ章では具体的な拡張アイデアを示しています。一つ実装するごとに動作確認することで、変更が予期せぬ副作用を起こさないかも体感できます。
2. 機能一覧
- 「+ ストップウォッチ追加」ボタン(
_add())・「+ カウントダウン追加」ボタン(_add_countdown())・「全てリセット」ボタン(_reset_all())で操作 - JSON ファイルの保存・読み込み(起動時に自動実行)
messagebox.showwarning()によるダイアログ通知- ウィンドウはタイトル「マルチタイマー」・サイズ 900x440 で起動
3. 事前準備・環境
Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)
Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は標準ライブラリの範囲で動作する想定ですが、未検証です。
- Python 3.10 以上
- OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)
4. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
import tkinter as tk
from tkinter import ttk, messagebox
import time
import threading
import json
import os
from datetime import timedelta
try:
import winsound
SOUND_AVAILABLE = True
except ImportError:
SOUND_AVAILABLE = False
SAVE_PATH = os.path.join(os.path.dirname(__file__), "timers.json")
COLORS = ["#ef5350", "#26a69a", "#4fc3f7", "#ffa726", "#ab47bc",
"#66bb6a", "#ec407a", "#29b6f6"]
class TimerWidget:
"""個別タイマーウィジェット"""
def __init__(self, parent, timer_id, data, on_delete, on_save):
self._id = timer_id
self._data = data
self._on_delete = on_delete
self._on_save = on_save
self._running = False
self._elapsed = data.get("elapsed", 0.0)
self._target = data.get("target", 0) # 0 = ストップウォッチ
self._start_t = None
self._color = data.get("color", COLORS[timer_id % len(COLORS)])
self.frame = tk.Frame(parent, bg=self._color, padx=6, pady=4,
relief=tk.RAISED, bd=1)
self._build()
def _build(self):
f = self.frame
color = self._color
# タイトル行
top = tk.Frame(f, bg=color)
top.pack(fill=tk.X)
self.name_var = tk.StringVar(value=self._data.get("name", f"タイマー {self._id+1}"))
name_entry = tk.Entry(top, textvariable=self.name_var,
bg=color, fg="white", relief=tk.FLAT,
font=("Noto Sans JP", 9, "bold"),
insertbackground="white", width=14)
name_entry.pack(side=tk.LEFT)
name_entry.bind("<FocusOut>", lambda e: self._on_save())
tk.Button(top, text="✕", bg=color, fg="white",
relief=tk.FLAT, font=("Arial", 8), bd=0,
command=self._delete).pack(side=tk.RIGHT)
# 時間表示
self.time_lbl = tk.Label(f, text="00:00:00",
bg=color, fg="white",
font=("Courier New", 20, "bold"))
self.time_lbl.pack(pady=4)
# プログレス (カウントダウン時)
self.progress = ttk.Progressbar(f, mode="determinate",
length=140, maximum=100)
if self._target > 0:
self.progress.pack(pady=2)
# ボタン行
btn_row = tk.Frame(f, bg=color)
btn_row.pack()
self.start_btn = tk.Button(
btn_row, text="▶", command=self._toggle,
bg=self._darken(color), fg="white", relief=tk.FLAT,
font=("Arial", 11), padx=8, pady=2,
activebackground=self._darken(color), bd=0)
self.start_btn.pack(side=tk.LEFT, padx=2)
tk.Button(btn_row, text="⏹", command=self._reset,
bg=self._darken(color), fg="white", relief=tk.FLAT,
font=("Arial", 11), padx=8, pady=2,
activebackground=self._darken(color), bd=0).pack(
side=tk.LEFT, padx=2)
# ラップタイム
self.lap_lbl = tk.Label(f, text="", bg=color, fg="#e0e0e0",
font=("Arial", 7))
self.lap_lbl.pack()
self._update_display()
def _darken(self, hex_color):
try:
r = max(0, int(hex_color[1:3], 16) - 40)
g = max(0, int(hex_color[3:5], 16) - 40)
b = max(0, int(hex_color[5:7], 16) - 40)
return f"#{r:02x}{g:02x}{b:02x}"
except Exception:
return hex_color
def _toggle(self):
if self._running:
self._pause()
else:
self._start()
def _start(self):
self._running = True
self._start_t = time.time()
self.start_btn.config(text="⏸")
self._tick()
def _pause(self):
self._running = False
if self._start_t:
self._elapsed += time.time() - self._start_t
self._start_t = None
self.start_btn.config(text="▶")
self._on_save()
def _reset(self):
self._running = False
self._elapsed = 0.0
self._start_t = None
self.start_btn.config(text="▶")
self._update_display()
self._on_save()
def _tick(self):
if not self._running:
return
elapsed = self._elapsed + (time.time() - self._start_t)
self._update_display(elapsed)
if self._target > 0:
remaining = self._target - elapsed
if remaining <= 0:
self._running = False
self._elapsed = self._target
self._update_display(self._target)
self._alarm()
return
self.frame.after(50, self._tick)
def _alarm(self):
self.time_lbl.config(fg="#ff0", text="時間です!")
if SOUND_AVAILABLE:
threading.Thread(
target=lambda: winsound.Beep(880, 800), daemon=True).start()
else:
self.frame.bell()
def _update_display(self, elapsed=None):
if elapsed is None:
elapsed = self._elapsed
if self._target > 0:
remaining = max(0, self._target - elapsed)
display = remaining
pct = (1 - remaining / self._target) * 100 if self._target else 0
self.progress["value"] = pct
else:
display = elapsed
h = int(display // 3600)
m = int((display % 3600) // 60)
s = int(display % 60)
ms = int((display % 1) * 10)
if h > 0:
self.time_lbl.config(text=f"{h:02d}:{m:02d}:{s:02d}")
else:
self.time_lbl.config(text=f"{m:02d}:{s:02d}.{ms}")
def _delete(self):
self._running = False
self._on_delete(self._id)
def get_data(self):
elapsed = self._elapsed
if self._running and self._start_t:
elapsed += time.time() - self._start_t
return {
"name": self.name_var.get(),
"elapsed": elapsed,
"target": self._target,
"color": self._color,
}
class App090:
"""マルチタイマー"""
MAX_TIMERS = 8
def __init__(self, root):
self.root = root
self.root.title("マルチタイマー")
self.root.geometry("900x440")
self.root.configure(bg="#1e1e1e")
self._timers = {} # {timer_id: TimerWidget}
self._next_id = 0
self._build_ui()
self._load()
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
def _build_ui(self):
header = tk.Frame(self.root, bg="#252526", pady=6)
header.pack(fill=tk.X)
tk.Label(header, text="⏱ マルチタイマー",
font=("Noto Sans JP", 12, "bold"),
bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)
# コントロール
ctrl = tk.Frame(self.root, bg="#2d2d2d", pady=4)
ctrl.pack(fill=tk.X)
tk.Button(ctrl, text="+ ストップウォッチ追加", command=lambda: self._add(0),
bg="#1565c0", fg="white", relief=tk.FLAT,
font=("Arial", 10), padx=12, pady=4,
activebackground="#0d47a1", bd=0).pack(side=tk.LEFT, padx=4)
tk.Label(ctrl, text="カウントダウン:", bg="#2d2d2d", fg="#ccc",
font=("Arial", 9)).pack(side=tk.LEFT, padx=(8, 2))
self.cd_min_var = tk.IntVar(value=5)
ttk.Spinbox(ctrl, from_=0, to=99, textvariable=self.cd_min_var,
width=4).pack(side=tk.LEFT)
tk.Label(ctrl, text="分", bg="#2d2d2d", fg="#ccc",
font=("Arial", 9)).pack(side=tk.LEFT)
self.cd_sec_var = tk.IntVar(value=0)
ttk.Spinbox(ctrl, from_=0, to=59, textvariable=self.cd_sec_var,
width=4).pack(side=tk.LEFT, padx=2)
tk.Label(ctrl, text="秒", bg="#2d2d2d", fg="#ccc",
font=("Arial", 9)).pack(side=tk.LEFT)
tk.Button(ctrl, text="+ カウントダウン追加",
command=self._add_countdown,
bg="#6a1b9a", fg="white", relief=tk.FLAT,
font=("Arial", 10), padx=12, pady=4,
activebackground="#4a148c", bd=0).pack(side=tk.LEFT, padx=8)
ttk.Button(ctrl, text="全てリセット",
command=self._reset_all).pack(side=tk.RIGHT, padx=8)
# タイマーグリッド
self.grid_frame = tk.Frame(self.root, bg="#1e1e1e")
self.grid_frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
self.status_var = tk.StringVar(value="タイマーを追加してください")
tk.Label(self.root, textvariable=self.status_var,
bg="#252526", fg="#858585", font=("Arial", 9),
anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)
def _add(self, target_sec, data=None):
if len(self._timers) >= self.MAX_TIMERS:
messagebox.showwarning("警告", f"タイマーは最大 {self.MAX_TIMERS} 個まで")
return
tid = self._next_id
self._next_id += 1
d = data or {"name": f"タイマー {tid+1}", "elapsed": 0.0,
"target": target_sec,
"color": COLORS[tid % len(COLORS)]}
tw = TimerWidget(self.grid_frame, tid, d,
on_delete=self._remove,
on_save=self._save)
self._timers[tid] = tw
self._relayout()
self.status_var.set(f"タイマー: {len(self._timers)} 個")
self._save()
def _add_countdown(self):
total = self.cd_min_var.get() * 60 + self.cd_sec_var.get()
if total <= 0:
messagebox.showwarning("警告", "0より大きい時間を設定してください")
return
self._add(total)
def _remove(self, tid):
if tid in self._timers:
self._timers[tid].frame.destroy()
del self._timers[tid]
self._relayout()
self.status_var.set(f"タイマー: {len(self._timers)} 個")
self._save()
def _relayout(self):
"""グリッドに並べ直す"""
for tw in self._timers.values():
tw.frame.grid_forget()
cols = 4
for i, (tid, tw) in enumerate(self._timers.items()):
row = i // cols
col = i % cols
tw.frame.grid(row=row, column=col, padx=6, pady=6, sticky="nsew")
for c in range(cols):
self.grid_frame.columnconfigure(c, weight=1)
def _reset_all(self):
for tw in self._timers.values():
tw._reset()
def _save(self):
data = {tid: tw.get_data() for tid, tw in self._timers.items()}
try:
with open(SAVE_PATH, "w", encoding="utf-8") as f:
json.dump({"next_id": self._next_id, "timers": data}, f,
ensure_ascii=False)
except Exception:
pass
def _load(self):
if not os.path.exists(SAVE_PATH):
# デフォルト2個
self._add(0)
self._add(300)
return
try:
with open(SAVE_PATH, encoding="utf-8") as f:
saved = json.load(f)
self._next_id = saved.get("next_id", 0)
for tid_str, d in saved.get("timers", {}).items():
d["elapsed"] = 0.0 # 再起動時はリセット
self._add(d.get("target", 0), d)
except Exception:
self._add(0)
def _on_close(self):
self._save()
self.root.destroy()
if __name__ == "__main__":
root = tk.Tk()
app = App090(root)
root.mainloop()
5. コード解説
マルチタイマーのコードを、実際に書かれている実装に沿って解説します。
クラス設計とコンストラクタ
App090 クラスにアプリの全機能をまとめています(メソッド10個・全328行)。__init__ ではタイトル「マルチタイマー」とウィンドウサイズ 900x440 を設定します。あわせて状態を保持する変数(self._next_id)を初期化し、そのあと _build_ui() → _load() の順に呼び出して初期状態を作ります。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
ウィジェット構成
画面は tk.Frame×3・tk.Label×5・tk.Button×2・ttk.Spinbox×2・ttk.Button で構成しています。このほかに、別クラス TimerWidget が生成するウィジェットがあります(「4. 完全なソースコード」を参照してください)。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
イベント処理とボタンの接続
「+ ストップウォッチ追加」ボタン(_add())・「+ カウントダウン追加」ボタン(_add_countdown())・「全てリセット」ボタン(_reset_all())を command= で接続しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
JSON への保存と読み込み
データは json.dump() でファイル(timers.json)へ保存します。起動時は json.load() で読み戻します。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
クラス定数によるデータ定義
クラスの先頭でデータを定数として定義しています:MAX_TIMERS = 8。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
例外処理
try-except で Exception・ImportError を捕捉しています。あわせて messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app090.py と保存します。使うのは
datetime・json・os・threading・time・tkinter・winsoundだけなので、追加インストールは不要です。 -
2クラスの骨格を作る
App090クラスを定義し、__init__と、末尾のroot = tk.Tk()~mainloop()の最小構成を書いて、まず空のウィンドウが出ることを確認します。 -
3ウィンドウを設定する
title("マルチタイマー")・geometry("900x440")・configure(bg="#1e1e1e")をコンストラクタで設定します。 -
4画面部品を並べる
_build_ui()の中で、tk.Frame×3・tk.Label×5・tk.Button×2・ttk.Spinbox×2・ttk.Buttonを作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。 -
5イベントを接続する
「2. 機能一覧」で挙げた各ボタンを
command=で対応するメソッド(_add()・_add_countdown()・_reset_all())につなぎます。 -
6中心になるメソッドを実装する
アプリの本体である
_add()(16行)・_add_countdown()(6行)・_reset_all()(3行) を実装します。 -
7動作確認する
python app090.pyで起動し、各ボタンが反応することを確認します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。
💡 ダークモードを追加する
bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。
💡 クラス定数を書き換えて動きを変える
データはクラス定数(MAX_TIMERS)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。
💡 設定ダイアログ
フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。
8. よくある問題と解決法
❌ 文字のフォントが崩れる・見た目が違う
原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。
解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。
❌ ウィンドウの大きさが画面に合わない
原因:geometry("900x440") の固定サイズで起動するためです。
解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。
9. 練習問題
アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。タイマーの中身は、同じフォルダの timers.json に自動で保存されます(15行・295-302行)。完了の通知音は winsound.Beep(880, 800) です(143-149行)。winsound は Windows だけの標準モジュールなので、他の OS では frame.bell() に切り替わります(9-13行)。夜間やイヤホンでの練習は、先に音量を下げてから試してください。課題1・課題2に追加のライブラリは要りません。
-
課題1:カウントダウンが終わると、数字の色が黄色のまま戻らない
時間になると、表示が黄色の「時間です!」に変わります。ところが色を白へ戻す処理はどこにもありません。「⏹」で数字を戻しても、色は黄色のままです。
期待結果:「カウントダウン」の分を
0・秒を5にして「+ カウントダウン追加」を押し、追加されたタイマーの「▶」を押す。5秒後に表示が時間です!に変わって黄色くなり、通知音が鳴る(ボタンは「⏸」のまま)。そのボタンをもう一度押すと、数えずにすぐ「時間です!」へ戻る。「⏹」を押すと表示は00:05.0に戻るが、数字の色は黄色のまま。直したあとは、「⏹」で白に戻り、終わったタイマーの「▶」で5秒から数え直せる。合格条件
- カウントダウンが終わったときの黄色い
時間です!は今までどおり出る - 「⏹」を押すと、数字の色が最初の白に戻る
- 終わったタイマーの「▶」を押すと、鳴り直さずに最初から数え直す
ヒント①(どこを触るか): 触るのは3か所です。
TimerWidget._reset()のself._update_display()(125行)の手前、_tick()の完了の分岐(135-140行)、_start()(106-110行)の先頭。
ヒント②(使うもの): 色はself.time_lbl.config(fg="white")で戻せます(58-60行で最初に指定している色と同じ)。ボタンの文字はself.start_btn.config(text="▶")(117行と同じ書き方)。数え直しは、self._target > 0かつself._elapsed >= self._targetのときにself._elapsedを0.0へ戻す形にします。
つまずきやすい点: 完了の分岐(135-140行)は_runningをFalseにするだけで、ボタンの文字は「⏸」のまま残ります。_update_display()(151-168行)が書き換えるのは文字列だけで、色はそのままです。_alarm()は別スレッドでwinsound.Beepを鳴らすので、音が出るかどうかは環境によって変わります(145-149行)。 - カウントダウンが終わったときの黄色い
-
課題2:閉じたときの時間を保存しているのに、次の起動では0に戻る
ウィンドウを閉じると、そのときの経過時間が
timers.jsonに書き込まれます。ところが読み込む側は、その値を0.0で上書きしてから並べ直す作りです。保存した意味が無くなっています。期待結果:ストップウォッチの「▶」を押して10秒ほど動かし、ウィンドウを閉じる。同じフォルダの
timers.jsonをメモ帳で開くと、"elapsed"に 10 前後の数字が入っている。もう一度起動すると、表示は00:00.0に戻っている。直したあとは、閉じたときの続き(00:10.4のような値)から表示される。合格条件
- ストップウォッチは、閉じたときの経過時間から続きが表示される
- カウントダウンは、閉じたときの残り時間から始まる
timers.jsonが無いときは、今までどおりストップウォッチとカウントダウン(5分)が1つずつ出る
ヒント①(どこを触るか): 触るのは1か所です。
_load()の315行、d["elapsed"] = 0.0の行。
ヒント②(使うもの): この行を消すだけで、TimerWidget.__init__()がdata.get("elapsed", 0.0)(30行)から続きを読み、_build()の最後の_update_display()(89行)が画面に出します。残り時間の計算は_update_display()のmax(0, self._target - elapsed)が守っています(155行)。
つまずきやすい点: 保存が走るのは、閉じたとき・一時停止・リセット・名前欄からフォーカスが外れたときです(52行・118行・126行・320-322行)。動かしっぱなしの途中では書き込まれないので、強制終了すると最後に保存した値まで戻ります。復元したカウントダウンが残り0の状態だと、「▶」を押した瞬間にまた鳴ります(課題1の3つ目を直しておくと、この場合も最初から数え直せます)。 -
課題3:新しいボタンを追加する
既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と
command=の接続がセットで身に付きます。
写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。
このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。