初心者向け No.005

PythonでToDoリストアプリを作る(tkinter Listbox)

タスクを追加して、終わったらチェックを付けて、要らなくなったら消す。tkinter の Listbox でこれを作ると、必ず「削除したら別のタスクが消えた」という壁にぶつかります。そこを正面から解説します。

🎯 難易度: ★☆☆ 易しい 📦 ライブラリ: tkinter(標準ライブラリ) ⏱️ 制作時間: 40〜120分 🖥️ 確認環境: Windows 11(Python 3.12.10 / Tcl/Tk 8.6.15)/Linux Mint 22.3(起動確認) 🔄 最終更新: 2026年9月10日
🧭
急いでいる人へ

動くコードだけ欲しい方は「4. 完全なソースコード」へ。削除したら別のタスクが消えて困っている方は症状別チェックの該当項目へ。Listbox の使い方そのものを確認したい方は「2. データと画面」の実測表からどうぞ。

1. このページで作るもの

ToDoリストは、GUIアプリの入門でいちばん多く選ばれる題材です。作るものが具体的で、動いたときに実際に使えるからです。ところが実際に書き始めると、電卓や単位変換では出てこなかった問題が一気に出てきます。「一覧の中のどれが選ばれているのか」をプログラムが知らないといけないからです。

完成する画面が図1です。タスクを4件入れて、2件目を完了にした状態を撮影しました。並べたもう1枚は、同じコードを Linux Mint 22.3 で起動した直後(タスク0件)の画面です。

ToDoリストアプリの実行画面。上部に青い帯で「ToDoリストアプリ」、その下の「タスクを追加」枠に入力欄と追加ボタン、次の行に「表示:」と すべて・未完了・完了済み のラジオボタン、中央の「タスク一覧」枠には 牛乳を買う、レポートを書く(完了済みで灰色)、部屋を掃除する、本を返す の4件が並び、右側にスクロールバー、下部に 完了/未完了・削除・全て削除 の3つのボタンと、右下に「表示 4件 / 全 4件(完了 1件)」の状態表示がある
図1: 完成したToDoリスト。完了したタスクは灰色になり、下の表示に件数が出ます
Linux Mint 22.3 で起動した直後のToDoリストアプリ。「タスクを追加」枠の入力欄と追加ボタン、すべて・未完了・完了済みのラジオボタン、空の「タスク一覧」枠、下部の3つのボタンと「表示 0件 / 全 0件(完了 0件)」の状態表示が並んでいる
実行画面(Linux Mint)

読み終わったとき、あなたは次の3つができるようになります。①一覧から選ばれた項目を正しく取り出せる②複数まとめて削除しても取り違えないコードが書ける③アプリを閉じても内容が残るようにできる。特に①と②は、ToDoリストに限らず「一覧から選んで操作する」あらゆるアプリで同じ形が使えます。

このページで扱う機能

  • Listbox へのタスクの表示と、curselection() による選択の取得
  • 画面の行番号と、Python側のリストの位置を対応させる考え方
  • 複数選択(selectmode=tk.EXTENDED)とまとめて削除
  • チェックの切り替えと、完了したタスクの色分け(itemconfig
  • 「すべて / 未完了 / 完了済み」の絞り込み表示
  • json モジュールによる保存と読み込み、WM_DELETE_WINDOW での終了時保存
📌
この記事の確認環境と方針

完成コードは Windows 11 / Python 3.12.10 / Tcl/Tk 8.6.15root.tk.call("info", "patchlevel") で確認)で実行して動作を確認しました。本文の表と「実行結果」ブロックに載せた既定値・戻り値・エラーメッセージ・所要時間は、すべてこの環境での実行結果です。仕様の説明は Python公式ドキュメントと Tcl/Tk 8.6 のマニュアルで裏取りし、引用箇所には出典と確認日を添えています。図1はWindows 11で撮影した実際の画面です。並べたもう1枚は Linux Mint 22.3(仮想マシン)で撮影しました。Linux Mint 22.3 では起動して画面が表示されるところまで確認しています(実測値は Windows 11 のみです)。macOS は未検証です。

tkinter でウィンドウを出すところがまだ不安な場合は、先にtkinterで最初のウィンドウを表示するを読んでおくと、この記事のコードが読みやすくなります。入力欄から値を取り出す流れはPythonで温度変換プログラムを作るで扱っているので、そちらも土台になります。

2. ToDoリストは「データ」と「画面」に分かれている

最初に押さえてほしいのは、Listbox はタスクを保管する場所ではないということです。Listbox はただの表示係で、本当のタスクは Python のリストのほうに持ちます。この2つを別物として扱えるかどうかで、あとの苦労が大きく変わります。

この記事のコードでは、タスクの本体をこの形で持ちます。

self.tasks = [
    {"text": "牛乳を買う", "done": False},
    {"text": "レポートを書く", "done": True},
]

文字列だけのリストにしないのは、完了かどうかを一緒に持ちたいからです。「✅ 牛乳を買う」のように印を文字列に埋め込んでしまうと、完了を判定するために毎回先頭の文字を調べることになり、印を変えた瞬間に壊れます。表示のための飾りと、判断に使うデータは分けて持つのが基本です。

Listbox を触る前に知っておく7つの事実

ここは実際に動かして確かめた結果です。ネット上の情報には Python 2 時代のものが混ざっていて、特に curselection() の戻り値については古い説明が今も残っています。

