中級者向け No.095

カンバンボード

ToDo・進行中・完了の3列でタスクをドラッグ移動できるカンバンボード。JSONで永続管理する。

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

1. アプリ概要

ToDo・進行中・完了の3列でタスクをドラッグ移動できるカンバンボード。JSONで永続管理する。

このアプリはutilカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter(標準ライブラリ)、難易度は ★★★ です。

Pythonの豊富なライブラリを活用することで、実用的なアプリを短いコードで実装できます。ソースコードをコピーして実行し、仕組みを理解したうえでカスタマイズに挑戦してみてください。

GUIアプリ開発はプログラミングの楽しさを実感できる最も効果的な学習方法のひとつです。変数・関数・クラス・イベント処理などの重要な概念が自然と身につきます。

2. 機能一覧

  • カンバンボードのメイン機能
  • 直感的なGUIインターフェース
  • 入力値のバリデーション
  • エラーハンドリング
  • 結果の見やすい表示
  • クリア機能付き

3. 事前準備・環境

ℹ️
動作確認環境

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

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

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

4. 完全なソースコード

💡
コードのコピー方法

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

app095.py
import tkinter as tk
from tkinter import ttk, messagebox, simpledialog
import json
import os
from datetime import datetime

SAVE_PATH = os.path.join(os.path.dirname(__file__), "kanban.json")

PRIORITIES = {"高": "#ef5350", "中": "#ffa726", "低": "#26a69a"}

DEFAULT_COLUMNS = ["TODO", "進行中", "レビュー", "完了"]


class KanbanCard:
    """カンバンカードウィジェット"""

    def __init__(self, parent, card_data, on_move, on_edit, on_delete):
        self._data     = card_data
        self._on_move  = on_move
        self._on_edit  = on_edit
        self._on_delete = on_delete

        prio_color = PRIORITIES.get(card_data.get("priority", "中"), "#ffa726")

        self.frame = tk.Frame(parent, bg="#21262d", bd=1, relief=tk.RAISED,
                               padx=6, pady=4, cursor="hand2")

        # 優先度インジケーター
        tk.Frame(self.frame, bg=prio_color, width=4).pack(
            side=tk.LEFT, fill=tk.Y)

        content = tk.Frame(self.frame, bg="#21262d")
        content.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(4, 0))

        title_row = tk.Frame(content, bg="#21262d")
        title_row.pack(fill=tk.X)
        tk.Label(title_row, text=card_data["title"],
                 bg="#21262d", fg="#c9d1d9", font=("Arial", 9, "bold"),
                 anchor="w", wraplength=160).pack(side=tk.LEFT, fill=tk.X, expand=True)

        # コンテキストメニュー
        self.frame.bind("<Button-3>", self._popup)
        content.bind("<Button-3>", self._popup)

        # サブ情報
        desc = card_data.get("desc", "")
        if desc:
            tk.Label(content, text=desc[:50], bg="#21262d", fg="#8b949e",
                     font=("Arial", 7), anchor="w", wraplength=160).pack(
                fill=tk.X)

        # タグ
        tags = card_data.get("tags", "")
        if tags:
            tag_row = tk.Frame(content, bg="#21262d")
            tag_row.pack(fill=tk.X, pady=(2, 0))
            for t in tags.split(",")[:3]:
                t = t.strip()
                if t:
                    tk.Label(tag_row, text=t, bg="#1f6feb", fg="white",
                             font=("Arial", 7), padx=3, pady=1).pack(
                        side=tk.LEFT, padx=1)

        due = card_data.get("due", "")
        if due:
            tk.Label(content, text=f"期限: {due}", bg="#21262d",
                     fg="#8b949e", font=("Arial", 7)).pack(anchor="w")

    def _popup(self, event):
        m = tk.Menu(self.frame, tearoff=False, bg="#21262d", fg="#c9d1d9")
        m.add_command(label="✏ 編集",  command=self._on_edit)
        m.add_separator()
        m.add_command(label="← 前へ移動", command=lambda: self._on_move(-1))
        m.add_command(label="→ 次へ移動", command=lambda: self._on_move(1))
        m.add_separator()
        m.add_command(label="🗑 削除",  command=self._on_delete)
        m.post(event.x_root, event.y_root)


