初心者向け No.063

カウントダウンタイマー

指定日時まで残り日数・時間・分・秒をリアルタイム表示するカウントダウンアプリ。複数イベント対応。

🎯 難易度: ★☆☆ 📦 ライブラリ: tkinter(標準ライブラリ) ⏱️ 制作時間: 30〜90分

1. アプリ概要

指定日時まで残り日数・時間・分・秒をリアルタイム表示するカウントダウンアプリ。複数イベント対応。

このアプリはtoolsカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter(標準ライブラリ)、難易度は ★☆☆ です。

このアプリは「ツール」カテゴリです。日々の作業を自動化する実用ツールは Python が最も得意とする領域です。短いコードで実用性のある成果物が作れる点が魅力です。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。

コピー&実行で動作確認 → コード解説で構造理解 → カスタマイズで応用、の 3 ステップで進めると、短時間で本質を押さえられます。

アプリを完成させた後は、自分の使い方に合わせて改造するのが学びを定着させる近道です。カスタマイズ章のアイデアを足がかりに、独自機能を一つ追加してみてください。

カウントダウンタイマー 実行画面(Windows)
実行画面(Windows)
カウントダウンタイマー 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 「追加」ボタン(add_event())・「選択を削除」ボタン(delete_selected())・「全消去」ボタン(clear_all())で操作
  • after(1000, ...) による1000ミリ秒間隔の _tick() 呼び出し(画面の自動更新)
  • ttk.Treeview による表形式の一覧表示
  • messagebox.askyesno()messagebox.showerror() によるダイアログ通知
  • ウィンドウはタイトル「カウントダウンタイマー」・サイズ 687x509 で起動

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. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

追加インストール不要(標準ライブラリのみ使用)
app063.py
import datetime as _dt
import tkinter as tk
from tkinter import ttk, messagebox


