中級者向け No.49

コードスニペットマネージャー

よく使うコードスニペットを言語別・タグ別に保存・検索・コピーできる管理ツール。SQLiteとクリップボード操作を学びます。

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

1. アプリ概要

よく使うコードスニペットを言語別・タグ別に保存・検索・コピーできる管理ツール。SQLiteとクリップボード操作を学びます。

このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ) で、難易度は ★★☆ です。

このアプリは「ユーティリティ」カテゴリです。小回りの効くユーティリティは Python の真骨頂で、汎用的なユーザー操作を組み合わせる発想が他のアプリにもそのまま流用できます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。

コードを読む前に実行することをおすすめします。動いている挙動を先に把握しておくと、解説で出てくる関数や処理がどこに対応するかが頭に入りやすくなります。

応用のヒントは、機能を 1 つ増やす・見た目を整える・例外を一つでも丁寧に扱う、のいずれかから始めるのがおすすめです。

コードスニペットマネージャー 実行画面(Windows)
実行画面(Windows)
コードスニペットマネージャー 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 「+ 新規」ボタン(_new_snippet())・「💾 保存」ボタン(_save_snippet())・「🗑 削除」ボタン(_delete_snippet())・「📋 コードコピー」ボタン(_copy_code())・「☆ お気に入り」ボタン(_toggle_fav_filter())で操作
  • イベント <<TreeviewSelect>> から _on_select() を実行
  • マウス操作 <Double-1> から _copy_code() を実行
  • キー <KeyRelease> から _on_code_change() を実行
  • ttk.Treeview による表形式の一覧表示
  • sqlite3.connect() による SQLite データベース接続と SQL 実行
  • messagebox.askyesno()messagebox.showerror() によるダイアログ通知
  • ウィンドウはタイトル「コードスニペットマネージャー」・サイズ 1100x700 で起動

3. 事前準備・環境

ℹ️
動作確認環境

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

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

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

4. 完全なソースコード

💡
コードのコピー方法

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

追加インストール不要(標準ライブラリのみ使用)
app49.py
import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import os
import re
from datetime import datetime