class App095:
    """カンバンボード"""

    def __init__(self, root):
        self.root = root
        self.root.title("カンバンボード")
        self.root.geometry("1100x680")
        self.root.configure(bg="#0d1117")
        self._columns  = []   # [{"name": ..., "cards": [...]}]
        self._card_widgets = {}   # {card_id: KanbanCard}
        self._next_id  = 1
        self._build_ui()
        self._load()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#161b22", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="📋 カンバンボード",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#161b22", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        # ツールバー
        tb = tk.Frame(self.root, bg="#161b22", pady=4)
        tb.pack(fill=tk.X)
        ttk.Button(tb, text="+ カードを追加",
                   command=self._add_card).pack(side=tk.LEFT, padx=4)
        ttk.Button(tb, text="+ 列を追加",
                   command=self._add_column).pack(side=tk.LEFT, padx=2)
        ttk.Button(tb, text="💾 保存",
                   command=self._save).pack(side=tk.LEFT, padx=2)
        ttk.Button(tb, text="↩ リセット",
                   command=self._reset).pack(side=tk.LEFT, padx=2)

        # フィルター
        tk.Label(tb, text="優先度:", bg="#161b22", fg="#8b949e",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(12, 2))
        self.filter_prio = tk.StringVar(value="ALL")
        ttk.Combobox(tb, textvariable=self.filter_prio,
                     values=["ALL", "高", "中", "低"],
                     state="readonly", width=6).pack(side=tk.LEFT)
        self.filter_prio.trace_add("write", lambda *_: self._render())

        # ボードエリア (スクロール可能)
        canvas = tk.Canvas(self.root, bg="#0d1117", highlightthickness=0)
        xsb = ttk.Scrollbar(self.root, orient=tk.HORIZONTAL,
                             command=canvas.xview)
        canvas.configure(xscrollcommand=xsb.set)
        xsb.pack(side=tk.BOTTOM, fill=tk.X)
        canvas.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        self.board_frame = tk.Frame(canvas, bg="#0d1117")
        self._canvas_win = canvas.create_window((0, 0), window=self.board_frame,
                                                  anchor="nw")
        self.board_frame.bind("<Configure>",
                               lambda e: canvas.configure(
                                   scrollregion=canvas.bbox("all")))
        self._canvas = canvas

        self.status_var = tk.StringVar(value="カードを追加してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#21262d", fg="#8b949e", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    def _render(self):
        # ボードをクリア
        for widget in self.board_frame.winfo_children():
            widget.destroy()
        self._card_widgets.clear()

        filter_prio = self.filter_prio.get()

        for col_idx, col in enumerate(self._columns):
            col_frame = tk.Frame(self.board_frame, bg="#161b22",
                                  width=210, padx=4, pady=4)
            col_frame.pack(side=tk.LEFT, fill=tk.Y, padx=4, pady=4)
            col_frame.pack_propagate(False)

            # 列ヘッダー
            hdr = tk.Frame(col_frame, bg="#21262d")
            hdr.pack(fill=tk.X, pady=(0, 4))
            tk.Label(hdr, text=col["name"],
                     bg="#21262d", fg="#c9d1d9",
                     font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=4)
            count = len(col["cards"])
            tk.Label(hdr, text=str(count), bg="#4fc3f7", fg="#000",
                     font=("Arial", 8, "bold"), padx=4).pack(side=tk.LEFT)
            ttk.Button(hdr, text="+",
                       command=lambda ci=col_idx: self._add_card(ci)
                       ).pack(side=tk.RIGHT, padx=2)

            # カード
            cards_area = tk.Frame(col_frame, bg="#161b22")
            cards_area.pack(fill=tk.BOTH, expand=True)

            for card in col["cards"]:
                if filter_prio != "ALL" and card.get("priority") != filter_prio:
                    continue
                cid = card["id"]
                kc = KanbanCard(
                    cards_area, card,
                    on_move   =lambda d, ci=col_idx, c=card: self._move_card(ci, c, d),
                    on_edit   =lambda ci=col_idx, c=card: self._edit_card(ci, c),
                    on_delete =lambda ci=col_idx, c=card: self._delete_card(ci, c),
                )
                kc.frame.pack(fill=tk.X, pady=2)
                self._card_widgets[cid] = kc

        total = sum(len(c["cards"]) for c in self._columns)
        self.status_var.set(
            f"列: {len(self._columns)}  カード合計: {total}")

    def _add_column(self):
        name = simpledialog.askstring("列を追加", "列名を入力してください:",
                                       parent=self.root)
        if name:
            self._columns.append({"name": name, "cards": []})
            self._render()
            self._save()

    def _add_card(self, col_idx=0):
        if not self._columns:
            messagebox.showwarning("警告", "列を追加してください")
            return
        card = self._card_dialog()
        if card:
            card["id"] = self._next_id
            self._next_id += 1
            ci = min(col_idx, len(self._columns) - 1)
            self._columns[ci]["cards"].append(card)
            self._render()
            self._save()

    def _edit_card(self, col_idx, card):
        updated = self._card_dialog(card)
        if updated:
            card.update(updated)
            self._render()
            self._save()

    def _delete_card(self, col_idx, card):
        if messagebox.askyesno("確認", f"「{card['title']}」を削除しますか?"):
            self._columns[col_idx]["cards"].remove(card)
            self._render()
            self._save()

    def _move_card(self, col_idx, card, direction):
        target = col_idx + direction
        if 0 <= target < len(self._columns):
            self._columns[col_idx]["cards"].remove(card)
            self._columns[target]["cards"].append(card)
            self._render()
            self._save()

    def _card_dialog(self, existing=None):
        dlg = tk.Toplevel(self.root)
        dlg.title("カード編集" if existing else "カードを追加")
        dlg.configure(bg="#21262d")
        dlg.grab_set()
        result = {}

        fields = [
            ("タイトル", "title",  existing.get("title", "") if existing else ""),
            ("説明",     "desc",   existing.get("desc",  "") if existing else ""),
            ("タグ (,区切り)", "tags", existing.get("tags", "") if existing else ""),
            ("期限 (YYYY-MM-DD)", "due", existing.get("due", "") if existing else ""),
        ]
        vars_ = {}
        for label, key, default in fields:
            row = tk.Frame(dlg, bg="#21262d")
            row.pack(fill=tk.X, padx=12, pady=3)
            tk.Label(row, text=f"{label}:", bg="#21262d", fg="#ccc",
                     width=18, anchor="w").pack(side=tk.LEFT)
            v = tk.StringVar(value=default)
            vars_[key] = v
            ttk.Entry(row, textvariable=v, width=24).pack(side=tk.LEFT)

        prio_var = tk.StringVar(value=existing.get("priority", "中") if existing else "中")
        prio_row = tk.Frame(dlg, bg="#21262d")
        prio_row.pack(fill=tk.X, padx=12, pady=3)
        tk.Label(prio_row, text="優先度:", bg="#21262d", fg="#ccc",
                 width=18, anchor="w").pack(side=tk.LEFT)
        ttk.Combobox(prio_row, textvariable=prio_var,
                     values=list(PRIORITIES.keys()),
                     state="readonly", width=8).pack(side=tk.LEFT)

        def _ok():
            if not vars_["title"].get().strip():
                return
            for k, v in vars_.items():
                result[k] = v.get()
            result["priority"] = prio_var.get()
            result["updated"]  = datetime.now().strftime("%Y-%m-%d %H:%M")
            dlg.destroy()

        ttk.Button(dlg, text="保存", command=_ok).pack(pady=8)
        dlg.wait_window()
        return result if result else None

    def _reset(self):
        if messagebox.askyesno("確認", "ボードをリセットしますか?"):
            self._columns = [{"name": c, "cards": []} for c in DEFAULT_COLUMNS]
            self._next_id = 1
            self._render()
            self._save()

    def _save(self):
        try:
            with open(SAVE_PATH, "w", encoding="utf-8") as f:
                json.dump({"next_id": self._next_id,
                           "columns": self._columns}, f,
                          ensure_ascii=False, indent=2)
        except Exception:
            pass

    def _load(self):
        if os.path.exists(SAVE_PATH):
            try:
                with open(SAVE_PATH, encoding="utf-8") as f:
                    data = json.load(f)
                self._next_id = data.get("next_id", 1)
                self._columns = data.get("columns", [])
            except Exception:
                self._columns = [{"name": c, "cards": []}
                                  for c in DEFAULT_COLUMNS]
        else:
            self._columns = [{"name": c, "cards": []}
                              for c in DEFAULT_COLUMNS]
            # サンプルカード
            samples = [
                (0, "トップページのデザイン変更", "高", "UIチーム", "design"),
                (0, "APIエンドポイント追加",   "中", "バックエンド対応", "api,backend"),
                (1, "ログイン機能の実装",       "高", "認証モジュール", "auth"),
                (2, "単体テスト作成",           "低", "テストカバレッジ80%以上", "test"),
            ]
            for ci, title, prio, desc, tags in samples:
                self._columns[ci]["cards"].append({
                    "id": self._next_id, "title": title,
                    "priority": prio, "desc": desc, "tags": tags, "due": "",
                    "updated": datetime.now().strftime("%Y-%m-%d %H:%M"),
                })
                self._next_id += 1
        self._render()


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

5. コード解説

カンバンボードのコードを詳しく解説します。クラスベースの設計で各機能を整理して実装しています。

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

App095クラスにアプリの全機能をまとめています。__init__でウィンドウ設定、_build_ui()でUI構築、process()でメイン処理を担当します。責任の分離により、コードが読みやすくなります。

import tkinter as tk
from tkinter import ttk, messagebox, simpledialog
import json
import os
from datetime import datetime

SAVE_PATH = os.path.join(os.path.dirname(__file__), "kanban.json")

PRIORITIES = {"高": "#ef5350", "中": "#ffa726", "低": "#26a69a"}

DEFAULT_COLUMNS = ["TODO", "進行中", "レビュー", "完了"]


class KanbanCard:
    """カンバンカードウィジェット"""

    def __init__(self, parent, card_data, on_move, on_edit, on_delete):
        self._data     = card_data
        self._on_move  = on_move
        self._on_edit  = on_edit
        self._on_delete = on_delete

        prio_color = PRIORITIES.get(card_data.get("priority", "中"), "#ffa726")

        self.frame = tk.Frame(parent, bg="#21262d", bd=1, relief=tk.RAISED,
                               padx=6, pady=4, cursor="hand2")

        # 優先度インジケーター
        tk.Frame(self.frame, bg=prio_color, width=4).pack(
            side=tk.LEFT, fill=tk.Y)

        content = tk.Frame(self.frame, bg="#21262d")
        content.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(4, 0))

        title_row = tk.Frame(content, bg="#21262d")
        title_row.pack(fill=tk.X)
        tk.Label(title_row, text=card_data["title"],
                 bg="#21262d", fg="#c9d1d9", font=("Arial", 9, "bold"),
                 anchor="w", wraplength=160).pack(side=tk.LEFT, fill=tk.X, expand=True)

        # コンテキストメニュー
        self.frame.bind("<Button-3>", self._popup)
        content.bind("<Button-3>", self._popup)

        # サブ情報
        desc = card_data.get("desc", "")
        if desc:
            tk.Label(content, text=desc[:50], bg="#21262d", fg="#8b949e",
                     font=("Arial", 7), anchor="w", wraplength=160).pack(
                fill=tk.X)

        # タグ
        tags = card_data.get("tags", "")
        if tags:
            tag_row = tk.Frame(content, bg="#21262d")
            tag_row.pack(fill=tk.X, pady=(2, 0))
            for t in tags.split(",")[:3]:
                t = t.strip()
                if t:
                    tk.Label(tag_row, text=t, bg="#1f6feb", fg="white",
                             font=("Arial", 7), padx=3, pady=1).pack(
                        side=tk.LEFT, padx=1)

        due = card_data.get("due", "")
        if due:
            tk.Label(content, text=f"期限: {due}", bg="#21262d",
                     fg="#8b949e", font=("Arial", 7)).pack(anchor="w")

    def _popup(self, event):
        m = tk.Menu(self.frame, tearoff=False, bg="#21262d", fg="#c9d1d9")
        m.add_command(label="✏ 編集",  command=self._on_edit)
        m.add_separator()
        m.add_command(label="← 前へ移動", command=lambda: self._on_move(-1))
        m.add_command(label="→ 次へ移動", command=lambda: self._on_move(1))
        m.add_separator()
        m.add_command(label="🗑 削除",  command=self._on_delete)
        m.post(event.x_root, event.y_root)


