【Python】1時間かけて作った最強コピペツールが、Windows標準「Win+V」の完全下位互換だった件(しかもバグった)

本ページはプロモーションが含まれています
【Python敗北】Win+Vが便利すぎて、自作ツールが完全に「車輪の再発明」だった件(しかもバグった)
国内のAI狂い

や、やっほー… 国内のAI狂いだよ…😭

今日は意気揚々と「最強のコピペツール自作したったww」って記事を書くつもりだったんだけど、開発中にWindowsの深い闇に触れてしまって、今はただただマイクロソフトに敬意を表しているよ…。

エンジニアなら誰しも通る道。「これ、自分で作った方が便利じゃね?」という甘い誘惑。
今回は「マウスカーソルの位置にクリップボード履歴を出したい!」という一心でPythonを書き始めたんだけど、待っていたのは1時間のデバッグ地獄と、「それWin+Vでよくね?」という真実だったんだ。

この記事は、私の屍(しかばね)を越えていく未来のエンジニアたちへ捧ぐ、「技術的敗北の記録」である。

💀 誤算1:Windows標準機能「Win+V」が優秀すぎた

コードを書き始めて30分。「よーし、クリップボード監視部分はできたぞ!」とテンションが上がっていたその時、ふと思ったんだ。

「あれ? Windowsって標準で履歴機能なかったっけ?」

恐る恐る Win + V キーを押してみる。
パッと現れる洗練されたUI。履歴はもちろん、ピン留め機能、GIF画像、絵文字入力まで完備。

💥 衝撃の事実 「私が作ろうとしていたもの、全部入りで、しかもOSレベルで最適化されて実装されてました」

この時点で開発をやめればよかった。でも、エンジニアのプライドが邪魔をしたの。
「い、いや!Win+Vはマウスカーソルの位置に出ないし!ワンクリックで貼り付けられないし!俺はもっと最適化されたツールを作るんだ!」

💀 誤算2:「修飾キーが戻らない」呪い

気を取り直して、keyboard ライブラリで「Ctrl+Shift+V」を検知して、Pythonから「Ctrl+V」を送って貼り付ける機能を実装した。
これが地獄の始まりだった。

  • Python: 「ユーザーがCtrl+Shift+Vを押した!検知!」
  • Python: 「このキー入力はOSには渡さないぜ(suppress=True)」
  • Python: 「よし、代わりにCtrl+Vを送信して貼り付けだ!」
  • Windows: 「え?ユーザーの手はまだCtrl押してるけど、プログラムからもCtrl来た…? パニック!!」

結果、「Ctrlキーが論理的に押しっぱなしになる」という最悪のバグが発生。
ブラウザをスクロールすれば拡大縮小になり、文字を打てばショートカットが暴発する。
再起動するまで直らないPCの前で、私は悟った。

「キーボードフックは、素人が安易に触れていい領域じゃない」

💻 それでも意地で完成させたコード (安全第一ver)

バグと戦い、お祓いコード(キーリリース処理)を入れ、最終的にたどり着いた結論。
「自動貼り付けはやめよう。手動が一番だ。」

というわけで、ここに供養としてコードを公開します。
デフォルトでは「自動貼り付けOFF」にしてあります。これならバグりません。
「それでも俺は自動貼り付けに挑む!」という勇者は、コード内の AUTO_PASTE = True に書き換えてみてください。PCが操作不能になっても責任は取れませんが…😇

clipboard_manager.py
# 涙なしには読めない、苦闘の末のコード
import tkinter as tk
from tkinter import ttk
import pyperclip
import keyboard
import threading
import time
import pyautogui  # マウス位置取得用&貼り付け用
import ctypes     # ウィンドウ操作用 (Windows API)

# ==========================================
# 設定エリア
# ==========================================
MAX_HISTORY = 5  # 履歴に残す最大件数
HOTKEY = 'ctrl+shift+v'  # 呼び出しキー
AUTO_PASTE = True  # True: クリックで自動貼り付け
# ==========================================