class App49:
    """コードスニペットマネージャー"""

    LANGUAGES = ["Python", "JavaScript", "TypeScript", "HTML", "CSS",
                 "SQL", "Bash", "Go", "Rust", "Java", "C", "C++",
                 "Ruby", "PHP", "その他"]

    SAMPLE_SNIPPETS = [
        ("リスト内包表記", "Python", "comprehension,list",
         "squares = [x**2 for x in range(10)]\neven = [x for x in range(20) if x % 2 == 0]"),
        ("fetch API", "JavaScript", "fetch,async,api",
         "const res = await fetch('https://api.example.com/data');\n"
         "const data = await res.json();\nconsole.log(data);"),
        ("flexbox中央揃え", "CSS", "flexbox,center,layout",
         ".container {\n  display: flex;\n  justify-content: center;\n  align-items: center;\n}"),
        ("SELECT with JOIN", "SQL", "join,select,query",
         "SELECT u.name, o.amount\nFROM users u\n"
         "INNER JOIN orders o ON u.id = o.user_id\nWHERE o.amount > 1000;"),
        ("デコレーター", "Python", "decorator,functools",
         "import functools\n\ndef timer(func):\n    @functools.wraps(func)\n"
         "    def wrapper(*args, **kwargs):\n        import time\n"
         "        start = time.time()\n        result = func(*args, **kwargs)\n"
         "        print(f'{func.__name__}: {time.time()-start:.3f}s')\n"
         "        return result\n    return wrapper"),
        ("Goのgoroutine", "Go", "goroutine,channel",
         "ch := make(chan int)\ngo func() {\n    ch <- 42\n}()\n"
         "value := <-ch\nfmt.Println(value)"),
    ]

    def __init__(self, root):
        self.root = root
        self.root.title("コードスニペットマネージャー")
        self.root.geometry("1100x700")
        self.root.configure(bg="#1e1e1e")

        db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                               "snippets.db")
        self._conn = sqlite3.connect(db_path)
        self._init_db()

        self._current_id = None
        self._fav_only = False
        self._build_ui()
        self._load_list()

    def _init_db(self):
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS snippets (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                language TEXT DEFAULT '',
                tags TEXT DEFAULT '',
                code TEXT DEFAULT '',
                note TEXT DEFAULT '',
                created_at TEXT,
                updated_at TEXT,
                favorite INTEGER DEFAULT 0
            )
        """)
        self._conn.commit()
        count = self._conn.execute(
            "SELECT COUNT(*) FROM snippets").fetchone()[0]
        if count == 0:
            now = datetime.now().isoformat(timespec="seconds")
            for title, lang, tags, code in self.SAMPLE_SNIPPETS:
                self._conn.execute(
                    "INSERT INTO snippets "
                    "(title,language,tags,code,created_at,updated_at)"
                    " VALUES (?,?,?,?,?,?)",
                    (title, lang, tags, code, now, now))
            self._conn.commit()

    def _build_ui(self):
        # ヘッダー
        header = tk.Frame(self.root, bg="#252526", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="📋 コードスニペットマネージャー",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)
        ttk.Button(header, text="+ 新規",
                   command=self._new_snippet).pack(side=tk.LEFT, padx=4)
        ttk.Button(header, text="💾 保存",
                   command=self._save_snippet).pack(side=tk.LEFT, padx=4)
        ttk.Button(header, text="🗑 削除",
                   command=self._delete_snippet).pack(side=tk.LEFT, padx=4)
        ttk.Button(header, text="📋 コードコピー",
                   command=self._copy_code).pack(side=tk.LEFT, padx=4)

        # 検索バー
        search_f = tk.Frame(self.root, bg="#1e1e1e", pady=4)
        search_f.pack(fill=tk.X, padx=8)
        tk.Label(search_f, text="🔍", bg="#1e1e1e",
                 fg="#ccc").pack(side=tk.LEFT)
        self.search_var = tk.StringVar()
        self.search_var.trace_add("write", lambda *a: self._load_list())
        ttk.Entry(search_f, textvariable=self.search_var,
                  width=28).pack(side=tk.LEFT, padx=4)
        tk.Label(search_f, text="言語:", bg="#1e1e1e", fg="#ccc",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(8, 2))
        self.lang_filter_var = tk.StringVar(value="すべて")
        ttk.Combobox(search_f, textvariable=self.lang_filter_var,
                     values=["すべて"] + self.LANGUAGES,
                     state="readonly", width=12).pack(side=tk.LEFT)
        self.lang_filter_var.trace_add("write", lambda *a: self._load_list())
        self.fav_btn = ttk.Button(search_f, text="☆ お気に入り",
                                   command=self._toggle_fav_filter)
        self.fav_btn.pack(side=tk.LEFT, padx=8)

        # メインエリア (PanedWindow)
        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        left = tk.Frame(paned, bg="#1e1e1e")
        paned.add(left, weight=1)
        self._build_list_panel(left)

        right = tk.Frame(paned, bg="#1e1e1e")
        paned.add(right, weight=2)
        self._build_edit_panel(right)

        # ステータスバー
        self.status_var = tk.StringVar(value="スニペットを選択してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#252526", fg="#858585", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    def _build_list_panel(self, parent):
        tk.Label(parent, text="スニペット一覧", bg="#1e1e1e",
                 fg="#888", font=("Arial", 9)).pack(anchor="w", padx=4)

        cols = ("title", "lang", "tags", "fav")
        self.tree = ttk.Treeview(parent, columns=cols, show="headings",
                                  selectmode="browse")
        self.tree.heading("title", text="タイトル")
        self.tree.heading("lang",  text="言語")
        self.tree.heading("tags",  text="タグ")
        self.tree.heading("fav",   text="★")
        self.tree.column("title", width=130, anchor="w")
        self.tree.column("lang",  width=80,  anchor="w")
        self.tree.column("tags",  width=100, anchor="w")
        self.tree.column("fav",   width=28,  anchor="center")

        sb = ttk.Scrollbar(parent, command=self.tree.yview)
        self.tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.tree.pack(fill=tk.BOTH, expand=True)
        self.tree.bind("<<TreeviewSelect>>", self._on_select)
        self.tree.bind("<Double-1>", lambda e: self._copy_code())

        self.count_lbl = tk.Label(parent, text="0 件", bg="#1e1e1e",
                                   fg="#555", font=("Arial", 8))
        self.count_lbl.pack(anchor="e", padx=4)

    def _build_edit_panel(self, parent):
        # フォーム
        form = tk.Frame(parent, bg="#252526", pady=6)
        form.pack(fill=tk.X, padx=4)

        r0 = tk.Frame(form, bg="#252526")
        r0.pack(fill=tk.X, padx=6, pady=2)
        tk.Label(r0, text="タイトル:", bg="#252526", fg="#ccc",
                 font=("Arial", 9), width=8, anchor="e").pack(side=tk.LEFT)
        self.title_entry = ttk.Entry(r0)
        self.title_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=4)
        self.fav_var = tk.IntVar()
        tk.Checkbutton(r0, variable=self.fav_var, bg="#252526",
                       text="★ お気に入り", fg="#ffd700",
                       selectcolor="#252526",
                       activebackground="#252526",
                       font=("Arial", 9)).pack(side=tk.LEFT, padx=4)

        r1 = tk.Frame(form, bg="#252526")
        r1.pack(fill=tk.X, padx=6, pady=2)
        tk.Label(r1, text="言語:", bg="#252526", fg="#ccc",
                 font=("Arial", 9), width=8, anchor="e").pack(side=tk.LEFT)
        self.lang_var = tk.StringVar(value="Python")
        ttk.Combobox(r1, textvariable=self.lang_var,
                     values=self.LANGUAGES, width=14).pack(side=tk.LEFT, padx=4)
        self.lang_var.trace_add("write", lambda *a: self._highlight_code())

        tk.Label(r1, text="タグ(カンマ区切り):", bg="#252526", fg="#ccc",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(12, 2))
        self.tags_entry = ttk.Entry(r1)
        self.tags_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=4)

        # コードエディタ
        editor_f = tk.Frame(parent, bg="#1e1e1e")
        editor_f.pack(fill=tk.BOTH, expand=True, padx=4, pady=(4, 0))
        tk.Label(editor_f, text="コード:", bg="#1e1e1e", fg="#888",
                 font=("Arial", 9)).pack(anchor="w")

        code_area = tk.Frame(editor_f, bg="#1e1e1e")
        code_area.pack(fill=tk.BOTH, expand=True)

        self.line_canvas = tk.Canvas(code_area, width=36, bg="#0d1117",
                                      highlightthickness=0)
        self.line_canvas.pack(side=tk.LEFT, fill=tk.Y)

        self.code_text = tk.Text(
            code_area, bg="#0d1117", fg="#d4d4d4",
            font=("Courier New", 10), relief=tk.FLAT,
            insertbackground="#fff", selectbackground="#264f78",
            undo=True, wrap=tk.NONE, tabs=("4m",))
        ysb = ttk.Scrollbar(code_area, orient=tk.VERTICAL,
                             command=self.code_text.yview)
        xsb = ttk.Scrollbar(editor_f, orient=tk.HORIZONTAL,
                             command=self.code_text.xview)
        self.code_text.configure(xscrollcommand=xsb.set,
                                  yscrollcommand=ysb.set)
        ysb.pack(side=tk.RIGHT, fill=tk.Y)
        self.code_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        xsb.pack(fill=tk.X)

        self.code_text.bind("<KeyRelease>", self._on_code_change)

        # メモ
        note_f = tk.Frame(parent, bg="#1e1e1e")
        note_f.pack(fill=tk.X, padx=4, pady=4)
        tk.Label(note_f, text="メモ:", bg="#1e1e1e", fg="#888",
                 font=("Arial", 9)).pack(anchor="w")
        self.note_text = tk.Text(note_f, height=3, bg="#1a1a2e", fg="#8b949e",
                                  font=("Arial", 9), relief=tk.FLAT,
                                  insertbackground="#fff")
        self.note_text.pack(fill=tk.X)

    # ── DB 操作 ───────────────────────────────────────────────────

    def _load_list(self):
        query = self.search_var.get().strip().lower()
        lang_f = self.lang_filter_var.get()

        sql = "SELECT id, title, language, tags, favorite FROM snippets WHERE 1=1"
        params = []
        if query:
            sql += (" AND (LOWER(title) LIKE ? OR LOWER(tags) LIKE ?"
                    " OR LOWER(code) LIKE ?)")
            params += [f"%{query}%"] * 3
        if lang_f != "すべて":
            sql += " AND language = ?"
            params.append(lang_f)
        if self._fav_only:
            sql += " AND favorite = 1"
        sql += " ORDER BY updated_at DESC"

        rows = self._conn.execute(sql, params).fetchall()
        self.tree.delete(*self.tree.get_children())
        for sid, title, lang, tags, fav in rows:
            self.tree.insert("", tk.END, iid=str(sid),
                              values=(title, lang, tags,
                                      "★" if fav else ""))
        self.count_lbl.config(text=f"{len(rows)} 件")

    def _on_select(self, event=None):
        sel = self.tree.selection()
        if not sel:
            return
        sid = int(sel[0])
        row = self._conn.execute(
            "SELECT id,title,language,tags,code,note,favorite"
            " FROM snippets WHERE id=?", (sid,)).fetchone()
        if not row:
            return
        self._current_id = row[0]
        self.title_entry.delete(0, tk.END)
        self.title_entry.insert(0, row[1])
        self.lang_var.set(row[2])
        self.tags_entry.delete(0, tk.END)
        self.tags_entry.insert(0, row[3])
        self.code_text.delete("1.0", tk.END)
        self.code_text.insert(tk.END, row[4])
        self.note_text.delete("1.0", tk.END)
        self.note_text.insert(tk.END, row[5] or "")
        self.fav_var.set(row[6])
        self._update_line_numbers()
        self._highlight_code()
        self.status_var.set(f"ID={sid}  {row[1]}  [{row[2]}]")

    def _new_snippet(self):
        self._current_id = None
        self.title_entry.delete(0, tk.END)
        self.lang_var.set("Python")
        self.tags_entry.delete(0, tk.END)
        self.code_text.delete("1.0", tk.END)
        self.note_text.delete("1.0", tk.END)
        self.fav_var.set(0)
        self._update_line_numbers()
        self.status_var.set("新規スニペット")
        self.title_entry.focus_set()

    def _save_snippet(self):
        title = self.title_entry.get().strip()
        if not title:
            messagebox.showerror("エラー", "タイトルを入力してください")
            return
        lang = self.lang_var.get()
        tags = self.tags_entry.get().strip()
        code = self.code_text.get("1.0", tk.END).rstrip("\n")
        note = self.note_text.get("1.0", tk.END).rstrip("\n")
        fav  = self.fav_var.get()
        now  = datetime.now().isoformat(timespec="seconds")

        if self._current_id is None:
            cur = self._conn.execute(
                "INSERT INTO snippets "
                "(title,language,tags,code,note,favorite,created_at,updated_at)"
                " VALUES (?,?,?,?,?,?,?,?)",
                (title, lang, tags, code, note, fav, now, now))
            self._current_id = cur.lastrowid
            self._conn.commit()
            self.status_var.set(f"新規保存: {title}")
        else:
            self._conn.execute(
                "UPDATE snippets SET title=?,language=?,tags=?,code=?,"
                "note=?,favorite=?,updated_at=? WHERE id=?",
                (title, lang, tags, code, note, fav, now, self._current_id))
            self._conn.commit()
            self.status_var.set(f"更新: {title}")

        self._load_list()
        try:
            self.tree.selection_set(str(self._current_id))
        except Exception:
            pass

    def _delete_snippet(self):
        if self._current_id is None:
            return
        title = self.title_entry.get().strip() or f"ID={self._current_id}"
        if not messagebox.askyesno("削除確認", f"「{title}」を削除しますか?"):
            return
        self._conn.execute("DELETE FROM snippets WHERE id=?",
                            (self._current_id,))
        self._conn.commit()
        self._current_id = None
        self._new_snippet()
        self._load_list()
        self.status_var.set("削除しました")

    def _copy_code(self):
        code = self.code_text.get("1.0", tk.END).strip()
        if not code:
            return
        self.root.clipboard_clear()
        self.root.clipboard_append(code)
        self.status_var.set("コードをクリップボードにコピーしました")

    def _toggle_fav_filter(self):
        self._fav_only = not self._fav_only
        self.fav_btn.config(text="★ お気に入り" if self._fav_only else "☆ お気に入り")
        self._load_list()

    # ── エディタ支援 ──────────────────────────────────────────────

    def _on_code_change(self, event=None):
        self._update_line_numbers()
        self._highlight_code()

    def _update_line_numbers(self):
        self.line_canvas.delete("all")
        try:
            line_num = int(self.code_text.index("@0,0").split(".")[0])
            while True:
                dline = self.code_text.dlineinfo(f"{line_num}.0")
                if dline is None:
                    break
                self.line_canvas.create_text(
                    32, dline[1] + dline[3] // 2,
                    text=str(line_num), anchor="e",
                    fill="#858585", font=("Courier New", 10))
                line_num += 1
        except Exception:
            pass

    def _highlight_code(self):
        lang = self.lang_var.get()
        for tag in ("kw", "str_", "cmt", "num", "bi"):
            self.code_text.tag_remove(tag, "1.0", tk.END)
        self.code_text.tag_configure("kw",   foreground="#569cd6")
        self.code_text.tag_configure("str_", foreground="#ce9178")
        self.code_text.tag_configure("cmt",  foreground="#6a9955")
        self.code_text.tag_configure("num",  foreground="#b5cea8")
        self.code_text.tag_configure("bi",   foreground="#4ec9b0")

        content = self.code_text.get("1.0", tk.END)

        if lang == "Python":
            kw_pat  = (r"\b(def|class|import|from|return|if|elif|else|for|"
                       r"while|try|except|finally|with|as|pass|break|continue|"
                       r"in|not|and|or|is|None|True|False|lambda|yield|async|"
                       r"await|raise|del|global|nonlocal)\b")
            bi_pat  = (r"\b(print|len|range|type|str|int|float|list|dict|set|"
                       r"tuple|bool|open|sum|max|min|sorted|enumerate|zip|map|"
                       r"filter|super|self)\b")
            str_pat = r'("""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'|"[^"\n]*"|\'[^\'\n]*\')'
            cmt_pat = r"#[^\n]*"
        elif lang in ("JavaScript", "TypeScript"):
            kw_pat  = (r"\b(const|let|var|function|return|if|else|for|while|"
                       r"class|new|import|export|from|await|async|try|catch|"
                       r"finally|typeof|instanceof|of|in|true|false|null|"
                       r"undefined|this|super)\b")
            bi_pat  = (r"\b(console|document|window|Array|Object|Promise|fetch|"
                       r"JSON|Math|Date|Error|parseInt|parseFloat|String|"
                       r"Boolean|Number|Map|Set)\b")
            str_pat = r'(`[^`]*`|"[^"\n]*"|\'[^\'\n]*\')'
            cmt_pat = r"//[^\n]*"
        elif lang == "SQL":
            kw_pat  = (r"\b(SELECT|FROM|WHERE|JOIN|INNER|LEFT|RIGHT|OUTER|ON|"
                       r"GROUP|BY|ORDER|HAVING|INSERT|UPDATE|DELETE|CREATE|"
                       r"TABLE|DROP|ALTER|AS|AND|OR|NOT|IN|LIKE|BETWEEN|"
                       r"EXISTS|DISTINCT|LIMIT|OFFSET|SET|VALUES|INTO)\b")
            bi_pat  = (r"\b(COUNT|SUM|AVG|MAX|MIN|COALESCE|NULLIF|CASE|WHEN|"
                       r"THEN|ELSE|END|NOW|DATE|CAST|CONVERT)\b")
            str_pat = r"'[^']*'"
            cmt_pat = r"--[^\n]*"
        else:
            kw_pat  = None
            bi_pat  = None
            str_pat = r'"[^"\n]*"|\'[^\'\n]*\''
            cmt_pat = r"//[^\n]*|#[^\n]*"

        flags = re.IGNORECASE if lang == "SQL" else 0

        def apply(pattern, tag):
            if not pattern:
                return
            for m in re.finditer(pattern, content, flags):
                s = f"1.0 + {m.start()} chars"
                e = f"1.0 + {m.end()} chars"
                self.code_text.tag_add(tag, s, e)

        apply(r"\b\d+(\.\d+)?\b", "num")
        apply(str_pat, "str_")
        apply(cmt_pat, "cmt")
        apply(bi_pat, "bi")
        apply(kw_pat, "kw")


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