class App095:
    """カンバンボード"""

    def __init__(self, root):
        self.root = root
        self.root.title("カンバンボード")
        self.root.geometry("1100x680")
        self.root.configure(bg="#0d1117")
        self._columns  = []   # [{"name": ..., "cards": [...]}]
        self._card_widgets = {}   # {card_id: KanbanCard}
        self._next_id  = 1
        self._build_ui()
        self._load()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#161b22", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="📋 カンバンボード",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#161b22", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        # ツールバー
        tb = tk.Frame(self.root, bg="#161b22", pady=4)
        tb.pack(fill=tk.X)
        ttk.Button(tb, text="+ カードを追加",
                   command=self._add_card).pack(side=tk.LEFT, padx=4)
        ttk.Button(tb, text="+ 列を追加",
                   command=self._add_column).pack(side=tk.LEFT, padx=2)
        ttk.Button(tb, text="💾 保存",
                   command=self._save).pack(side=tk.LEFT, padx=2)
        ttk.Button(tb, text="↩ リセット",
                   command=self._reset).pack(side=tk.LEFT, padx=2)

        # フィルター
        tk.Label(tb, text="優先度:", bg="#161b22", fg="#8b949e",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(12, 2))
        self.filter_prio = tk.StringVar(value="ALL")
        ttk.Combobox(tb, textvariable=self.filter_prio,
                     values=["ALL", "高", "中", "低"],
                     state="readonly", width=6).pack(side=tk.LEFT)
        self.filter_prio.trace_add("write", lambda *_: self._render())

        # ボードエリア (スクロール可能)
        canvas = tk.Canvas(self.root, bg="#0d1117", highlightthickness=0)
        xsb = ttk.Scrollbar(self.root, orient=tk.HORIZONTAL,
                             command=canvas.xview)
        canvas.configure(xscrollcommand=xsb.set)
        xsb.pack(side=tk.BOTTOM, fill=tk.X)
        canvas.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        self.board_frame = tk.Frame(canvas, bg="#0d1117")
        self._canvas_win = canvas.create_window((0, 0), window=self.board_frame,
                                                  anchor="nw")
        self.board_frame.bind("<Configure>",
                               lambda e: canvas.configure(
                                   scrollregion=canvas.bbox("all")))
        self._canvas = canvas

        self.status_var = tk.StringVar(value="カードを追加してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#21262d", fg="#8b949e", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    def _render(self):
        # ボードをクリア
        for widget in self.board_frame.winfo_children():
            widget.destroy()
        self._card_widgets.clear()

        filter_prio = self.filter_prio.get()

        for col_idx, col in enumerate(self._columns):
            col_frame = tk.Frame(self.board_frame, bg="#161b22",
                                  width=210, padx=4, pady=4)
            col_frame.pack(side=tk.LEFT, fill=tk.Y, padx=4, pady=4)
            col_frame.pack_propagate(False)

            # 列ヘッダー
            hdr = tk.Frame(col_frame, bg="#21262d")
            hdr.pack(fill=tk.X, pady=(0, 4))
            tk.Label(hdr, text=col["name"],
                     bg="#21262d", fg="#c9d1d9",
                     font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=4)
            count = len(col["cards"])
            tk.Label(hdr, text=str(count), bg="#4fc3f7", fg="#000",
                     font=("Arial", 8, "bold"), padx=4).pack(side=tk.LEFT)
            ttk.Button(hdr, text="+",
                       command=lambda ci=col_idx: self._add_card(ci)
                       ).pack(side=tk.RIGHT, padx=2)

            # カード
            cards_area = tk.Frame(col_frame, bg="#161b22")
            cards_area.pack(fill=tk.BOTH, expand=True)

            for card in col["cards"]:
                if filter_prio != "ALL" and card.get("priority") != filter_prio:
                    continue
                cid = card["id"]
                kc = KanbanCard(
                    cards_area, card,
                    on_move   =lambda d, ci=col_idx, c=card: self._move_card(ci, c, d),
                    on_edit   =lambda ci=col_idx, c=card: self._edit_card(ci, c),
                    on_delete =lambda ci=col_idx, c=card: self._delete_card(ci, c),
                )
                kc.frame.pack(fill=tk.X, pady=2)
                self._card_widgets[cid] = kc

        total = sum(len(c["cards"]) for c in self._columns)
        self.status_var.set(
            f"列: {len(self._columns)}  カード合計: {total}")

    def _add_column(self):
        name = simpledialog.askstring("列を追加", "列名を入力してください:",
                                       parent=self.root)
        if name:
            self._columns.append({"name": name, "cards": []})
            self._render()
            self._save()

    def _add_card(self, col_idx=0):
        if not self._columns:
            messagebox.showwarning("警告", "列を追加してください")
            return
        card = self._card_dialog()
        if card:
            card["id"] = self._next_id
            self._next_id += 1
            ci = min(col_idx, len(self._columns) - 1)
            self._columns[ci]["cards"].append(card)
            self._render()
            self._save()

    def _edit_card(self, col_idx, card):
        updated = self._card_dialog(card)
        if updated:
            card.update(updated)
            self._render()
            self._save()

    def _delete_card(self, col_idx, card):
        if messagebox.askyesno("確認", f"「{card['title']}」を削除しますか?"):
            self._columns[col_idx]["cards"].remove(card)
            self._render()
            self._save()

    def _move_card(self, col_idx, card, direction):
        target = col_idx + direction
        if 0 <= target < len(self._columns):
            self._columns[col_idx]["cards"].remove(card)
            self._columns[target]["cards"].append(card)
            self._render()
            self._save()

    def _card_dialog(self, existing=None):
        dlg = tk.Toplevel(self.root)
        dlg.title("カード編集" if existing else "カードを追加")
        dlg.configure(bg="#21262d")
        dlg.grab_set()
        result = {}

        fields = [
            ("タイトル", "title",  existing.get("title", "") if existing else ""),
            ("説明",     "desc",   existing.get("desc",  "") if existing else ""),
            ("タグ (,区切り)", "tags", existing.get("tags", "") if existing else ""),
            ("期限 (YYYY-MM-DD)", "due", existing.get("due", "") if existing else ""),
        ]
        vars_ = {}
        for label, key, default in fields:
            row = tk.Frame(dlg, bg="#21262d")
            row.pack(fill=tk.X, padx=12, pady=3)
            tk.Label(row, text=f"{label}:", bg="#21262d", fg="#ccc",
                     width=18, anchor="w").pack(side=tk.LEFT)
            v = tk.StringVar(value=default)
            vars_[key] = v
            ttk.Entry(row, textvariable=v, width=24).pack(side=tk.LEFT)

        prio_var = tk.StringVar(value=existing.get("priority", "中") if existing else "中")
        prio_row = tk.Frame(dlg, bg="#21262d")
        prio_row.pack(fill=tk.X, padx=12, pady=3)
        tk.Label(prio_row, text="優先度:", bg="#21262d", fg="#ccc",
                 width=18, anchor="w").pack(side=tk.LEFT)
        ttk.Combobox(prio_row, textvariable=prio_var,
                     values=list(PRIORITIES.keys()),
                     state="readonly", width=8).pack(side=tk.LEFT)

        def _ok():
            if not vars_["title"].get().strip():
                return
            for k, v in vars_.items():
                result[k] = v.get()
            result["priority"] = prio_var.get()
            result["updated"]  = datetime.now().strftime("%Y-%m-%d %H:%M")
            dlg.destroy()

        ttk.Button(dlg, text="保存", command=_ok).pack(pady=8)
        dlg.wait_window()
        return result if result else None

    def _reset(self):
        if messagebox.askyesno("確認", "ボードをリセットしますか?"):
            self._columns = [{"name": c, "cards": []} for c in DEFAULT_COLUMNS]
            self._next_id = 1
            self._render()
            self._save()

    def _save(self):
        try:
            with open(SAVE_PATH, "w", encoding="utf-8") as f:
                json.dump({"next_id": self._next_id,
                           "columns": self._columns}, f,
                          ensure_ascii=False, indent=2)
        except Exception:
            pass

    def _load(self):
        if os.path.exists(SAVE_PATH):
            try:
                with open(SAVE_PATH, encoding="utf-8") as f:
                    data = json.load(f)
                self._next_id = data.get("next_id", 1)
                self._columns = data.get("columns", [])
            except Exception:
                self._columns = [{"name": c, "cards": []}
                                  for c in DEFAULT_COLUMNS]
        else:
            self._columns = [{"name": c, "cards": []}
                              for c in DEFAULT_COLUMNS]
            # サンプルカード
            samples = [
                (0, "トップページのデザイン変更", "高", "UIチーム", "design"),
                (0, "APIエンドポイント追加",   "中", "バックエンド対応", "api,backend"),
                (1, "ログイン機能の実装",       "高", "認証モジュール", "auth"),
                (2, "単体テスト作成",           "低", "テストカバレッジ80%以上", "test"),
            ]
            for ci, title, prio, desc, tags in samples:
                self._columns[ci]["cards"].append({
                    "id": self._next_id, "title": title,
                    "priority": prio, "desc": desc, "tags": tags, "due": "",
                    "updated": datetime.now().strftime("%Y-%m-%d %H:%M"),
                })
                self._next_id += 1
        self._render()


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

UIレイアウトの構築

LabelFrameで入力エリアと結果エリアを視覚的に分けています。pack()で縦に並べ、expand=Trueで結果エリアが画面いっぱいに広がるよう設定しています。

import tkinter as tk
from tkinter import ttk, messagebox, simpledialog
import json
import os
from datetime import datetime

SAVE_PATH = os.path.join(os.path.dirname(__file__), "kanban.json")

PRIORITIES = {"高": "#ef5350", "中": "#ffa726", "低": "#26a69a"}

DEFAULT_COLUMNS = ["TODO", "進行中", "レビュー", "完了"]


class KanbanCard:
    """カンバンカードウィジェット"""

    def __init__(self, parent, card_data, on_move, on_edit, on_delete):
        self._data     = card_data
        self._on_move  = on_move
        self._on_edit  = on_edit
        self._on_delete = on_delete

        prio_color = PRIORITIES.get(card_data.get("priority", "中"), "#ffa726")

        self.frame = tk.Frame(parent, bg="#21262d", bd=1, relief=tk.RAISED,
                               padx=6, pady=4, cursor="hand2")

        # 優先度インジケーター
        tk.Frame(self.frame, bg=prio_color, width=4).pack(
            side=tk.LEFT, fill=tk.Y)

        content = tk.Frame(self.frame, bg="#21262d")
        content.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(4, 0))

        title_row = tk.Frame(content, bg="#21262d")
        title_row.pack(fill=tk.X)
        tk.Label(title_row, text=card_data["title"],
                 bg="#21262d", fg="#c9d1d9", font=("Arial", 9, "bold"),
                 anchor="w", wraplength=160).pack(side=tk.LEFT, fill=tk.X, expand=True)

        # コンテキストメニュー
        self.frame.bind("<Button-3>", self._popup)
        content.bind("<Button-3>", self._popup)

        # サブ情報
        desc = card_data.get("desc", "")
        if desc:
            tk.Label(content, text=desc[:50], bg="#21262d", fg="#8b949e",
                     font=("Arial", 7), anchor="w", wraplength=160).pack(
                fill=tk.X)

        # タグ
        tags = card_data.get("tags", "")
        if tags:
            tag_row = tk.Frame(content, bg="#21262d")
            tag_row.pack(fill=tk.X, pady=(2, 0))
            for t in tags.split(",")[:3]:
                t = t.strip()
                if t:
                    tk.Label(tag_row, text=t, bg="#1f6feb", fg="white",
                             font=("Arial", 7), padx=3, pady=1).pack(
                        side=tk.LEFT, padx=1)

        due = card_data.get("due", "")
        if due:
            tk.Label(content, text=f"期限: {due}", bg="#21262d",
                     fg="#8b949e", font=("Arial", 7)).pack(anchor="w")

    def _popup(self, event):
        m = tk.Menu(self.frame, tearoff=False, bg="#21262d", fg="#c9d1d9")
        m.add_command(label="✏ 編集",  command=self._on_edit)
        m.add_separator()
        m.add_command(label="← 前へ移動", command=lambda: self._on_move(-1))
        m.add_command(label="→ 次へ移動", command=lambda: self._on_move(1))
        m.add_separator()
        m.add_command(label="🗑 削除",  command=self._on_delete)
        m.post(event.x_root, event.y_root)


