初心者向け No.41

タイピング速度テスト

例文をタイプして速度(WPM)と正確率を計測するアプリ。時間計測と文字比較の実装を学びます。

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

1. アプリ概要

例文をタイプして速度(WPM)と正確率を計測するアプリ。時間計測と文字比較の実装を学びます。

このアプリはゲームカテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ) で、難易度は ★★☆ です。

このアプリは「ゲーム」カテゴリです。ゲーム実装は GUI イベント処理・状態管理・描画ループを同時に学べる教材で、画面・入力・ロジックの三層分離の感覚が掴めます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。

コードを読む前に実行することをおすすめします。動いている挙動を先に把握しておくと、解説で出てくる関数や処理がどこに対応するかが頭に入りやすくなります。

応用のヒントは、機能を 1 つ増やす・見た目を整える・例外を一つでも丁寧に扱う、のいずれかから始めるのがおすすめです。

タイピング速度テスト 実行画面(Windows)
実行画面(Windows)
タイピング速度テスト 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 「新しいテスト」ボタン(new_test())で操作
  • キー <KeyRelease> から on_type() を実行
  • ウィンドウはタイトル「タイピング速度テスト」・サイズ 560x440 で起動

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

💡
コードのコピー方法

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

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


class App41:
    """タイピング速度テスト"""

    TEXTS = [
        "Python is a versatile programming language that is easy to learn.",
        "The quick brown fox jumps over the lazy dog.",
        "Programming is the art of telling another human what one wants the computer to do.",
        "In Python, indentation is used to define code blocks.",
        "Functions allow you to organize code into reusable pieces.",
    ]

    def __init__(self, root):
        self.root = root
        self.root.title("タイピング速度テスト")
        self.root.geometry("560x440")
        self.root.minsize(560, 440)
        self.root.configure(bg="#f8f9fc")
        self.start_time = None
        self.current_text = ""
        self._build_ui()
        self.new_test()

    def _build_ui(self):
        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_frame = tk.Frame(self.root, bg="#f8f9fc", padx=20, pady=14)
        main_frame.pack(fill=tk.BOTH, expand=True)

        # お題テキスト
        text_frame = ttk.LabelFrame(main_frame, text="入力するテキスト", padding=10)
        text_frame.pack(fill=tk.X, pady=(0, 10))
        self.target_label = tk.Label(text_frame, text="", bg=self.root.cget("bg"),
                                     font=("Courier New", 12), wraplength=500,
                                     justify="left", anchor="w")
        self.target_label.pack(fill=tk.X)

        # 入力エリア
        input_frame = ttk.LabelFrame(main_frame, text="ここに入力してください", padding=10)
        input_frame.pack(fill=tk.X, pady=(0, 10))
        self.input_text = tk.Text(input_frame, font=("Courier New", 12), height=3,
                                  bg="white", fg="#333", wrap=tk.WORD,
                                  insertbackground="#3776ab")
        self.input_text.pack(fill=tk.X)
        self.input_text.bind("<KeyRelease>", self.on_type)

        # 統計
        stats_frame = tk.Frame(main_frame, bg="#f8f9fc")
        stats_frame.pack(fill=tk.X, pady=(0, 8))

        self.stat_labels = {}
        for col, (label, key) in enumerate([
            ("WPM", "wpm"), ("正確率", "accuracy"), ("経過時間", "elapsed"), ("進捗", "progress")
        ]):
            box = tk.Frame(stats_frame, bg="white", relief=tk.SOLID, bd=1, padx=12, pady=8)
            box.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=4)
            tk.Label(box, text=label, bg="white", fg="#888",
                     font=("Noto Sans JP", 9)).pack()
            lbl = tk.Label(box, text="--", bg="white", fg="#3776ab",
                           font=("Noto Sans JP", 16, "bold"))
            lbl.pack()
            self.stat_labels[key] = lbl

        # ボタン
        btn_frame = tk.Frame(main_frame, bg="#f8f9fc")
        btn_frame.pack()
        ttk.Button(btn_frame, text="新しいテスト", command=self.new_test).pack(side=tk.LEFT, padx=6)

        self.result_label = tk.Label(main_frame, text="", bg="#f8f9fc",
                                     font=("Noto Sans JP", 12, "bold"))
        self.result_label.pack(pady=(6, 0))

    def new_test(self):
        import random
        self.current_text = random.choice(self.TEXTS)
        self.target_label.config(text=self.current_text)
        self.input_text.delete("1.0", tk.END)
        self.input_text.config(state=tk.NORMAL, bg="white")
        self.input_text.focus()
        self.start_time = None
        self.result_label.config(text="")
        for lbl in self.stat_labels.values():
            lbl.config(text="--", fg="#3776ab")

    def on_type(self, event=None):
        typed = self.input_text.get("1.0", tk.END).rstrip("\n")
        if not typed:
            self.start_time = None
            return

        if self.start_time is None:
            self.start_time = time.time()

        elapsed = time.time() - self.start_time
        target = self.current_text

        # 正確率
        correct = sum(1 for a, b in zip(typed, target) if a == b)
        accuracy = correct / max(len(typed), 1) * 100

        # WPM
        words = len(typed.split())
        wpm = words / (elapsed / 60) if elapsed > 0 else 0

        # 進捗
        progress = len(typed) / len(target) * 100

        self.stat_labels["wpm"].config(text=f"{wpm:.0f}")
        self.stat_labels["accuracy"].config(text=f"{accuracy:.1f}%")
        self.stat_labels["elapsed"].config(text=f"{elapsed:.1f}秒")
        self.stat_labels["progress"].config(text=f"{min(progress, 100):.0f}%")

        # 完了判定
        if typed == target:
            self.input_text.config(bg="#d4edda")
            self.result_label.config(
                text=f"🎉 完了! WPM: {wpm:.0f}  正確率: {accuracy:.1f}%  時間: {elapsed:.1f}秒",
                fg="#27ae60")
        elif not target.startswith(typed):
            self.input_text.config(bg="#f8d7da")
        else:
            self.input_text.config(bg="white")


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

