中級者向け No.08

CSVデータビューア・エディタ

CSVファイルを表形式で表示・編集・フィルタリング・ソートできるアプリ。pandasとTtk Treeviewの組み合わせを学びます。

🎯 難易度: ★★☆ 📦 ライブラリ: tkinter(標準ライブラリ), pandas 🧩 作るもの: 検索・並べ替え・セル編集ができるCSVビューア(全339行)

1. アプリ概要

CSV の中身を確かめたり数か所だけ直したりするたびに Excel を開く手間を、自作のビューアで置き換えます。表形式での表示・編集・フィルタリング・並べ替えを1つのウィンドウで扱い、データの保持は pandas、表の描画は Ttk Treeview が受け持ちます。

Excel や CSV の集計・加工を自動化したいだけなら、PythonでExcel作業を自動化するのほうが近道です。

pandas が入っていない環境でも簡易版の SimpleDf に切り替わって表示・検索・編集・保存はできますが、列見出しクリックでの並べ替えと「📊 統計情報」は pandas がないと動きません。

CSVデータビューア・エディタ 実行画面(Windows)
実行画面(Windows)
CSVデータビューア・エディタ 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • ループで生成するボタン群(それぞれ _open_csv()・_save_csv()・_save_as() を実行)
  • 「➕ 行追加」ボタン(_add_row())・「🗑️ 行削除 (Del)」ボタン(_delete_row())・「📊 統計情報」ボタン(_show_stats())で操作
  • キー <KeyRelease> から _filter() を実行
  • イベント <<ComboboxSelected>> から _filter() を実行
  • マウス操作 <Double-1> から _on_double_click() を実行
  • キー <Delete> から _delete_row() を実行
  • 編集欄でキー <Return> を押すと confirm() で確定(_set_cell() が先に self.df を書き換え、その行の表示を self.df の値で描き直す)
  • キー <Escape> から lambda e: entry.destroy() を実行(編集を取り消す)
  • 編集欄からフォーカスが外れたとき(<FocusOut>)も confirm() で確定
  • ttk.Treeview による表形式の一覧表示(見出しクリックで並べ替え。空欄以外がすべて数値の列は数値の大小順)
  • CSV ファイルの読み込みと書き出し(値は書いてあるとおりの文字列で持つので、007 が 7 になったり空欄が nan になったりしない)
  • 読み込み時に文字コードを自動で試し(BOM ありなら utf-8-sig、なしなら utf-8 → cp932 → latin-1)、ステータス欄に表示。保存も同じ文字コードで書き出す
  • 保存は検索で絞り込み中でも全行が対象(表示中の行数が全行より少ないときは、ステータス欄に「保存済み(絞り込みに関係なく全行)」と表示)
  • filedialog.askopenfilename() によるファイル選択ダイアログ
  • filedialog.asksaveasfilename() によるファイル保存ダイアログ
  • messagebox.askyesno()・messagebox.showerror()・messagebox.showinfo() によるダイアログ通知
  • ウィンドウはタイトル「CSVデータビューア・エディタ」・サイズ 900x600 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.11 以上(Ubuntu で 3.14・3.15 の起動を確認) / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は必要なライブラリ(pip install)を入れれば動作する想定ですが、未検証です。

  • Python 3.11 以上(Ubuntu で 3.14・3.15 の起動を確認)
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

4. 完全なソースコード

💡
コードのコピー方法

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

📦 必要なライブラリをインストール(初回のみ)
pip install pandas
app08.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import csv
import os

try:
    import pandas as pd
    PANDAS_AVAILABLE = True
except ImportError:
    pd = None  # pandas なしでも動くよう、下の SimpleDf に切り替える
    PANDAS_AVAILABLE = False


class SimpleDf:
    """pandas が無いときの簡易版 DataFrame(値はすべて文字列のまま持つ)"""

    def __init__(self, headers, rows):
        self.columns = headers
        self._rows = dict(enumerate(rows))  # 行番号 → 行。削除しても番号はずれない

    def iterrows(self):
        return iter(list(self._rows.items()))

    def __len__(self):
        return len(self._rows)

    def to_csv(self, path, index=False, encoding="utf-8"):
        with open(path, "w", newline="", encoding=encoding) as f:
            w = csv.writer(f)
            w.writerow(self.columns)
            w.writerows(self._rows.values())