確かめたこと手元での結果意味
curselection() の戻り値(1, 3) / 要素は intタプルで返る。文字列ではない(Python 3.12.10)
何も選ばれていないとき()(空のタプル)if not sel: で判定できる
selectmode の既定値browse指定しないと1件しか選べない
exportselection の既定値1他の場所を選択すると選択が外れる(後述
index(tk.END)件数と同じ値(4件なら 4最後の要素ではなく「その次」を指す
itemconfig(tk.END, ...)最後の要素(3番)に効いた同じ END でもコマンドで意味が違う
ttk.ListboxAttributeErrorttk版は存在しない。tk.Listbox を使う

5つ目と6つ目は同じ tk.END を渡しているのに結果が違う、という紛らわしいところです。Tcl/Tk 8.6 のマニュアルはこの点を明記しています。「リストボックスの終わりを示す。ほとんどのコマンドではこれはリストボックスの最後の要素を指すが、index や insert のようないくつかのコマンドでは、最後の要素のちょうど次の要素を指す。insert(tk.END, ...) が末尾への追加になり、itemconfig(tk.END, ...) が最後の要素への装飾になるのは、この使い分けのためです。

出典: Tcl/Tk 8.6 マニュアル listbox(INDICES)より翻訳。原文は英語(2026年8月17日確認)

curselection() についても、マニュアルは「リストボックスの中で現在選択されているすべての要素の数値インデックスを含むリストを返す。選択されている要素がなければ空文字列を返す。」と説明しています。Tcl側の「リスト」が、Python側ではタプルになって返ってきます。

画面の行番号は、いつでもデータの位置とは限らない

ここがこの記事の中心です。タスクを全件表示しているあいだは、画面の1行目が tasks[0]、2行目が tasks[1] と素直に対応します。だから多くの入門記事は del tasks[sel[0]] と書いていて、それで動いてしまいます。

ところが「未完了だけ表示」のような絞り込みを足した瞬間に、この前提が崩れます。図2を見てください。

ToDoリストで、Listboxに表示されている行番号とPython側のリスト self.tasks の位置がずれることを示した図。左側の self.tasks には0番から4番まで5件のタスクがあり、0番の牛乳を買うが未完了、1番のレポートを書くが完了、2番の部屋を掃除するが未完了、3番の本を返すが完了、4番の歯医者を予約が未完了。画面では未完了だけを表示しているためListboxには3行しか並ばず、0行目が牛乳を買う、1行目が部屋を掃除する、2行目が歯医者を予約になる。両者をつなぐ対応表が view_map イコール 0, 2, 4 である。画面の行番号をそのまま del self.tasks の添字に使うと部屋を掃除するが消えてしまうため、del self.tasks の添字には self.view_map を通した値を使う。Windows 11 と Python 3.12.10、Tk 8.6 での実測では、画面の3行目に対応する self.tasks の位置は 4 だった
図2: 絞り込むと行番号と位置がずれます。橋渡しをするのが view_map です

実際に手元で再現した結果がこちらです。5件のうち未完了の3件だけを並べ、画面の3行目(インデックス2)を選んでいます。

実行結果
画面の並び        : ('買い物', '掃除', '予約')
選んだ表示index   : 2 = 予約
tasks[2] は       : 掃除   <- 選んだものと違う

画面では「予約」を選んだのに、tasks[2] は「掃除」です。この状態で del tasks[2] を実行すると、画面上は無関係な「掃除」が消えます。エラーは一切出ません。しかも全件表示のときはずっと正しく動くので、原因にたどり着くまでに時間がかかります。

解決は単純で、画面を作るときに対応表を一緒に作っておくだけです。この記事のコードでは view_map という名前のリストにしています。

self.view_map = []
for pos, task in enumerate(self.tasks):
    if mode == "未完了" and task["done"]:
        continue
    self.view_map.append(pos)          # 画面に出す行の「元の位置」を控える
    self.listbox.insert(tk.END, task["text"])

# 使うとき
row = self.listbox.curselection()[0]   # 画面の行番号
pos = self.view_map[row]               # 元データの位置
del self.tasks[pos]

1行増えるだけで、絞り込みを足しても並べ替えを足しても壊れなくなります。「画面の番号は、必ず翻訳してからデータに使う」と決めてしまってください。

3. まとめて削除するとインデックスがずれる

もうひとつの定番の落とし穴が、複数選択したときの削除です。curselection() は選択された行番号を昇順のタプルで返します。これをそのまま for で回して消していくと、途中から番号の意味が変わります。

A、B、C、D、Eの5件が並んだListboxで1番のBと3番のDを選んでまとめて削除する場面を、前から消す場合と後ろから消す場合で比べた図。前から消す場合はまずdelete(1)でBが消えて残りがA、C、D、Eの4件になり番号が1つずつ前へずれる。続けてdelete(3)を実行するとそのとき3番になっているEが消えてしまい、結果はA、C、Dになる。つまり狙っていないEが消える。後ろから消す場合はまずdelete(3)でDが消えてA、B、C、Eになり、Dより前の番号は動かない。続けてdelete(1)でBが消えて結果はA、C、Eとなり狙いどおりBとDだけが消える。選択された複数の項目を消すときは reversed で後ろから処理する。Windows 11とPython 3.12.10、Tk 8.6での実測値
図3: 前から消すと、まだ消していない項目の番号が1つずつ前へずれます

実測: BとDを選んだのに、BとEが消えた

A〜Eの5件を入れて、1番のBと3番のDを選び、昇順のまま削除したときの結果です。

実行結果
削除対象 curselection() = (1, 3) -> ['B レポート', 'D 返却']
昇順削除の結果 : ('A 買い物', 'C 掃除', 'D 返却')
降順削除の結果 : ('A 買い物', 'C 掃除', 'E 予約')

昇順で消したほうは、選んだはずのDが残り、選んでいないEが消えていますdelete(1) でBを消した時点で、それより後ろの項目が1つずつ前へ詰まり、Dが2番・Eが3番になったためです。そこへ delete(3) が来るので、Eが巻き添えになります。

reversed() で後ろから消す

対処はワンフレーズです。後ろから消せば、まだ消していない項目の番号は動きません。

for i in reversed(self.listbox.curselection()):
    del self.tasks[i]

組み込み関数の reversed() はタプルをそのまま逆順にたどってくれるので、並べ替えの手間もいりません。上の実行結果の「降順削除の結果」の行がこの書き方の結果で、狙いどおりBとDだけが消えています。

💡
同じ形はリスト操作でも出てくる

「ループしながら要素を消すと番号がずれる」のは Listbox 固有の話ではなく、Pythonのリストでも同じです。ふつうのリストを for で回しながら remove() すると要素が飛ばされるのも、原因は同じ「詰まる」現象です。この記事のコードでは、実験4のように消すのではなく残すものだけで作り直す書き方も使っています。

4. 完全なソースコード(190行・コピーしてそのまま実行できます)

💡
コードのコピー方法

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

追加インストール不要(標準ライブラリのみ使用)

全体で空行を含めて190行です。そのうち61行は画面を組み立てる _build_ui() で、タスクを操作する3つのメソッド(add_tasktoggle_taskdelete_task)は合わせて23行しかありません。長く見えても、考えることが多いのは一部だけです。まずは app005.py という名前で保存して実行し、動く画面を触ってから読み進めてください。

💾
保存ファイルが作られます

このアプリはウィンドウを閉じるときに、app005.py と同じフォルダに todo_tasks.json を作ります。次に起動するとその内容を読み込みます。保存先を変えたい場合は SAVE_FILE の行を書き換えてください(FAQで例を挙げています)。

app005.py
import json
import tkinter as tk
from pathlib import Path
from tkinter import messagebox, ttk

SAVE_FILE = Path(__file__).with_name("todo_tasks.json")


def load_tasks():
    """保存済みのタスクを読み込む。ファイルが無い・壊れているときは空リストを返す"""
    try:
        with SAVE_FILE.open(encoding="utf-8") as f:
            data = json.load(f)
    except (OSError, json.JSONDecodeError):
        return []

    tasks = []
    for item in data if isinstance(data, list) else []:
        if isinstance(item, dict) and item.get("text"):
            tasks.append({"text": str(item["text"]), "done": bool(item.get("done"))})
    return tasks


def save_tasks(tasks):
    """タスクをJSONファイルに書き出す"""
    try:
        with SAVE_FILE.open("w", encoding="utf-8") as f:
            json.dump(tasks, f, ensure_ascii=False, indent=2)
    except OSError as e:
        messagebox.showerror("エラー", f"保存できませんでした: {e}")


class App05:
    """ToDoリストアプリ"""

    FILTERS = ("すべて", "未完了", "完了済み")

    def __init__(self, root):
        self.root = root
        self.root.title("ToDoリストアプリ")
        self.root.geometry("520x580")
        self.root.configure(bg="#f8f9fc")
        self.tasks = load_tasks()   # [{"text": 内容, "done": 完了か}, ...]
        self.view_map = []          # 画面の行番号 -> self.tasks の位置
        self._build_ui()
        self._refresh()
        self.root.protocol("WM_DELETE_WINDOW", self._on_close)

    # ---------- 画面を組み立てる ----------

    def _build_ui(self):
        title_frame = tk.Frame(self.root, bg="#3776ab", pady=12)
        title_frame.pack(fill=tk.X)
        tk.Label(title_frame, text="ToDoリストアプリ",
                 font=("Noto Sans JP", 16, "bold"),
                 bg="#3776ab", fg="white").pack()

        main_frame = tk.Frame(self.root, bg="#f8f9fc", padx=20, pady=16)
        main_frame.pack(fill=tk.BOTH, expand=True)

        # 入力エリア
        input_frame = ttk.LabelFrame(main_frame, text="タスクを追加", padding=10)
        input_frame.pack(fill=tk.X, pady=(0, 10))
        self.entry = ttk.Entry(input_frame, font=("Noto Sans JP", 12))
        self.entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8))
        self.entry.bind("<Return>", lambda e: self.add_task())
        ttk.Button(input_frame, text="追加", command=self.add_task).pack(side=tk.LEFT)

        # 表示の絞り込み
        filter_frame = tk.Frame(main_frame, bg="#f8f9fc")
        filter_frame.pack(fill=tk.X, pady=(0, 6))
        tk.Label(filter_frame, text="表示:", bg="#f8f9fc",
                 font=("Noto Sans JP", 10)).pack(side=tk.LEFT)
        self.filter_var = tk.StringVar(value=self.FILTERS[0])
        for name in self.FILTERS:
            ttk.Radiobutton(filter_frame, text=name, value=name,
                            variable=self.filter_var,
                            command=self._refresh).pack(side=tk.LEFT, padx=4)

        # リストエリア
        list_frame = ttk.LabelFrame(main_frame, text="タスク一覧", padding=10)
        list_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
        scrollbar = ttk.Scrollbar(list_frame)
        scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
        self.listbox = tk.Listbox(
            list_frame, font=("Noto Sans JP", 11),
            selectmode=tk.EXTENDED,   # Ctrl / Shift で複数選択できる
            exportselection=False,    # 入力欄の文字を選んでも選択が外れない
            activestyle="none",
            bg="white", relief=tk.FLAT, height=12,
            yscrollcommand=scrollbar.set,
        )
        self.listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scrollbar.config(command=self.listbox.yview)
        self.listbox.bind("<Double-Button-1>", lambda e: self.toggle_task())
        self.listbox.bind("<Delete>", lambda e: self.delete_task())

        # ボタンエリア
        btn_frame = tk.Frame(main_frame, bg="#f8f9fc")
        btn_frame.pack(fill=tk.X)
        ttk.Button(btn_frame, text="完了/未完了",
                   command=self.toggle_task).pack(side=tk.LEFT, padx=(0, 8))
        ttk.Button(btn_frame, text="削除",
                   command=self.delete_task).pack(side=tk.LEFT, padx=(0, 8))
        ttk.Button(btn_frame, text="全て削除",
                   command=self.clear_all).pack(side=tk.LEFT)

        self.status_label = tk.Label(main_frame, text="",
                                     bg="#f8f9fc", fg="#666",
                                     font=("Noto Sans JP", 10))
        self.status_label.pack(anchor="e", pady=(8, 0))

    # ---------- 操作 ----------

    def _selected_positions(self):
        """選択された行番号を self.tasks の位置に翻訳して返す(昇順)"""
        return [self.view_map[row] for row in self.listbox.curselection()]

    def add_task(self):
        text = self.entry.get().strip()
        if not text:
            return
        self.tasks.append({"text": text, "done": False})
        self.entry.delete(0, tk.END)
        self._refresh()

    def toggle_task(self):
        positions = self._selected_positions()
        if not positions:
            messagebox.showinfo("情報", "タスクを選択してください")
            return
        for pos in positions:
            self.tasks[pos]["done"] = not self.tasks[pos]["done"]
        self._refresh(keep=positions)

    def delete_task(self):
        positions = self._selected_positions()
        if not positions:
            messagebox.showinfo("情報", "タスクを選択してください")
            return
        for pos in reversed(positions):   # 後ろから消せば残りの位置がずれない
            del self.tasks[pos]
        self._refresh()

    def clear_all(self):
        if not self.tasks:
            return
        if messagebox.askyesno("確認", "全てのタスクを削除しますか?"):
            self.tasks.clear()
            self._refresh()

    # ---------- 表示 ----------

    def _refresh(self, keep=()):
        """self.tasks を元に画面を作り直す。keep に渡した位置は選択を復元する"""
        mode = self.filter_var.get()
        self.listbox.delete(0, tk.END)
        self.view_map = []

        for pos, task in enumerate(self.tasks):
            if mode == "未完了" and task["done"]:
                continue
            if mode == "完了済み" and not task["done"]:
                continue
            self.view_map.append(pos)
            mark = "✅" if task["done"] else "⬜"
            self.listbox.insert(tk.END, f"{mark} {task['text']}")
            if task["done"]:
                self.listbox.itemconfig(tk.END, fg="#aaa")

        for row, pos in enumerate(self.view_map):
            if pos in keep:
                self.listbox.selection_set(row)

        done = sum(1 for t in self.tasks if t["done"])
        self.status_label.config(
            text=f"表示 {len(self.view_map)}件 / 全 {len(self.tasks)}件(完了 {done}件)"
        )

    # ---------- 終了 ----------

    def _on_close(self):
        save_tasks(self.tasks)
        self.root.destroy()


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