class App063:
    """カウントダウンタイマー"""

    def __init__(self, root):
        self.root = root
        self.root.title("カウントダウンタイマー")
        self.root.geometry("687x509")
        self.root.minsize(687, 509)
        self.root.configure(bg="#f8f9fc")
        # events: list of dict(name, target: datetime)
        self.events = []
        self._tick_after = None
        self._build_ui()
        self._tick()

    def _build_ui(self):
        """UIを構築する"""
        title_frame = tk.Frame(self.root, bg="#3776ab", pady=12)
        title_frame.pack(fill=tk.X)
        tk.Label(
            title_frame, text="カウントダウンタイマー",
            font=("Noto Sans JP", 16, "bold"),
            bg="#3776ab", fg="white"
        ).pack()

        main = tk.Frame(self.root, bg="#f8f9fc", padx=18, pady=12)
        main.pack(fill=tk.BOTH, expand=True)

        # 入力
        form = ttk.LabelFrame(main, text="イベント追加", padding=10)
        form.pack(fill=tk.X)
        ttk.Label(form, text="名前:").grid(row=0, column=0, sticky="w")
        self.name_var = tk.StringVar()
        ttk.Entry(form, textvariable=self.name_var, width=18
                  ).grid(row=0, column=1, padx=4)

        ttk.Label(form, text="日時(YYYY-MM-DD HH:MM):").grid(row=0, column=2, sticky="w")
        self.dt_var = tk.StringVar(
            value=(_dt.datetime.now() + _dt.timedelta(days=1)).strftime("%Y-%m-%d %H:%M")
        )
        ttk.Entry(form, textvariable=self.dt_var, width=18
                  ).grid(row=0, column=3, padx=4)
        ttk.Button(form, text="追加", command=self.add_event
                   ).grid(row=0, column=4, padx=4)

        btns = tk.Frame(main, bg="#f8f9fc")
        btns.pack(fill=tk.X, pady=4)
        ttk.Button(btns, text="選択を削除", command=self.delete_selected).pack(side=tk.LEFT, padx=2)
        ttk.Button(btns, text="全消去", command=self.clear_all).pack(side=tk.LEFT, padx=2)

        # リスト
        list_frame = ttk.LabelFrame(main, text="残り時間(昇順表示)", padding=4)
        list_frame.pack(fill=tk.BOTH, expand=True, pady=4)
        cols = ("name", "target", "remain")
        self.tree = ttk.Treeview(list_frame, columns=cols, show="headings", height=12)
        for c, w, t in [("name", 160, "イベント名"),
                         ("target", 180, "目標日時"),
                         ("remain", 280, "残り時間")]:
            self.tree.heading(c, text=t)
            self.tree.column(c, width=w, anchor="center")
        self.tree.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
        sb = ttk.Scrollbar(list_frame, command=self.tree.yview)
        self.tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)

        # 現在時刻
        self.now_var = tk.StringVar()
        tk.Label(main, textvariable=self.now_var, bg="#f8f9fc",
                 font=("Noto Sans JP", 10)).pack(anchor="w", pady=(2, 0))

    def add_event(self):
        """イベントを追加する"""
        name = self.name_var.get().strip()
        dt_s = self.dt_var.get().strip()
        if not name:
            messagebox.showerror("エラー", "イベント名を入力してください")
            return
        target = None
        for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
            try:
                target = _dt.datetime.strptime(dt_s, fmt)
                break
            except ValueError:
                continue
        if target is None:
            messagebox.showerror("エラー", "日時は YYYY-MM-DD HH:MM 形式で入力してください")
            return
        self.events.append({"name": name, "target": target})
        self.name_var.set("")
        self._render()

    def delete_selected(self):
        """選択イベントを削除"""
        sel = self.tree.selection()
        if not sel:
            return
        for item in sel:
            idx = int(self.tree.item(item, "tags")[0])
            self.events[idx] = None
        self.events = [e for e in self.events if e is not None]
        self._render()

    def clear_all(self):
        """全イベントを削除"""
        if self.events and messagebox.askyesno("確認", "全て削除しますか?"):
            self.events.clear()
            self._render()

    @staticmethod
    def _format_remaining(td: _dt.timedelta) -> str:
        """timedelta を 日 時間 分 秒 表記に"""
        secs = int(td.total_seconds())
        sign = ""
        if secs < 0:
            sign = "経過 "
            secs = -secs
        d, rem = divmod(secs, 86400)
        h, rem = divmod(rem, 3600)
        m, s = divmod(rem, 60)
        return f"{sign}{d}日 {h:02d}時間 {m:02d}分 {s:02d}秒"

    def _render(self):
        """ツリービュー再描画"""
        now = _dt.datetime.now()
        rows = sorted(enumerate(self.events),
                      key=lambda x: x[1]["target"])
        self.tree.delete(*self.tree.get_children())
        for idx, ev in rows:
            remain = ev["target"] - now
            self.tree.insert("", tk.END, values=(
                ev["name"],
                ev["target"].strftime("%Y-%m-%d %H:%M"),
                self._format_remaining(remain),
            ), tags=(str(idx),))
        self.now_var.set("現在時刻: " + now.strftime("%Y-%m-%d %H:%M:%S"))

    def _tick(self):
        """1秒ごとに再描画"""
        try:
            self._render()
        except tk.TclError:
            return
        self._tick_after = self.root.after(1000, self._tick)


if __name__ == "__main__":
    root = tk.Tk()
    app = App063(root)
    root.mainloop()

5. コード解説

カウントダウンタイマーのコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App063 クラスにアプリの全機能をまとめています(メソッド8個・全154行)。__init__ ではタイトル「カウントダウンタイマー」とウィンドウサイズ 687x509 を設定します。あわせて状態を保持する変数(self._tick_after)を初期化し、そのあと _build_ui()_tick() の順に呼び出して初期状態を作ります。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×3・tk.Label×2・ttk.LabelFrame×2・ttk.Label×2・ttk.Entry×2・ttk.Button×3・ttk.Treeviewttk.Scrollbar で構成しています。見出し付きの枠(LabelFrame)は「イベント追加」「残り時間(昇順表示)」のラベルでエリアを区切っています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「追加」ボタン(add_event())・「選択を削除」ボタン(delete_selected())・「全消去」ボタン(clear_all())を command= で接続しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

after() による定期処理

_tick() の末尾で after(1000, ...) を呼び、1000ミリ秒後に自分自身の再実行を予約しています。tkinter では while ループで待つと画面が固まるため、この「予約の連鎖」で定期処理を作るのが定石です。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で ValueErrortk.TclError を捕捉しています。あわせて messagebox.askyesno()messagebox.showerror() のダイアログで、ユーザーへの通知・確認を行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app063.py と保存します。使うのは datetimetkinter だけなので、追加インストールは不要です。

  2. 2
    クラスの骨格を作る

    App063 クラスを定義し、__init__ と、末尾の root = tk.Tk()mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("カウントダウンタイマー")geometry("687x509")configure(bg="#f8f9fc")minsize(687, 509) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×3・tk.Label×2・ttk.LabelFrame×2・ttk.Label×2・ttk.Entry×2・ttk.Button×3・ttk.Treeviewttk.Scrollbar を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(add_event()delete_selected()clear_all())につなぎます。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _tick()(7行)・add_event()(20行)・delete_selected()(10行)・clear_all()(5行) を実装します。定期処理は after(1000, ...) で自分自身を予約し続ける形にします。

  7. 7
    動作確認する

    python app063.py で起動し、各ボタンが反応すること・表示が自動で更新されることを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 更新間隔を変えてみる

