中級者向け No.30

クリップボードマネージャー

コピーしたテキストの履歴を保存・管理・検索できるクリップボードマネージャー。pyperclipの活用を学びます。

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

1. アプリ概要

コピーしたテキストの履歴を保存・管理・検索できるクリップボードマネージャー。pyperclipの活用を学びます。

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

このアプリはこんなときに使います。コピーした内容が次のコピーで消えてしまう「上書き地獄」を解決したいとき。コーディング中に何度も使う定型文・URL・コードスニペットを履歴からすぐ呼び出したいとき——そんな日常の小さなストレスを自前ツールで消せます。pyperclip によるクリップボード監視は、翻訳ツール・スニペットランチャー・定型文入力支援など「コピー内容をフックして加工する」アプリ全般の基礎になります。

pyperclip でつまずきやすい点が3つあります。① Linux で Pyperclip could not find a copy/paste mechanism と出る: バックエンドが必要です(apt install xclip または xsel)。Windows・Mac は標準で動きます。② 履歴監視の CPU 使用率が高い: paste() を tight loop で回すと重くなります。root.after(500, check) で 0.5 秒間隔のポーリングにしましょう。③ 同じ内容が何度も履歴に積まれる: 直前の値と比較し、変化したときだけ追加するようにします。

慣れたら、こう応用できます。履歴を SQLite に永続化すれば再起動後も残せます(保存の型は SQLiteタスク管理アプリ の実装が参考になります)。よく使う項目のピン留め、履歴の絞り込み検索、グローバルホットキーでの呼び出しなども定番の拡張です。標準ライブラリ以外のツールの探し方は Pythonライブラリ一覧 も参考にしてください。

クリップボードマネージャー 実行画面(Windows)
実行画面(Windows)
クリップボードマネージャー 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 「⏸ 監視停止」ボタン(_toggle_monitoring())・「全削除」ボタン(_clear_history())・「📌 ピン留め」ボタン(_pin_selected())・「📋 クリップボードにコピー」ボタン(_copy_to_clipboard())・「🗑 削除」ボタン(_delete_selected())・「📋 コピー」ボタン(_copy_pinned())・「✏ ラベル変更」ボタン(_rename_pin())・「🗑 削除」ボタン(_delete_pin())・「➕ 履歴に追加」ボタン(_add_manual())・「保存」ボタンで操作
  • イベント <<TreeviewSelect>> から _on_select_hist() を実行
  • マウス操作 <Double-1> から _copy_to_clipboard() を実行
  • イベント <<TreeviewSelect>> から _on_select_pin() を実行
  • マウス操作 <Double-1> から _copy_pinned() を実行
  • ttk.Treeview による表形式の一覧表示
  • JSON ファイルの保存・読み込み(起動時に自動実行)
  • messagebox.askyesno()messagebox.showerror()messagebox.showinfo() によるダイアログ通知
  • ウィンドウはタイトル「クリップボードマネージャー」・サイズ 1002x712 で起動

3. 事前準備・環境

ℹ️
動作確認環境

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

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

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

4. 完全なソースコード

💡
コードのコピー方法

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

📦 必要なライブラリをインストール(初回のみ)
pip install pyperclip
app30.py
import tkinter as tk
from tkinter import ttk, messagebox
import json
import os
import threading
import time
from datetime import datetime

try:
    import pyperclip
    PYPERCLIP_AVAILABLE = True
except ImportError:
    PYPERCLIP_AVAILABLE = False