5. コード解説: 190行を8つの部品に分けて読む

上から順に、意味のあるまとまりで分解します。変数や関数の書き方そのものに不安があれば、Pythonの基本構文関数の使い方もあわせて確認してください。

5-1. import と保存先の決め方

import json
import tkinter as tk
from pathlib import Path
from tkinter import messagebox, ttk

SAVE_FILE = Path(__file__).with_name("todo_tasks.json")

json は保存用、pathlib は保存先のパスを組み立てるために使います。どちらも標準ライブラリなので追加インストールは不要です。

注目してほしいのは SAVE_FILE の行です。単に "todo_tasks.json" と書くと、ファイルはスクリプトの場所ではなく「実行したときのカレントディレクトリ」に作られます。ターミナルの現在地によって保存先が変わるので、「保存したはずのタスクが次に起動したら無い」という混乱が起きます。Path(__file__) はこのスクリプト自身のパス、.with_name() はファイル名だけを差し替えたパスを返すので、この書き方なら保存先は常に app005.py の隣で固定です。

5-2. load_tasks() と save_tasks(): 閉じても消えないようにする

def load_tasks():
    try:
        with SAVE_FILE.open(encoding="utf-8") as f:
            data = json.load(f)
    except (OSError, json.JSONDecodeError):
        return []

    tasks = []
    for item in data if isinstance(data, list) else []:
        if isinstance(item, dict) and item.get("text"):
            tasks.append({"text": str(item["text"]), "done": bool(item.get("done"))})
    return tasks

読み込みで大事なのは「失敗しても起動できること」です。初回起動ではファイルが無いので FileNotFoundErrorOSError の一種)になりますし、書き込み中に強制終了すると途中で切れたJSONが残ります。手元で試したところ、切れたファイルは json.JSONDecodeError: Unterminated string starting at: line 1 column 11 (char 10) になりました。どちらも except で受けて空のリストを返すので、壊れたファイルが残っていてもアプリは普通に起動します(実際に壊したファイルを置いて起動を確認しました)。

読み込んだ内容をそのまま self.tasks にしていないのも意図的です。JSONファイルは手で書き換えられるので、text が無い項目や辞書でない要素が混ざる可能性があります。str()bool() を通して形をそろえておけば、あとの処理で KeyError に悩まされません。

def save_tasks(tasks):
    try:
        with SAVE_FILE.open("w", encoding="utf-8") as f:
            json.dump(tasks, f, ensure_ascii=False, indent=2)
    except OSError as e:
        messagebox.showerror("エラー", f"保存できませんでした: {e}")

ensure_ascii=False は日本語のためです。Python公式ドキュメントは、この引数について「ensure_ascii が true(デフォルト)の場合、出力は、受け取ったすべての非ASCII文字および印字不可能な文字がエスケープされることが保証されます」と説明しています。つまり既定のままだと日本語が \u725b\u4e73 のような形で保存されます。読み戻せば元に戻るので動作に支障はありませんが、ファイルを開いて中身を確認できなくなります。手元で両方書き出して比べた結果がこちらです。

実行結果
ensure_ascii=False : {"text": "牛乳を買う", "done": false}
既定(True)       : {"text": "\u725b\u4e73\u3092\u8cb7\u3046", "done": false}

出典: Python公式ドキュメント json(json.dump の ensure_ascii)より翻訳。日本語版でもこの項目は英語のまま掲載されています(2026年8月17日確認)

5-3. _build_ui(): Listbox とスクロールバーを並べる

scrollbar = ttk.Scrollbar(list_frame)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.listbox = tk.Listbox(
    list_frame, font=("Noto Sans JP", 11),
    selectmode=tk.EXTENDED,
    exportselection=False,
    activestyle="none",
    bg="white", relief=tk.FLAT, height=12,
    yscrollcommand=scrollbar.set,
)
self.listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.config(command=self.listbox.yview)

スクロールバーを先に pack() しています。pack() は書いた順に場所を取っていくので、右端を先に確保してから残りを Listbox に使わせる形です。

ただし正直に書くと、この記事のレイアウトでは順序を入れ替えても見た目は変わりません。実際に測ったところ、どちらの順序でもスクロールバーは 17px で表示されました。順序が効いてくるのは、先に置いたウィジェットの要求サイズだけで枠が埋まってしまうときです。width=80Listbox(要求幅 484px)で試すと差が出ました。