5. コード解説

コードスニペットマネージャーのコードを、実際に書かれている実装に沿って解説します。

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

App49 クラスにアプリの全機能をまとめています(メソッド15個・全449行)。__init__ ではタイトル「コードスニペットマネージャー」とウィンドウサイズ 1100x700 を設定します。あわせて状態を保持する変数(self._current_idself._fav_only)を初期化し、そのあと _init_db()_build_ui()_load_list() の順に呼び出して初期状態を作ります。

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

ウィジェット構成

画面は tk.Frame×10・tk.Label×11・ttk.Button×5・ttk.Entry×3・ttk.Combobox×2・ttk.PanedWindowttk.Treeviewttk.Scrollbar×3・tk.Checkbuttontk.Canvastk.Text×2 で構成しています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。

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

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

「+ 新規」ボタン(_new_snippet())・「💾 保存」ボタン(_save_snippet())・「🗑 削除」ボタン(_delete_snippet())・「📋 コードコピー」ボタン(_copy_code())・「☆ お気に入り」ボタン(_toggle_fav_filter())を command= で接続しています。また bind("<<TreeviewSelect>>", ...) から _on_select()bind("<Double-1>", ...) から _copy_code()bind("<KeyRelease>", ...) から _on_code_change() を呼べるようにイベントも登録しています。

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

