中級者向け No.29

単位変換(履歴付き)

長さ・重さ・温度・速度など多彩な単位変換ツール。変換履歴の保存と検索機能の実装を学びます。

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

1. アプリ概要

長さ・重さ・温度・速度など多彩な単位変換ツール。変換履歴の保存と検索機能の実装を学びます。

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

このアプリは「ユーティリティ」カテゴリです。小回りの効くユーティリティは Python の真骨頂で、汎用的なユーザー操作を組み合わせる発想が他のアプリにもそのまま流用できます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。

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

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

単位変換(履歴付き) 実行画面(Windows)
実行画面(Windows)
単位変換(履歴付き) 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 「⇅ 入れ替え」ボタン(_swap())・「📋 一括変換(全単位)」ボタン(_bulk_convert())・「クリア」ボタン(_clear_history())で操作
  • イベント <<ComboboxSelected>> から _on_category_change() を実行
  • キー <Return> から _convert() を実行
  • キー <KeyRelease> から _convert() を実行
  • イベント <<ComboboxSelected>> から _convert() を実行
  • イベント <<ComboboxSelected>> から _convert() を実行
  • マウス操作 <Double-1> から _restore_history() を実行
  • ttk.Treeview による表形式の一覧表示
  • JSON ファイルの保存・読み込み(起動時に自動実行)
  • messagebox.askyesno() によるダイアログ通知
  • ウィンドウはタイトル「単位変換(履歴付き)」・サイズ 860x621 で起動

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

💡
コードのコピー方法

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

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