実行結果
width=80 の Listbox を先に pack : スクロールバー幅 = 12 px(押しつぶされる)
スクロールバーを先に pack       : スクロールバー幅 = 17 px
width=20(既定)で先に pack     : スクロールバー幅 = 17 px

※この測定は root 520×580 に ttk.LabelFrame(padding=10) を1枚だけ置いた最小構成・Listbox は既定フォントでの値です。完成コードのレイアウトでは押しつぶされ方がさらに強く出ます(実測1px)。

幅の広いウィジェットを入れた瞬間に崩れるので、スクロールバーを先に置く癖にしておくほうが安全です。この順序ならウィジェットの要求サイズに左右されません。

この2つを連動させるには、お互いを相手に教える2本の線が必要です。yscrollcommand=scrollbar.set が「リストがスクロールしたらバーの位置を更新して」、command=self.listbox.yview が「バーが動かされたらリストを動かして」にあたります。片方だけだと、バーが動かないか、バーを動かしてもリストが動かないかのどちらかになります。scrollbar.config() を後から呼んでいるのは、作成の時点ではまだ self.listbox が存在しないためです。

4つのオプションはそれぞれ理由があります。

オプション指定した値指定しないとどうなるか
selectmodetk.EXTENDED既定は browse で、マウスでは1件しか選べない
exportselectionFalse既定は 1。入力欄の文字を選ぶとリストの選択が外れる
activestyle"none"既定は underline。最後に触った行に下線が付く
height12既定は10。単位はピクセルではなく行数

キーボード操作も2行で足しています。<Double-Button-1> はダブルクリック、<Delete> はDeleteキーです。ダブルクリックのときに curselection() がすでに更新済みかどうかは気になるところですが、実際に確かめたところダブルクリックのハンドラの中で選択は取得できていました(1回目のクリックで選択が確定するためです)。

self.listbox.bind("<Double-Button-1>", lambda e: self.toggle_task())
self.listbox.bind("<Delete>", lambda e: self.delete_task())

lambda e: と引数を1つ受け取っているのは、bind() で呼ばれる関数にはイベント情報のオブジェクトが必ず1つ渡されるからです。Python公式ドキュメントも「ウィジェットコマンドからの bind メソッドによって、あるイベントを待つことと、そのイベント型が起きたときにコールバック関数を呼び出すことができるようになります」と説明しています。

出典: Python公式ドキュメント tkinter(2026年8月17日確認)

5-4. _selected_positions(): 行番号を位置に翻訳する

def _selected_positions(self):
    """選択された行番号を self.tasks の位置に翻訳して返す(昇順)"""
    return [self.view_map[row] for row in self.listbox.curselection()]

たった1行ですが、この記事でいちばん重要なメソッドです。選択を扱う処理を、必ずここ1か所に通すと決めておけば、絞り込みや並べ替えを足したときに直す場所が1つで済みます。

curselection() が空のタプルを返すときは、内包表記の結果も空のリストになります。だから呼び出し側は if not positions: だけで「何も選ばれていない」の判定に足ります。sel[0] のように直接添字を取ると、選択が無い場合に IndexError: tuple index out of range になります(症状別チェック参照)。

5-5. add_task() と toggle_task(): 選択を残したまま作り直す

def add_task(self):
    text = self.entry.get().strip()
    if not text:
        return
    self.tasks.append({"text": text, "done": False})
    self.entry.delete(0, tk.END)
    self._refresh()

.strip() で前後の空白を落としてから、if not text: で空欄をはじいています。これが無いと、スペースだけのタスクや空のタスクが作れてしまいます。Listbox は空文字列でも黙って1行として数えるので(size() が増えます)、見えない行が増えていく不気味な状態になります。

def toggle_task(self):
    positions = self._selected_positions()
    if not positions:
        messagebox.showinfo("情報", "タスクを選択してください")
        return
    for pos in positions:
        self.tasks[pos]["done"] = not self.tasks[pos]["done"]
    self._refresh(keep=positions)

not で真偽を反転させているので、押すたびに完了と未完了が入れ替わります。最後の keep=positions が地味に効きます。_refresh() は画面を全消しして作り直すため、そのままだと操作のたびに選択が外れます。手元で確かめた結果がこちらです。

実行結果
再構築前 curselection() = (2,)
再構築後 curselection() = ()      ← 選択が消えている
selection_set(2) 後   = (2,)

連続で完了を切り替えたいときに毎回選び直すのは面倒なので、keep に渡された位置は作り直したあとに選択し直しています。

5-6. delete_task(): 後ろから消す

def delete_task(self):
    positions = self._selected_positions()
    if not positions:
        messagebox.showinfo("情報", "タスクを選択してください")
        return
    for pos in reversed(positions):   # 後ろから消せば残りの位置がずれない
        del self.tasks[pos]
    self._refresh()

3章で見たとおりです。_selected_positions() が昇順のリストを返すので、reversed() をかけて後ろから消します。ここで self.listbox.delete() を呼んでいないことにも注目してください。画面はデータから作り直すので、画面を直接いじる必要はありません。データだけを正しく変えて _refresh() を呼ぶ、という一方通行にしておくと、データと画面がずれる余地がなくなります。

5-7. _refresh(): 全部消して全部書き直す

def _refresh(self, keep=()):
    mode = self.filter_var.get()
    self.listbox.delete(0, tk.END)
    self.view_map = []

    for pos, task in enumerate(self.tasks):
        if mode == "未完了" and task["done"]:
            continue
        if mode == "完了済み" and not task["done"]:
            continue
        self.view_map.append(pos)
        mark = "✅" if task["done"] else "⬜"
        self.listbox.insert(tk.END, f"{mark} {task['text']}")
        if task["done"]:
            self.listbox.itemconfig(tk.END, fg="#aaa")

「1件変えるだけなのに全部作り直すのは無駄では」と思うかもしれません。ToDoリストの規模なら、無駄よりも安全のほうが価値があります。実際にかかる時間を測りました。

タスク件数_refresh() 1回の所要時間体感
10件1.72 ミリ秒まったく分からない
100件4.27 ミリ秒分からない
1,000件27.64 ミリ秒言われれば分かるかどうか

Windows 11 / Python 3.12.10 で5回実行した最速値です。1,000件でも 30 ミリ秒程度なので、個人のToDoリストでは問題になりません。差分だけを更新するコードは、「どの行がどのタスクだったか」を自分で追いかける必要があり、そこがバグの温床になります。件数が数千を超えて重くなってきたら、そのときは Listbox ではなく Treeview やデータベースへ移る合図です。

itemconfig(tk.END, fg="#aaa") は、いま追加したばかりの行を灰色にしています。2章の実測表のとおり、itemconfig における tk.END は最後の要素を指すので、insert の直後に書けば「たったいま足した行」に効きます。なお delete(0, tk.END) で作り直すと色の設定も消えるので、毎回付け直す必要があります。

ステータス表示では、絞り込みで隠れている件数も分かるようにしています。表示 2件 / 全 5件(完了 3件) のように出るので、「未完了だけ表示」にしていることを忘れて「タスクが消えた」と勘違いするのを防げます。

5-8. _on_close(): 閉じるボタンを横取りする

self.root.protocol("WM_DELETE_WINDOW", self._on_close)

def _on_close(self):
    save_tasks(self.tasks)
    self.root.destroy()

ウィンドウ右上の × を押したときの動きを差し替えています。Tcl/Tk 8.6 のマニュアルは「原則として、wm protocol コマンドでプロトコルのハンドラが設定されていない場合、そのプロトコルのメッセージはすべて無視される。WM_DELETE_WINDOW プロトコルはこの規則の例外である。起動時に Tk はこのプロトコルのハンドラを設定しており、それはウィンドウを破棄することで応答する。」と説明しています。つまり×ボタンには最初から「ウィンドウを壊す」処理が入っていて、それを自分の関数に置き換えているわけです。

だから self.root.destroy() を忘れると、×を押しても閉じないアプリになります。保存してから閉じる、の順序も大事です。

