中級者向け No.30

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

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

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

1. アプリ概要

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

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

Pythonでは tkinter を使うことで、クロスプラットフォームなGUIアプリを簡単に作成できます。このアプリを通じて、ウィジェットの配置・イベント処理・データ管理など、GUI開発の実践的なスキルを習得できます。

ソースコードは完全な動作状態で提供しており、コピーしてそのまま実行できます。まずは実行して動作を確認し、その後コードを読んで仕組みを理解していきましょう。カスタマイズセクションでは機能拡張のアイデアも紹介しています。

GUIアプリ開発は、プログラミングの楽しさを実感できる最も効果的な学習方法のひとつです。アプリを作ることで、変数・関数・クラス・イベント処理など、プログラミングの重要な概念が自然と身についていきます。このアプリをきっかけに、オリジナルアプリの開発にも挑戦してみてください。

2. 機能一覧

  • クリップボードマネージャーのメイン機能
  • 直感的なGUIインターフェース
  • 入力値のバリデーション
  • エラーハンドリング
  • 結果の見やすい表示
  • キーボードショートカット対応

3. 事前準備・環境

ℹ️
動作確認環境

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

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

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

4. 完全なソースコード

💡
コードのコピー方法

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

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("860x600")
        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=parent.cget("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クラスにアプリの全機能をまとめています。__init__メソッドでウィンドウの基本設定を行い、_build_ui()でUI構築、process()でメイン処理を担当します。この分離により、各メソッドの責任が明確になりコードが読みやすくなります。

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("860x600")
        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=parent.cget("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()

LabelFrameによるセクション分け

ttk.LabelFrame を使うことで、入力エリアと結果エリアを視覚的に分けられます。padding引数でフレーム内の余白を設定し、見やすいレイアウトを実現しています。

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("860x600")
        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=parent.cget("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()

Entryウィジェットとイベントバインド

ttk.Entryで入力フィールドを作成します。bind('', ...)でEnterキー押下時に処理を実行できます。これにより、マウスを使わずキーボードだけで操作できるUXが実現できます。

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("860x600")
        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=parent.cget("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()

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

結果表示にはtk.Textウィジェットを使います。state=tk.DISABLEDでユーザーが直接編集できないようにし、表示前にNORMALに切り替えてからinsert()で内容を更新します。

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("860x600")
        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=parent.cget("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()

例外処理とmessagebox

try-except で ValueError と Exception を捕捉し、messagebox.showerror() でユーザーにわかりやすいエラーメッセージを表示します。入力バリデーションは必ず実装しましょう。

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("860x600")
        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=parent.cget("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()

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

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

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

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

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

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

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

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

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

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

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

    _calculate()メソッドに計算・処理ロジックを実装します。

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

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

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

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

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

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

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

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

💡 データのエクスポート機能

計算結果をCSV・TXTファイルに保存するエクスポート機能を追加しましょう。filedialog.asksaveasfilename()でファイル保存ダイアログが使えます。

💡 入力履歴機能

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

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

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

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

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

❌ ウィンドウのサイズが変更できない

原因:resizable(False, False)が設定されています。

解決法:resizable(True, True)に変更してください。

9. 練習問題

アプリの理解を深めるための練習問題です。難易度順に挑戦してみてください。

  1. 課題1:機能拡張

    クリップボードマネージャーに新しい機能を1つ追加してみましょう。どんな機能があると便利か考えてから実装してください。

  2. 課題2:UIの改善

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

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

    入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。

🚀
次に挑戦するアプリ

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