単位変換(履歴付き)
長さ・重さ・温度・速度など多彩な単位変換ツール。変換履歴の保存と検索機能の実装を学びます。
1. アプリ概要
長さ・重さ・温度・速度など多彩な単位変換ツール。変換履歴の保存と検索機能の実装を学びます。
このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ) で、難易度は ★★☆ です。
このアプリは「ユーティリティ」カテゴリです。小回りの効くユーティリティは Python の真骨頂で、汎用的なユーザー操作を組み合わせる発想が他のアプリにもそのまま流用できます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。
コードを読む前に実行することをおすすめします。動いている挙動を先に把握しておくと、解説で出てくる関数や処理がどこに対応するかが頭に入りやすくなります。
応用のヒントは、機能を 1 つ増やす・見た目を整える・例外を一つでも丁寧に扱う、のいずれかから始めるのがおすすめです。
2. 機能一覧
- 「⇅ 入れ替え」ボタン(
_swap())・「📋 一括変換(全単位)」ボタン(_bulk_convert())・「クリア」ボタン(_clear_history())で操作 - イベント
<<ComboboxSelected>>から_on_category_change()を実行 - キー
<Return>から_convert()を実行 - キー
<KeyRelease>から_convert()を実行 - イベント
<<ComboboxSelected>>から_convert()を実行 - イベント
<<ComboboxSelected>>から_convert()を実行 - マウス操作
<Double-1>から_restore_history()を実行 ttk.Treeviewによる表形式の一覧表示- JSON ファイルの保存・読み込み(起動時に自動実行)
messagebox.askyesno()によるダイアログ通知- ウィンドウはタイトル「単位変換(履歴付き)」・サイズ 860x621 で起動
3. 事前準備・環境
Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)
Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は標準ライブラリの範囲で動作する想定ですが、未検証です。
- Python 3.10 以上
- OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)
4. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
import tkinter as tk
from tkinter import ttk, messagebox
import json
import os
from datetime import datetime
class App29:
"""単位変換(履歴付き)"""
CATEGORIES = {
"長さ": {
"メートル (m)": 1.0,
"キロメートル (km)": 1000.0,
"センチメートル (cm)": 0.01,
"ミリメートル (mm)": 0.001,
"マイル (mi)": 1609.344,
"ヤード (yd)": 0.9144,
"フィート (ft)": 0.3048,
"インチ (in)": 0.0254,
"海里 (nmi)": 1852.0,
},
"重さ": {
"キログラム (kg)": 1.0,
"グラム (g)": 0.001,
"ミリグラム (mg)": 1e-6,
"トン (t)": 1000.0,
"ポンド (lb)": 0.45359237,
"オンス (oz)": 0.028349523,
"貫 (kan)": 3.75,
"匁 (monme)": 0.00375,
},
"温度": {
"摂氏 (°C)": "celsius",
"華氏 (°F)": "fahrenheit",
"ケルビン (K)": "kelvin",
},
"速度": {
"m/s": 1.0,
"km/h": 1/3.6,
"mph": 0.44704,
"ノット": 0.514444,
"マッハ": 340.29,
},
"面積": {
"平方メートル (m²)": 1.0,
"平方キロメートル (km²)": 1e6,
"平方センチメートル (cm²)": 1e-4,
"ヘクタール (ha)": 1e4,
"アール (a)": 100.0,
"平方フィート (ft²)": 0.09290304,
"平方マイル (mi²)": 2589988.11,
"坪": 3.305785,
},
"体積": {
"リットル (L)": 1.0,
"ミリリットル (mL)": 0.001,
"立方メートル (m³)": 1000.0,
"ガロン (US gal)": 3.785411784,
"液量オンス (fl oz)": 0.0295735296,
"カップ (cup)": 0.2365882365,
"大さじ (tbsp)": 0.01478676478,
"小さじ (tsp)": 0.00492892159,
},
"データ": {
"バイト (B)": 1.0,
"キロバイト (KB)": 1024.0,
"メガバイト (MB)": 1024**2,
"ギガバイト (GB)": 1024**3,
"テラバイト (TB)": 1024**4,
"ビット (bit)": 0.125,
"キロビット (Kbit)": 125.0,
"メガビット (Mbit)": 125000.0,
},
"圧力": {
"パスカル (Pa)": 1.0,
"キロパスカル (kPa)": 1000.0,
"気圧 (atm)": 101325.0,
"バール (bar)": 100000.0,
"mmHg": 133.322,
"psi": 6894.757,
},
"エネルギー": {
"ジュール (J)": 1.0,
"キロジュール (kJ)": 1000.0,
"カロリー (cal)": 4.184,
"キロカロリー (kcal)": 4184.0,
"ワット時 (Wh)": 3600.0,
"キロワット時 (kWh)": 3600000.0,
"電子ボルト (eV)": 1.60218e-19,
},
}
HISTORY_FILE = os.path.join(os.path.dirname(__file__), "unit_history.json")
def __init__(self, root):
self.root = root
self.root.title("単位変換(履歴付き)")
self.root.geometry("860x621")
self.root.minsize(860, 621)
self.root.configure(bg="#f8f9fc")
self._history = self._load_history()
self._build_ui()
def _load_history(self):
if os.path.exists(self.HISTORY_FILE):
try:
with open(self.HISTORY_FILE, encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return []
def _save_history(self):
try:
with open(self.HISTORY_FILE, "w", encoding="utf-8") as f:
json.dump(self._history[-200:], f, ensure_ascii=False, indent=2)
except Exception:
pass
def _build_ui(self):
# ヘッダー
header = tk.Frame(self.root, bg="#0288d1", pady=10)
header.pack(fill=tk.X)
tk.Label(header, text="🔄 単位変換ツール(履歴付き)",
font=("Noto Sans JP", 14, "bold"),
bg="#0288d1", fg="white").pack(side=tk.LEFT, padx=12)
# メインエリア
paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
paned.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)
# 左: 変換パネル
left = tk.Frame(paned, bg="#f8f9fc")
paned.add(left, weight=3)
# カテゴリ選択
cat_f = tk.Frame(left, bg="#f8f9fc")
cat_f.pack(fill=tk.X, padx=8, pady=6)
tk.Label(cat_f, text="カテゴリ:", bg="#f8f9fc",
font=("Arial", 10, "bold")).pack(side=tk.LEFT)
self.cat_var = tk.StringVar(value=list(self.CATEGORIES.keys())[0])
cat_cb = ttk.Combobox(cat_f, textvariable=self.cat_var,
values=list(self.CATEGORIES.keys()),
state="readonly", width=16)
cat_cb.pack(side=tk.LEFT, padx=6)
cat_cb.bind("<<ComboboxSelected>>", self._on_category_change)
# 変換フォーム
conv_f = ttk.LabelFrame(left, text="変換", padding=12)
conv_f.pack(fill=tk.X, padx=8, pady=4)
# 入力行
row1 = tk.Frame(conv_f, bg=ttk.Style().lookup("TLabelframe", "background"))
row1.pack(fill=tk.X, pady=4)
tk.Label(row1, text="入力値:", bg=row1.cget("bg"), width=8,
anchor="e").pack(side=tk.LEFT)
self.input_var = tk.StringVar(value="1")
self.input_entry = ttk.Entry(row1, textvariable=self.input_var,
font=("Arial", 14), width=16)
self.input_entry.pack(side=tk.LEFT, padx=6)
self.input_entry.bind("<Return>", lambda e: self._convert())
self.input_entry.bind("<KeyRelease>", lambda e: self._convert())
# 単位選択(from)
row2 = tk.Frame(conv_f, bg=ttk.Style().lookup("TLabelframe", "background"))
row2.pack(fill=tk.X, pady=4)
tk.Label(row2, text="変換元:", bg=row2.cget("bg"), width=8,
anchor="e").pack(side=tk.LEFT)
self.from_var = tk.StringVar()
self.from_cb = ttk.Combobox(row2, textvariable=self.from_var,
state="readonly", width=26)
self.from_cb.pack(side=tk.LEFT, padx=6)
self.from_cb.bind("<<ComboboxSelected>>", lambda e: self._convert())
# 矢印
tk.Label(conv_f, text="↓", bg=ttk.Style().lookup("TLabelframe", "background"),
font=("Arial", 18)).pack(pady=2)
# 単位選択(to)
row3 = tk.Frame(conv_f, bg=ttk.Style().lookup("TLabelframe", "background"))
row3.pack(fill=tk.X, pady=4)
tk.Label(row3, text="変換先:", bg=row3.cget("bg"), width=8,
anchor="e").pack(side=tk.LEFT)
self.to_var = tk.StringVar()
self.to_cb = ttk.Combobox(row3, textvariable=self.to_var,
state="readonly", width=26)
self.to_cb.pack(side=tk.LEFT, padx=6)
self.to_cb.bind("<<ComboboxSelected>>", lambda e: self._convert())
# 入れ替えボタン
swap_btn = ttk.Button(conv_f, text="⇅ 入れ替え", command=self._swap)
swap_btn.pack(pady=4)
# 結果
result_f = tk.Frame(conv_f, bg="#e3f2fd", relief=tk.FLAT, bd=1)
result_f.pack(fill=tk.X, pady=8, ipady=8, ipadx=8)
self.result_var = tk.StringVar(value="—")
tk.Label(result_f, textvariable=self.result_var,
bg="#e3f2fd", fg="#0277bd",
font=("Arial", 20, "bold")).pack()
# 一括変換ボタン
ttk.Button(left, text="📋 一括変換(全単位)",
command=self._bulk_convert).pack(padx=8, pady=4, anchor="w")
# 一括変換テキスト
self.bulk_text = tk.Text(left, height=10, bg="#fafafa",
font=("Courier New", 10), state=tk.DISABLED,
relief=tk.FLAT, bd=1)
self.bulk_text.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
# 右: 履歴パネル
right = ttk.LabelFrame(paned, text="変換履歴", padding=4)
paned.add(right, weight=2)
# 検索
search_f = tk.Frame(right, bg=ttk.Style().lookup("TLabelframe", "background"))
search_f.pack(fill=tk.X, pady=4)
tk.Label(search_f, text="🔍").pack(side=tk.LEFT)
self.search_var = tk.StringVar()
self.search_var.trace_add("write", lambda *a: self._filter_history())
ttk.Entry(search_f, textvariable=self.search_var,
width=20).pack(side=tk.LEFT, padx=4, fill=tk.X, expand=True)
ttk.Button(search_f, text="クリア",
command=self._clear_history).pack(side=tk.RIGHT, padx=4)
# 履歴リスト
hist_cols = ("time", "expr", "result")
self.hist_tree = ttk.Treeview(right, columns=hist_cols,
show="headings", height=18)
for c, h, w in [("time", "時刻", 68), ("expr", "変換式", 180),
("result", "結果", 120)]:
self.hist_tree.heading(c, text=h)
self.hist_tree.column(c, width=w, minwidth=40)
sb = ttk.Scrollbar(right, command=self.hist_tree.yview)
self.hist_tree.configure(yscrollcommand=sb.set)
sb.pack(side=tk.RIGHT, fill=tk.Y)
self.hist_tree.pack(fill=tk.BOTH, expand=True)
self.hist_tree.bind("<Double-1>", self._restore_history)
self.status_var = tk.StringVar(value="単位を選択して変換してください")
tk.Label(self.root, textvariable=self.status_var,
bg="#dde", font=("Arial", 9), anchor="w", padx=8
).pack(fill=tk.X, side=tk.BOTTOM)
# 初期化
self._on_category_change(None)
self._refresh_history()
def _on_category_change(self, event):
cat = self.cat_var.get()
units = list(self.CATEGORIES[cat].keys())
self.from_cb.configure(values=units)
self.to_cb.configure(values=units)
self.from_var.set(units[0])
self.to_var.set(units[1] if len(units) > 1 else units[0])
self._convert()
def _convert(self):
cat = self.cat_var.get()
from_unit = self.from_var.get()
to_unit = self.to_var.get()
if not from_unit or not to_unit:
return
try:
value = float(self.input_var.get())
except ValueError:
self.result_var.set("—")
return
result = self._do_convert(cat, value, from_unit, to_unit)
if result is None:
self.result_var.set("変換不可")
return
# 表示フォーマット
if abs(result) >= 1e10 or (abs(result) < 1e-4 and result != 0):
result_str = f"{result:.6e}"
elif result == int(result) and abs(result) < 1e9:
result_str = f"{int(result):,}"
else:
result_str = f"{result:,.8g}"
self.result_var.set(f"{result_str} {to_unit}")
self.status_var.set(
f"{value} {from_unit} = {result_str} {to_unit}")
def _do_convert(self, cat, value, from_unit, to_unit):
units = self.CATEGORIES.get(cat, {})
if from_unit not in units or to_unit not in units:
return None
if cat == "温度":
return self._convert_temperature(value, from_unit, to_unit)
from_factor = units[from_unit]
to_factor = units[to_unit]
base = value * from_factor
return base / to_factor
def _convert_temperature(self, value, from_unit, to_unit):
# まず摂氏に変換
fv = self.CATEGORIES["温度"][from_unit]
tv = self.CATEGORIES["温度"][to_unit]
if fv == "celsius":
celsius = value
elif fv == "fahrenheit":
celsius = (value - 32) * 5 / 9
elif fv == "kelvin":
celsius = value - 273.15
else:
return None
# 摂氏から変換先へ
if tv == "celsius":
return celsius
elif tv == "fahrenheit":
return celsius * 9 / 5 + 32
elif tv == "kelvin":
return celsius + 273.15
return None
def _swap(self):
f = self.from_var.get()
t = self.to_var.get()
self.from_var.set(t)
self.to_var.set(f)
self._convert()
def _bulk_convert(self):
cat = self.cat_var.get()
from_unit = self.from_var.get()
try:
value = float(self.input_var.get())
except ValueError:
return
units = list(self.CATEGORIES[cat].keys())
lines = [f"{'=' * 48}",
f" {value} {from_unit} の一括変換",
f"{'=' * 48}"]
for unit in units:
if unit == from_unit:
continue
result = self._do_convert(cat, value, from_unit, unit)
if result is not None:
if abs(result) >= 1e10 or (abs(result) < 1e-4 and result != 0):
rs = f"{result:.6e}"
else:
rs = f"{result:,.8g}"
lines.append(f" {unit:<30} {rs}")
lines.append(f"{'=' * 48}")
self.bulk_text.config(state=tk.NORMAL)
self.bulk_text.delete("1.0", tk.END)
self.bulk_text.insert("1.0", "\n".join(lines))
self.bulk_text.config(state=tk.DISABLED)
# 履歴に記録
self._add_history(cat, value, from_unit,
self.result_var.get(), self.to_var.get())
def _add_history(self, cat, value, from_unit, result_str, to_unit):
entry = {
"time": datetime.now().strftime("%H:%M:%S"),
"cat": cat,
"value": value,
"from": from_unit,
"to": to_unit,
"result": result_str,
}
self._history.insert(0, entry)
self._save_history()
self._refresh_history()
def _refresh_history(self):
query = self.search_var.get().strip().lower() if hasattr(self, "search_var") else ""
self.hist_tree.delete(*self.hist_tree.get_children())
for entry in self._history[:100]:
expr = f"{entry.get('value','')} {entry.get('from','')} → {entry.get('to','')}"
result = entry.get("result", "")
time_str = entry.get("time", "")
if query and query not in expr.lower() and query not in result.lower():
continue
self.hist_tree.insert("", "end",
values=(time_str, expr, result))
def _filter_history(self):
self._refresh_history()
def _clear_history(self):
if messagebox.askyesno("確認", "履歴をすべて削除しますか?"):
self._history.clear()
self._save_history()
self._refresh_history()
def _restore_history(self, event):
sel = self.hist_tree.selection()
if not sel:
return
idx = self.hist_tree.index(sel[0])
entries = [e for e in self._history[:100]
if not self.search_var.get() or
self.search_var.get().lower() in
f"{e.get('value','')} {e.get('from','')} {e.get('to','')}".lower()]
if idx < len(entries):
entry = entries[idx]
# カテゴリ→単位を復元
cat = entry.get("cat", "")
if cat in self.CATEGORIES:
self.cat_var.set(cat)
self._on_category_change(None)
self.input_var.set(str(entry.get("value", "")))
if entry.get("from") in self.CATEGORIES[cat]:
self.from_var.set(entry["from"])
if entry.get("to") in self.CATEGORIES[cat]:
self.to_var.set(entry["to"])
self._convert()
if __name__ == "__main__":
root = tk.Tk()
app = App29(root)
root.mainloop()
5. コード解説
単位変換(履歴付き)のコードを、実際に書かれている実装に沿って解説します。
クラス設計とコンストラクタ
App29 クラスにアプリの全機能をまとめています(メソッド15個・全422行)。__init__ ではタイトル「単位変換(履歴付き)」とウィンドウサイズ 860x621 を設定します。最後に _build_ui() で画面を組み立てます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
ウィジェット構成
画面は tk.Frame×8・tk.Label×9・ttk.PanedWindow・ttk.Combobox×3・ttk.LabelFrame×2・ttk.Style×5・ttk.Entry×2・ttk.Button×3・tk.Text・ttk.Treeview・ttk.Scrollbar で構成しています。見出し付きの枠(LabelFrame)は「変換」「変換履歴」のラベルでエリアを区切っています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
イベント処理とボタンの接続
「⇅ 入れ替え」ボタン(_swap())・「📋 一括変換(全単位)」ボタン(_bulk_convert())・「クリア」ボタン(_clear_history())を command= で接続しています。また bind("<<ComboboxSelected>>", ...) から _on_category_change()、bind("<Return>", ...) から _convert()、bind("<KeyRelease>", ...) から _convert()、bind("<<ComboboxSelected>>", ...) から _convert()、bind("<<ComboboxSelected>>", ...) から _convert()、bind("<Double-1>", ...) から _restore_history() を呼べるようにイベントも登録しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
JSON への保存と読み込み
データは json.dump() でファイル(unit_history.json)へ保存します。起動時は json.load() で読み戻します。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
クラス定数によるデータ定義
クラスの先頭でデータを定数として定義しています:HISTORY_FILE = os.path.join(os.path.dirname(__file__), 'unit_history.json')。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
例外処理
try-except で Exception・ValueError を捕捉しています。あわせて messagebox.askyesno() のダイアログで、ユーザーへの通知・確認を行います。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app29.py と保存します。使うのは
datetime・json・os・tkinterだけなので、追加インストールは不要です。 -
2クラスの骨格を作る
App29クラスを定義し、__init__と、末尾のroot = tk.Tk()~mainloop()の最小構成を書いて、まず空のウィンドウが出ることを確認します。 -
3ウィンドウを設定する
title("単位変換(履歴付き)")・geometry("860x621")・configure(bg="#f8f9fc")・minsize(860, 621)をコンストラクタで設定します。 -
4画面部品を並べる
_build_ui()の中で、tk.Frame×8・tk.Label×9・ttk.PanedWindow・ttk.Combobox×3・ttk.LabelFrame×2・ttk.Style×5・ttk.Entry×2・ttk.Button×3・tk.Text・ttk.Treeview・ttk.Scrollbarを作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。 -
5イベントを接続する
「2. 機能一覧」で挙げた各ボタンを
command=で対応するメソッド(_swap()・_bulk_convert()・_clear_history())につなぎます。bind("<<ComboboxSelected>>", ...)・bind("<Return>", ...)・bind("<KeyRelease>", ...)・bind("<<ComboboxSelected>>", ...)・bind("<<ComboboxSelected>>", ...)・bind("<Double-1>", ...)の登録も忘れずに。 -
6中心になるメソッドを実装する
アプリの本体である
_bulk_convert()(30行)・_convert()(28行)・_restore_history()(22行)など を実装します。 -
7動作確認する
python app29.pyで起動し、各ボタンが反応すること・キー操作(<Return>・<KeyRelease>)が効くこと・マウス操作(クリック・ホイールなど)が効くことを確認します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。
💡 ダークモードを追加する
bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。
💡 クラス定数を書き換えて動きを変える
データはクラス定数(CATEGORIES・HISTORY_FILE)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。
💡 配色をまとめて管理する
背景色 #f8f9fc をはじめ、色コードが各所に直書きされています。色を1つの辞書にまとめると、テーマの切り替えが1か所の変更で済むようになります。
8. よくある問題と解決法
❌ 文字のフォントが崩れる・見た目が違う
原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。
解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。
❌ <Return> キーを押しても反応しない
原因:bind("<Return>", ...) はウィジェット単位で登録されるため、ウィンドウ(またはバインド先)にキーボードフォーカスが無いとイベントが届きません。
解決法:一度ウィンドウ内をクリックしてフォーカスを与えてから、キーを押してください。
❌ ウィンドウの大きさが画面に合わない
原因:geometry("860x621") の固定サイズで起動するためです。
解決法:geometry() と minsize() の両方の数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。ただし minsize(860, 621) を指定しているため、起動時の大きさより小さくは縮められません(縮めると画面部品が表示されなくなるのを防ぐためです)。
9. 練習問題
アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。履歴は同じフォルダの unit_history.json に保存されます(94行・114-119行)。増えるのは「📋 一括変換(全単位)」を押したときだけで、ふつうの変換では記録されません(358-359行)。「クリア」を押すと履歴はすべて消え、ファイルにも空の状態で書き戻されます(389-393行)。ここに並ぶ換算はプログラムを学ぶためのもので、取引や計量の証明に使えるものではありません。使うのは標準ライブラリだけなので、追加インストールなしで動かせます。
-
課題1:ふつうの変換は履歴に残らず、記録できるのは「一括変換」を押したときだけ
履歴を追加する
_add_history()を呼んでいるのは、_bulk_convert()の末尾にある1か所だけです(358-359行)。しかも渡しているのは、そのとき画面に出ていた単発の結果(self.result_var.get())です。_convert()の側には、記録に関する処理がありません(260-287行)。期待結果:入力値に
3を入れ、変換元「メートル (m)」・変換先「キロメートル (km)」を選ぶ。結果の欄に0.003 キロメートル (km)と出るのに、右の履歴は1件も増えない。ここで「📋 一括変換(全単位)」を押すと、左下に一覧が出て履歴に3.0 メートル (m) → キロメートル (km)が1件だけ増える。直したあとは、単発の変換でも履歴に残る。合格条件
- 変換元・変換先・入力値を決めて変換したとき、その1件が履歴に残る
- 入力欄に1文字打つたびに履歴が増えることはない
- 「📋 一括変換(全単位)」を押したときの動きは、いままでどおり
ヒント①(どこを触るか): 触るのは1〜2か所です。
_convert()の末尾(285-287行)と、必要なら入力欄の結び付け(162-163行)。
ヒント②(使うもの):_add_history()が受け取るのは「カテゴリ・値・変換元・結果の文字列・変換先」の5つです(361-369行)。_convert()の中には同じ5つがそろっているので、そのまま渡せます。記録するきっかけは、Enter キー(162行)と単位を選び直したとき(174行・189行)に絞ると扱いやすくなります。
つまずきやすい点: 入力欄はキーを離すたびに_convert()を呼びます(163行)。ここで何も考えずに記録すると、123と打つだけで3件たまります。_add_history()は呼ばれるたびにファイルへ書き込むので(371行)、記録の回数はそのまま保存の回数になります。 -
課題2:結果の文字で履歴を検索すると、ダブルクリックしても復元されない
一覧に出す行を絞る条件(381行)と、ダブルクリックで戻す候補を絞る条件(400-403行)が、別々に書かれています。前者は変換式と結果の両方を見て、後者は結果を見ません。この差のぶんだけ、画面上の行の位置と候補の位置がずれます。
期待結果:入力値や単位を変えながら「📋 一括変換(全単位)」を2回以上押して、履歴を2件以上作る。検索欄に結果側の数字(たとえば
0.003)を入れると、一覧にはその行が残る。ところが、その行をダブルクリックしても左の入力欄と単位は何も変わらない。矢印の→で検索したときも同じで、一覧は全件出るのに復元だけができない。直したあとは、絞り込んだ状態でもダブルクリックで戻せる。合格条件
- 検索で絞った状態でも、ダブルクリックした行の内容が左の変換欄に戻る
- 検索欄が空のときの復元は、いままでどおり動く
- 履歴が0件のときや行を選んでいないときは、何も起きない
ヒント①(どこを触るか): 触るのは2か所です。
_refresh_history()(374-384行)と_restore_history()(395-405行)。
ヒント②(使うもの):self.hist_tree.insert()は作った行の iid を返します。表示した履歴だけを並べたリストを属性に持たせておけば、_restore_history()は 399行のindex()の結果でそのリストを引くだけで済むはずです。絞り込みの条件を小さなメソッドにまとめて、両方から呼ぶ手もあります。
つまずきやすい点: 381行の条件は変換式と結果の両方を見ますが、400-403行が作る文字列には→が入らず、結果も含まれません。片方だけ直すと、また別のずれ方をします。self._history[:100]の上限は両方に付いているので(377行・400行)、そろえるときは一緒に扱ってください。 -
課題3:新しいボタンを追加する
既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と
command=の接続がセットで身に付きます。
写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。
このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。