出典: Tcl/Tk 8.6 マニュアル wm(wm protocol)より翻訳。原文は英語(2026年8月17日確認)

⚠️
この方式で保存されないケース

終了時にまとめて保存する方式なので、アプリが強制終了したりPCが落ちたりすると、その回の変更は保存されません。確実に残したい場合は、add_task()toggle_task()delete_task() の最後にも save_tasks(self.tasks) を足して、操作のたびに書き出す方式に変えてください。書き出しにかかる時間を測ったところ、100件で 0.54 ミリ秒、1,000件でも 3.39 ミリ秒(ファイルサイズ 58KB)でした。操作のたびに保存しても引っかかりは出ません。

6. ToDoリストの作り方: リスト1本から7ステップで組み立てる

190行をいきなり写経すると、どこで失敗したのか分からなくなります。まずGUI抜きでデータの操作だけを確かめ、そのあと画面を1つずつ足す順番が確実です。各ステップで必ず実行して、動くことを確認してから次に進んでください。

  1. 1
    GUIなしで、リストだけ動かす

    ToDoリストの中身は、結局これだけです。追加・完了・削除を print() で確認します。ここが分かっていれば、あとは表示の話しか残りません。

    tasks = []
    
    tasks.append({"text": "牛乳を買う", "done": False})
    tasks.append({"text": "レポートを書く", "done": False})
    
    tasks[1]["done"] = True          # 2件目を完了にする
    del tasks[0]                     # 1件目を削除する
    
    for i, task in enumerate(tasks):
        mark = "✅" if task["done"] else "⬜"
        print(i, mark, task["text"])
    # 0 ✅ レポートを書く
  2. 2
    空のウィンドウを出す

    新しいファイルを app005.py という名前で保存し、この5行でウィンドウが出ることを確認します。ここが動かない場合は環境側の問題なので、tkinterが使えるかの確認手順に戻ってください。

    import tkinter as tk
    
    root = tk.Tk()
    root.title("ToDoリストアプリ")
    root.geometry("520x580")
    root.mainloop()
  3. 3
    Listbox を置いて、決め打ちで3件表示する

    root = tk.Tk()root.mainloop()に書き足します。以降のステップもすべてこの間に追加していきます。まずは入力のことは考えず、並ぶことだけ確認します。

    listbox = tk.Listbox(root, height=12)
    listbox.pack(fill=tk.BOTH, expand=True, padx=20, pady=10)
    for text in ["牛乳を買う", "レポートを書く", "部屋を掃除する"]:
        listbox.insert(tk.END, text)
  4. 4
    入力欄とボタンをつなぐ

    データ用のリストを用意して、追加できるようにします。この時点ではまだ完了マークを付けません。bind() でEnterキーからも追加できるようにしておくと、テストが一気に楽になります。

    from tkinter import ttk
    
    tasks = []
    
    entry = ttk.Entry(root)
    entry.pack(fill=tk.X, padx=20)
    
    
    def add_task():
        text = entry.get().strip()
        if not text:
            return
        tasks.append({"text": text, "done": False})
        entry.delete(0, tk.END)
        listbox.insert(tk.END, text)
    
    
    ttk.Button(root, text="追加", command=add_task).pack(pady=6)
    entry.bind("<Return>", lambda e: add_task())
  5. 5
    選んで削除する

    ここで curselection() が登場します。最初から reversed() を付けておいてください。1件だけのときも正しく動くので、あとから複数選択にしても壊れません。

    def delete_task():
        sel = listbox.curselection()      # 例: (1,)
        if not sel:
            return
        for i in reversed(sel):
            del tasks[i]
            listbox.delete(i)
    
    
    ttk.Button(root, text="削除", command=delete_task).pack()
  6. 6
    「作り直す」方式に切り替えて、完了マークを付ける

    データと画面を別々にいじると、必ずどこかでずれます。データを変えたら画面を作り直す形に変えましょう。ここから listbox.insert()listbox.delete() を直接書くのは refresh() の中だけになります。

    def refresh():
        listbox.delete(0, tk.END)
        for task in tasks:
            mark = "✅" if task["done"] else "⬜"
            listbox.insert(tk.END, f"{mark} {task['text']}")
    
    
    def toggle_task():
        sel = listbox.curselection()
        if not sel:
            return
        i = sel[0]
        tasks[i]["done"] = not tasks[i]["done"]
        refresh()
        listbox.selection_set(i)          # 作り直すと選択が外れるので戻す
    
    
    ttk.Button(root, text="完了/未完了", command=toggle_task).pack()

    add_task()delete_task() の中も、listbox を直接触る行を消して refresh() の呼び出しに置き換えてください。

  7. 7
    閉じても消えないようにする

    最後に保存です。×ボタンの動きを差し替えて、閉じる直前に書き出します。ここまでで実用になります。

    import json
    from pathlib import Path
    
    SAVE_FILE = Path(__file__).with_name("todo_tasks.json")
    
    
    def on_close():
        with SAVE_FILE.open("w", encoding="utf-8") as f:
            json.dump(tasks, f, ensure_ascii=False, indent=2)
        root.destroy()
    
    
    root.protocol("WM_DELETE_WINDOW", on_close)

    読み込み側と絞り込み表示、複数選択への対応を足すと4章の完成コードになります。クラスにまとめるのは、taskslistbox を各関数から見られるようにするためです。ここまで関数だけで書いてきて不便を感じていなければ、無理にクラスへ移す必要はありません。

💡
エディタの設定でつまずいたら

VSCodeで実行ボタンが出ない・Pythonが選べないといった場合は、VSCodeのPython環境セットアップで拡張機能とインタープリターの設定を確認してください。

7. 自分でいじって試す5つの実験

動いたら、次は意図的に書き換えて結果を見る番です。どれも数分で終わります。1か所ずつ変えて実行すると、「この行が結果のここに効いている」という感覚が短時間で身につきます。

実験1: チェック記号を変える(幅を測って選ぶ)

完了マークの "✅""⬜" を別の記号に変えてみましょう。ここで注意したいのは見た目の好みではなく幅です。未完了と完了で文字幅が違うと、切り替えるたびにタスク名の位置が左右にずれます。tkinter.font で実際に測れます。

import tkinter as tk
from tkinter import font as tkfont

root = tk.Tk()
f = tkfont.Font(family="Noto Sans JP", size=11)
for mark in ["⬜", "✅", "□", "☑", "[ ]", "[x]", "・", "○", "●"]:
    print(f"{mark}  幅 {f.measure(mark)} px")

Windows 11 / Python 3.12.10 で実行した結果です。

記号の組み合わせ幅(px)判定
21 と 21そろう(この記事の採用)
15 と 15そろう。細めで落ち着いた見た目
15 と 15そろう
[ ][x]13 と 17ずれる。4pxぶん名前が動く

[ ][x] はテキストのToDoリストでよく使われる書き方ですが、プロポーショナルフォントでは幅が違います。等幅フォントを指定すればそろうので、この記法を使いたい場合は font=("Consolas", 11) のように等幅を指定してください。

実験2: タスクを上下に並べ替える

「これを先にやろう」と思ったときに順番を変えられると、一気に実用的になります。App05 にメソッドを1つ足して、ボタンからつなぎます。

def move_up(self):
    sel = self.listbox.curselection()
    if not sel:
        return
    pos = self.view_map[sel[0]]
    if pos == 0:
        return
    self.tasks[pos - 1], self.tasks[pos] = self.tasks[pos], self.tasks[pos - 1]
    self._refresh(keep=[pos - 1])

Pythonの多重代入で2つの要素を入れ替えています。_refresh(keep=[pos - 1]) と移動先の位置を渡しているので、連続で押して1件を上まで運べます。手元で試したところ、3行目のタスクを選んで押すと2行目に移動し、選択もそのまま付いてきました。

実行結果
最初 : ('⬜ 牛乳', '⬜ レポート', '⬜ 掃除', '⬜ 返却')
上へ : ('⬜ 牛乳', '⬜ 掃除', '⬜ レポート', '⬜ 返却')  選択: (1,)