class App30:
    """クリップボードマネージャー"""

    MAX_HISTORY = 200
    HISTORY_FILE = os.path.join(os.path.dirname(__file__), "clipboard_history.json")
    POLL_INTERVAL_MS = 500

    def __init__(self, root):
        self.root = root
        self.root.title("クリップボードマネージャー")
        self.root.geometry("1002x712")
        self.root.minsize(1002, 712)
        self.root.configure(bg="#f8f9fc")
        self._history = []
        self._pinned = []
        self._last_clip = ""
        self._monitoring = False
        self._load_history()
        self._build_ui()
        self._start_monitoring()
        self.root.protocol("WM_DELETE_WINDOW", self._on_close)

    def _load_history(self):
        if os.path.exists(self.HISTORY_FILE):
            try:
                with open(self.HISTORY_FILE, encoding="utf-8") as f:
                    data = json.load(f)
                    self._history = data.get("history", [])
                    self._pinned = data.get("pinned", [])
            except Exception:
                pass

    def _save_history(self):
        try:
            with open(self.HISTORY_FILE, "w", encoding="utf-8") as f:
                json.dump({
                    "history": self._history[-self.MAX_HISTORY:],
                    "pinned": self._pinned,
                }, f, ensure_ascii=False, indent=2)
        except Exception:
            pass

    def _build_ui(self):
        # ヘッダー
        header = tk.Frame(self.root, bg="#37474f", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="📋 クリップボードマネージャー",
                 font=("Noto Sans JP", 14, "bold"),
                 bg="#37474f", fg="white").pack(side=tk.LEFT, padx=12)
        self.monitor_btn = tk.Button(
            header, text="⏸ 監視停止", bg="#546e7a", fg="white",
            relief=tk.FLAT, font=("Arial", 10), padx=10,
            command=self._toggle_monitoring)
        self.monitor_btn.pack(side=tk.RIGHT, padx=8)
        tk.Label(header, text="監視中:", bg="#37474f", fg="#b0bec5",
                 font=("Arial", 9)).pack(side=tk.RIGHT)

        if not PYPERCLIP_AVAILABLE:
            tk.Label(self.root,
                     text="⚠ pyperclip が未インストールです (pip install pyperclip)。"
                          "クリップボード監視は無効です。",
                     bg="#fff3cd", fg="#856404",
                     font=("Arial", 9), anchor="w", padx=8
                     ).pack(fill=tk.X)

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

        # 左: 履歴 + ピン留め
        left = tk.Frame(paned, bg="#f8f9fc")
        paned.add(left, weight=5)

        notebook = ttk.Notebook(left)
        notebook.pack(fill=tk.BOTH, expand=True)

        # 履歴タブ
        hist_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(hist_tab, text="履歴")
        self._build_history_tab(hist_tab)

        # ピン留めタブ
        pin_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(pin_tab, text="⭐ ピン留め")
        self._build_pinned_tab(pin_tab)

        # 右: プレビュー
        right = ttk.LabelFrame(paned, text="プレビュー", padding=4)
        paned.add(right, weight=3)
        self._build_preview(right)

        # ステータス
        self.status_var = tk.StringVar(value="監視中...")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

        self._refresh_history_list()
        self._refresh_pinned_list()

    def _build_history_tab(self, parent):
        # ツールバー
        bar = tk.Frame(parent, bg="#f8f9fc")
        bar.pack(fill=tk.X, padx=4, pady=4)
        tk.Label(bar, text="🔍", bg="#f8f9fc").pack(side=tk.LEFT)
        self.search_var = tk.StringVar()
        self.search_var.trace_add("write", lambda *a: self._refresh_history_list())
        ttk.Entry(bar, textvariable=self.search_var,
                  width=20).pack(side=tk.LEFT, padx=4, fill=tk.X, expand=True)
        ttk.Button(bar, text="全削除",
                   command=self._clear_history).pack(side=tk.RIGHT, padx=4)

        # カテゴリフィルター
        cat_f = tk.Frame(parent, bg="#f8f9fc")
        cat_f.pack(fill=tk.X, padx=4)
        tk.Label(cat_f, text="種別:", bg="#f8f9fc").pack(side=tk.LEFT)
        self.cat_var = tk.StringVar(value="すべて")
        for val in ["すべて", "テキスト", "URL", "コード", "数字"]:
            ttk.Radiobutton(cat_f, text=val, variable=self.cat_var,
                            value=val,
                            command=self._refresh_history_list
                            ).pack(side=tk.LEFT, padx=3)

        # リスト
        cols = ("time", "type", "preview")
        self.hist_tree = ttk.Treeview(parent, columns=cols,
                                       show="headings", height=16,
                                       selectmode="browse")
        for c, h, w in [("time", "時刻", 72), ("type", "種別", 56),
                         ("preview", "内容プレビュー", 300)]:
            self.hist_tree.heading(c, text=h)
            self.hist_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(parent, command=self.hist_tree.yview)
        self.hist_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.hist_tree.pack(fill=tk.BOTH, expand=True, padx=4)
        self.hist_tree.bind("<<TreeviewSelect>>", self._on_select_hist)
        self.hist_tree.bind("<Double-1>", self._copy_to_clipboard)

        # ボタン行
        btn_f = tk.Frame(parent, bg="#f8f9fc")
        btn_f.pack(fill=tk.X, padx=4, pady=4)
        ttk.Button(btn_f, text="📌 ピン留め",
                   command=self._pin_selected).pack(side=tk.LEFT, padx=4)
        ttk.Button(btn_f, text="📋 クリップボードにコピー",
                   command=self._copy_to_clipboard).pack(side=tk.LEFT, padx=4)
        ttk.Button(btn_f, text="🗑 削除",
                   command=self._delete_selected).pack(side=tk.LEFT, padx=4)

    def _build_pinned_tab(self, parent):
        cols = ("label", "type", "preview")
        self.pin_tree = ttk.Treeview(parent, columns=cols,
                                      show="headings", height=18,
                                      selectmode="browse")
        for c, h, w in [("label", "ラベル", 120), ("type", "種別", 56),
                         ("preview", "内容プレビュー", 280)]:
            self.pin_tree.heading(c, text=h)
            self.pin_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(parent, command=self.pin_tree.yview)
        self.pin_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.pin_tree.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
        self.pin_tree.bind("<<TreeviewSelect>>", self._on_select_pin)
        self.pin_tree.bind("<Double-1>", self._copy_pinned)

        btn_f = tk.Frame(parent, bg=parent.cget("bg"))
        btn_f.pack(fill=tk.X, padx=4, pady=4)
        ttk.Button(btn_f, text="📋 コピー",
                   command=self._copy_pinned).pack(side=tk.LEFT, padx=4)
        ttk.Button(btn_f, text="✏ ラベル変更",
                   command=self._rename_pin).pack(side=tk.LEFT, padx=4)
        ttk.Button(btn_f, text="🗑 削除",
                   command=self._delete_pin).pack(side=tk.LEFT, padx=4)

    def _build_preview(self, parent):
        self.preview_text = tk.Text(parent, bg="#0d1117", fg="#c9d1d9",
                                     font=("Courier New", 11), relief=tk.FLAT,
                                     wrap=tk.WORD, state=tk.DISABLED)
        sb = ttk.Scrollbar(parent, command=self.preview_text.yview)
        self.preview_text.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.preview_text.pack(fill=tk.BOTH, expand=True)

        info_f = tk.Frame(parent, bg=ttk.Style().lookup("TLabelframe", "background"))
        info_f.pack(fill=tk.X, pady=4)
        self.char_count_var = tk.StringVar(value="")
        tk.Label(info_f, textvariable=self.char_count_var,
                 bg=info_f.cget("bg"), font=("Arial", 9),
                 fg="#666").pack(anchor="w")

        # 手動追加
        manual_f = ttk.LabelFrame(parent, text="手動追加", padding=6)
        manual_f.pack(fill=tk.X, pady=4)
        self.manual_text = tk.Text(manual_f, height=4, bg="#fafafa",
                                    font=("Arial", 10), relief=tk.FLAT)
        self.manual_text.pack(fill=tk.X, pady=2)
        ttk.Button(manual_f, text="➕ 履歴に追加",
                   command=self._add_manual).pack(anchor="e")

    # ── データ処理 ──────────────────────────────────────────────

    def _classify(self, text):
        import re
        text_s = text.strip()
        if re.match(r"https?://", text_s):
            return "URL"
        if re.search(r"^\s*(def |class |import |#|//|<!)", text_s, re.MULTILINE):
            return "コード"
        if re.match(r"^[\d\s,.\-+*/()%$¥€£]+$", text_s):
            return "数字"
        return "テキスト"

    def _add_entry(self, text):
        if not text or not text.strip():
            return
        # 重複チェック(直近)
        if self._history and self._history[0].get("text") == text:
            return
        entry = {
            "time": datetime.now().strftime("%H:%M:%S"),
            "date": datetime.now().strftime("%Y-%m-%d"),
            "text": text,
            "type": self._classify(text),
        }
        self._history.insert(0, entry)
        if len(self._history) > self.MAX_HISTORY:
            self._history.pop()
        self._save_history()
        self.root.after(0, self._refresh_history_list)

    def _refresh_history_list(self):
        query = self.search_var.get().strip().lower() if hasattr(self, "search_var") else ""
        cat = self.cat_var.get() if hasattr(self, "cat_var") else "すべて"
        self.hist_tree.delete(*self.hist_tree.get_children())
        for entry in self._history:
            if cat != "すべて" and entry.get("type") != cat:
                continue
            text = entry.get("text", "")
            if query and query not in text.lower():
                continue
            preview = text[:80].replace("\n", "↵")
            self.hist_tree.insert("", "end",
                                  values=(entry.get("time", ""),
                                          entry.get("type", ""),
                                          preview))
        self.status_var.set(
            f"履歴: {len(self._history)} 件 / ピン: {len(self._pinned)} 件")

    def _refresh_pinned_list(self):
        self.pin_tree.delete(*self.pin_tree.get_children())
        for p in self._pinned:
            preview = p.get("text", "")[:80].replace("\n", "↵")
            self.pin_tree.insert("", "end",
                                 values=(p.get("label", ""),
                                         p.get("type", ""),
                                         preview))

    def _set_preview(self, text):
        self.preview_text.config(state=tk.NORMAL)
        self.preview_text.delete("1.0", tk.END)
        self.preview_text.insert("1.0", text)
        self.preview_text.config(state=tk.DISABLED)
        lines = text.count("\n") + 1
        self.char_count_var.set(
            f"{len(text)} 文字 / {lines} 行")

    def _get_selected_text(self):
        sel = self.hist_tree.selection()
        if not sel:
            return None
        idx = self.hist_tree.index(sel[0])
        query = self.search_var.get().strip().lower()
        cat = self.cat_var.get()
        filtered = [e for e in self._history
                    if (cat == "すべて" or e.get("type") == cat) and
                    (not query or query in e.get("text", "").lower())]
        if idx < len(filtered):
            return filtered[idx]
        return None

    # ── イベントハンドラ ─────────────────────────────────────

    def _on_select_hist(self, event):
        entry = self._get_selected_text()
        if entry:
            self._set_preview(entry.get("text", ""))

    def _on_select_pin(self, event):
        sel = self.pin_tree.selection()
        if sel:
            idx = self.pin_tree.index(sel[0])
            if idx < len(self._pinned):
                self._set_preview(self._pinned[idx].get("text", ""))

    def _copy_to_clipboard(self, event=None):
        entry = self._get_selected_text()
        if not entry:
            return
        text = entry.get("text", "")
        if PYPERCLIP_AVAILABLE:
            try:
                pyperclip.copy(text)
                self.status_var.set(f"コピーしました: {text[:40]}")
            except Exception as e:
                messagebox.showerror("エラー", str(e))
        else:
            self.root.clipboard_clear()
            self.root.clipboard_append(text)
            self.status_var.set(f"コピーしました: {text[:40]}")

    def _copy_pinned(self, event=None):
        sel = self.pin_tree.selection()
        if not sel:
            return
        idx = self.pin_tree.index(sel[0])
        if idx < len(self._pinned):
            text = self._pinned[idx].get("text", "")
            if PYPERCLIP_AVAILABLE:
                try:
                    pyperclip.copy(text)
                except Exception:
                    pass
            else:
                self.root.clipboard_clear()
                self.root.clipboard_append(text)
            self.status_var.set(f"コピーしました: {text[:40]}")

    def _pin_selected(self):
        entry = self._get_selected_text()
        if not entry:
            return
        label = entry.get("text", "")[:20].replace("\n", " ")
        pin_entry = {**entry, "label": label}
        if any(p.get("text") == entry.get("text") for p in self._pinned):
            messagebox.showinfo("情報", "すでにピン留めされています")
            return
        self._pinned.append(pin_entry)
        self._save_history()
        self._refresh_pinned_list()
        self.status_var.set(f"ピン留めしました: {label}")

    def _rename_pin(self):
        sel = self.pin_tree.selection()
        if not sel:
            return
        idx = self.pin_tree.index(sel[0])
        if idx >= len(self._pinned):
            return
        win = tk.Toplevel(self.root)
        win.title("ラベル変更")
        win.geometry("320x120")
        tk.Label(win, text="新しいラベル:").pack(pady=8)
        var = tk.StringVar(value=self._pinned[idx].get("label", ""))
        ttk.Entry(win, textvariable=var, width=30).pack()

        def save():
            self._pinned[idx]["label"] = var.get()
            self._save_history()
            self._refresh_pinned_list()
            win.destroy()

        ttk.Button(win, text="保存", command=save).pack(pady=8)

    def _delete_selected(self):
        sel = self.hist_tree.selection()
        if not sel:
            return
        idx = self.hist_tree.index(sel[0])
        query = self.search_var.get().strip().lower()
        cat = self.cat_var.get()
        filtered = [e for e in self._history
                    if (cat == "すべて" or e.get("type") == cat) and
                    (not query or query in e.get("text", "").lower())]
        if idx < len(filtered):
            self._history.remove(filtered[idx])
            self._save_history()
            self._refresh_history_list()

    def _delete_pin(self):
        sel = self.pin_tree.selection()
        if not sel:
            return
        idx = self.pin_tree.index(sel[0])
        if idx < len(self._pinned):
            self._pinned.pop(idx)
            self._save_history()
            self._refresh_pinned_list()

    def _clear_history(self):
        if messagebox.askyesno("確認", "履歴をすべて削除しますか?"):
            self._history.clear()
            self._save_history()
            self._refresh_history_list()

    def _add_manual(self):
        text = self.manual_text.get("1.0", tk.END).strip()
        if not text:
            return
        self._add_entry(text)
        self.manual_text.delete("1.0", tk.END)

    # ── クリップボード監視 ──────────────────────────────────

    def _start_monitoring(self):
        self._monitoring = True
        self._poll()

    def _poll(self):
        if not self._monitoring:
            return
        if PYPERCLIP_AVAILABLE:
            try:
                current = pyperclip.paste()
                if current and current != self._last_clip:
                    self._last_clip = current
                    self._add_entry(current)
            except Exception:
                pass
        else:
            # tkinter クリップボードで代替
            try:
                current = self.root.clipboard_get()
                if current and current != self._last_clip:
                    self._last_clip = current
                    self._add_entry(current)
            except Exception:
                pass
        self._poll_id = self.root.after(self.POLL_INTERVAL_MS, self._poll)

    def _toggle_monitoring(self):
        self._monitoring = not self._monitoring
        if self._monitoring:
            self.monitor_btn.config(text="⏸ 監視停止")
            self._poll()
        else:
            self.monitor_btn.config(text="▶ 監視開始")
            if hasattr(self, "_poll_id"):
                self.root.after_cancel(self._poll_id)

    def _on_close(self):
        self._monitoring = False
        self._save_history()
        self.root.destroy()


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