class App08:
    """CSVデータビューア・エディタ"""

    def __init__(self, root):
        self.root = root
        self.root.title("CSVデータビューア・エディタ")
        self.root.geometry("900x600")
        self.root.configure(bg="#f8f9fc")
        self.df = None
        self.filepath = None
        self.encoding = "utf-8-sig"  # 読み込んだときの文字コード(保存にも使う)
        self._build_ui()

    def _build_ui(self):
        title_frame = tk.Frame(self.root, bg="#3776ab", pady=10)
        title_frame.pack(fill=tk.X)
        tk.Label(title_frame, text="📊 CSVデータビューア・エディタ",
                 font=("Noto Sans JP", 15, "bold"),
                 bg="#3776ab", fg="white").pack(side=tk.LEFT, padx=12)

        # ツールバー
        toolbar = tk.Frame(self.root, bg="#e8eef5", pady=5)
        toolbar.pack(fill=tk.X)
        for text, cmd in [("📂 CSVを開く", self._open_csv),
                          ("💾 保存", self._save_csv),
                          ("💾 名前を付けて保存", self._save_as)]:
            ttk.Button(toolbar, text=text, command=cmd).pack(side=tk.LEFT, padx=4, pady=2)

        tk.Label(toolbar, text="  🔍 検索:",
                 bg="#e8eef5").pack(side=tk.LEFT, padx=(16, 4))
        self.search_var = tk.StringVar()
        search_entry = ttk.Entry(toolbar, textvariable=self.search_var, width=16)
        search_entry.pack(side=tk.LEFT)
        search_entry.bind("<KeyRelease>", lambda e: self._filter())

        tk.Label(toolbar, text="  列:", bg="#e8eef5").pack(side=tk.LEFT, padx=(12, 4))
        self.filter_col_var = tk.StringVar(value="すべて")
        self.filter_col_cb = ttk.Combobox(toolbar, textvariable=self.filter_col_var,
                                           values=["すべて"], state="readonly", width=12)
        self.filter_col_cb.pack(side=tk.LEFT)
        self.filter_col_cb.bind("<<ComboboxSelected>>", lambda e: self._filter())

        # グリッド
        grid_frame = tk.Frame(self.root, bg="#f8f9fc")
        grid_frame.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        self.tree = ttk.Treeview(grid_frame, show="headings", selectmode="browse")
        h_sb = ttk.Scrollbar(grid_frame, orient=tk.HORIZONTAL,
                             command=self.tree.xview)
        v_sb = ttk.Scrollbar(grid_frame, orient=tk.VERTICAL,
                             command=self.tree.yview)
        self.tree.configure(xscrollcommand=h_sb.set, yscrollcommand=v_sb.set)
        v_sb.pack(side=tk.RIGHT, fill=tk.Y)
        h_sb.pack(side=tk.BOTTOM, fill=tk.X)
        self.tree.pack(fill=tk.BOTH, expand=True)
        self.tree.bind("<Double-1>", self._on_double_click)
        self.tree.bind("<Delete>", self._delete_row)

        # 行操作パネル
        row_frame = tk.Frame(self.root, bg="#e8eef5", pady=4)
        row_frame.pack(fill=tk.X)
        ttk.Button(row_frame, text="➕ 行追加",
                   command=self._add_row).pack(side=tk.LEFT, padx=8)
        ttk.Button(row_frame, text="🗑️ 行削除 (Del)",
                   command=self._delete_row).pack(side=tk.LEFT, padx=4)
        ttk.Button(row_frame, text="📊 統計情報",
                   command=self._show_stats).pack(side=tk.LEFT, padx=4)

        self.status_var = tk.StringVar(value="CSVファイルを開いてください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    def _open_csv(self):
        path = filedialog.askopenfilename(
            filetypes=[("CSVファイル", "*.csv"), ("テキストファイル", "*.txt"),
                       ("すべて", "*.*")])
        if not path:
            return
        try:
            self._load_file(path)
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _load_file(self, path):
        # 先頭に BOM があれば utf-8-sig、無ければ utf-8 → cp932 の順に試す(最後は latin-1)
        with open(path, "rb") as f:
            has_bom = f.read(3) == b"\xef\xbb\xbf"
        encodings = (["utf-8-sig"] if has_bom else ["utf-8", "cp932"]) + ["latin-1"]
        if PANDAS_AVAILABLE:
            for enc in encodings:
                try:
                    # dtype=str: 007 や 100 を数値にせず、書いてあるとおりの文字列で持つ
                    # keep_default_na=False: 空欄を NaN にせず "" のまま持つ
                    df = pd.read_csv(path, encoding=enc, dtype=str,
                                     keep_default_na=False)
                    break
                except UnicodeDecodeError:
                    continue
        else:
            # pandas なし: csv モジュールで読込
            for enc in encodings:
                try:
                    with open(path, newline="", encoding=enc) as f:
                        reader = csv.reader(f)
                        headers = next(reader)
                        rows = list(reader)
                    break
                except UnicodeDecodeError:
                    continue
            df = SimpleDf(headers, rows)
        self.df = df
        self.encoding = enc
        self.filepath = path
        self._setup_columns()
        self._fill_rows(self.df)
        self.root.title(f"CSVビューア — {os.path.basename(path)}")
        self.status_var.set(f"{os.path.basename(path)}  |  "
                            f"{len(self.df)} 行 × {len(self.df.columns)} 列"
                            f"  |  文字コード: {enc}")

    def _setup_columns(self):
        cols = list(self.df.columns)
        self.tree.configure(columns=cols)
        for c in cols:
            self.tree.heading(c, text=c,
                              command=lambda col=c: self._sort_by(col))
            self.tree.column(c, width=max(80, len(str(c))*10), minwidth=40)
        self.filter_col_cb.configure(values=["すべて"] + cols)
        self.filter_col_var.set("すべて")

    def _insert_row(self, idx, values):
        # iid に self.df の行番号を入れておくと、表の行から self.df の行をたどれる
        self.tree.insert("", "end", iid=str(idx), values=[str(v) for v in values])

    def _fill_rows(self, df):
        self.tree.delete(*self.tree.get_children())
        for idx, row in df.iterrows():
            self._insert_row(idx, list(row))

    def _filter(self):
        if self.df is None:
            return
        q = self.search_var.get().strip().lower()
        col = self.filter_col_var.get()
        if PANDAS_AVAILABLE:
            if not q:
                filtered = self.df
            elif col == "すべて":
                mask = self.df.apply(
                    lambda s: s.str.lower().str.contains(q, regex=False)).any(axis=1)
                filtered = self.df[mask]
            else:
                filtered = self.df[
                    self.df[col].str.lower().str.contains(q, regex=False)]
            self._fill_rows(filtered)
        else:
            self.tree.delete(*self.tree.get_children())
            ci = self.df.columns.index(col) if col in self.df.columns else None
            for idx, row in self.df.iterrows():
                target = row if ci is None else row[ci:ci + 1]
                if not q or any(q in v.lower() for v in target):
                    self._insert_row(idx, row)
        shown = len(self.tree.get_children())
        self.status_var.set(f"{shown} 件表示 / 全 {len(self.df)} 件")

    def _to_number(self, s):
        """空欄以外がすべて数値の列なら数値の Series を返す(空欄は NaN)。違えば None"""
        filled = s[s.str.strip() != ""]
        if len(filled) == 0 or pd.to_numeric(filled, errors="coerce").isna().any():
            return None
        return pd.to_numeric(s.where(s.str.strip() != ""), errors="coerce")

    def _sort_by(self, col):
        if self.df is None or not PANDAS_AVAILABLE:
            return
        num = self._to_number(self.df[col])
        if num is not None:  # 数値の列は数値の大小で並べる(文字列だと "100" < "20" になる)
            order = num.sort_values(kind="stable", na_position="last").index
            self.df = self.df.loc[order].copy()
        else:
            self.df = self.df.sort_values(col, kind="stable")
        self._filter()  # 検索中なら絞り込みを保ったまま並べ直す

    def _set_cell(self, row_id, col_idx, new_val):
        """self.df を書き換えてから、その行の表示を self.df の値で描き直す"""
        if PANDAS_AVAILABLE:
            pos = self.df.index.get_loc(int(row_id))
            self.df.iat[pos, col_idx] = new_val
            values = list(self.df.iloc[pos])
        else:
            values = self.df._rows[int(row_id)]
            values[col_idx] = new_val
        # 表から値を読み戻すと "007" が 7 に変わるので、self.df の文字列を使う
        self.tree.item(row_id, values=[str(v) for v in values])

    def _on_double_click(self, event):
        """セルをダブルクリックで編集"""
        region = self.tree.identify_region(event.x, event.y)
        if region != "cell":
            return
        row_id = self.tree.identify_row(event.y)
        col_id = self.tree.identify_column(event.x)
        col_idx = int(col_id.replace("#", "")) - 1
        bbox = self.tree.bbox(row_id, col_id)
        if not bbox:
            return
        x, y, w, h = bbox
        # 表示用の値は "007" が 7 に変わることがあるので、元の値は self.df から取る
        if PANDAS_AVAILABLE:
            val = self.df.iat[self.df.index.get_loc(int(row_id)), col_idx]
        else:
            val = self.df._rows[int(row_id)][col_idx]
        entry = tk.Entry(self.tree, font=("Arial", 11))
        entry.insert(0, str(val))
        entry.select_range(0, tk.END)
        entry.place(x=x, y=y, width=w, height=h)
        entry.focus_set()

        def confirm(e=None):
            if not entry.winfo_exists():
                return
            self._set_cell(row_id, col_idx, entry.get())
            entry.destroy()

        entry.bind("<Return>", confirm)
        entry.bind("<Escape>", lambda e: entry.destroy())
        entry.bind("<FocusOut>", confirm)

    def _add_row(self):
        if self.df is None:
            return
        n = len(self.df.columns)
        if PANDAS_AVAILABLE:
            idx = int(self.df.index.max()) + 1 if len(self.df) else 0
            self.df.loc[idx] = [""] * n
        else:
            idx = max(self.df._rows, default=-1) + 1
            self.df._rows[idx] = [""] * n
        self._insert_row(idx, [""] * n)
        self.status_var.set(f"行を追加しました(全 {len(self.df)} 件)")

    def _delete_row(self, event=None):
        sel = self.tree.selection()
        if sel:
            if messagebox.askyesno("確認", "選択行を削除しますか?"):
                for item in sel:
                    if PANDAS_AVAILABLE:
                        self.df = self.df.drop(index=int(item))
                    else:
                        del self.df._rows[int(item)]
                    self.tree.delete(item)
                self.status_var.set(f"削除しました(全 {len(self.df)} 件)")

    def _show_stats(self):
        if self.df is None or not PANDAS_AVAILABLE:
            messagebox.showinfo("情報", "pandasが必要です")
            return
        view = self.df.copy()
        for c in view.columns:  # 文字列で読んだので、数値の列だけ数値に直して集計する
            num = self._to_number(view[c])
            if num is not None:
                view[c] = num
        desc = view.describe(include="all").to_string()
        win = tk.Toplevel(self.root)
        win.title("統計情報")
        win.geometry("600x400")
        txt = tk.Text(win, font=("Courier New", 10), wrap=tk.NONE)
        txt.pack(fill=tk.BOTH, expand=True)
        txt.insert("1.0", desc)
        txt.config(state=tk.DISABLED)

    def _save_csv(self):
        if self.df is None:
            return
        if not self.filepath:
            self._save_as()
            return
        self._write_csv(self.filepath)

    def _save_as(self):
        if self.df is None:
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSVファイル", "*.csv"), ("すべて", "*.*")])
        if path:
            self.filepath = path
            self._write_csv(path)

    def _write_csv(self, path):
        # 表(Treeview)ではなく self.df を書き出す。検索で絞り込み中でも全行を保存する
        try:
            self.df.to_csv(path, index=False, encoding=self.encoding)
            shown = len(self.tree.get_children())
            note = "" if shown == len(self.df) else "(絞り込みに関係なく全行)"
            self.status_var.set(f"保存済み{note}: {len(self.df)} 行 / "
                                f"{self.encoding} / {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))


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

5. コード解説

CSVデータビューア・エディタのコードを、実際に書かれている実装に沿って解説します。

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

App08 クラスにアプリの全機能をまとめています(メソッド18個。ファイル全体は339行)。__init__ ではタイトル「CSVデータビューア・エディタ」とウィンドウサイズ 900x600 を設定します。あわせて状態を保持する変数(self.df・self.filepath・読み込んだ文字コードを覚える self.encoding)を初期化し、最後に _build_ui() で画面を組み立てます。

pandas が入っていない環境のために、モジュール直下に簡易版の SimpleDf クラス(メソッド4個)も用意しています。行を「行番号→行」の辞書で持つので、途中の行を削除しても残りの行番号がずれません。この場合も表示・検索(列指定を含む)・編集・行の追加と削除・保存は使えますが、並べ替えと統計情報は pandas が必要です。

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

ウィジェット構成

画面は tk.Frame×4・tk.Label×4・ttk.Button×3(ほかにループでも生成)・ttk.Entry・ttk.Combobox・ttk.Treeview・ttk.Scrollbar×2・tk.Entry・tk.Toplevel・tk.Text で構成しています。

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

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

「➕ 行追加」ボタン(_add_row())・「🗑️ 行削除 (Del)」ボタン(_delete_row())・「📊 統計情報」ボタン(_show_stats())を command= で接続しています。ボタンはラベルと処理をペアで並べたデータをループで回して生成し、それぞれ _open_csv()・_save_csv()・_save_as() を command= に渡しています。ボタンを増やしたいときはこのペアを1行足すだけで済みます。また bind("<KeyRelease>", ...) から _filter()、bind("<<ComboboxSelected>>", ...) から _filter()、bind("<Double-1>", ...) から _on_double_click()、bind("<Delete>", ...) から _delete_row() を呼べるようにイベントも登録しています。セル編集用の tk.Entry には、bind("<Return>", ...) と bind("<FocusOut>", ...) で編集の確定 confirm()、bind("<Escape>", ...) で lambda e: entry.destroy() を登録しています。Enter で確定すると編集欄が消え、そのあと FocusOut でもう一度 confirm() が呼ばれることがあるため、confirm() の先頭で entry.winfo_exists() を確かめて二重に確定しないようにしています。

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

読み込み: 文字コードの判定と dtype=str

_load_file() は、ファイルの先頭3バイトが UTF-8 の BOM なら utf-8-sig、そうでなければ utf-8 → cp932 の順に試し、どれでも読めなければ最後に latin-1 で読みます。使った文字コードは self.encoding に覚えて、ステータス欄にも表示します。

pandas では pd.read_csv(path, encoding=enc, dtype=str, keep_default_na=False) で読みます。dtype=str を付けないと、pandas が列の型を推測して 007 を 7、100 を 100.0(同じ列に空欄があるとき)に変えてしまい、そのまま保存すると元の表記が失われます。keep_default_na=False は空欄を NaN にせず空文字のまま持つための指定です。こうして、書いてあるとおりの文字列で持つようにしています。

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

表の行と self.df を iid で対応づける

画面の表(Treeview)と、データ本体の self.df は別物です。そこで _insert_row() で行を入れるとき、Treeview の各行の iid(行の識別子)に self.df の行番号を入れておきます。検索や並べ替えで表示順が変わっても、iid から self.df の行をたどれます。

セルをダブルクリックすると、_on_double_click() が self.df から元の値を取り出して編集欄に入れます(表に表示されている値は "007" が 7 になって返ってくることがあるためです)。確定すると _set_cell()(11行)が先に self.df を書き換え、その行の表示を self.df の値(文字列)で描き直します。表から値を読み戻さないので、同じ行にある "007" が 7 と表示し直されることもありません。行追加 _add_row() と行削除 _delete_row() も同じ行番号で self.df 側を更新するので、編集のあとに検索や並べ替えをしても内容が残ります。

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

並べ替えと統計情報

値をすべて文字列で持っているので、そのまま並べると "100" が "20" より前に来ます。_sort_by()(10行)は、_to_number()(6行)で「空欄以外がすべて数値の列か」を調べ、数値の列なら数値の大小で、そうでなければ文字列の順で self.df を並べ替えます(数値の列では空欄は末尾に回ります)。並べ替えたあとは _filter() を呼ぶので、検索で絞り込んでいる最中でも絞り込みは保たれます。

_show_stats()(17行)も同じ _to_number() を使い、数値の列だけ数値に直したコピーに対して describe(include="all") を実行します。元の self.df は文字列のまま変わりません。

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

検索と保存

_filter()(25行)は、入力した語を含む行だけを表に入れ直します。pandas では str.contains(q, regex=False) を使うので、( や * などの記号を入れても正規表現としては解釈されず、文字どおりに探します。

保存(_save_csv()・_save_as())は、どちらも _write_csv() から self.df.to_csv(path, index=False, encoding=self.encoding) で書き出します。表(Treeview)ではなく self.df を書き出すため、検索で絞り込んでいる最中でも全行が保存されます。表示中の行数が全行より少ないときは、ステータス欄に「保存済み(絞り込みに関係なく全行)」と表示されます。文字コードは読み込んだときと同じものを使います。見出しクリックで並べ替えたあとに保存すると、並べ替えた順で書き出されます。self.df が無い(まだ CSV を開いていない)ときは、保存ボタンを押しても何もしません。

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

例外処理

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

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

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

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

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

    新しいファイルを作成して app08.py と保存します。外部ライブラリ pandas を使います。先に pip install pandas を実行してください。

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

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

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

    title("CSVデータビューア・エディタ")・geometry("900x600")・configure(bg="#f8f9fc") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

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

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

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_add_row()・_delete_row()・_show_stats())につなぎます。ループ生成のボタンは、ラベルと処理(_open_csv()・_save_csv()・_save_as())をペアで並べたデータをループで回し、command= に渡して接続します。bind("<KeyRelease>", ...)・bind("<<ComboboxSelected>>", ...)・bind("<Double-1>", ...)・bind("<Delete>", ...)・bind("<Return>", ...)・bind("<Escape>", ...)・bind("<FocusOut>", ...) の登録も忘れずに(最後の3つはセル編集用の tk.Entry に付けます)。

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

    まず読み込みの _load_file()(36行)を書き、dtype=str で読めていること(たとえば print(self.df.dtypes) で各列が文字列の型(pandas 3 以降は str、pandas 2 では object)になっていること)を確かめます。次に、表に行を入れる _insert_row()(3行)で iid に self.df の行番号を入れ、セル編集の _on_double_click()(32行)と、先に self.df を書き換えてからその行の表示を描き直す _set_cell()(11行)を実装します。続けて検索の _filter()(25行)、並べ替えの _to_number()(6行)・_sort_by()(10行)、統計の _show_stats()(17行)、保存の _write_csv()(10行)を足していきます。

  7. 7
    動作確認する

    python app08.py で起動し、各ボタンが反応すること・キー操作(<KeyRelease>・<Delete>・<Return>・<Escape>)が効くこと・マウス操作(クリック・ホイールなど)が効くことを確認します。あわせて「セルを編集 → 検索で絞り込む → 検索欄を空にする」の順に操作して編集が残ること、絞り込み中に保存しても全行が書き出されることを、元のファイルとは別名で保存して確かめておくと安心です。

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

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

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

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

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

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

💡 画面の文言を自分好みに変える

ラベルやボタンの文言を自分の好みに書き換えてみましょう。文言を変えるだけでも、どのコードがどの表示に対応しているかが立体的に見えてきます。

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

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

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

解決法:フォント名を指定しているのは、タイトル(Noto Sans JP)、ステータスバーとセル編集欄(Arial)、統計情報ウィンドウ(Courier New)です。Courier New が等幅でない書体に置き換わると統計表の列がずれて見えますが、読み込んだ値と保存される CSV の中身には影響しません。列を揃えたい場合は、_show_stats() の font=("Courier New", 10) を自分の OS にある等幅フォントの名前へ書き換えてください。ほかの font 引数は省略しても動きます。

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

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

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

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

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

解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。

❌ 検索で絞り込んで保存したのに、全行が保存された

原因:仕様です。保存は表(Treeview)ではなく self.df を書き出すため、絞り込みは保存内容に影響しません。絞り込みで表示中の行数が全行より少ないときに保存すると、ステータス欄に「保存済み(絞り込みに関係なく全行)」と出ます。

解決法:絞り込んだ行だけを別ファイルにしたい場合は、不要な行を「🗑️ 行削除」で消してから「💾 名前を付けて保存」で別名保存してください。元のファイルを上書きしないよう注意してください。

❌ 日本語が文字化けして表示される

原因:このコードは utf-8 → cp932 → latin-1 の順に試し、エラーなく読めた最初の文字コードを採用します。EUC-JP など、このコードが試さない文字コードのファイルは、cp932 でたまたまエラーなく読めてしまうか、最後の latin-1(どんなバイト列でもエラーなく読める)で読まれます。どちらの場合も日本語は文字化けして表示されます。

解決法:ステータス欄の「文字コード:」と元のファイルの文字コードを見比べてください。食い違っている場合は、テキストエディタで UTF-8 か Shift_JIS(cp932)に変換してから開き直すか、_load_file() の試す文字コードの並びに、そのファイルの文字コードを足してください。latin-1 で読んだまま日本語を入力して保存すると、latin-1 で表せない文字はエラーになります。

❌ 見出しをクリックしても並べ替わらない・数値順にならない

原因:並べ替え(_sort_by())は pandas が入っていないと何もしません。また、空欄以外に1つでも数値でない値(例: 「1,200」「-」)がある列は、文字列の順で並べます。

解決法:pip install pandas を実行してください。数値順にしたい列は、カンマや記号を含まない数値だけにそろえてください。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。

  1. 課題1:数値の列を右寄せにする

    読み込んだ CSV のうち、数値だけの列を右寄せ表示にします。文字が混じる列は左寄せのままにしてください。

    期待結果:「商品,単価,数量」の CSV を開くと、単価と数量の値が右端に揃い、商品名は左寄せのまま表示される。

    合格条件

    • 別の CSV を開き直すと、その CSV の数値列だけが右寄せになる
    • 数字と文字が混ざる列(例: 「A-1」「B-2」)は左寄せのまま
    • 見出し(ヘッダー)の表示は今までどおり

    ヒント①(どこを触るか): 列の設定は _setup_columns() の self.tree.column(c, width=…, minwidth=40) の1行です。ここに anchor を渡します。
    ヒント②(使うもの): 右寄せは anchor="e"、左寄せは "w"。数値列かどうかは、並べ替えでも使っている self._to_number(self.df[c]) is not None で判定できます。
    つまずきやすい点: このコードは dtype=str で読むので、すべての列の型が文字列(pandas 3 以降は str、pandas 2 では object)になります。pd.api.types.is_numeric_dtype() で型を調べても数値列は見つかりません。また pandas が入っていない環境では self.df が簡易クラス SimpleDf に差し替わり、self.df[列名] の書き方も _to_number() も使えません。判定の条件に PANDAS_AVAILABLE and … を付けておいてください。

  2. 課題2:未保存の変更があるときに確認する

    いまのコードは、セルを編集したあと保存せずにウィンドウを閉じたり、別の CSV を開いたりすると、編集内容が確認なしで失われます。未保存の変更があるときは、確認のダイアログを出すようにします。

    期待結果:セルを編集してから「📂 CSVを開く」を押すか、ウィンドウの × を押すと「保存していない変更があります。破棄しますか?」と聞かれる。「いいえ」を選ぶと元の画面に戻る。

    合格条件

    • 編集・行追加・行削除のどれをしても「未保存」の扱いになる
    • 保存したあとや、CSV を開き直した直後は確認が出ない
    • ステータス欄かタイトルに、未保存かどうかが分かる印(例: 先頭に「*」)が付く

    ヒント①(どこを触るか): self.df を書き換えているのは _set_cell()・_add_row()・_delete_row() の3か所です。ここで self.dirty = True にし、_load_file() と _write_csv() の成功時に False に戻します(__init__ で初期値 False を忘れずに)。
    ヒント②(使うもの): × ボタンを押したときの処理は self.root.protocol("WM_DELETE_WINDOW", 関数) で差し替えられます。確認には既に使っている messagebox.askyesno() が使えます。
    つまずきやすい点: ウィンドウを閉じる関数の中で、確認が通ったら最後に self.root.destroy() を呼ばないと、× を押してもウィンドウが閉じなくなります。また並べ替え(_sort_by())も self.df の行の順番を変えるので、保存すればファイルの行順も変わります。並べ替えを「変更」に数えるかどうかも決めておきましょう。

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

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

🚀
次に挑戦するアプリ

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

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

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

📖
この記事の書き方を本で確かめるなら

このアプリで中心になるのは文字コードを順に試す with open と、csv / pandas による読み込み。下の表は『退屈なこと』と『Effective』について、出版社や著者が公開している目次から引いた該当章です(各章の解説内容は当サイトでは未確認。「—」は目次の見出しにその語が無いもので、扱っていないとは限りません)。

この記事で使う書き方退屈なことはPythonにやらせよう 第3版Effective Python 第3版
csv(CSV の読み書き)18章(18.1 CSV形式)—
ファイルの読み書き(open・with)10章(ファイルの読み書き)10章(項目82 contextlibとwith文)
try-except(例外処理)4章(4.8 例外処理)10章(項目80 try/except/else/finally)
class(クラス)—7章(クラスとインタフェース)
内包表記—6章(内包表記とジェネレータ)

本が無くても進めます。tkinter の部品はtkinter ウィジェット一覧、文法は基本文法一覧(どちらも当サイト)。2冊の違いと向く人は書籍紹介で比較しています。