下へ動かす move_down() は、pos == len(self.tasks) - 1 のときに何もしない形にすれば同じ要領で書けます。なお絞り込み表示のときは、画面上で隣に見えるタスクが元データでは隣とは限りません。絞り込み中は並べ替えボタンを無効にするのが素直な設計です。

実験3: ダブルクリックで内容を書き換える

タイプミスに気づいたとき、消して入れ直すのは面倒です。simpledialog を使うと、入力用の小窓を1行で出せます。

from tkinter import simpledialog


def rename_task(self):
    positions = self._selected_positions()
    if not positions:
        return
    pos = positions[0]
    new_text = simpledialog.askstring(
        "タスクを編集", "新しい内容:", initialvalue=self.tasks[pos]["text"]
    )
    if new_text and new_text.strip():
        self.tasks[pos]["text"] = new_text.strip()
        self._refresh(keep=[pos])

askstring() はキャンセルされると None を返すので、if new_text and ... で二重に確認しています。None のまま .strip() を呼ぶと AttributeError になるため、順序を入れ替えないでください。

この機能を入れるなら、ダブルクリックの割り当てを完了トグルから編集に変えて、完了はボタンかDeleteキー側に寄せるほうが迷いません。ダブルクリックに2つの意味を持たせないことが使いやすさの分かれ目です。

実験4: 完了済みだけまとめて消す

終わったタスクがたまってきたときの一括削除です。ここでは消すのではなく、残すものだけで新しいリストを作ります。インデックスを一切扱わないので、ずれる余地がありません。

def clear_done(self):
    before = len(self.tasks)
    self.tasks = [t for t in self.tasks if not t["done"]]
    if len(self.tasks) != before:
        self._refresh()

実際に4件中2件を完了にしてから呼んだ結果です。

実行結果
完了2件      : ('✅ 牛乳', '⬜ 掃除', '⬜ レポート', '✅ 返却')
一括削除の後 : ('⬜ 掃除', '⬜ レポート')   status: 表示 2件 / 全 2件(完了 0件)

件数が変わったときだけ _refresh() を呼んでいるのは、完了が0件のときに押しても画面がちらつかないようにするためです。この「残すものだけで作り直す」書き方は、リストから条件で取り除きたい場面すべてで使えます。

実験5: 列を増やしたくなったらTreeviewへ

使っているうちに「期限も表示したい」「優先度で並べたい」と思うはずです。Listbox1行に1つの文字列しか持てないので、そこが限界になります。列を持てるのが ttk.Treeview です。

from tkinter import ttk

tree = ttk.Treeview(root, columns=("done", "text", "due"),
                    show="headings", height=8)
for col, title, width in [("done", "状態", 50), ("text", "タスク", 220),
                          ("due", "期限", 90)]:
    tree.heading(col, text=title)
    tree.column(col, width=width)
tree.pack(fill=tk.BOTH, expand=True)

tree.insert("", tk.END, values=("⬜", "牛乳を買う", "2026-08-20"))
tree.insert("", tk.END, values=("✅", "レポートを書く", "2026-08-18"))

置き換えで一番変わるのが選択の扱いです。Treeview は行番号ではなく行ごとのIDで管理します。手元で確認したところ、tree.get_children()('I001', 'I002') というIDのタプルを返し、tree.item(iid, "values") でその行の値が取れました。

つまりこの記事で作った view_map の仕事を、Treeviewは最初から持っていることになります。ずれの問題から解放される代わりに、覚えることは少し増えます。期限や優先度、カテゴリまで扱う本格版の作り方はタスク管理アプリ(SQLite付き)で扱っているので、次の題材にどうぞ。

8. うまく動かないときの症状別チェック

一覧を扱うアプリは、エラーが出るパターンより「エラーは出ないのに動きがおかしい」パターンのほうが多いのが厄介なところです。まず症状から当たりを付けてください。

