初心者向け No.41

タイピング速度テスト

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

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

1. アプリ概要

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

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

Pythonでは tkinter を使うことで、クロスプラットフォームなGUIアプリを簡単に作成できます。このアプリを通じて、ウィジェットの配置・イベント処理・データ管理など、GUI開発の実践的なスキルを習得できます。

ソースコードは完全な動作状態で提供しており、コピーしてそのまま実行できます。まずは実行して動作を確認し、その後コードを読んで仕組みを理解していきましょう。カスタマイズセクションでは機能拡張のアイデアも紹介しています。

GUIアプリ開発は、プログラミングの楽しさを実感できる最も効果的な学習方法のひとつです。アプリを作ることで、変数・関数・クラス・イベント処理など、プログラミングの重要な概念が自然と身についていきます。このアプリをきっかけに、オリジナルアプリの開発にも挑戦してみてください。

2. 機能一覧

  • タイピング速度テストのメイン機能
  • 直感的なGUIインターフェース
  • 入力値のバリデーション
  • エラーハンドリング
  • 結果の見やすい表示
  • キーボードショートカット対応

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows・Mac・Linux すべて対応

以下の環境で動作確認しています。

  • Python 3.10 以上
  • OS: Windows 10/11・macOS 12+・Ubuntu 20.04+

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.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=text_frame.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クラスにアプリの全機能をまとめています。__init__メソッドでウィンドウの基本設定を行い、_build_ui()でUI構築、process()でメイン処理を担当します。この分離により、各メソッドの責任が明確になりコードが読みやすくなります。

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.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=text_frame.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()

LabelFrameによるセクション分け

ttk.LabelFrame を使うことで、入力エリアと結果エリアを視覚的に分けられます。padding引数でフレーム内の余白を設定し、見やすいレイアウトを実現しています。

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.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=text_frame.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()

Entryウィジェットとイベントバインド

ttk.Entryで入力フィールドを作成します。bind('', ...)でEnterキー押下時に処理を実行できます。これにより、マウスを使わずキーボードだけで操作できるUXが実現できます。

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.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=text_frame.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()

Textウィジェットでの結果表示

結果表示にはtk.Textウィジェットを使います。state=tk.DISABLEDでユーザーが直接編集できないようにし、表示前にNORMALに切り替えてからinsert()で内容を更新します。

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.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=text_frame.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()

例外処理とmessagebox

try-except で ValueError と Exception を捕捉し、messagebox.showerror() でユーザーにわかりやすいエラーメッセージを表示します。入力バリデーションは必ず実装しましょう。

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.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=text_frame.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()

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

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

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

    新しいファイルを作成して app41.py と保存します。

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

    App41クラスを定義し、__init__とmainloop()の最小構成を作ります。

  3. 3
    タイトルバーを作る

    Frameを使ってカラーバー付きのタイトルエリアを作ります。

  4. 4
    入力フォームを実装する

    LabelFrameとEntryウィジェットで入力エリアを作ります。

  5. 5
    処理ロジックを実装する

    _calculate()メソッドに計算・処理ロジックを実装します。

  6. 6
    結果表示を実装する

    TextウィジェットかLabelに結果を表示する_show_result()を実装します。

  7. 7
    エラー処理を追加する

    try-exceptとmessageboxでエラーハンドリングを追加します。

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

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

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

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

💡 データのエクスポート機能

計算結果をCSV・TXTファイルに保存するエクスポート機能を追加しましょう。filedialog.asksaveasfilename()でファイル保存ダイアログが使えます。

💡 入力履歴機能

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

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

❌ 日本語フォントが表示されない

原因:システムに日本語フォントが見つからない場合があります。

解決法:font引数を省略するかシステムに合ったフォントを指定してください。

❌ ウィンドウのサイズが変更できない

原因:resizable(False, False)が設定されています。

解決法:resizable(True, True)に変更してください。

9. 練習問題

アプリの理解を深めるための練習問題です。難易度順に挑戦してみてください。

  1. 課題1:機能拡張

    タイピング速度テストに新しい機能を1つ追加してみましょう。どんな機能があると便利か考えてから実装してください。

  2. 課題2:UIの改善

    色・フォント・レイアウトを変更して、より使いやすいUIにカスタマイズしてみましょう。

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

    入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。

🚀
次に挑戦するアプリ

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