5. コード解説

クリップボードマネージャーのコードを、実際に書かれている実装に沿って解説します。

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

App30 クラスにアプリの全機能をまとめています(メソッド27個・全464行)。__init__ ではタイトル「クリップボードマネージャー」とウィンドウサイズ 1002x712 を設定します。あわせて状態を保持する変数(self._last_clipself._monitoring)を初期化し、そのあと _load_history()_build_ui()_start_monitoring() の順に呼び出して初期状態を作ります。

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

ウィジェット構成

画面は tk.Frame×9・tk.Label×8・tk.Buttonttk.PanedWindowttk.Notebookttk.LabelFrame×2・ttk.Entry×2・ttk.Button×9・ttk.Radiobutton(ループで生成)・ttk.Treeview×2・ttk.Scrollbar×3・tk.Text×2・ttk.Styletk.Toplevel で構成しています。見出し付きの枠(LabelFrame)は「プレビュー」「手動追加」のラベルでエリアを区切っています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。

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

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

「⏸ 監視停止」ボタン(_toggle_monitoring())・「全削除」ボタン(_clear_history())・「📌 ピン留め」ボタン(_pin_selected())・「📋 クリップボードにコピー」ボタン(_copy_to_clipboard())・「🗑 削除」ボタン(_delete_selected())・「📋 コピー」ボタン(_copy_pinned())・「✏ ラベル変更」ボタン(_rename_pin())・「🗑 削除」ボタン(_delete_pin())・「➕ 履歴に追加」ボタン(_add_manual())・「保存」ボタンを command= で接続しています。また bind("<<TreeviewSelect>>", ...) から _on_select_hist()bind("<Double-1>", ...) から _copy_to_clipboard()bind("<<TreeviewSelect>>", ...) から _on_select_pin()bind("<Double-1>", ...) から _copy_pinned() を呼べるようにイベントも登録しています。

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