class App095:
    """カンバンボード"""

    def __init__(self, root):
        self.root = root
        self.root.title("カンバンボード")
        self.root.geometry("1100x680")
        self.root.configure(bg="#0d1117")
        self._columns  = []   # [{"name": ..., "cards": [...]}]
        self._card_widgets = {}   # {card_id: KanbanCard}
        self._next_id  = 1
        self._build_ui()
        self._load()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#161b22", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="📋 カンバンボード",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#161b22", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        # ツールバー
        tb = tk.Frame(self.root, bg="#161b22", pady=4)
        tb.pack(fill=tk.X)
        ttk.Button(tb, text="+ カードを追加",
                   command=self._add_card).pack(side=tk.LEFT, padx=4)
        ttk.Button(tb, text="+ 列を追加",
                   command=self._add_column).pack(side=tk.LEFT, padx=2)
        ttk.Button(tb, text="💾 保存",
                   command=self._save).pack(side=tk.LEFT, padx=2)
        ttk.Button(tb, text="↩ リセット",
                   command=self._reset).pack(side=tk.LEFT, padx=2)

        # フィルター
        tk.Label(tb, text="優先度:", bg="#161b22", fg="#8b949e",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(12, 2))
        self.filter_prio = tk.StringVar(value="ALL")
        ttk.Combobox(tb, textvariable=self.filter_prio,
                     values=["ALL", "高", "中", "低"],
                     state="readonly", width=6).pack(side=tk.LEFT)
        self.filter_prio.trace_add("write", lambda *_: self._render())

        # ボードエリア (スクロール可能)
        canvas = tk.Canvas(self.root, bg="#0d1117", highlightthickness=0)
        xsb = ttk.Scrollbar(self.root, orient=tk.HORIZONTAL,
                             command=canvas.xview)
        canvas.configure(xscrollcommand=xsb.set)
        xsb.pack(side=tk.BOTTOM, fill=tk.X)
        canvas.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        self.board_frame = tk.Frame(canvas, bg="#0d1117")
        self._canvas_win = canvas.create_window((0, 0), window=self.board_frame,
                                                  anchor="nw")
        self.board_frame.bind("<Configure>",
                               lambda e: canvas.configure(
                                   scrollregion=canvas.bbox("all")))
        self._canvas = canvas

        self.status_var = tk.StringVar(value="カードを追加してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#21262d", fg="#8b949e", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    def _render(self):
        # ボードをクリア
        for widget in self.board_frame.winfo_children():
            widget.destroy()
        self._card_widgets.clear()

        filter_prio = self.filter_prio.get()

        for col_idx, col in enumerate(self._columns):
            col_frame = tk.Frame(self.board_frame, bg="#161b22",
                                  width=210, padx=4, pady=4)
            col_frame.pack(side=tk.LEFT, fill=tk.Y, padx=4, pady=4)
            col_frame.pack_propagate(False)

            # 列ヘッダー
            hdr = tk.Frame(col_frame, bg="#21262d")
            hdr.pack(fill=tk.X, pady=(0, 4))
            tk.Label(hdr, text=col["name"],
                     bg="#21262d", fg="#c9d1d9",
                     font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=4)
            count = len(col["cards"])
            tk.Label(hdr, text=str(count), bg="#4fc3f7", fg="#000",
                     font=("Arial", 8, "bold"), padx=4).pack(side=tk.LEFT)
            ttk.Button(hdr, text="+",
                       command=lambda ci=col_idx: self._add_card(ci)
                       ).pack(side=tk.RIGHT, padx=2)

            # カード
            cards_area = tk.Frame(col_frame, bg="#161b22")
            cards_area.pack(fill=tk.BOTH, expand=True)

            for card in col["cards"]:
                if filter_prio != "ALL" and card.get("priority") != filter_prio:
                    continue
                cid = card["id"]
                kc = KanbanCard(
                    cards_area, card,
                    on_move   =lambda d, ci=col_idx, c=card: self._move_card(ci, c, d),
                    on_edit   =lambda ci=col_idx, c=card: self._edit_card(ci, c),
                    on_delete =lambda ci=col_idx, c=card: self._delete_card(ci, c),
                )
                kc.frame.pack(fill=tk.X, pady=2)
                self._card_widgets[cid] = kc

        total = sum(len(c["cards"]) for c in self._columns)
        self.status_var.set(
            f"列: {len(self._columns)}  カード合計: {total}")

    def _add_column(self):
        name = simpledialog.askstring("列を追加", "列名を入力してください:",
                                       parent=self.root)
        if name:
            self._columns.append({"name": name, "cards": []})
            self._render()
            self._save()

    def _add_card(self, col_idx=0):
        if not self._columns:
            messagebox.showwarning("警告", "列を追加してください")
            return
        card = self._card_dialog()
        if card:
            card["id"] = self._next_id
            self._next_id += 1
            ci = min(col_idx, len(self._columns) - 1)
            self._columns[ci]["cards"].append(card)
            self._render()
            self._save()

    def _edit_card(self, col_idx, card):
        updated = self._card_dialog(card)
        if updated:
            card.update(updated)
            self._render()
            self._save()

    def _delete_card(self, col_idx, card):
        if messagebox.askyesno("確認", f"「{card['title']}」を削除しますか?"):
            self._columns[col_idx]["cards"].remove(card)
            self._render()
            self._save()

    def _move_card(self, col_idx, card, direction):
        target = col_idx + direction
        if 0 <= target < len(self._columns):
            self._columns[col_idx]["cards"].remove(card)
            self._columns[target]["cards"].append(card)
            self._render()
            self._save()

    def _card_dialog(self, existing=None):
        dlg = tk.Toplevel(self.root)
        dlg.title("カード編集" if existing else "カードを追加")
        dlg.configure(bg="#21262d")
        dlg.grab_set()
        result = {}

        fields = [
            ("タイトル", "title",  existing.get("title", "") if existing else ""),
            ("説明",     "desc",   existing.get("desc",  "") if existing else ""),
            ("タグ (,区切り)", "tags", existing.get("tags", "") if existing else ""),
            ("期限 (YYYY-MM-DD)", "due", existing.get("due", "") if existing else ""),
        ]
        vars_ = {}
        for label, key, default in fields:
            row = tk.Frame(dlg, bg="#21262d")
            row.pack(fill=tk.X, padx=12, pady=3)
            tk.Label(row, text=f"{label}:", bg="#21262d", fg="#ccc",
                     width=18, anchor="w").pack(side=tk.LEFT)
            v = tk.StringVar(value=default)
            vars_[key] = v
            ttk.Entry(row, textvariable=v, width=24).pack(side=tk.LEFT)

        prio_var = tk.StringVar(value=existing.get("priority", "中") if existing else "中")
        prio_row = tk.Frame(dlg, bg="#21262d")
        prio_row.pack(fill=tk.X, padx=12, pady=3)
        tk.Label(prio_row, text="優先度:", bg="#21262d", fg="#ccc",
                 width=18, anchor="w").pack(side=tk.LEFT)
        ttk.Combobox(prio_row, textvariable=prio_var,
                     values=list(PRIORITIES.keys()),
                     state="readonly", width=8).pack(side=tk.LEFT)

        def _ok():
            if not vars_["title"].get().strip():
                return
            for k, v in vars_.items():
                result[k] = v.get()
            result["priority"] = prio_var.get()
            result["updated"]  = datetime.now().strftime("%Y-%m-%d %H:%M")
            dlg.destroy()

        ttk.Button(dlg, text="保存", command=_ok).pack(pady=8)
        dlg.wait_window()
        return result if result else None

    def _reset(self):
        if messagebox.askyesno("確認", "ボードをリセットしますか?"):
            self._columns = [{"name": c, "cards": []} for c in DEFAULT_COLUMNS]
            self._next_id = 1
            self._render()
            self._save()

    def _save(self):
        try:
            with open(SAVE_PATH, "w", encoding="utf-8") as f:
                json.dump({"next_id": self._next_id,
                           "columns": self._columns}, f,
                          ensure_ascii=False, indent=2)
        except Exception:
            pass

    def _load(self):
        if os.path.exists(SAVE_PATH):
            try:
                with open(SAVE_PATH, encoding="utf-8") as f:
                    data = json.load(f)
                self._next_id = data.get("next_id", 1)
                self._columns = data.get("columns", [])
            except Exception:
                self._columns = [{"name": c, "cards": []}
                                  for c in DEFAULT_COLUMNS]
        else:
            self._columns = [{"name": c, "cards": []}
                              for c in DEFAULT_COLUMNS]
            # サンプルカード
            samples = [
                (0, "トップページのデザイン変更", "高", "UIチーム", "design"),
                (0, "APIエンドポイント追加",   "中", "バックエンド対応", "api,backend"),
                (1, "ログイン機能の実装",       "高", "認証モジュール", "auth"),
                (2, "単体テスト作成",           "低", "テストカバレッジ80%以上", "test"),
            ]
            for ci, title, prio, desc, tags in samples:
                self._columns[ci]["cards"].append({
                    "id": self._next_id, "title": title,
                    "priority": prio, "desc": desc, "tags": tags, "due": "",
                    "updated": datetime.now().strftime("%Y-%m-%d %H:%M"),
                })
                self._next_id += 1
        self._render()


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

イベント処理

ボタンのcommand引数でクリックイベントを、bind('')でEnterキーイベントを処理します。どちらの操作でも同じprocess()が呼ばれ、コードの重複を避けられます。

import tkinter as tk
from tkinter import ttk, messagebox, simpledialog
import json
import os
from datetime import datetime

SAVE_PATH = os.path.join(os.path.dirname(__file__), "kanban.json")

PRIORITIES = {"高": "#ef5350", "中": "#ffa726", "低": "#26a69a"}

DEFAULT_COLUMNS = ["TODO", "進行中", "レビュー", "完了"]


class KanbanCard:
    """カンバンカードウィジェット"""

    def __init__(self, parent, card_data, on_move, on_edit, on_delete):
        self._data     = card_data
        self._on_move  = on_move
        self._on_edit  = on_edit
        self._on_delete = on_delete

        prio_color = PRIORITIES.get(card_data.get("priority", "中"), "#ffa726")

        self.frame = tk.Frame(parent, bg="#21262d", bd=1, relief=tk.RAISED,
                               padx=6, pady=4, cursor="hand2")

        # 優先度インジケーター
        tk.Frame(self.frame, bg=prio_color, width=4).pack(
            side=tk.LEFT, fill=tk.Y)

        content = tk.Frame(self.frame, bg="#21262d")
        content.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(4, 0))

        title_row = tk.Frame(content, bg="#21262d")
        title_row.pack(fill=tk.X)
        tk.Label(title_row, text=card_data["title"],
                 bg="#21262d", fg="#c9d1d9", font=("Arial", 9, "bold"),
                 anchor="w", wraplength=160).pack(side=tk.LEFT, fill=tk.X, expand=True)

        # コンテキストメニュー
        self.frame.bind("<Button-3>", self._popup)
        content.bind("<Button-3>", self._popup)

        # サブ情報
        desc = card_data.get("desc", "")
        if desc:
            tk.Label(content, text=desc[:50], bg="#21262d", fg="#8b949e",
                     font=("Arial", 7), anchor="w", wraplength=160).pack(
                fill=tk.X)

        # タグ
        tags = card_data.get("tags", "")
        if tags:
            tag_row = tk.Frame(content, bg="#21262d")
            tag_row.pack(fill=tk.X, pady=(2, 0))
            for t in tags.split(",")[:3]:
                t = t.strip()
                if t:
                    tk.Label(tag_row, text=t, bg="#1f6feb", fg="white",
                             font=("Arial", 7), padx=3, pady=1).pack(
                        side=tk.LEFT, padx=1)

        due = card_data.get("due", "")
        if due:
            tk.Label(content, text=f"期限: {due}", bg="#21262d",
                     fg="#8b949e", font=("Arial", 7)).pack(anchor="w")

    def _popup(self, event):
        m = tk.Menu(self.frame, tearoff=False, bg="#21262d", fg="#c9d1d9")
        m.add_command(label="✏ 編集",  command=self._on_edit)
        m.add_separator()
        m.add_command(label="← 前へ移動", command=lambda: self._on_move(-1))
        m.add_command(label="→ 次へ移動", command=lambda: self._on_move(1))
        m.add_separator()
        m.add_command(label="🗑 削除",  command=self._on_delete)
        m.post(event.x_root, event.y_root)