5. コード解説

タイピング速度テストのコードを、実際に書かれている実装に沿って解説します。

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

App41 クラスにアプリの全機能をまとめています(メソッド4個・全136行)。__init__ ではタイトル「タイピング速度テスト」とウィンドウサイズ 560x440 を設定します。あわせて状態を保持する変数(self.start_timeself.current_text)を初期化し、そのあと _build_ui()new_test() の順に呼び出して初期状態を作ります。

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

ウィジェット構成

画面は tk.Frame×4(ほかにループでも生成)・tk.Label×3(ほかにループでも生成)・ttk.LabelFrame×2・tk.Textttk.Button で構成しています。見出し付きの枠(LabelFrame)は「入力するテキスト」「ここに入力してください」のラベルでエリアを区切っています。

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

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

「新しいテスト」ボタン(new_test())を command= で接続しています。また bind("<KeyRelease>", ...) から on_type() を呼べるようにイベントも登録しています。

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

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

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

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

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

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

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

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

    title("タイピング速度テスト")geometry("560x440")configure(bg="#f8f9fc")minsize(560, 440) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

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

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

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(new_test())につなぎます。bind("<KeyRelease>", ...) の登録も忘れずに。

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

    アプリの本体である on_type()(38行)・new_test()(11行) を実装します。

  7. 7
    動作確認する

    python app41.py で起動し、各ボタンが反応すること・キー操作(<KeyRelease>)が効くことを確認します。

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

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。

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

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

💡 クラス定数を書き換えて動きを変える

データはクラス定数(TEXTS)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。

💡 入力履歴機能

以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。

💡 打ち終わった文字を色分けする

正しく打てた部分と間違えた部分を色で分けると、どこまで打てたか一目でわかります。Text ウィジェットの tag_add / tag_config を使い、on_type() の中で1文字ずつ照合しながらタグを付け替える方針です。

💡 ミスした回数を数えて結果に出す

現在はWPM・正確率・経過時間・進捗の4指標を表示していますが、間違えて打ち直した回数(ミス数)は数えていません。ミス数もカウントして結果に添えると達成感が増します。on_type() に1文字ごとの誤字をカウントする変数を1つ追加するだけで実装できます。

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

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

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

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

<KeyRelease> キーを押しても反応しない

原因:bind("<KeyRelease>", ...) はウィジェット単位で登録されるため、ウィンドウ(またはバインド先)にキーボードフォーカスが無いとイベントが届きません。