JSON への保存と読み込み

データは json.dump() でファイル(clipboard_history.json)へ保存します。起動時は json.load() で読み戻します。

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

クラス定数によるデータ定義

クラスの先頭でデータを定数として定義しています:MAX_HISTORY = 200HISTORY_FILE = os.path.join(os.path.dirname(__file__), 'clipboard_history.json')。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。

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

例外処理

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

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

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

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

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

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

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

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

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

    title("クリップボードマネージャー")geometry("1002x712")configure(bg="#f8f9fc")minsize(1002, 712) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

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

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

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_toggle_monitoring()_clear_history()_pin_selected()_copy_to_clipboard()_delete_selected()_copy_pinned()_rename_pin()_delete_pin()_add_manual()save)につなぎます。bind("<<TreeviewSelect>>", ...)bind("<Double-1>", ...)bind("<<TreeviewSelect>>", ...)bind("<Double-1>", ...) の登録も忘れずに。

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

    アプリの本体である _refresh_history_list()(17行)・_poll()(21行)・_rename_pin()(21行)など を実装します。

  7. 7
    動作確認する

    python app30.py で起動し、各ボタンが反応すること・マウス操作(クリック・ホイールなど)が効くことを確認します。

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

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

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

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

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

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

💡 入力履歴機能

以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。

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

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

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

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

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

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