class App095:
    """カンバンボード"""

    def __init__(self, root):
        self.root = root
        self.root.title("カンバンボード")
        self.root.geometry("1100x680")
        self.root.configure(bg="#0d1117")
        self._columns  = []   # [{"name": ..., "cards": [...]}]
        self._card_widgets = {}   # {card_id: KanbanCard}
        self._next_id  = 1
        self._build_ui()
        self._load()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#161b22", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="📋 カンバンボード",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#161b22", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        # ツールバー
        tb = tk.Frame(self.root, bg="#161b22", pady=4)
        tb.pack(fill=tk.X)
        ttk.Button(tb, text="+ カードを追加",
                   command=self._add_card).pack(side=tk.LEFT, padx=4)
        ttk.Button(tb, text="+ 列を追加",
                   command=self._add_column).pack(side=tk.LEFT, padx=2)
        ttk.Button(tb, text="💾 保存",
                   command=self._save).pack(side=tk.LEFT, padx=2)
        ttk.Button(tb, text="↩ リセット",
                   command=self._reset).pack(side=tk.LEFT, padx=2)

        # フィルター
        tk.Label(tb, text="優先度:", bg="#161b22", fg="#8b949e",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(12, 2))
        self.filter_prio = tk.StringVar(value="ALL")
        ttk.Combobox(tb, textvariable=self.filter_prio,
                     values=["ALL", "高", "中", "低"],
                     state="readonly", width=6).pack(side=tk.LEFT)
        self.filter_prio.trace_add("write", lambda *_: self._render())

        # ボードエリア (スクロール可能)
        canvas = tk.Canvas(self.root, bg="#0d1117", highlightthickness=0)
        xsb = ttk.Scrollbar(self.root, orient=tk.HORIZONTAL,
                             command=canvas.xview)
        canvas.configure(xscrollcommand=xsb.set)
        xsb.pack(side=tk.BOTTOM, fill=tk.X)
        canvas.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        self.board_frame = tk.Frame(canvas, bg="#0d1117")
        self._canvas_win = canvas.create_window((0, 0), window=self.board_frame,
                                                  anchor="nw")
        self.board_frame.bind("<Configure>",
                               lambda e: canvas.configure(
                                   scrollregion=canvas.bbox("all")))
        self._canvas = canvas

        self.status_var = tk.StringVar(value="カードを追加してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#21262d", fg="#8b949e", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    def _render(self):
        # ボードをクリア
        for widget in self.board_frame.winfo_children():
            widget.destroy()
        self._card_widgets.clear()

        filter_prio = self.filter_prio.get()

        for col_idx, col in enumerate(self._columns):
            col_frame = tk.Frame(self.board_frame, bg="#161b22",
                                  width=210, padx=4, pady=4)
            col_frame.pack(side=tk.LEFT, fill=tk.Y, padx=4, pady=4)
            col_frame.pack_propagate(False)

            # 列ヘッダー
            hdr = tk.Frame(col_frame, bg="#21262d")
            hdr.pack(fill=tk.X, pady=(0, 4))
            tk.Label(hdr, text=col["name"],
                     bg="#21262d", fg="#c9d1d9",
                     font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=4)
            count = len(col["cards"])
            tk.Label(hdr, text=str(count), bg="#4fc3f7", fg="#000",
                     font=("Arial", 8, "bold"), padx=4).pack(side=tk.LEFT)
            ttk.Button(hdr, text="+",
                       command=lambda ci=col_idx: self._add_card(ci)
                       ).pack(side=tk.RIGHT, padx=2)

            # カード
            cards_area = tk.Frame(col_frame, bg="#161b22")
            cards_area.pack(fill=tk.BOTH, expand=True)

            for card in col["cards"]:
                if filter_prio != "ALL" and card.get("priority") != filter_prio:
                    continue
                cid = card["id"]
                kc = KanbanCard(
                    cards_area, card,
                    on_move   =lambda d, ci=col_idx, c=card: self._move_card(ci, c, d),
                    on_edit   =lambda ci=col_idx, c=card: self._edit_card(ci, c),
                    on_delete =lambda ci=col_idx, c=card: self._delete_card(ci, c),
                )
                kc.frame.pack(fill=tk.X, pady=2)
                self._card_widgets[cid] = kc

        total = sum(len(c["cards"]) for c in self._columns)
        self.status_var.set(
            f"列: {len(self._columns)}  カード合計: {total}")

    def _add_column(self):
        name = simpledialog.askstring("列を追加", "列名を入力してください:",
                                       parent=self.root)
        if name:
            self._columns.append({"name": name, "cards": []})
            self._render()
            self._save()

    def _add_card(self, col_idx=0):
        if not self._columns:
            messagebox.showwarning("警告", "列を追加してください")
            return
        card = self._card_dialog()
        if card:
            card["id"] = self._next_id
            self._next_id += 1
            ci = min(col_idx, len(self._columns) - 1)
            self._columns[ci]["cards"].append(card)
            self._render()
            self._save()

    def _edit_card(self, col_idx, card):
        updated = self._card_dialog(card)
        if updated:
            card.update(updated)
            self._render()
            self._save()

    def _delete_card(self, col_idx, card):
        if messagebox.askyesno("確認", f"「{card['title']}」を削除しますか?"):
            self._columns[col_idx]["cards"].remove(card)
            self._render()
            self._save()

    def _move_card(self, col_idx, card, direction):
        target = col_idx + direction
        if 0 <= target < len(self._columns):
            self._columns[col_idx]["cards"].remove(card)
            self._columns[target]["cards"].append(card)
            self._render()
            self._save()

    def _card_dialog(self, existing=None):
        dlg = tk.Toplevel(self.root)
        dlg.title("カード編集" if existing else "カードを追加")
        dlg.configure(bg="#21262d")
        dlg.grab_set()
        result = {}

        fields = [
            ("タイトル", "title",  existing.get("title", "") if existing else ""),
            ("説明",     "desc",   existing.get("desc",  "") if existing else ""),
            ("タグ (,区切り)", "tags", existing.get("tags", "") if existing else ""),
            ("期限 (YYYY-MM-DD)", "due", existing.get("due", "") if existing else ""),
        ]
        vars_ = {}
        for label, key, default in fields:
            row = tk.Frame(dlg, bg="#21262d")
            row.pack(fill=tk.X, padx=12, pady=3)
            tk.Label(row, text=f"{label}:", bg="#21262d", fg="#ccc",
                     width=18, anchor="w").pack(side=tk.LEFT)
            v = tk.StringVar(value=default)
            vars_[key] = v
            ttk.Entry(row, textvariable=v, width=24).pack(side=tk.LEFT)

        prio_var = tk.StringVar(value=existing.get("priority", "中") if existing else "中")
        prio_row = tk.Frame(dlg, bg="#21262d")
        prio_row.pack(fill=tk.X, padx=12, pady=3)
        tk.Label(prio_row, text="優先度:", bg="#21262d", fg="#ccc",
                 width=18, anchor="w").pack(side=tk.LEFT)
        ttk.Combobox(prio_row, textvariable=prio_var,
                     values=list(PRIORITIES.keys()),
                     state="readonly", width=8).pack(side=tk.LEFT)

        def _ok():
            if not vars_["title"].get().strip():
                return
            for k, v in vars_.items():
                result[k] = v.get()
            result["priority"] = prio_var.get()
            result["updated"]  = datetime.now().strftime("%Y-%m-%d %H:%M")
            dlg.destroy()

        ttk.Button(dlg, text="保存", command=_ok).pack(pady=8)
        dlg.wait_window()
        return result if result else None

    def _reset(self):
        if messagebox.askyesno("確認", "ボードをリセットしますか?"):
            self._columns = [{"name": c, "cards": []} for c in DEFAULT_COLUMNS]
            self._next_id = 1
            self._render()
            self._save()

    def _save(self):
        try:
            with open(SAVE_PATH, "w", encoding="utf-8") as f:
                json.dump({"next_id": self._next_id,
                           "columns": self._columns}, f,
                          ensure_ascii=False, indent=2)
        except Exception:
            pass

    def _load(self):
        if os.path.exists(SAVE_PATH):
            try:
                with open(SAVE_PATH, encoding="utf-8") as f:
                    data = json.load(f)
                self._next_id = data.get("next_id", 1)
                self._columns = data.get("columns", [])
            except Exception:
                self._columns = [{"name": c, "cards": []}
                                  for c in DEFAULT_COLUMNS]
        else:
            self._columns = [{"name": c, "cards": []}
                              for c in DEFAULT_COLUMNS]
            # サンプルカード
            samples = [
                (0, "トップページのデザイン変更", "高", "UIチーム", "design"),
                (0, "APIエンドポイント追加",   "中", "バックエンド対応", "api,backend"),
                (1, "ログイン機能の実装",       "高", "認証モジュール", "auth"),
                (2, "単体テスト作成",           "低", "テストカバレッジ80%以上", "test"),
            ]
            for ci, title, prio, desc, tags in samples:
                self._columns[ci]["cards"].append({
                    "id": self._next_id, "title": title,
                    "priority": prio, "desc": desc, "tags": tags, "due": "",
                    "updated": datetime.now().strftime("%Y-%m-%d %H:%M"),
                })
                self._next_id += 1
        self._render()


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

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

tk.Textウィジェットをstate=DISABLED(読み取り専用)で作成し、更新時はNORMALに変更してinsert()で内容を書き込み、再びDISABLEDに戻します。

import tkinter as tk
from tkinter import ttk, messagebox, simpledialog
import json
import os
from datetime import datetime

SAVE_PATH = os.path.join(os.path.dirname(__file__), "kanban.json")

PRIORITIES = {"高": "#ef5350", "中": "#ffa726", "低": "#26a69a"}

DEFAULT_COLUMNS = ["TODO", "進行中", "レビュー", "完了"]


class KanbanCard:
    """カンバンカードウィジェット"""

    def __init__(self, parent, card_data, on_move, on_edit, on_delete):
        self._data     = card_data
        self._on_move  = on_move
        self._on_edit  = on_edit
        self._on_delete = on_delete

        prio_color = PRIORITIES.get(card_data.get("priority", "中"), "#ffa726")

        self.frame = tk.Frame(parent, bg="#21262d", bd=1, relief=tk.RAISED,
                               padx=6, pady=4, cursor="hand2")

        # 優先度インジケーター
        tk.Frame(self.frame, bg=prio_color, width=4).pack(
            side=tk.LEFT, fill=tk.Y)

        content = tk.Frame(self.frame, bg="#21262d")
        content.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(4, 0))

        title_row = tk.Frame(content, bg="#21262d")
        title_row.pack(fill=tk.X)
        tk.Label(title_row, text=card_data["title"],
                 bg="#21262d", fg="#c9d1d9", font=("Arial", 9, "bold"),
                 anchor="w", wraplength=160).pack(side=tk.LEFT, fill=tk.X, expand=True)

        # コンテキストメニュー
        self.frame.bind("<Button-3>", self._popup)
        content.bind("<Button-3>", self._popup)

        # サブ情報
        desc = card_data.get("desc", "")
        if desc:
            tk.Label(content, text=desc[:50], bg="#21262d", fg="#8b949e",
                     font=("Arial", 7), anchor="w", wraplength=160).pack(
                fill=tk.X)

        # タグ
        tags = card_data.get("tags", "")
        if tags:
            tag_row = tk.Frame(content, bg="#21262d")
            tag_row.pack(fill=tk.X, pady=(2, 0))
            for t in tags.split(",")[:3]:
                t = t.strip()
                if t:
                    tk.Label(tag_row, text=t, bg="#1f6feb", fg="white",
                             font=("Arial", 7), padx=3, pady=1).pack(
                        side=tk.LEFT, padx=1)

        due = card_data.get("due", "")
        if due:
            tk.Label(content, text=f"期限: {due}", bg="#21262d",
                     fg="#8b949e", font=("Arial", 7)).pack(anchor="w")

    def _popup(self, event):
        m = tk.Menu(self.frame, tearoff=False, bg="#21262d", fg="#c9d1d9")
        m.add_command(label="✏ 編集",  command=self._on_edit)
        m.add_separator()
        m.add_command(label="← 前へ移動", command=lambda: self._on_move(-1))
        m.add_command(label="→ 次へ移動", command=lambda: self._on_move(1))
        m.add_separator()
        m.add_command(label="🗑 削除",  command=self._on_delete)
        m.post(event.x_root, event.y_root)