症状まず疑うところ
削除したら選んでいないタスクが消えた行番号をそのままデータに使っている(対処
複数選んでも1件しか消えない・変なものが消えるselectmode と削除の順序(対処
ボタンを押すたびに選択が外れる作り直しで選択が失われている(対処
入力欄の文字を選ぶとリストの選択が消えるexportselection が既定のまま(対処
IndexError: tuple index out of range選択が無いのに sel[0] を読んでいる(対処
クリックしたのに1つ前のタスクが処理される<Button-1> に処理を割り当てている(対処
閉じたらタスクが全部消える保存処理がない・保存先がずれている(対処
module 'tkinter.ttk' has no attribute 'Listbox'ttk にListboxは無い(対処
item number "N" out of rangeitemconfig の範囲外指定(対処
行がガタつく・記号が四角(豆腐)になる記号の幅とフォントの指定(対処

❌ 削除したら、選んでいないタスクが消えた

原因: Listbox の行番号を、そのまま Python のリストの添字に使っています。全件を素直に並べているあいだは一致するので気づきませんが、絞り込み・並べ替え・完了を下へ移動といった機能を足した瞬間にずれます。エラーが出ないので、原因の特定に一番時間がかかる部類です。

手元で再現した結果です。5件のうち未完了3件だけを表示し、画面の3行目を選んでいます。

tasks = [
    {"text": "買い物", "done": False},   # 0
    {"text": "レポート", "done": True},   # 1
    {"text": "掃除", "done": False},     # 2
    {"text": "返却", "done": True},      # 3
    {"text": "予約", "done": False},     # 4
]
# 画面には 買い物 / 掃除 / 予約 の3件だけが並ぶ

row = listbox.curselection()[0]   # 2(画面の3行目 = 予約)
print(tasks[row]["text"])         # 掃除  ← 別のタスク

対処: 画面を作るときに「表示した行が、元データの何番だったか」の対応表を作ります。完成コードの view_map がそれです。

self.view_map = []
for pos, task in enumerate(self.tasks):
    if 表示しない条件:
        continue
    self.view_map.append(pos)
    self.listbox.insert(tk.END, ...)

# 選択を扱うときは必ずここを通す
positions = [self.view_map[row] for row in self.listbox.curselection()]

「あとで絞り込みを足すかもしれない」なら、最初からこの形で書いておくのが安全です。1行増えるだけで、将来の作り直しがなくなります。

❌ 複数選んでも1件しか消えない・関係ないものが消える

原因1(そもそも選べない): selectmode の既定値は browse です。Tcl/Tk 8.6 のマニュアルも「既定値は browse である」と明記しています。この状態ではマウスで複数選択できません。

listbox = tk.Listbox(root, selectmode=tk.EXTENDED)   # Ctrl / Shift で複数選択
listbox = tk.Listbox(root, selectmode=tk.MULTIPLE)   # クリックのたびに追加/解除

紛らわしいのは、selectmode はマウスやキーボードの操作に効くもので、プログラムからの selection_set() は制限しない点です。4種類すべてで試したところ、selection_set(0)selection_set(2) を続けて呼ぶと、どのモードでも curselection()(0, 2) を返しました。「single にしたのに2件選ばれている」ときは、コードのどこかで選択を追加しています。

原因2(消す順序): curselection() の返す番号を前から順に消しています。3章のとおり、消すたびに後ろが詰まって番号がずれます。

for i in sel:                 # ❌ 前から
    del tasks[i]

for i in reversed(sel):       # ✅ 後ろから
    del tasks[i]

対処: selectmode=tk.EXTENDED を指定し、削除は必ず reversed() を通します。1件しか選べない設計でも reversed() を付けておいて損はありません。

出典: Tcl/Tk 8.6 マニュアル listbox(-selectmode)より翻訳。原文は英語(2026年8月17日確認)

❌ ボタンを押すたびに選択が外れる

原因: delete(0, tk.END) で全消しして作り直すと、選択の情報も一緒に消えます。実測した結果がこちらです。

実行結果
再構築前 curselection() = (2,)
再構築後 curselection() = ()

完了を切り替えるたびに選び直すことになるので、連続操作が非常にやりにくくなります。

対処: 作り直したあとで selection_set() し直します。完成コードでは _refresh(keep=positions) の形で、元データの位置を渡して復元しています。

for row, pos in enumerate(self.view_map):
    if pos in keep:
        self.listbox.selection_set(row)

行番号ではなく元データの位置で覚えておくのがコツです。絞り込みが変わって行番号がずれても、正しいタスクに選択が戻ります。あわせて see(row) を呼ぶと、その行が画面外にあってもスクロールして見せてくれます。

❌ 入力欄の文字を選ぶと、リストの選択が消える

原因: exportselection オプションです。既定値は 1(有効)で、ウィジェットの選択がシステム全体の「選択範囲」と連動します。Tcl/Tk 8.6 のマニュアルは「選択がエクスポートされる場合、ウィジェット内で選択すると現在のX選択が解除され、ウィジェットの外で選択するとウィジェットの選択が解除される」と説明しています。

Windows 11 でも同じ挙動になることを確認しました。

実行結果
Listbox を2つ並べた場合
  a を選択した直後 : a= (0,)  b= ()
  b も選択した後   : a= ()    b= (2,)   ← a の選択が消えた

Entry のテキストを選択した場合
  Entry 選択前 a = (1,)
  Entry 選択後 a = ()                   ← やはり消えた

入力欄の文字をドラッグで選び直しただけでリストの選択が消えるので、「削除ボタンを押したら『タスクを選択してください』と言われる」という不可解な状態になります。

対処: exportselection=False を指定します。これで他の場所を触っても選択が保持されます。一覧と入力欄を同じ画面に置くアプリでは、ほぼ必ず必要になる設定です。

self.listbox = tk.Listbox(list_frame, exportselection=False)

出典: Tcl/Tk 8.6 マニュアル options(-exportselection)より翻訳。原文は英語(2026年8月17日確認)

❌ IndexError: tuple index out of range

原因: 何も選ばれていない状態で curselection()[0] を読んでいます。選択が無いときの戻り値は空のタプルなので、0番目は存在しません。

idx = listbox.curselection()[0]
# IndexError: tuple index out of range

対処: 添字を取る前に空かどうかを確認します。空のタプルは偽と評価されるので、if not sel: だけで足ります。

# delete_task() のようなメソッドの中に書くコードです(return を使うため)
sel = listbox.curselection()
if not sel:
    messagebox.showinfo("情報", "タスクを選択してください")
    return
idx = sel[0]

GUIアプリでこの確認を省くと厄介です。例外が起きても画面には何も出ず、ターミナルにだけエラーが表示されます。ボタンを押しても無反応に見えるため、原因の特定に時間がかかりがちです。IndexError 全般の読み方はPythonエラー一覧&解決法のIndexErrorの章にまとめてあります。

❌ クリックしたのに、1つ前のタスクが処理される

原因: <Button-1>(マウスボタンを押した瞬間)に処理を割り当てています。このタイミングでは、まだ選択が更新されていません。Tcl/Tk のマニュアルは「既定では、各ウィンドウは4つのバインドタグを持つ。ウィンドウの名前、ウィンドウのクラス名、ウィンドウのもっとも近いトップレベルの祖先の名前、そして all で、この順序である。」と説明しています。自分で bind() した処理はウィジェット名のタグに付くので、選択を更新するクラス側の処理より先に動くのです。

実際にログを取りました。2行目をクリックしたときの、それぞれのタイミングでの curselection() です。

実行結果
bindtags = ('.!listbox', 'Listbox', '.', 'all')
  Button-1 の中              curselection() = ()      ← まだ空
  <<ListboxSelect>> の中     curselection() = (1,)
  ButtonRelease-1 の中       curselection() = (1,)

対処: 選択に反応したいなら、<Button-1> ではなく仮想イベントの <<ListboxSelect>> を使います。マニュアルにも「リストボックスの選択項目がキーボードまたはマウスによって利用者に更新されるたびに、仮想イベント <<ListboxSelect>> が生成される」とあります。

listbox.bind("<<ListboxSelect>>", on_select)     # 選択が変わったら
listbox.bind("<Double-Button-1>", on_double)     # ダブルクリック
listbox.bind("<ButtonRelease-1>", on_release)    # 離したとき

ダブルクリックとボタン離しのタイミングでは選択が更新済みだったので、完成コードのダブルクリック割り当ては問題なく動きます。

出典: Tcl/Tk 8.6 マニュアル bindtags同 listboxより翻訳。原文は英語(いずれも2026年8月17日確認)

❌ アプリを閉じたらタスクが全部消える

原因: Pythonのリストはメモリの上にあるだけなので、プログラムが終われば消えます。保存処理を書かない限り、内容は残りません。ToDoリストを「作ったのに使わなくなる」最大の理由がこれです。

対処: json でファイルに書き出します。辞書のリストをそのまま渡せるので、専用の変換は要りません。

import json
from pathlib import Path

SAVE_FILE = Path(__file__).with_name("todo_tasks.json")

with SAVE_FILE.open("w", encoding="utf-8") as f:
    json.dump(tasks, f, ensure_ascii=False, indent=2)

保存したのに読み込まれない場合は、保存先のパスを疑ってください。ファイル名だけを書くと実行時のカレントディレクトリに作られるため、ターミナルの現在地が変わると別の場所を見に行きます。print(SAVE_FILE) で実際のパスを確かめるのが早道です。手元の環境では C:\Users\...\app005.py と同じフォルダの todo_tasks.json になりました。

保存のタイミングは、×ボタンを差し替える方法(5-8)と、操作のたびに保存する方法があります。確実さを取るなら操作のたびに保存です。完了率のグラフや明示的な保存ボタンまで作りたい場合は、チェックリストアプリfiledialog を使った保存と読み込みを扱っています。

❌ module 'tkinter.ttk' has no attribute 'Listbox'

原因: ttk.Listbox は存在しません。ttk.Entryttk.Button があるので同じ調子で書いてしまいがちですが、Listbox は tk 側だけにあります。

ttk.Listbox(root)
# AttributeError: module 'tkinter.ttk' has no attribute 'Listbox'

tk.Listbox(root)   # 正しい

対処: tk.Listbox を使います。見た目が他の ttk ウィジェットと少し違うのが気になる場合は、relief=tk.FLATbg="white" を指定すると馴染みます(完成コードもそうしています)。

逆に tk.Listbox だからこそ使えるのが itemconfig() による1行ごとの色指定です。ttk.Treeview でも tag_configure() で似たことはできますが、書き方は変わります。AttributeError 全般の読み方はエラー解決ページのAttributeErrorの章にあります。

❌ _tkinter.TclError: item number "N" out of range

原因: itemconfig() に、存在しない位置を渡しています。よくあるのは insert() の前に色を付けようとしたときと、size() の値をそのまま渡したときです。

listbox.itemconfig(listbox.size(), fg="red")
# _tkinter.TclError: item number "4" out of range   ← 4件のとき最後は 3

対処: insert() の直後に itemconfig(tk.END, ...) と書くのが確実です。2章の表のとおり、itemconfig における tk.END は最後の要素を指すので、いま足した行に効きます。

紛らわしいのは、同じ範囲外でも delete()get() は例外を出さないことです。手元で確かめたところ、delete(99) は何も起こさず None を返し、get(99) は空文字列を返しました。itemconfig だけが厳しいので、「削除が効いていないのにエラーも出ない」ときは、渡している番号が範囲外になっていないかを print() で確認してください。

❌ 行がガタつく・記号が四角(豆腐)になる

原因1(幅の不一致): 未完了と完了で幅の違う記号を使っています。実験1のとおり、[ ] は13px、[x] は17pxで、切り替えるたびにタスク名が4pxずれます。

原因2(フォントが無い): インストールされていないフォント名を指定しています。tkinter はエラーを出さず、黙って別のフォントを使います。手元で存在しない名前を指定したところ、cget("font") は指定した名前をそのまま返すのに、実際に使われていたのは MS Pゴシック でした。設定を読んでも気づけないので厄介です。

from tkinter import font as tkfont

lb = tk.Listbox(root, font=("ExistiNgNotFont999", 11))
print(lb.cget("font"))                              # ExistiNgNotFont999 11
print(tkfont.Font(font=lb.cget("font")).actual())   # family が別のものになっている

対処: 環境にあるフォントを確認してから指定します。tkinter.font.families() で一覧が取れるので、候補を順に試して最初に見つかったものを使うと、どのPCでも崩れません。

from tkinter import font as tkfont

def pick_font(candidates, size):
    available = set(tkfont.families())
    for name in candidates:
        if name in available:
            return (name, size)
    return ("TkDefaultFont", size)


FONT = pick_font(["Noto Sans JP", "Yu Gothic UI", "Meiryo"], 11)

フォント名を指定しない場合は、システムの既定フォントが使われます。配布して他の人に使ってもらうなら、特定のフォント名を決め打ちしないほうが安全です。

9. 練習問題

手を動かして初めて身につきます。ヒントだけ添えるので、答えを見る前に自分で書いてみてください。

  1. 課題1: 下へ動かすボタンを作る

    実験2move_up() を参考に、move_down() を書きましょう。
    ヒント: 一番下のタスクを選んでいるときに何もしないよう、pos == len(self.tasks) - 1 で早めに return します。入れ替えるのは pospos + 1 です。

  2. 課題2: 追加した日時を一緒に保存する

    タスクの辞書に "created" を足して、いつ追加したかを残しましょう。
    ヒント: from datetime import datetime のあと datetime.now().isoformat(timespec="seconds") で文字列になります。JSONは日時型をそのまま保存できないので、文字列にしてから渡すのがポイントです。load_tasks() 側で、古い保存ファイルに created が無い場合の初期値も決めておきましょう。

  3. 課題3: 検索欄でタスクを絞り込む

    入力した文字を含むタスクだけを表示する検索欄を足しましょう。
    ヒント: _refresh() の中の判定に if keyword and keyword not in task["text"]: continue を足すだけです。view_map があるので、絞り込んだ状態で削除しても正しく消えます。検索欄には StringVartrace_add("write", ...) を使うと、打つそばから絞り込めます。

  4. 課題4: 保存の失敗をわざと起こしてみる

    SAVE_FILE を書き込めない場所(存在しないフォルダの中など)に変えて実行し、エラーメッセージが出ることを確かめましょう。
    ヒント: Path("Z:/no_such_dir/todo.json") のような値にします。save_tasks()except OSError が働いて、アプリが落ちずにダイアログが出れば成功です。例外処理は「書いたら必ず発生させて確かめる」まででワンセットです。

10. よくある質問

Q. ToDoリストを作るのに追加のインストールは必要ですか?

不要です。tkinterjsonpathlib はすべてPythonの標準ライブラリなので、pip install は要りません。ただしLinuxでは tkinter が別パッケージになっていることがあり、その場合は sudo apt install python3-tk のようにOS側でインストールします。

Q. curselection() は文字列を返すと書いてある記事を見ました。

Python 2 時代の情報です。Python 3.12.10 で確認したところ、curselection() は整数のタプルを返しました(例: (1, 3)、要素の型は int)。古い記事では int(sel[0]) と書かれていることがありますが、今はそのまま添字に使えます。

Q. Listbox と Treeview はどちらを使えばいいですか?

表示するのが1行1項目の文字列だけなら Listbox で十分です。期限・優先度・カテゴリのように列が増えるなら Treeview に切り替えてください。Treeview は行をIDで管理するので、この記事で作った view_map のような対応表が不要になります。移行の入り口は実験5に置きました。

Q. 保存先を変えたいのですが。

SAVE_FILE の1行を書き換えます。ユーザーのホームフォルダに置きたい場合は次のようにします。スクリプトを別の場所へコピーしても、同じデータを読み書きできます。

SAVE_FILE = Path.home() / ".todo_tasks.json"

Q. 保存したファイルの日本語が \u725b のようになります。

json.dump()ensure_ascii が既定の True のままです。ensure_ascii=False を付けると日本語のまま保存されます。どちらでも読み戻した結果は同じなので、動作に影響はありません。ファイルを直接開いて確認したいなら False にしてください。

Q. 完了したタスクを取り消し線で表示できますか?

Listbox では行ごとにフォントを変えられないため、取り消し線は付けられません。itemconfig(0) で指定できる項目を調べたところ、backgroundbgfgforegroundselectbackgroundselectforeground の6つだけでした。実際に itemconfig(0, font=...) を試すと _tkinter.TclError: unknown option "-font" になります。この記事では代わりに文字色を #aaa にして薄く見せています。取り消し線が必要なら Text ウィジェットのタグ機能を使うか、Treeview に移ることになります。

Q. Enterキーで追加できません。

まず割り当て先を確認してください。<Return> がキーボードのEnterキーで、<Enter> は「マウスカーソルがウィジェットに入った」という別のイベントです。次に、バインドしている相手が入力欄になっているかを見ます。self.entry.bind("<Return>", ...) のように入力欄に付けると、そこにカーソルがあるときだけ反応します。テンキーのEnterも拾いたい場合は <KP_Enter> を追加で割り当てます。

Q. タスクが増えると重くなりませんか?

個人利用の範囲では問題ありません。実測では1,000件でも画面の作り直しが1回あたり 27.64 ミリ秒でした。数千件を超えて動きが気になり始めたら、そのときは表示件数を絞るか、SQLiteを使ったタスク管理のようにデータベース側で絞り込む形に移ります。

Q. 同じタスク名を2回追加できてしまいます。

重複を防ぎたい場合は add_task() の中で確認します。if any(t["text"] == text for t in self.tasks): で既存かどうかを判定し、あれば messagebox.showinfo() で知らせて return します。ただし「牛乳を買う」を別々の日に2回登録したいこともあるので、重複を許すかどうかは仕様として決める話です。禁止するなら、その理由を自分で説明できるようにしておきましょう。

Q. コードは動いたけれど、クラスの書き方がまだ分かりません。

その段階で問題ありません。まずは「self. を付けた変数は、同じクラスのどのメソッドからも見える」という一点だけ押さえてください。6章のようにクラスなしの形から始めて、taskslistbox を各関数から触るのが面倒になってきたタイミングでクラスに移すと、必要性が実感として分かります。

11. 次の一歩

「一覧から選ぶ → 選ばれたものを特定する → データを変える → 画面を作り直す」という流れは、ファイル管理でも在庫管理でも同じ形で出てきます。この型を保ったまま、扱うデータか表示方法のどちらかを1つだけ複雑にするのが次の一歩です。

次に作るものここで新しく身につく要素
チェックリストアプリ完了率のプログレスバー表示と、filedialog による明示的な保存・読み込み
デイリープランナータブで画面を切り替える Notebook の使い方
タスク管理(SQLite付き)期限・優先度・カテゴリを持つ Treeview と、データベースでの絞り込み
カンバンボードTODO・進行中・レビュー・完了の4列でカードを移動させる操作と、JSONでの永続化

同じ「一覧を扱う」でも狙いが違うので、順番に作ると差分がよく分かります。チェックリストは進み具合を見せること、この記事のToDoリストは選択とデータの対応を正しく保つこと、SQLite版はデータが増えても破綻しないことが主題です。

🚀
次に挑戦するアプリ

入力と表示の基本を固めたいならBMI計算アプリ、時間を扱う仕組みを見たいならストップウォッチが近道です。どちらもこのページで書いた「データを変えたら画面を作り直す」型がそのまま使えます。

tkinter のウィジェット全体を見渡したい場合はtkinterで最初のウィンドウを表示するの3層構造の説明が土台になります。GUI以外も含めたライブラリの全体像はPythonライブラリ一覧に、独学の順番を1冊で固めたい場合はPython学習本の比較にまとめてあります。

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

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

📖
独学を1冊で体系化するなら

写経しながら「なぜこう書くのか」が気になり始めたら、入門書を1冊通して読むと断片的な知識がつながります。Python入門書のおすすめ2冊(当サイトの参考書ランキング総合1〜2位)で、独学者向けの最初の1冊を比較しています。