解決法:geometry()minsize() の両方の数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。ただし minsize(1002, 712) を指定しているため、起動時の大きさより小さくは縮められません(縮めると画面部品が表示されなくなるのを防ぐためです)。

9. 練習問題

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

  1. 課題1:メールアドレスを種別として見分ける

    _classify() はコピーした文字列を「URL・コード・数字・テキスト」の4種類に分けています。メールアドレスは「テキスト」に入ってしまい、種別で絞り込めません。「メール」を足します。

    期待結果abc@example.com をコピーすると履歴の「種別」列が「メール」になり、種別で「メール」を選ぶとその行だけが残る。

    合格条件

    • https://example.com は今までどおり「URL」のまま
    • 「➕ 履歴に追加」で手動追加したメールアドレスにも同じ種別が付く
    • 種別「すべて」を選ぶと、これまでどおり全件が表示される

    ヒント①(どこを触るか): 触るのは _classify()_build_history_tab() の2か所です。_classify() は3つの if を上から順に試し、どれにも当たらなければ「テキスト」を返します。絞り込みのラジオボタンは _build_history_tab()["すべて", "テキスト", "URL", "コード", "数字"] の並びです。
    ヒント②(使うもの): 追加する if は最後の return "テキスト" の直前で構いません。判定は re.match(r"[^@\s]+@[^@\s]+\.[a-zA-Z]+$", text_s) のような簡単な形で十分です(import re_classify() の先頭にあります)。
    つまずきやすい点: 種別は履歴に追加した時点で決まり、clipboard_history.json に保存されます。すでにある履歴の種別は後から変わらないので、判定を足したあとにコピーし直して確かめてください。なお履歴は暗号化されずに clipboard_history.json にそのまま保存されます。テストにはこの例のようなダミーのアドレスを使い、実在のアドレスやパスワードはコピーしないでください。

  2. 課題2:ピン留めタブにも検索欄を付ける

    検索と種別フィルターがあるのは履歴タブだけで、ピン留めタブは全件が並ぶだけです。ピン留めにも検索を足します。ただし絞り込むと、画面の行番号と self._pinned の番号がずれる点に注意してください。

    期待結果:ピン留めタブの検索欄に文字を入れると一致するピンだけが並び、その行を選んだときのプレビューが選んだピンの中身になる。

    合格条件

    • 絞り込んだ状態で「🗑 削除」を押すと、画面に出ている行が消える(別のピンは消えない)
    • 「✏ ラベル変更」で付けた名前が、絞り込み中でも選んだ行に付く
    • 検索欄を空にすると全件が戻る

    ヒント①(どこを触るか): 検索欄を足すのは _build_pinned_tab()、絞り込むのは _refresh_pinned_list() です。さらに self.pin_tree.index(sel[0]) をそのまま self._pinned の番号として使っているのが _on_select_pin()_copy_pinned()_rename_pin()_delete_pin() の4か所あり、ここを直さないと絞り込み中に別のピンを操作します。
    ヒント②(使うもの): 履歴タブの self.search_vartrace_add("write", ...) が手本です。_refresh_pinned_list()insert()iid=str(元の番号) を渡し、各メソッドで int(sel[0]) として受け取ると対応づけが崩れません。
    つまずきやすい点: 履歴タブと同じ self.search_var を使い回すと、履歴側の一覧まで一緒に絞り込まれます。ピン用は別の名前(例: self.pin_search_var)にしてください。削除の動作を試す前に clipboard_history.json をコピーして控えておくと、取り違えて消したときに戻せます。

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

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

🚀
次に挑戦するアプリ

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

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

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

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

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