SQLite データベースの接続と実行

sqlite3.connect() でデータベースファイルに接続し、SQL の実行は execute()、確定は commit() で行います。

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

例外処理

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

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

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

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

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

    新しいファイルを作成して app49.py と保存します。使うのは datetimeosresqlite3tkinter だけなので、追加インストールは不要です。

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

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

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

    title("コードスニペットマネージャー")geometry("1100x700")configure(bg="#1e1e1e") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×10・tk.Label×11・ttk.Button×5・ttk.Entry×3・ttk.Combobox×2・ttk.PanedWindowttk.Treeviewttk.Scrollbar×3・tk.Checkbuttontk.Canvastk.Text×2 を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

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

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_new_snippet()_save_snippet()_delete_snippet()_copy_code()_toggle_fav_filter())につなぎます。bind("<<TreeviewSelect>>", ...)bind("<Double-1>", ...)bind("<KeyRelease>", ...) の登録も忘れずに。

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

    アプリの本体である _save_snippet()(34行)・_on_select()(24行)・_delete_snippet()(13行)など を実装します。SQL の実行結果は execute() の戻り値から取り出します。

  7. 7
    動作確認する

    python app49.py で起動し、各ボタンが反応すること・データベースファイルを開いて内容が表示されること・キー操作(<KeyRelease>)が効くこと・マウス操作(クリック・ホイールなど)が効くことを確認します。

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

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

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

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