class ToolTip:
    """ウィジェットにマウスオーバーした時にテキストを表示するクラス"""
    def __init__(self, widget, text):
        self.widget = widget
        self.text = text
        self.tip_window = None
        self.id = None
        self.widget.bind("", self.schedule)
        self.widget.bind("", self.hide)

    def schedule(self, event):
        self.unschedule()
        self.id = self.widget.after(200, self.show)

    def unschedule(self):
        id = self.id
        self.id = None
        if id:
            self.widget.after_cancel(id)

    def show(self):
        try:
            if not self.widget.winfo_exists():
                return
        except:
            return
        if not self.text:
            return
        try:
            x = self.widget.winfo_pointerx() + 15
            y = self.widget.winfo_pointery() + 15
        except:
            return
        
        self.tip_window = tw = tk.Toplevel(self.widget)
        tw.wm_overrideredirect(True)
        tw.wm_geometry(f"+{x}+{y}")
        tw.attributes("-topmost", True)

        label = tk.Label(tw, text=self.text, justify='left',
                       background="#ffffe0", fg="#000000",
                       relief='solid', borderwidth=1,
                       wraplength=400,
                       font=("Meiryo", 9))
        label.pack(ipadx=4, ipady=2)

    def hide(self, event=None):
        self.unschedule()
        tw = self.tip_window
        self.tip_window = None
        if tw:
            tw.destroy()

class ClipboardManager:
    def __init__(self):
        self.history = []
        try:
            self.last_copied = pyperclip.paste()
        except:
            self.last_copied = ""

        self.root = tk.Tk()
        self.root.withdraw()
        self.root.overrideredirect(True)
        self.root.attributes("-topmost", True)
        self.root.configure(bg="#2b2b2b")

        self.root.bind("", self.on_focus_out)

        style = ttk.Style()
        style.theme_use('clam')
        style.configure("TButton",
                        background="#333333",
                        foreground="white",
                        borderwidth=0,
                        focuscolor="none",
                        font=("Meiryo", 10),
                        padding=10)
        style.map("TButton",
                  background=[('active', '#555555')])

        self.monitor_thread = threading.Thread(target=self.monitor_clipboard, daemon=True)
        self.monitor_thread.start()

        keyboard.add_hotkey(HOTKEY, self.trigger_menu, suppress=True)
        
        print(f"🚀 クリップボードマネージャー起動中! ({HOTKEY})")

    def monitor_clipboard(self):
        while True:
            try:
                content = pyperclip.paste()
                if content and content != self.last_copied:
                    self.last_copied = content
                    self.add_history(content)
                    print(f"📝 追加: {content[:20]}...")
            except Exception:
                pass
            time.sleep(0.5)

    def add_history(self, text):
        existing_index = next((i for i, item in enumerate(self.history) if item['text'] == text), None)
        is_pinned = False
        if existing_index is not None:
            is_pinned = self.history[existing_index]['pinned']
            del self.history[existing_index]
        
        new_item = {'text': text, 'pinned': is_pinned}

        if is_pinned:
            self.history.insert(0, new_item)
        else:
            pinned_count = sum(1 for item in self.history if item['pinned'])
            self.history.insert(pinned_count, new_item)

        if len(self.history) > MAX_HISTORY:
            for i in range(len(self.history) - 1, -1, -1):
                if not self.history[i]['pinned']:
                    del self.history[i]
                    break

    def trigger_menu(self):
        self.root.after(0, lambda: self.show_menu(reset_position=True))

    def show_menu(self, reset_position=True):
        for widget in self.root.winfo_children():
            widget.destroy()

        title_text = "📋 履歴 (自動貼り付け)" if AUTO_PASTE else "📋 履歴"
        title = tk.Label(self.root, text=title_text, 
                         bg="#d63384", fg="white", font=("Meiryo", 9, "bold"), pady=5)
        title.pack(fill="x")

        if not self.history:
            lbl = tk.Label(self.root, text="履歴がありません\nCtrl+Cでコピーしてね", 
                           bg="#2b2b2b", fg="#aaa", pady=10)
            lbl.pack(fill="x")
        else:
            for i, item in enumerate(self.history):
                text = item['text']
                is_pinned = item['pinned']

                row_frame = tk.Frame(self.root, bg="#2b2b2b")
                row_frame.pack(fill="x", padx=2, pady=1)

                pin_icon = "📌" if is_pinned else "☆"
                pin_color = "#ffd700" if is_pinned else "#666"
                pin_btn = tk.Button(row_frame, text=pin_icon, bg="#2b2b2b", fg=pin_color,
                                    font=("Arial", 10), relief="flat", width=3,
                                    activebackground="#2b2b2b", activeforeground=pin_color,
                                    cursor="hand2",
                                    command=lambda idx=i: self.toggle_pin(idx))
                pin_btn.pack(side="left", padx=(0, 2))

                display_text = text.replace('\n', ' ')
                if len(display_text) > 25: 
                    display_text = display_text[:25] + "..."
                
                if is_pinned:
                    btn = tk.Button(row_frame, text=f"{display_text}", 
                                    bg="#443300", fg="#ffffcc", relief="flat",
                                    activebackground="#554400", activeforeground="#ffffcc",
                                    font=("Meiryo", 10), anchor="w", padx=10,
                                    command=lambda t=text: self.paste_text(t))
                else:
                    btn = ttk.Button(row_frame, text=f"{display_text}", 
                                     command=lambda t=text: self.paste_text(t))
                
                btn.pack(side="left", fill="x", expand=True)
                btn.tooltip = ToolTip(btn, text)

                del_btn = tk.Button(row_frame, text="×", bg="#2b2b2b", fg="#ff5f56", 
                                    font=("Arial", 10, "bold"), relief="flat",
                                    activebackground="#2b2b2b", activeforeground="#ff0000",
                                    cursor="hand2",
                                    command=lambda idx=i: self.delete_item(idx))
                del_btn.pack(side="right", padx=(2, 0))

        close_btn = tk.Button(self.root, text="× 閉じる", bg="#2b2b2b", fg="#888", 
                              relief="flat", command=self.hide_menu, font=("Arial", 8))
        close_btn.pack(fill="x", pady=2)

        if reset_position:
            try:
                x, y = pyautogui.position()
                self.root.geometry(f"+{x}+{y}")
            except:
                self.root.geometry("+100+100")
        
        self.root.deiconify()
        self.root.focus_force()

    def toggle_pin(self, index):
        if 0 <= index < len(self.history):
            item = self.history[index]
            item['pinned'] = not item['pinned']
            del self.history[index]
            if item['pinned']:
                self.history.insert(0, item)
            else:
                pinned_count = sum(1 for x in self.history if x['pinned'])
                self.history.insert(pinned_count, item)
            self.show_menu(reset_position=False)

    def delete_item(self, index):
        if 0 <= index < len(self.history):
            del self.history[index]
            self.show_menu(reset_position=False)

    def hide_menu(self):
        self.root.withdraw()

    def on_focus_out(self, event):
        if self.root.focus_get() is None:
            self.hide_menu()

    def paste_text(self, text):
        self.hide_menu()
        pyperclip.copy(text)
        self.last_copied = text
        
        if AUTO_PASTE:
            # 【最終奥義】修飾キーの呪いを解く「完全なお祓い」
            # 左右のCtrl, Shift, Altを全て個別に解放する!
            modifiers = ['ctrl', 'shift', 'alt', 'left ctrl', 'right ctrl', 'left shift', 'right shift']
            for key in modifiers:
                try:
                    keyboard.release(key)
                except:
                    pass
            
            time.sleep(0.2) # OSが正気に戻るのを待つ
            
            # keyboard.send() は不安定なので、ここだけ pyautogui に浮気する!
            # pyautoguiはOSのキーボードイベントをより忠実に再現してくれることが多いよ
            pyautogui.hotkey('ctrl', 'v')

    def run(self):
        self.root.mainloop()