after(1000, ...) の 1000 が更新間隔(ミリ秒)です。大きくすると更新頻度が下がって CPU 負荷が軽くなり、小さくすると更新が細かくなります(表示の刻み幅より細かくしても見た目は変わりません)。数値を変えて違いを確かめてみましょう。

💡 設定ダイアログ

フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

❌ 改造したら表示の更新が二重になった・速くなった

原因:_tick()after(1000, ...) で自分自身を予約し続ける構造です。予約を止めずにもう一度開始経路を通ると、予約の連鎖が2本になり、更新が倍速になります。

解決法:開始処理の前に動作中かどうかをフラグで判定してください。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("687x509") の固定サイズで起動するためです。

解決法:geometry()minsize() の両方の数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。ただし minsize(687, 509) を指定しているため、起動時の大きさより小さくは縮められません(縮めると画面部品が表示されなくなるのを防ぐためです)。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。一覧は1秒ごとに書き直されるので、どちらの課題も追加してすぐ画面で確かめられます。

  1. 課題1:過ぎたイベントの行に色を付ける

    目標日時を過ぎた行は「経過 …」と出ますが、まだ来ていない行と同じ色です。過ぎた行だけ色を変えて区別しましょう。

    期待結果:1分後の日時で追加してそのまま待つと、過ぎた時点から残り時間が「経過 0日 00時間 00分 05秒」のように増え、その行の文字が赤くなる

    合格条件

    • 過ぎた行だけ色が変わり、まだ来ていない行は変わらない
    • 「選択を削除」が今までどおり効く
    • 並び順(目標日時の昇順)は変わらない

    ヒント①(どこを触るか): 触るのは2か所です。_build_ui() で Treeview の列を設定した後(60〜65行の下)と、_render()tags=(str(idx),)(139行)です。
    ヒント②(使うもの): 色の名前と見た目は self.tree.tag_configure("past", foreground="#e74c3c") で決めます。過ぎたかどうかは134行の remain を使い、remain.total_seconds() < 0 で判定できます。
    つまずきやすい点: タグは並び順が大事です。tags=("past", str(idx)) と前に足すと、delete_selected()self.tree.item(item, "tags")[0]int() に渡すため ValueError: invalid literal for int() with base 10: 'past' になり、削除が動かなくなります。番号は先頭のままにします。

  2. 課題2:過去の日時を追加するときに確認する

    いまは去年の日付でも黙って追加され、いきなり「経過 …」の行が増えます。打ち間違いに気づけるよう、確認を出しましょう。

    期待結果:日時に昨日の日付を入れて「追加」を押すと「過去の日時です。追加しますか?」の確認が出る。「いいえ」なら一覧は増えず、「はい」なら今までどおり追加される

    合格条件

    • 未来の日時のときは確認が出ない
    • 「いいえ」のときは一覧が増えず、入力した名前も消えない
    • 日時の形式が違うときは、今までどおり91行のエラーが先に出る

    ヒント①(どこを触るか): 触るのは1か所です。add_event()self.events.append({"name": name, "target": target})(93行)の手前です。
    ヒント②(使うもの): messagebox は3行目で読み込み済みで、askyesno は「はい」で True を返します。110行の clear_all() が手本です。比べる相手は _dt.datetime.now() です。
    つまずきやすい点: 比べる相手を _dt.date.today() にすると TypeError: can't compare datetime.datetime to datetime.date が出ます。target は86行の strptime() が返す datetime だからです。日付だけを入れたときは84行の3つ目の書式に合って 0時0分として読まれるので、今日の日付でも過去扱いになります。

  3. 課題3:保存機能の追加

    処理結果をファイルに保存する機能を追加しましょう。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリに挑戦しましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
独学を1冊で体系化するなら

写経しながら「なぜこう書くのか」が気になり始めたら、入門書を1冊通して読むと断片的な知識がつながります。Python入門書のおすすめ2冊(当サイトの参考書ランキング総合1〜2位)で、独学者向けの最初の1冊を比較しています。