💡 クラス定数を書き換えて動きを変える

データはクラス定数(LANGUAGESSAMPLE_SNIPPETS)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。

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

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

💡 マウスホイールでも行番号を追従させる

行番号は編集操作のタイミングでしか再描画されません。code_text.bind("<MouseWheel>", ...) を追加し、root.after(10, self._update_line_numbers) を呼ぶと、スクロール操作でも行番号が追従します。

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

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

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

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

❌ 「database is locked」というエラーが出る

原因:同じデータベースファイルを別のツール(DB Browser など)で開いたまま書き込むと、SQLite がファイルをロックしてこのエラーになります。

解決法:他のツールでの接続を閉じてから実行してください。書き込みは commit() で確定するまで反映されない点にも注意しましょう。

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

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

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

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

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

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

❌ スクロールすると行番号が本文とズレる

原因:行番号を描画する _update_line_numbers()<KeyRelease> など編集操作のタイミングでしか呼ばれません。マウスホイールでコード欄をスクロールしても行番号側は再描画されないため、本文とずれて見えることがあります。

解決法:code_text.bind("<MouseWheel>", lambda e: self.root.after(10, self._update_line_numbers)) のように、ホイール操作にも行番号の再描画をひもづけましょう。少し遅らせて呼ぶことで、スクロール後の表示位置に行番号を合わせられます。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。どちらの課題も、初回起動で入るサンプル6件だけで確かめられます。スニペットはコードと同じフォルダの snippets.db に保存され、「🗑 削除」で消した行は元に戻せません。追加のライブラリは要りません。

  1. 課題1:検索欄に入れた記号を文字として扱う

    検索は SQL の LIKE で組み立てています。% は「何文字でも」、_ は「任意の1文字」という意味を持つため、そのまま入れると全件に一致します。記号もふつうの文字として検索してください。

    期待結果:初回起動でサンプル6件が入った状態にする。検索欄に % を1文字入れても件数は 6 件 のままで、まったく絞り込めない。直したあとは 1 件 になり、コードに x % 2 == 0 を含む「リスト内包表記」だけが残る。_ を入れたときは「SELECT with JOIN」と「デコレーター」の 2 件 になる。

    合格条件

    • list のような普通の語での検索結果は、これまでと変わらない
    • 言語フィルタと「☆ お気に入り」の絞り込みは、これまでどおり効く
    • 検索欄を空に戻すと全件表示に戻る

    ヒント①(どこを触るか): 触るのは _load_list()if query: の中1か所です。SQL文と params を組み立てているところです。
    ヒント②(使うもの): SQLite の LIKEESCAPE 句で逃がし文字を決められます。3つの LIKE ? すべてに ESCAPE を付けたうえで、query 側は %_ の前に逃がし文字を差し込んでから渡します。
    つまずきやすい点: 逃がし文字そのものも先に二重にしないと、その記号を検索したときに壊れます。ESCAPE に渡せるのは1文字だけで、SQL に2文字が届くと sqlite3.OperationalError: ESCAPE expression must be a single character が出ます。パラメータは params += [f"%{query}%"] * 3 と同じ値を3つ使い回しているので、逃がす処理は1回で足ります。

  2. 課題2:絞り込み中に保存すると一覧から消えたように見える

    「☆ お気に入り」で絞り込んだまま新規保存すると、保存はできているのに一覧には出ません。選択も外れたままです。保存したものがどうなったか分かるようにしてください。

    期待結果:「☆ お気に入り」ボタンを1回押す(表示が ★ お気に入り に変わり、件数は 0 件 になる)。「+ 新規」を押してタイトルに テスト と入れ、「💾 保存」を押す。ステータスは 新規保存: テスト になるのに、一覧は 0 件 のままで行が増えない。直したあとは、いまの絞り込みでは表示されないことが画面から分かる。

    合格条件

    • 絞り込んでいないときは、これまでどおり保存した行が選択された状態になる
    • 「★ お気に入り」にチェックを入れて保存したときは、絞り込み中でも一覧に残る
    • 保存したデータ自体は消えない(絞り込みを外すと出てくる)

    ヒント①(どこを触るか): 触るのは _save_snippet() の末尾1か所です。self._load_list() の下にある try:except Exception: のところです。
    ヒント②(使うもの): self.tree.exists(str(self._current_id)) で、その行が一覧にあるかを確かめられます。無いときは self.status_var.set(...) で知らせます。いまの絞り込み状態は self._fav_only と検索欄の self.search_var で分かります。
    つまずきやすい点: いまは selection_set() が失敗しても except Exception:pass が握りつぶすので、画面には何も出ません。検索欄に文字が入っているときも同じことが起きるため、条件をお気に入りだけに絞ると取りこぼします。判定は self._load_list() のあとに置いてください(前に置くと、作り直す前の一覧を見ることになります)。

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

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

🚀
次に挑戦するアプリ

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

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

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

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。