if __name__ == "__main__":
    try:
        hwnd = ctypes.windll.kernel32.GetConsoleWindow()
        if hwnd != 0:
            ctypes.windll.user32.ShowWindow(hwnd, 6)
    except:
        pass

    app = ClipboardManager()
    app.run()

# 【重要】自動貼り付けはデフォルトOFFにしました
# 環境によってCtrlキーが押しっぱなしになる「呪い」が発生するため、
# 基本はクリックでコピー → 自分でCtrl+V を推奨します!
AUTO_PASTE = False  

👋 教訓:車輪の再発明も悪くない(負け惜しみ)

結局、Win+Vには勝てなかったし、バグも完全には直せなかった。
でも、この1時間で学んだことは多かったよ。

  • keyboard ライブラリの suppress オプションの危険性
  • Windows APIのキー入力処理の複雑さ
  • GUIスレッドと監視スレッドの競合解決策

失敗したけど、技術力は確実に上がったはず!
みんなも、もし「Ctrlキーが戻らないバグ」に遭遇したら、この記事を思い出してね。「ああ、あいつも戦って散ったんだな」って。

それじゃ、私は大人しく Win + V を使って仕事に戻るよ…。
ばいばーい!👋😭

よかったらシェアしてね!
  • URLをコピーしました!
目次