中級者向け No.29

単位変換(履歴付き)

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

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

1. アプリ概要

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

このアプリは中級カテゴリに分類される実践的な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. 完全なソースコード

💡
コードのコピー方法

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

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("860x580")
        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=conv_f.cget("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=conv_f.cget("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=conv_f.cget("background"),
                 font=("Arial", 18)).pack(pady=2)

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

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("860x580")
        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=conv_f.cget("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=conv_f.cget("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=conv_f.cget("background"),
                 font=("Arial", 18)).pack(pady=2)

        # 単位選択(to)
        row3 = tk.Frame(conv_f, bg=conv_f.cget("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=right.cget("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()

LabelFrameによるセクション分け

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

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("860x580")
        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=conv_f.cget("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=conv_f.cget("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=conv_f.cget("background"),
                 font=("Arial", 18)).pack(pady=2)

        # 単位選択(to)
        row3 = tk.Frame(conv_f, bg=conv_f.cget("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=right.cget("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()

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

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

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("860x580")
        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=conv_f.cget("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=conv_f.cget("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=conv_f.cget("background"),
                 font=("Arial", 18)).pack(pady=2)

        # 単位選択(to)
        row3 = tk.Frame(conv_f, bg=conv_f.cget("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=right.cget("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()

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

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

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("860x580")
        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=conv_f.cget("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=conv_f.cget("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=conv_f.cget("background"),
                 font=("Arial", 18)).pack(pady=2)

        # 単位選択(to)
        row3 = tk.Frame(conv_f, bg=conv_f.cget("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=right.cget("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()

例外処理とmessagebox

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

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("860x580")
        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=conv_f.cget("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=conv_f.cget("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=conv_f.cget("background"),
                 font=("Arial", 18)).pack(pady=2)

        # 単位選択(to)
        row3 = tk.Frame(conv_f, bg=conv_f.cget("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=right.cget("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()

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

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

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

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

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

    App29クラスを定義し、__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.30に挑戦しましょう。