class App095:
    """カンバンボード"""

    def __init__(self, root):
        self.root = root
        self.root.title("カンバンボード")
        self.root.geometry("1100x680")
        self.root.configure(bg="#0d1117")
        self._columns  = []   # [{"name": ..., "cards": [...]}]
        self._card_widgets = {}   # {card_id: KanbanCard}
        self._next_id  = 1
        self._build_ui()
        self._load()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#161b22", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="📋 カンバンボード",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#161b22", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        # ツールバー
        tb = tk.Frame(self.root, bg="#161b22", pady=4)
        tb.pack(fill=tk.X)
        ttk.Button(tb, text="+ カードを追加",
                   command=self._add_card).pack(side=tk.LEFT, padx=4)
        ttk.Button(tb, text="+ 列を追加",
                   command=self._add_column).pack(side=tk.LEFT, padx=2)
        ttk.Button(tb, text="💾 保存",
                   command=self._save).pack(side=tk.LEFT, padx=2)
        ttk.Button(tb, text="↩ リセット",
                   command=self._reset).pack(side=tk.LEFT, padx=2)

        # フィルター
        tk.Label(tb, text="優先度:", bg="#161b22", fg="#8b949e",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(12, 2))
        self.filter_prio = tk.StringVar(value="ALL")
        ttk.Combobox(tb, textvariable=self.filter_prio,
                     values=["ALL", "高", "中", "低"],
                     state="readonly", width=6).pack(side=tk.LEFT)
        self.filter_prio.trace_add("write", lambda *_: self._render())

        # ボードエリア (スクロール可能)
        canvas = tk.Canvas(self.root, bg="#0d1117", highlightthickness=0)
        xsb = ttk.Scrollbar(self.root, orient=tk.HORIZONTAL,
                             command=canvas.xview)
        canvas.configure(xscrollcommand=xsb.set)
        xsb.pack(side=tk.BOTTOM, fill=tk.X)
        canvas.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        self.board_frame = tk.Frame(canvas, bg="#0d1117")
        self._canvas_win = canvas.create_window((0, 0), window=self.board_frame,
                                                  anchor="nw")
        self.board_frame.bind("<Configure>",
                               lambda e: canvas.configure(
                                   scrollregion=canvas.bbox("all")))
        self._canvas = canvas

        self.status_var = tk.StringVar(value="カードを追加してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#21262d", fg="#8b949e", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    def _render(self):
        # ボードをクリア
        for widget in self.board_frame.winfo_children():
            widget.destroy()
        self._card_widgets.clear()

        filter_prio = self.filter_prio.get()

        for col_idx, col in enumerate(self._columns):
            col_frame = tk.Frame(self.board_frame, bg="#161b22",
                                  width=210, padx=4, pady=4)
            col_frame.pack(side=tk.LEFT, fill=tk.Y, padx=4, pady=4)
            col_frame.pack_propagate(False)

            # 列ヘッダー
            hdr = tk.Frame(col_frame, bg="#21262d")
            hdr.pack(fill=tk.X, pady=(0, 4))
            tk.Label(hdr, text=col["name"],
                     bg="#21262d", fg="#c9d1d9",
                     font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=4)
            count = len(col["cards"])
            tk.Label(hdr, text=str(count), bg="#4fc3f7", fg="#000",
                     font=("Arial", 8, "bold"), padx=4).pack(side=tk.LEFT)
            ttk.Button(hdr, text="+",
                       command=lambda ci=col_idx: self._add_card(ci)
                       ).pack(side=tk.RIGHT, padx=2)

            # カード
            cards_area = tk.Frame(col_frame, bg="#161b22")
            cards_area.pack(fill=tk.BOTH, expand=True)

            for card in col["cards"]:
                if filter_prio != "ALL" and card.get("priority") != filter_prio:
                    continue
                cid = card["id"]
                kc = KanbanCard(
                    cards_area, card,
                    on_move   =lambda d, ci=col_idx, c=card: self._move_card(ci, c, d),
                    on_edit   =lambda ci=col_idx, c=card: self._edit_card(ci, c),
                    on_delete =lambda ci=col_idx, c=card: self._delete_card(ci, c),
                )
                kc.frame.pack(fill=tk.X, pady=2)
                self._card_widgets[cid] = kc

        total = sum(len(c["cards"]) for c in self._columns)
        self.status_var.set(
            f"列: {len(self._columns)}  カード合計: {total}")

    def _add_column(self):
        name = simpledialog.askstring("列を追加", "列名を入力してください:",
                                       parent=self.root)
        if name:
            self._columns.append({"name": name, "cards": []})
            self._render()
            self._save()

    def _add_card(self, col_idx=0):
        if not self._columns:
            messagebox.showwarning("警告", "列を追加してください")
            return
        card = self._card_dialog()
        if card:
            card["id"] = self._next_id
            self._next_id += 1
            ci = min(col_idx, len(self._columns) - 1)
            self._columns[ci]["cards"].append(card)
            self._render()
            self._save()

    def _edit_card(self, col_idx, card):
        updated = self._card_dialog(card)
        if updated:
            card.update(updated)
            self._render()
            self._save()

    def _delete_card(self, col_idx, card):
        if messagebox.askyesno("確認", f"「{card['title']}」を削除しますか?"):
            self._columns[col_idx]["cards"].remove(card)
            self._render()
            self._save()

    def _move_card(self, col_idx, card, direction):
        target = col_idx + direction
        if 0 <= target < len(self._columns):
            self._columns[col_idx]["cards"].remove(card)
            self._columns[target]["cards"].append(card)
            self._render()
            self._save()

    def _card_dialog(self, existing=None):
        dlg = tk.Toplevel(self.root)
        dlg.title("カード編集" if existing else "カードを追加")
        dlg.configure(bg="#21262d")
        dlg.grab_set()
        result = {}

        fields = [
            ("タイトル", "title",  existing.get("title", "") if existing else ""),
            ("説明",     "desc",   existing.get("desc",  "") if existing else ""),
            ("タグ (,区切り)", "tags", existing.get("tags", "") if existing else ""),
            ("期限 (YYYY-MM-DD)", "due", existing.get("due", "") if existing else ""),
        ]
        vars_ = {}
        for label, key, default in fields:
            row = tk.Frame(dlg, bg="#21262d")
            row.pack(fill=tk.X, padx=12, pady=3)
            tk.Label(row, text=f"{label}:", bg="#21262d", fg="#ccc",
                     width=18, anchor="w").pack(side=tk.LEFT)
            v = tk.StringVar(value=default)
            vars_[key] = v
            ttk.Entry(row, textvariable=v, width=24).pack(side=tk.LEFT)

        prio_var = tk.StringVar(value=existing.get("priority", "中") if existing else "中")
        prio_row = tk.Frame(dlg, bg="#21262d")
        prio_row.pack(fill=tk.X, padx=12, pady=3)
        tk.Label(prio_row, text="優先度:", bg="#21262d", fg="#ccc",
                 width=18, anchor="w").pack(side=tk.LEFT)
        ttk.Combobox(prio_row, textvariable=prio_var,
                     values=list(PRIORITIES.keys()),
                     state="readonly", width=8).pack(side=tk.LEFT)

        def _ok():
            if not vars_["title"].get().strip():
                return
            for k, v in vars_.items():
                result[k] = v.get()
            result["priority"] = prio_var.get()
            result["updated"]  = datetime.now().strftime("%Y-%m-%d %H:%M")
            dlg.destroy()

        ttk.Button(dlg, text="保存", command=_ok).pack(pady=8)
        dlg.wait_window()
        return result if result else None

    def _reset(self):
        if messagebox.askyesno("確認", "ボードをリセットしますか?"):
            self._columns = [{"name": c, "cards": []} for c in DEFAULT_COLUMNS]
            self._next_id = 1
            self._render()
            self._save()

    def _save(self):
        try:
            with open(SAVE_PATH, "w", encoding="utf-8") as f:
                json.dump({"next_id": self._next_id,
                           "columns": self._columns}, f,
                          ensure_ascii=False, indent=2)
        except Exception:
            pass

    def _load(self):
        if os.path.exists(SAVE_PATH):
            try:
                with open(SAVE_PATH, encoding="utf-8") as f:
                    data = json.load(f)
                self._next_id = data.get("next_id", 1)
                self._columns = data.get("columns", [])
            except Exception:
                self._columns = [{"name": c, "cards": []}
                                  for c in DEFAULT_COLUMNS]
        else:
            self._columns = [{"name": c, "cards": []}
                              for c in DEFAULT_COLUMNS]
            # サンプルカード
            samples = [
                (0, "トップページのデザイン変更", "高", "UIチーム", "design"),
                (0, "APIエンドポイント追加",   "中", "バックエンド対応", "api,backend"),
                (1, "ログイン機能の実装",       "高", "認証モジュール", "auth"),
                (2, "単体テスト作成",           "低", "テストカバレッジ80%以上", "test"),
            ]
            for ci, title, prio, desc, tags in samples:
                self._columns[ci]["cards"].append({
                    "id": self._next_id, "title": title,
                    "priority": prio, "desc": desc, "tags": tags, "due": "",
                    "updated": datetime.now().strftime("%Y-%m-%d %H:%M"),
                })
                self._next_id += 1
        self._render()


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

例外処理とエラーハンドリング

try-exceptでValueErrorとExceptionを捕捉し、messagebox.showerror()でエラーメッセージを表示します。予期しないエラーも処理することで、アプリの堅牢性が向上します。

import tkinter as tk
from tkinter import ttk, messagebox, simpledialog
import json
import os
from datetime import datetime

SAVE_PATH = os.path.join(os.path.dirname(__file__), "kanban.json")

PRIORITIES = {"高": "#ef5350", "中": "#ffa726", "低": "#26a69a"}

DEFAULT_COLUMNS = ["TODO", "進行中", "レビュー", "完了"]


class KanbanCard:
    """カンバンカードウィジェット"""

    def __init__(self, parent, card_data, on_move, on_edit, on_delete):
        self._data     = card_data
        self._on_move  = on_move
        self._on_edit  = on_edit
        self._on_delete = on_delete

        prio_color = PRIORITIES.get(card_data.get("priority", "中"), "#ffa726")

        self.frame = tk.Frame(parent, bg="#21262d", bd=1, relief=tk.RAISED,
                               padx=6, pady=4, cursor="hand2")

        # 優先度インジケーター
        tk.Frame(self.frame, bg=prio_color, width=4).pack(
            side=tk.LEFT, fill=tk.Y)

        content = tk.Frame(self.frame, bg="#21262d")
        content.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(4, 0))

        title_row = tk.Frame(content, bg="#21262d")
        title_row.pack(fill=tk.X)
        tk.Label(title_row, text=card_data["title"],
                 bg="#21262d", fg="#c9d1d9", font=("Arial", 9, "bold"),
                 anchor="w", wraplength=160).pack(side=tk.LEFT, fill=tk.X, expand=True)

        # コンテキストメニュー
        self.frame.bind("<Button-3>", self._popup)
        content.bind("<Button-3>", self._popup)

        # サブ情報
        desc = card_data.get("desc", "")
        if desc:
            tk.Label(content, text=desc[:50], bg="#21262d", fg="#8b949e",
                     font=("Arial", 7), anchor="w", wraplength=160).pack(
                fill=tk.X)

        # タグ
        tags = card_data.get("tags", "")
        if tags:
            tag_row = tk.Frame(content, bg="#21262d")
            tag_row.pack(fill=tk.X, pady=(2, 0))
            for t in tags.split(",")[:3]:
                t = t.strip()
                if t:
                    tk.Label(tag_row, text=t, bg="#1f6feb", fg="white",
                             font=("Arial", 7), padx=3, pady=1).pack(
                        side=tk.LEFT, padx=1)

        due = card_data.get("due", "")
        if due:
            tk.Label(content, text=f"期限: {due}", bg="#21262d",
                     fg="#8b949e", font=("Arial", 7)).pack(anchor="w")

    def _popup(self, event):
        m = tk.Menu(self.frame, tearoff=False, bg="#21262d", fg="#c9d1d9")
        m.add_command(label="✏ 編集",  command=self._on_edit)
        m.add_separator()
        m.add_command(label="← 前へ移動", command=lambda: self._on_move(-1))
        m.add_command(label="→ 次へ移動", command=lambda: self._on_move(1))
        m.add_separator()
        m.add_command(label="🗑 削除",  command=self._on_delete)
        m.post(event.x_root, event.y_root)