解決法:一度ウィンドウ内をクリックしてフォーカスを与えてから、キーを押してください。

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

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

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

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください。

  1. 課題1:WPM を「5文字=1語」の一般的な計算に変える

    いまの WPM は空白で区切った語数で計算しています。タイピング測定では 5 文字を 1 語と数えるのが一般的なので、その方式に変えます。

    期待結果TEXTS の3番目の例文(82文字)を経過時間 60.0秒 で打ち終えると、WPM の表示が 15 から 16 に変わる。

    合格条件

    • 入力の途中でも同じ式で WPM が更新される
    • 空白や記号も 1 文字として数える
    • 経過時間が 0 のときに ZeroDivisionError が出ない

    ヒント①(どこを触るか): 変えるのは on_type()words = len(typed.split()) の1行だけです。その下の wpm = words / (elapsed / 60) if elapsed > 0 else 0 はそのまま使えます。
    ヒント②(使うもの): 5 文字=1 語なので len(typed) / 5 です。小数のまま渡すと、短い例文でも WPM が 0 になりません。
    つまずきやすい点: elapsed > 0 の条件を消すと、1文字目を打った瞬間に ZeroDivisionError が出ることがあります。この条件は残してください。

  2. 課題2:完了したら入力欄と記録を確定させる

    いまは「🎉 完了!」が出たあとも入力を続けられ、1文字足すと入力欄の背景が赤に変わって WPM や正確率の数字も動いてしまいます。完了した時点で入力欄を編集不可にし、その後のキー入力では数字を更新しないようにします。

    期待結果:例文を最後まで正しく入力すると背景が緑になり、そのあとキーを押しても文字が増えない。「🎉 完了! WPM: …」の表示と各数字もそのまま残る。

    合格条件

    • 完了後にキーを押しても WPM・正確率・経過時間の数字が変わらない
    • 「新しいテスト」を押すと入力欄が空になり、また打てる状態に戻る
    • 完了前は今までどおり打ち直しや削除ができる

    ヒント①(どこを触るか): 触るのは4か所です。完了済みかを覚えるフラグを __init__() で作り、new_test() で戻します。on_type() は先頭でフラグを見て return します。フラグを立てるのは完了分岐(self.input_text.config(bg="#d4edda") の行)です。
    ヒント②(使うもの): 編集不可は state=tk.DISABLED、戻すのは state=tk.NORMAL です。戻す側は new_test()config(state=tk.NORMAL, ...) が既に担当しています。
    つまずきやすい点: state=tk.DISABLED にしても <KeyRelease> のバインドは外れず、on_type() は呼ばれ続けます。また new_test()delete() を先に呼ぶ順番なので、無効のままだと前回の文字が消えません。state を戻してから消すよう順番を入れ替えてください。

  3. 課題3:練習の記録をJSONに保存して、次に開いたときに前回の最高WPMを表示する

    タイピングが完了するたびに、そのときのWPMを記録用のJSONファイル(例: typing_history.json)に保存します。アプリを起動し直したときは保存済みの記録を読み込み、「これまでの最高WPM: ◯◯」のように画面へ表示してください。

    期待結果:WPM 45で完了した状態で一度アプリを終了し再度起動すると、起動直後に「これまでの最高WPM: 45」と表示される。続けてWPM 52で完了すると、最高記録が52に更新される。

    合格条件

    • アプリを再起動しても前回までの最高WPMが引き継がれる(メモリ上だけの変数ではない)
    • 記録ファイルがまだ存在しない初回起動時にエラーで落ちない
    • ファイルの中身が壊れていた場合もエラーで落ちず、「記録なし」の状態から再開できる

    ヒント①(どこを触るか): 保存は on_type() の完了判定(typed == target の分岐)に、読込は __init__()_build_ui() を呼んだ直後に追加します。
    ヒント②(使うもの): 保存は json.dump()、読込は json.load() を使い、try/exceptjson.JSONDecodeErrorOSError)で囲みます。ファイルが無い・壊れている場合は「記録なし」として続行してください。
    つまずきやすい点: JSONファイルは python app41.py を実行したカレントディレクトリに作られます。どこから起動したかで保存場所が変わる点に注意してください。

🚀
次に挑戦するアプリ

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

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

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

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

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