class App29:
    """単位変換(履歴付き)"""

    CATEGORIES = {
        "長さ": {
            "メートル (m)":      1.0,
            "キロメートル (km)": 1000.0,
            "センチメートル (cm)": 0.01,
            "ミリメートル (mm)": 0.001,
            "マイル (mi)":       1609.344,
            "ヤード (yd)":       0.9144,
            "フィート (ft)":     0.3048,
            "インチ (in)":       0.0254,
            "海里 (nmi)":        1852.0,
        },
        "重さ": {
            "キログラム (kg)":   1.0,
            "グラム (g)":        0.001,
            "ミリグラム (mg)":   1e-6,
            "トン (t)":          1000.0,
            "ポンド (lb)":       0.45359237,
            "オンス (oz)":       0.028349523,
            "貫 (kan)":          3.75,
            "匁 (monme)":        0.00375,
        },
        "温度": {
            "摂氏 (°C)": "celsius",
            "華氏 (°F)": "fahrenheit",
            "ケルビン (K)": "kelvin",
        },
        "速度": {
            "m/s":    1.0,
            "km/h":   1/3.6,
            "mph":    0.44704,
            "ノット": 0.514444,
            "マッハ": 340.29,
        },
        "面積": {
            "平方メートル (m²)":     1.0,
            "平方キロメートル (km²)": 1e6,
            "平方センチメートル (cm²)": 1e-4,
            "ヘクタール (ha)":       1e4,
            "アール (a)":           100.0,
            "平方フィート (ft²)":    0.09290304,
            "平方マイル (mi²)":      2589988.11,
            "坪":                   3.305785,
        },
        "体積": {
            "リットル (L)":      1.0,
            "ミリリットル (mL)": 0.001,
            "立方メートル (m³)": 1000.0,
            "ガロン (US gal)":   3.785411784,
            "液量オンス (fl oz)": 0.0295735296,
            "カップ (cup)":      0.2365882365,
            "大さじ (tbsp)":     0.01478676478,
            "小さじ (tsp)":      0.00492892159,
        },
        "データ": {
            "バイト (B)":     1.0,
            "キロバイト (KB)": 1024.0,
            "メガバイト (MB)": 1024**2,
            "ギガバイト (GB)": 1024**3,
            "テラバイト (TB)": 1024**4,
            "ビット (bit)":   0.125,
            "キロビット (Kbit)": 125.0,
            "メガビット (Mbit)": 125000.0,
        },
        "圧力": {
            "パスカル (Pa)":   1.0,
            "キロパスカル (kPa)": 1000.0,
            "気圧 (atm)":     101325.0,
            "バール (bar)":   100000.0,
            "mmHg":           133.322,
            "psi":            6894.757,
        },
        "エネルギー": {
            "ジュール (J)":        1.0,
            "キロジュール (kJ)":   1000.0,
            "カロリー (cal)":      4.184,
            "キロカロリー (kcal)": 4184.0,
            "ワット時 (Wh)":       3600.0,
            "キロワット時 (kWh)":  3600000.0,
            "電子ボルト (eV)":     1.60218e-19,
        },
    }

    HISTORY_FILE = os.path.join(os.path.dirname(__file__), "unit_history.json")

    def __init__(self, root):
        self.root = root
        self.root.title("単位変換(履歴付き)")
        self.root.geometry("860x621")
        self.root.minsize(860, 621)
        self.root.configure(bg="#f8f9fc")
        self._history = self._load_history()
        self._build_ui()

    def _load_history(self):
        if os.path.exists(self.HISTORY_FILE):
            try:
                with open(self.HISTORY_FILE, encoding="utf-8") as f:
                    return json.load(f)
            except Exception:
                pass
        return []

    def _save_history(self):
        try:
            with open(self.HISTORY_FILE, "w", encoding="utf-8") as f:
                json.dump(self._history[-200:], f, ensure_ascii=False, indent=2)
        except Exception:
            pass

    def _build_ui(self):
        # ヘッダー
        header = tk.Frame(self.root, bg="#0288d1", pady=10)
        header.pack(fill=tk.X)
        tk.Label(header, text="🔄 単位変換ツール(履歴付き)",
                 font=("Noto Sans JP", 14, "bold"),
                 bg="#0288d1", fg="white").pack(side=tk.LEFT, padx=12)

        # メインエリア
        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # 左: 変換パネル
        left = tk.Frame(paned, bg="#f8f9fc")
        paned.add(left, weight=3)

        # カテゴリ選択
        cat_f = tk.Frame(left, bg="#f8f9fc")
        cat_f.pack(fill=tk.X, padx=8, pady=6)
        tk.Label(cat_f, text="カテゴリ:", bg="#f8f9fc",
                 font=("Arial", 10, "bold")).pack(side=tk.LEFT)
        self.cat_var = tk.StringVar(value=list(self.CATEGORIES.keys())[0])
        cat_cb = ttk.Combobox(cat_f, textvariable=self.cat_var,
                               values=list(self.CATEGORIES.keys()),
                               state="readonly", width=16)
        cat_cb.pack(side=tk.LEFT, padx=6)
        cat_cb.bind("<<ComboboxSelected>>", self._on_category_change)

        # 変換フォーム
        conv_f = ttk.LabelFrame(left, text="変換", padding=12)
        conv_f.pack(fill=tk.X, padx=8, pady=4)

        # 入力行
        row1 = tk.Frame(conv_f, bg=ttk.Style().lookup("TLabelframe", "background"))
        row1.pack(fill=tk.X, pady=4)
        tk.Label(row1, text="入力値:", bg=row1.cget("bg"), width=8,
                 anchor="e").pack(side=tk.LEFT)
        self.input_var = tk.StringVar(value="1")
        self.input_entry = ttk.Entry(row1, textvariable=self.input_var,
                                      font=("Arial", 14), width=16)
        self.input_entry.pack(side=tk.LEFT, padx=6)
        self.input_entry.bind("<Return>", lambda e: self._convert())
        self.input_entry.bind("<KeyRelease>", lambda e: self._convert())

        # 単位選択(from)
        row2 = tk.Frame(conv_f, bg=ttk.Style().lookup("TLabelframe", "background"))
        row2.pack(fill=tk.X, pady=4)
        tk.Label(row2, text="変換元:", bg=row2.cget("bg"), width=8,
                 anchor="e").pack(side=tk.LEFT)
        self.from_var = tk.StringVar()
        self.from_cb = ttk.Combobox(row2, textvariable=self.from_var,
                                     state="readonly", width=26)
        self.from_cb.pack(side=tk.LEFT, padx=6)
        self.from_cb.bind("<<ComboboxSelected>>", lambda e: self._convert())

        # 矢印
        tk.Label(conv_f, text="↓", bg=ttk.Style().lookup("TLabelframe", "background"),
                 font=("Arial", 18)).pack(pady=2)

        # 単位選択(to)
        row3 = tk.Frame(conv_f, bg=ttk.Style().lookup("TLabelframe", "background"))
        row3.pack(fill=tk.X, pady=4)
        tk.Label(row3, text="変換先:", bg=row3.cget("bg"), width=8,
                 anchor="e").pack(side=tk.LEFT)
        self.to_var = tk.StringVar()
        self.to_cb = ttk.Combobox(row3, textvariable=self.to_var,
                                   state="readonly", width=26)
        self.to_cb.pack(side=tk.LEFT, padx=6)
        self.to_cb.bind("<<ComboboxSelected>>", lambda e: self._convert())

        # 入れ替えボタン
        swap_btn = ttk.Button(conv_f, text="⇅ 入れ替え", command=self._swap)
        swap_btn.pack(pady=4)

        # 結果
        result_f = tk.Frame(conv_f, bg="#e3f2fd", relief=tk.FLAT, bd=1)
        result_f.pack(fill=tk.X, pady=8, ipady=8, ipadx=8)
        self.result_var = tk.StringVar(value="—")
        tk.Label(result_f, textvariable=self.result_var,
                 bg="#e3f2fd", fg="#0277bd",
                 font=("Arial", 20, "bold")).pack()

        # 一括変換ボタン
        ttk.Button(left, text="📋 一括変換(全単位)",
                   command=self._bulk_convert).pack(padx=8, pady=4, anchor="w")

        # 一括変換テキスト
        self.bulk_text = tk.Text(left, height=10, bg="#fafafa",
                                  font=("Courier New", 10), state=tk.DISABLED,
                                  relief=tk.FLAT, bd=1)
        self.bulk_text.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)

        # 右: 履歴パネル
        right = ttk.LabelFrame(paned, text="変換履歴", padding=4)
        paned.add(right, weight=2)

        # 検索
        search_f = tk.Frame(right, bg=ttk.Style().lookup("TLabelframe", "background"))
        search_f.pack(fill=tk.X, pady=4)
        tk.Label(search_f, text="🔍").pack(side=tk.LEFT)
        self.search_var = tk.StringVar()
        self.search_var.trace_add("write", lambda *a: self._filter_history())
        ttk.Entry(search_f, textvariable=self.search_var,
                  width=20).pack(side=tk.LEFT, padx=4, fill=tk.X, expand=True)
        ttk.Button(search_f, text="クリア",
                   command=self._clear_history).pack(side=tk.RIGHT, padx=4)

        # 履歴リスト
        hist_cols = ("time", "expr", "result")
        self.hist_tree = ttk.Treeview(right, columns=hist_cols,
                                       show="headings", height=18)
        for c, h, w in [("time", "時刻", 68), ("expr", "変換式", 180),
                         ("result", "結果", 120)]:
            self.hist_tree.heading(c, text=h)
            self.hist_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(right, command=self.hist_tree.yview)
        self.hist_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.hist_tree.pack(fill=tk.BOTH, expand=True)
        self.hist_tree.bind("<Double-1>", self._restore_history)

        self.status_var = tk.StringVar(value="単位を選択して変換してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

        # 初期化
        self._on_category_change(None)
        self._refresh_history()

    def _on_category_change(self, event):
        cat = self.cat_var.get()
        units = list(self.CATEGORIES[cat].keys())
        self.from_cb.configure(values=units)
        self.to_cb.configure(values=units)
        self.from_var.set(units[0])
        self.to_var.set(units[1] if len(units) > 1 else units[0])
        self._convert()

    def _convert(self):
        cat = self.cat_var.get()
        from_unit = self.from_var.get()
        to_unit = self.to_var.get()
        if not from_unit or not to_unit:
            return
        try:
            value = float(self.input_var.get())
        except ValueError:
            self.result_var.set("—")
            return

        result = self._do_convert(cat, value, from_unit, to_unit)
        if result is None:
            self.result_var.set("変換不可")
            return

        # 表示フォーマット
        if abs(result) >= 1e10 or (abs(result) < 1e-4 and result != 0):
            result_str = f"{result:.6e}"
        elif result == int(result) and abs(result) < 1e9:
            result_str = f"{int(result):,}"
        else:
            result_str = f"{result:,.8g}"

        self.result_var.set(f"{result_str} {to_unit}")
        self.status_var.set(
            f"{value} {from_unit} = {result_str} {to_unit}")

    def _do_convert(self, cat, value, from_unit, to_unit):
        units = self.CATEGORIES.get(cat, {})
        if from_unit not in units or to_unit not in units:
            return None

        if cat == "温度":
            return self._convert_temperature(value, from_unit, to_unit)

        from_factor = units[from_unit]
        to_factor = units[to_unit]
        base = value * from_factor
        return base / to_factor

    def _convert_temperature(self, value, from_unit, to_unit):
        # まず摂氏に変換
        fv = self.CATEGORIES["温度"][from_unit]
        tv = self.CATEGORIES["温度"][to_unit]
        if fv == "celsius":
            celsius = value
        elif fv == "fahrenheit":
            celsius = (value - 32) * 5 / 9
        elif fv == "kelvin":
            celsius = value - 273.15
        else:
            return None
        # 摂氏から変換先へ
        if tv == "celsius":
            return celsius
        elif tv == "fahrenheit":
            return celsius * 9 / 5 + 32
        elif tv == "kelvin":
            return celsius + 273.15
        return None

    def _swap(self):
        f = self.from_var.get()
        t = self.to_var.get()
        self.from_var.set(t)
        self.to_var.set(f)
        self._convert()

    def _bulk_convert(self):
        cat = self.cat_var.get()
        from_unit = self.from_var.get()
        try:
            value = float(self.input_var.get())
        except ValueError:
            return
        units = list(self.CATEGORIES[cat].keys())
        lines = [f"{'=' * 48}",
                 f"  {value} {from_unit} の一括変換",
                 f"{'=' * 48}"]
        for unit in units:
            if unit == from_unit:
                continue
            result = self._do_convert(cat, value, from_unit, unit)
            if result is not None:
                if abs(result) >= 1e10 or (abs(result) < 1e-4 and result != 0):
                    rs = f"{result:.6e}"
                else:
                    rs = f"{result:,.8g}"
                lines.append(f"  {unit:<30} {rs}")
        lines.append(f"{'=' * 48}")
        self.bulk_text.config(state=tk.NORMAL)
        self.bulk_text.delete("1.0", tk.END)
        self.bulk_text.insert("1.0", "\n".join(lines))
        self.bulk_text.config(state=tk.DISABLED)

        # 履歴に記録
        self._add_history(cat, value, from_unit,
                          self.result_var.get(), self.to_var.get())

    def _add_history(self, cat, value, from_unit, result_str, to_unit):
        entry = {
            "time": datetime.now().strftime("%H:%M:%S"),
            "cat": cat,
            "value": value,
            "from": from_unit,
            "to": to_unit,
            "result": result_str,
        }
        self._history.insert(0, entry)
        self._save_history()
        self._refresh_history()

    def _refresh_history(self):
        query = self.search_var.get().strip().lower() if hasattr(self, "search_var") else ""
        self.hist_tree.delete(*self.hist_tree.get_children())
        for entry in self._history[:100]:
            expr = f"{entry.get('value','')} {entry.get('from','')} → {entry.get('to','')}"
            result = entry.get("result", "")
            time_str = entry.get("time", "")
            if query and query not in expr.lower() and query not in result.lower():
                continue
            self.hist_tree.insert("", "end",
                                  values=(time_str, expr, result))

    def _filter_history(self):
        self._refresh_history()

    def _clear_history(self):
        if messagebox.askyesno("確認", "履歴をすべて削除しますか?"):
            self._history.clear()
            self._save_history()
            self._refresh_history()

    def _restore_history(self, event):
        sel = self.hist_tree.selection()
        if not sel:
            return
        idx = self.hist_tree.index(sel[0])
        entries = [e for e in self._history[:100]
                   if not self.search_var.get() or
                   self.search_var.get().lower() in
                   f"{e.get('value','')} {e.get('from','')} {e.get('to','')}".lower()]
        if idx < len(entries):
            entry = entries[idx]
            # カテゴリ→単位を復元
            cat = entry.get("cat", "")
            if cat in self.CATEGORIES:
                self.cat_var.set(cat)
                self._on_category_change(None)
                self.input_var.set(str(entry.get("value", "")))
                if entry.get("from") in self.CATEGORIES[cat]:
                    self.from_var.set(entry["from"])
                if entry.get("to") in self.CATEGORIES[cat]:
                    self.to_var.set(entry["to"])
                self._convert()


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

5. コード解説

単位変換(履歴付き)のコードを、実際に書かれている実装に沿って解説します。

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

App29 クラスにアプリの全機能をまとめています(メソッド15個・全422行)。__init__ ではタイトル「単位変換(履歴付き)」とウィンドウサイズ 860x621 を設定します。最後に _build_ui() で画面を組み立てます。

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

ウィジェット構成

画面は tk.Frame×8・tk.Label×9・ttk.PanedWindowttk.Combobox×3・ttk.LabelFrame×2・ttk.Style×5・ttk.Entry×2・ttk.Button×3・tk.Textttk.Treeviewttk.Scrollbar で構成しています。見出し付きの枠(LabelFrame)は「変換」「変換履歴」のラベルでエリアを区切っています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。

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

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

「⇅ 入れ替え」ボタン(_swap())・「📋 一括変換(全単位)」ボタン(_bulk_convert())・「クリア」ボタン(_clear_history())を command= で接続しています。また bind("<<ComboboxSelected>>", ...) から _on_category_change()bind("<Return>", ...) から _convert()bind("<KeyRelease>", ...) から _convert()bind("<<ComboboxSelected>>", ...) から _convert()bind("<<ComboboxSelected>>", ...) から _convert()bind("<Double-1>", ...) から _restore_history() を呼べるようにイベントも登録しています。

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

JSON への保存と読み込み

データは json.dump() でファイル(unit_history.json)へ保存します。起動時は json.load() で読み戻します。

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

クラス定数によるデータ定義

クラスの先頭でデータを定数として定義しています:HISTORY_FILE = os.path.join(os.path.dirname(__file__), 'unit_history.json')。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。

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

例外処理

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

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

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

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

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

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

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

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

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

    title("単位変換(履歴付き)")geometry("860x621")configure(bg="#f8f9fc")minsize(860, 621) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

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

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

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

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

    アプリの本体である _bulk_convert()(30行)・_convert()(28行)・_restore_history()(22行)など を実装します。

  7. 7
    動作確認する

    python app29.py で起動し、各ボタンが反応すること・キー操作(<Return><KeyRelease>)が効くこと・マウス操作(クリック・ホイールなど)が効くことを確認します。

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

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

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

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

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

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

💡 配色をまとめて管理する

背景色 #f8f9fc をはじめ、色コードが各所に直書きされています。色を1つの辞書にまとめると、テーマの切り替えが1か所の変更で済むようになります。

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

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

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

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

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

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

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

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

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

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

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。履歴は同じフォルダの unit_history.json に保存されます(94行・114-119行)。増えるのは「📋 一括変換(全単位)」を押したときだけで、ふつうの変換では記録されません(358-359行)。「クリア」を押すと履歴はすべて消え、ファイルにも空の状態で書き戻されます(389-393行)。ここに並ぶ換算はプログラムを学ぶためのもので、取引や計量の証明に使えるものではありません。使うのは標準ライブラリだけなので、追加インストールなしで動かせます。

  1. 課題1:ふつうの変換は履歴に残らず、記録できるのは「一括変換」を押したときだけ

    履歴を追加する _add_history() を呼んでいるのは、_bulk_convert() の末尾にある1か所だけです(358-359行)。しかも渡しているのは、そのとき画面に出ていた単発の結果(self.result_var.get())です。_convert() の側には、記録に関する処理がありません(260-287行)。

    期待結果:入力値に 3 を入れ、変換元「メートル (m)」・変換先「キロメートル (km)」を選ぶ。結果の欄に 0.003 キロメートル (km) と出るのに、右の履歴は1件も増えない。ここで「📋 一括変換(全単位)」を押すと、左下に一覧が出て履歴に 3.0 メートル (m) → キロメートル (km) が1件だけ増える。直したあとは、単発の変換でも履歴に残る。

    合格条件

    • 変換元・変換先・入力値を決めて変換したとき、その1件が履歴に残る
    • 入力欄に1文字打つたびに履歴が増えることはない
    • 「📋 一括変換(全単位)」を押したときの動きは、いままでどおり

    ヒント①(どこを触るか): 触るのは1〜2か所です。_convert() の末尾(285-287行)と、必要なら入力欄の結び付け(162-163行)。
    ヒント②(使うもの): _add_history() が受け取るのは「カテゴリ・値・変換元・結果の文字列・変換先」の5つです(361-369行)。_convert() の中には同じ5つがそろっているので、そのまま渡せます。記録するきっかけは、Enter キー(162行)と単位を選び直したとき(174行・189行)に絞ると扱いやすくなります。
    つまずきやすい点: 入力欄はキーを離すたびに _convert() を呼びます(163行)。ここで何も考えずに記録すると、123 と打つだけで3件たまります。_add_history() は呼ばれるたびにファイルへ書き込むので(371行)、記録の回数はそのまま保存の回数になります。

  2. 課題2:結果の文字で履歴を検索すると、ダブルクリックしても復元されない

    一覧に出す行を絞る条件(381行)と、ダブルクリックで戻す候補を絞る条件(400-403行)が、別々に書かれています。前者は変換式と結果の両方を見て、後者は結果を見ません。この差のぶんだけ、画面上の行の位置と候補の位置がずれます。

    期待結果:入力値や単位を変えながら「📋 一括変換(全単位)」を2回以上押して、履歴を2件以上作る。検索欄に結果側の数字(たとえば 0.003)を入れると、一覧にはその行が残る。ところが、その行をダブルクリックしても左の入力欄と単位は何も変わらない。矢印の で検索したときも同じで、一覧は全件出るのに復元だけができない。直したあとは、絞り込んだ状態でもダブルクリックで戻せる。

    合格条件

    • 検索で絞った状態でも、ダブルクリックした行の内容が左の変換欄に戻る
    • 検索欄が空のときの復元は、いままでどおり動く
    • 履歴が0件のときや行を選んでいないときは、何も起きない

    ヒント①(どこを触るか): 触るのは2か所です。_refresh_history()(374-384行)と _restore_history()(395-405行)。
    ヒント②(使うもの): self.hist_tree.insert() は作った行の iid を返します。表示した履歴だけを並べたリストを属性に持たせておけば、_restore_history() は 399行の index() の結果でそのリストを引くだけで済むはずです。絞り込みの条件を小さなメソッドにまとめて、両方から呼ぶ手もあります。
    つまずきやすい点: 381行の条件は変換式と結果の両方を見ますが、400-403行が作る文字列には が入らず、結果も含まれません。片方だけ直すと、また別のずれ方をします。self._history[:100] の上限は両方に付いているので(377行・400行)、そろえるときは一緒に扱ってください。

  3. 課題3:新しいボタンを追加する

    既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と command= の接続がセットで身に付きます。

🚀
次に挑戦するアプリ

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

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

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

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。