class App095:
    """カンバンボード"""

    def __init__(self, root):
        self.root = root
        self.root.title("カンバンボード")
        self.root.geometry("1100x680")
        self.root.configure(bg="#0d1117")
        self._columns  = []   # [{"name": ..., "cards": [...]}]
        self._card_widgets = {}   # {card_id: KanbanCard}
        self._next_id  = 1
        self._build_ui()
        self._load()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#161b22", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="📋 カンバンボード",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#161b22", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        # ツールバー
        tb = tk.Frame(self.root, bg="#161b22", pady=4)
        tb.pack(fill=tk.X)
        ttk.Button(tb, text="+ カードを追加",
                   command=self._add_card).pack(side=tk.LEFT, padx=4)
        ttk.Button(tb, text="+ 列を追加",
                   command=self._add_column).pack(side=tk.LEFT, padx=2)
        ttk.Button(tb, text="💾 保存",
                   command=self._save).pack(side=tk.LEFT, padx=2)
        ttk.Button(tb, text="↩ リセット",
                   command=self._reset).pack(side=tk.LEFT, padx=2)

        # フィルター
        tk.Label(tb, text="優先度:", bg="#161b22", fg="#8b949e",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(12, 2))
        self.filter_prio = tk.StringVar(value="ALL")
        ttk.Combobox(tb, textvariable=self.filter_prio,
                     values=["ALL", "高", "中", "低"],
                     state="readonly", width=6).pack(side=tk.LEFT)
        self.filter_prio.trace_add("write", lambda *_: self._render())

        # ボードエリア (スクロール可能)
        canvas = tk.Canvas(self.root, bg="#0d1117", highlightthickness=0)
        xsb = ttk.Scrollbar(self.root, orient=tk.HORIZONTAL,
                             command=canvas.xview)
        canvas.configure(xscrollcommand=xsb.set)
        xsb.pack(side=tk.BOTTOM, fill=tk.X)
        canvas.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        self.board_frame = tk.Frame(canvas, bg="#0d1117")
        self._canvas_win = canvas.create_window((0, 0), window=self.board_frame,
                                                  anchor="nw")
        self.board_frame.bind("<Configure>",
                               lambda e: canvas.configure(
                                   scrollregion=canvas.bbox("all")))
        self._canvas = canvas

        self.status_var = tk.StringVar(value="カードを追加してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#21262d", fg="#8b949e", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    def _render(self):
        # ボードをクリア
        for widget in self.board_frame.winfo_children():
            widget.destroy()
        self._card_widgets.clear()

        filter_prio = self.filter_prio.get()

        for col_idx, col in enumerate(self._columns):
            col_frame = tk.Frame(self.board_frame, bg="#161b22",
                                  width=210, padx=4, pady=4)
            col_frame.pack(side=tk.LEFT, fill=tk.Y, padx=4, pady=4)
            col_frame.pack_propagate(False)

            # 列ヘッダー
            hdr = tk.Frame(col_frame, bg="#21262d")
            hdr.pack(fill=tk.X, pady=(0, 4))
            tk.Label(hdr, text=col["name"],
                     bg="#21262d", fg="#c9d1d9",
                     font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=4)
            count = len(col["cards"])
            tk.Label(hdr, text=str(count), bg="#4fc3f7", fg="#000",
                     font=("Arial", 8, "bold"), padx=4).pack(side=tk.LEFT)
            ttk.Button(hdr, text="+",
                       command=lambda ci=col_idx: self._add_card(ci)
                       ).pack(side=tk.RIGHT, padx=2)

            # カード
            cards_area = tk.Frame(col_frame, bg="#161b22")
            cards_area.pack(fill=tk.BOTH, expand=True)

            for card in col["cards"]:
                if filter_prio != "ALL" and card.get("priority") != filter_prio:
                    continue
                cid = card["id"]
                kc = KanbanCard(
                    cards_area, card,
                    on_move   =lambda d, ci=col_idx, c=card: self._move_card(ci, c, d),
                    on_edit   =lambda ci=col_idx, c=card: self._edit_card(ci, c),
                    on_delete =lambda ci=col_idx, c=card: self._delete_card(ci, c),
                )
                kc.frame.pack(fill=tk.X, pady=2)
                self._card_widgets[cid] = kc

        total = sum(len(c["cards"]) for c in self._columns)
        self.status_var.set(
            f"列: {len(self._columns)}  カード合計: {total}")

    def _add_column(self):
        name = simpledialog.askstring("列を追加", "列名を入力してください:",
                                       parent=self.root)
        if name:
            self._columns.append({"name": name, "cards": []})
            self._render()
            self._save()

    def _add_card(self, col_idx=0):
        if not self._columns:
            messagebox.showwarning("警告", "列を追加してください")
            return
        card = self._card_dialog()
        if card:
            card["id"] = self._next_id
            self._next_id += 1
            ci = min(col_idx, len(self._columns) - 1)
            self._columns[ci]["cards"].append(card)
            self._render()
            self._save()

    def _edit_card(self, col_idx, card):
        updated = self._card_dialog(card)
        if updated:
            card.update(updated)
            self._render()
            self._save()

    def _delete_card(self, col_idx, card):
        if messagebox.askyesno("確認", f"「{card['title']}」を削除しますか?"):
            self._columns[col_idx]["cards"].remove(card)
            self._render()
            self._save()

    def _move_card(self, col_idx, card, direction):
        target = col_idx + direction
        if 0 <= target < len(self._columns):
            self._columns[col_idx]["cards"].remove(card)
            self._columns[target]["cards"].append(card)
            self._render()
            self._save()

    def _card_dialog(self, existing=None):
        dlg = tk.Toplevel(self.root)
        dlg.title("カード編集" if existing else "カードを追加")
        dlg.configure(bg="#21262d")
        dlg.grab_set()
        result = {}

        fields = [
            ("タイトル", "title",  existing.get("title", "") if existing else ""),
            ("説明",     "desc",   existing.get("desc",  "") if existing else ""),
            ("タグ (,区切り)", "tags", existing.get("tags", "") if existing else ""),
            ("期限 (YYYY-MM-DD)", "due", existing.get("due", "") if existing else ""),
        ]
        vars_ = {}
        for label, key, default in fields:
            row = tk.Frame(dlg, bg="#21262d")
            row.pack(fill=tk.X, padx=12, pady=3)
            tk.Label(row, text=f"{label}:", bg="#21262d", fg="#ccc",
                     width=18, anchor="w").pack(side=tk.LEFT)
            v = tk.StringVar(value=default)
            vars_[key] = v
            ttk.Entry(row, textvariable=v, width=24).pack(side=tk.LEFT)

        prio_var = tk.StringVar(value=existing.get("priority", "中") if existing else "中")
        prio_row = tk.Frame(dlg, bg="#21262d")
        prio_row.pack(fill=tk.X, padx=12, pady=3)
        tk.Label(prio_row, text="優先度:", bg="#21262d", fg="#ccc",
                 width=18, anchor="w").pack(side=tk.LEFT)
        ttk.Combobox(prio_row, textvariable=prio_var,
                     values=list(PRIORITIES.keys()),
                     state="readonly", width=8).pack(side=tk.LEFT)

        def _ok():
            if not vars_["title"].get().strip():
                return
            for k, v in vars_.items():
                result[k] = v.get()
            result["priority"] = prio_var.get()
            result["updated"]  = datetime.now().strftime("%Y-%m-%d %H:%M")
            dlg.destroy()

        ttk.Button(dlg, text="保存", command=_ok).pack(pady=8)
        dlg.wait_window()
        return result if result else None

    def _reset(self):
        if messagebox.askyesno("確認", "ボードをリセットしますか?"):
            self._columns = [{"name": c, "cards": []} for c in DEFAULT_COLUMNS]
            self._next_id = 1
            self._render()
            self._save()

    def _save(self):
        try:
            with open(SAVE_PATH, "w", encoding="utf-8") as f:
                json.dump({"next_id": self._next_id,
                           "columns": self._columns}, f,
                          ensure_ascii=False, indent=2)
        except Exception:
            pass

    def _load(self):
        if os.path.exists(SAVE_PATH):
            try:
                with open(SAVE_PATH, encoding="utf-8") as f:
                    data = json.load(f)
                self._next_id = data.get("next_id", 1)
                self._columns = data.get("columns", [])
            except Exception:
                self._columns = [{"name": c, "cards": []}
                                  for c in DEFAULT_COLUMNS]
        else:
            self._columns = [{"name": c, "cards": []}
                              for c in DEFAULT_COLUMNS]
            # サンプルカード
            samples = [
                (0, "トップページのデザイン変更", "高", "UIチーム", "design"),
                (0, "APIエンドポイント追加",   "中", "バックエンド対応", "api,backend"),
                (1, "ログイン機能の実装",       "高", "認証モジュール", "auth"),
                (2, "単体テスト作成",           "低", "テストカバレッジ80%以上", "test"),
            ]
            for ci, title, prio, desc, tags in samples:
                self._columns[ci]["cards"].append({
                    "id": self._next_id, "title": title,
                    "priority": prio, "desc": desc, "tags": tags, "due": "",
                    "updated": datetime.now().strftime("%Y-%m-%d %H:%M"),
                })
                self._next_id += 1
        self._render()


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

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

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

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

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

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

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

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

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

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

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

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

    _execute()メソッドにメインロジックを実装します。

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

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

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

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

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

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。

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

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

💡 データの保存機能

処理結果をCSV・TXTファイルに保存する機能を追加しましょう。filedialog.asksaveasfilename()でファイル保存ダイアログが使えます。

💡 設定ダイアログ

フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。

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

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

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

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

❌ ライブラリのインポートエラー

原因:必要なライブラリがインストールされていません。

解決法:pip install コマンドで必要なライブラリをインストールしてください。

❌ ウィンドウサイズが合わない

原因:画面解像度や表示スケールによって異なる場合があります。

解決法:root.geometry()で適切なサイズに調整してください。

9. 練習問題

アプリの理解を深めるための練習問題です。

  1. 課題1:機能拡張

    カンバンボードに新しい機能を1つ追加してみましょう。

  2. 課題2:UIの改善

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

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

    処理結果をファイルに保存する機能を追加しましょう。

🚀
次に挑戦するアプリ

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