0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PySide6で他のアプリにオーバーレイするアプリケーションを作る (3) - 部分的クリックスルーとリアルタイム追従編

0
Last updated at Posted at 2025-12-31

はじめに

前回の記事では、PySide2を使って他のアプリケーション上にオーバーレイするウィンドウを作成しました。しかし、以下のような課題が残っていました:

  • 完全なマウス透過:オーバーレイ上のボタンをクリックできない
  • タイマーによる追従:1秒ごとのチェックで反応が遅い

今回は、これらの課題を解決し、より実用的なオーバーレイアプリケーションを実装します。

実装する機能

  1. 部分的クリックスルー:ボタン上だけクリック可能、それ以外は下のウィンドウに透過
  2. Windows Hookによるリアルタイム追従:イベント駆動でウィンドウ移動に即座に追従

1. 部分的クリックスルーの実装

1.1 課題:全体を透過すると何もクリックできない

前回の実装では WA_TransparentForMouseEvents を使ってマウスイベントを透過していましたが、これだとオーバーレイ上のボタンもクリックできません。

1.2 解決策:Win32 APIでクリックスルーを動的に切り替える

Win32 APIの WS_EX_TRANSPARENT スタイルをマウス位置に応じて動的にON/OFFすることで、部分的なクリックスルーを実現します。

必要なWin32定数

import ctypes
from ctypes import wintypes

user32 = ctypes.windll.user32

# Win32定数
GWL_EXSTYLE = -20
WS_EX_TRANSPARENT = 0x00000020
WS_EX_LAYERED = 0x00080000
WS_EX_NOACTIVATE = 0x08000000

クリックスルーの切り替え関数

def updateClickThrough(self, enable):
    """クリックスルーの有効/無効を切り替え"""
    hwnd = int(self.winId())
    ex_style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)

    if enable:
        ex_style |= WS_EX_TRANSPARENT  # クリックスルーON
    else:
        ex_style &= ~WS_EX_TRANSPARENT  # クリックスルーOFF

    user32.SetWindowLongW(hwnd, GWL_EXSTYLE, ex_style)

1.3 マウス位置の監視

10msごとにマウス位置をチェックし、ボタン上にあるかどうかを判定します。

def setupMouseTracking(self):
    """マウス位置を定期的にチェックしてクリックスルーを制御"""
    self.mouse_timer = QTimer(self)
    self.mouse_timer.timeout.connect(self.checkMousePosition)
    self.mouse_timer.start(10)  # 10msごとにマウス位置をチェック

def checkMousePosition(self):
    """マウスがボタンの上にあるかチェック"""
    # グローバルマウス位置を取得
    cursor_pos = wintypes.POINT()
    user32.GetCursorPos(ctypes.byref(cursor_pos))

    # ウィジェットの矩形を取得
    widget_rect = self.geometry()

    # ボタンのグローバル座標を計算
    button_global_rect = QRect(
        widget_rect.x() + self.button.x(),
        widget_rect.y() + self.button.y(),
        self.button.width(),
        self.button.height()
    )

    # マウスがボタンの上にあるか確認
    is_over_button = button_global_rect.contains(cursor_pos.x, cursor_pos.y)

    # クリックスルー状態を更新
    if is_over_button and self.is_clickthrough:
        # ボタンの上にある場合、クリックスルーを無効化
        self.updateClickThrough(False)
        self.is_clickthrough = False
    elif not is_over_button and not self.is_clickthrough:
        # ボタンの外にある場合、クリックスルーを有効化
        self.updateClickThrough(True)
        self.is_clickthrough = True

1.4 ポイント

  • グローバル座標とローカル座標:ボタンのローカル座標をウィジェットのグローバル座標に変換する必要がある
  • 高頻度チェック:10msごとにチェックすることで、マウス移動に滑らかに追従
  • 状態管理is_clickthroughフラグで現在の状態を保持し、変更時のみAPIを呼ぶ

2. Windows Hookによるリアルタイム追従

2.1 課題:タイマーによる追従の遅延

前回は1秒ごとにウィンドウ位置をチェックしていたため、メモ帳を移動しても追従が遅れていました。

2.2 解決策:SetWinEventHookでイベント駆動

Windows APIの SetWinEventHook を使うと、ウィンドウの移動イベントをリアルタイムで検出できます。

必要な定数とコールバック型の定義

# Windows Hook定数
EVENT_OBJECT_LOCATIONCHANGE = 0x800B
EVENT_OBJECT_DESTROY = 0x8001
WINEVENT_OUTOFCONTEXT = 0x0000
WINEVENT_SKIPOWNPROCESS = 0x0002

# Windows Hook用のコールバック型
WINEVENTPROC = ctypes.WINFUNCTYPE(
    None,
    ctypes.c_int,  # hWinEventHook
    ctypes.c_uint,  # event
    ctypes.c_int,  # hwnd
    ctypes.c_long,  # idObject
    ctypes.c_long,  # idChild
    ctypes.c_uint,  # idEventThread
    ctypes.c_uint   # dwmsEventTime
)

2.3 Hookの設定

def setupWindowHook(self):
    # メモ帳を検索
    self.findNotepad()

    if self.target_hwnd:
        # Windows Hookを設定してリアルタイムでウィンドウイベントを検出
        self.hook_callback = WINEVENTPROC(self.winEventCallback)
        self.hook = user32.SetWinEventHook(
            EVENT_OBJECT_LOCATIONCHANGE,  # 最小イベント
            EVENT_OBJECT_LOCATIONCHANGE,  # 最大イベント
            0,  # モジュールハンドル
            self.hook_callback,  # コールバック
            0,  # プロセスID (0 = 全プロセス)
            0,  # スレッドID (0 = 全スレッド)
            WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS
        )

    # フォールバックとして低頻度のタイマーも設定(メモ帳が再起動された場合など)
    self.timer = QTimer(self)
    self.timer.timeout.connect(self.checkNotepad)
    self.timer.start(1000)  # 1秒ごとにメモ帳の存在をチェック

2.4 コールバック関数

def winEventCallback(self, hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime):
    """Windowsイベントフックのコールバック"""
    if hwnd == self.target_hwnd and event == EVENT_OBJECT_LOCATIONCHANGE:
        # メモ帳のウィンドウが移動またはリサイズされた
        QTimer.singleShot(0, self.updatePosition)

2.5 重要なポイント

コールバック関数の参照を保持する

def __init__(self):
    super().__init__()
    self.hook = None
    self.hook_callback = None  # ← これが重要!

なぜ必要か?

Pythonのガベージコレクタがコールバック関数を回収してしまうと、Windowsからコールバックが呼ばれた時にクラッシュします。インスタンス変数として参照を保持することで、GCから保護します。

QTimer.singleShot(0, ...)を使う理由

QTimer.singleShot(0, self.updatePosition)

Windows Hookのコールバックは別スレッドから呼ばれる可能性があります。QTimer.singleShot(0, ...) を使うことで、Qtのメインスレッド(イベントループ)で処理を実行できます。

Hookの解放

def closeEvent(self, event):
    """ウィンドウを閉じる時にフックを解除"""
    if self.hook:
        user32.UnhookWinEvent(self.hook)
    if self.mouse_timer:
        self.mouse_timer.stop()
    super().closeEvent(event)

アプリケーション終了時に必ずHookを解放しないと、リソースリークが発生します。

2.6 タイマーとの併用

Hookだけでなく、1秒ごとのタイマーも併用しています。これは以下の理由からです:

  • メモ帳が閉じられて再起動された場合の検出
  • ウィンドウハンドルが無効になった場合の再検索
  • フォールバックとしての安全機構

3. 完成コード

完全なコードを表示
import sys
import ctypes
from ctypes import wintypes
from PySide6.QtWidgets import (QApplication, QWidget, QPushButton,
                               QSystemTrayIcon, QMenu)
from PySide6.QtCore import Qt, QTimer, QRect
from PySide6.QtGui import QIcon, QAction, QPainter, QColor

# Win32 API定義
user32 = ctypes.windll.user32
dwmapi = ctypes.windll.dwmapi

# Win32定数
GWL_EXSTYLE = -20
WS_EX_TRANSPARENT = 0x00000020
WS_EX_LAYERED = 0x00080000
WS_EX_NOACTIVATE = 0x08000000
HWND_TOPMOST = -1
SWP_NOACTIVATE = 0x0010
SWP_NOMOVE = 0x0002
SWP_NOSIZE = 0x0001

# Windows Hook定数
EVENT_OBJECT_LOCATIONCHANGE = 0x800B
EVENT_OBJECT_DESTROY = 0x8001
WINEVENT_OUTOFCONTEXT = 0x0000
WINEVENT_SKIPOWNPROCESS = 0x0002

# Windows Hook用のコールバック型
WINEVENTPROC = ctypes.WINFUNCTYPE(
    None,
    ctypes.c_int,  # hWinEventHook
    ctypes.c_uint,  # event
    ctypes.c_int,  # hwnd
    ctypes.c_long,  # idObject
    ctypes.c_long,  # idChild
    ctypes.c_uint,  # idEventThread
    ctypes.c_uint   # dwmsEventTime
)

# DWM API用の構造体
class RECT(ctypes.Structure):
    _fields_ = [
        ('left', wintypes.LONG),
        ('top', wintypes.LONG),
        ('right', wintypes.LONG),
        ('bottom', wintypes.LONG),
    ]

class OverlayWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.target_hwnd = None
        self.hook = None
        self.hook_callback = None  # コールバックの参照を保持
        self.mouse_timer = None  # マウス位置チェック用タイマー
        self.is_clickthrough = True  # クリックスルー状態
        self.initUI()
        self.setupWindowHook()
        self.setupMouseTracking()

    def initUI(self):
        # ウィンドウフラグの設定
        self.setWindowFlags(
            Qt.WindowType.FramelessWindowHint |  # タイトルバーなし
            Qt.WindowType.WindowStaysOnTopHint |  # 常に最前面
            Qt.WindowType.Tool  # タスクバーに表示しない
        )

        # 半透明を有効化
        self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)

        # 半透明のボタンを追加
        self.button = QPushButton("Overlay Button", self)
        self.button.setGeometry(10, 10, 150, 40)
        self.button.setStyleSheet("""
            QPushButton {
                background-color: rgba(100, 100, 255, 180);
                color: white;
                border: 2px solid white;
                border-radius: 5px;
                font-size: 14px;
            }
            QPushButton:hover {
                background-color: rgba(120, 120, 255, 200);
            }
        """)
        self.button.clicked.connect(self.onButtonClick)

        self.setGeometry(100, 100, 400, 300)

    def onButtonClick(self):
        """ボタンクリック時のハンドラ"""
        print("Hello World")

    def closeEvent(self, event):
        """ウィンドウを閉じる時にフックを解除"""
        if self.hook:
            user32.UnhookWinEvent(self.hook)
        if self.mouse_timer:
            self.mouse_timer.stop()
        super().closeEvent(event)

    def paintEvent(self, event):
        """半透明の赤背景を描画"""
        painter = QPainter(self)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)

        # 半透明の赤色で塗りつぶし
        color = QColor(255, 0, 0, 128)  # RGBA: 赤、アルファ値128
        painter.fillRect(self.rect(), color)

    def showEvent(self, event):
        super().showEvent(event)
        # ウィンドウが表示された後にWin32設定を適用
        QTimer.singleShot(100, self.applyWin32Settings)

    def applyWin32Settings(self):
        hwnd = int(self.winId())

        # クリックスルーを有効化(初期状態)
        ex_style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
        ex_style |= WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_NOACTIVATE
        user32.SetWindowLongW(hwnd, GWL_EXSTYLE, ex_style)

        # 常に最前面に設定
        user32.SetWindowPos(
            hwnd, HWND_TOPMOST,
            0, 0, 0, 0,
            SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE
        )

    def updateClickThrough(self, enable):
        """クリックスルーの有効/無効を切り替え"""
        hwnd = int(self.winId())
        ex_style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)

        if enable:
            ex_style |= WS_EX_TRANSPARENT
        else:
            ex_style &= ~WS_EX_TRANSPARENT

        user32.SetWindowLongW(hwnd, GWL_EXSTYLE, ex_style)

    def setupMouseTracking(self):
        """マウス位置を定期的にチェックしてクリックスルーを制御"""
        self.mouse_timer = QTimer(self)
        self.mouse_timer.timeout.connect(self.checkMousePosition)
        self.mouse_timer.start(10)  # 10msごとにマウス位置をチェック

    def checkMousePosition(self):
        """マウスがボタンの上にあるかチェック"""
        # グローバルマウス位置を取得
        cursor_pos = wintypes.POINT()
        user32.GetCursorPos(ctypes.byref(cursor_pos))

        # ウィジェットの矩形を取得
        widget_rect = self.geometry()

        # ボタンのグローバル座標を計算
        button_global_rect = QRect(
            widget_rect.x() + self.button.x(),
            widget_rect.y() + self.button.y(),
            self.button.width(),
            self.button.height()
        )

        # マウスがボタンの上にあるか確認
        is_over_button = button_global_rect.contains(cursor_pos.x, cursor_pos.y)

        # クリックスルー状態を更新
        if is_over_button and self.is_clickthrough:
            # ボタンの上にある場合、クリックスルーを無効化
            self.updateClickThrough(False)
            self.is_clickthrough = False
        elif not is_over_button and not self.is_clickthrough:
            # ボタンの外にある場合、クリックスルーを有効化
            self.updateClickThrough(True)
            self.is_clickthrough = True

    def setupWindowHook(self):
        # メモ帳を検索
        self.findNotepad()

        if self.target_hwnd:
            # Windows Hookを設定してリアルタイムでウィンドウイベントを検出
            self.hook_callback = WINEVENTPROC(self.winEventCallback)
            self.hook = user32.SetWinEventHook(
                EVENT_OBJECT_LOCATIONCHANGE,  # 最小イベント
                EVENT_OBJECT_LOCATIONCHANGE,  # 最大イベント
                0,  # モジュールハンドル
                self.hook_callback,  # コールバック
                0,  # プロセスID (0 = 全プロセス)
                0,  # スレッドID (0 = 全スレッド)
                WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS
            )

        # フォールバックとして低頻度のタイマーも設定(メモ帳が再起動された場合など)
        self.timer = QTimer(self)
        self.timer.timeout.connect(self.checkNotepad)
        self.timer.start(1000)  # 1秒ごとにメモ帳の存在をチェック

    def winEventCallback(self, hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime):
        """Windowsイベントフックのコールバック"""
        if hwnd == self.target_hwnd and event == EVENT_OBJECT_LOCATIONCHANGE:
            # メモ帳のウィンドウが移動またはリサイズされた
            QTimer.singleShot(0, self.updatePosition)

    def checkNotepad(self):
        """メモ帳が存在するかチェック(フォールバック)"""
        if not self.target_hwnd or not user32.IsWindow(self.target_hwnd):
            self.findNotepad()
            if self.target_hwnd:
                # 新しいメモ帳が見つかった場合、フックを再設定
                if self.hook:
                    user32.UnhookWinEvent(self.hook)
                self.hook_callback = WINEVENTPROC(self.winEventCallback)
                self.hook = user32.SetWinEventHook(
                    EVENT_OBJECT_LOCATIONCHANGE,
                    EVENT_OBJECT_LOCATIONCHANGE,
                    0,
                    self.hook_callback,
                    0,
                    0,
                    WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS
                )
                self.updatePosition()

    def findNotepad(self):
        # メモ帳のウィンドウを検索
        self.target_hwnd = user32.FindWindowW(None, "無題 - メモ帳")
        if not self.target_hwnd:
            self.target_hwnd = user32.FindWindowW("Notepad", None)
        if not self.target_hwnd:
            # 他のメモ帳タイトルパターンを試す
            self.target_hwnd = user32.FindWindowW(None, "*メモ帳")

    def updatePosition(self):
        if not self.target_hwnd:
            self.findNotepad()
            if not self.target_hwnd:
                return

        # メモ帳が存在するか確認
        if not user32.IsWindow(self.target_hwnd):
            self.target_hwnd = None
            self.findNotepad()
            return

        # メモ帳のウィンドウ領域を取得
        rect = RECT()

        # DWM APIでフレームを除いた実際のクライアント領域を取得
        if dwmapi.DwmGetWindowAttribute(
            self.target_hwnd,
            9,  # DWMWA_EXTENDED_FRAME_BOUNDS
            ctypes.byref(rect),
            ctypes.sizeof(rect)
        ) == 0:
            # タイトルバーの高さを計算
            window_rect = RECT()
            user32.GetWindowRect(self.target_hwnd, ctypes.byref(window_rect))

            client_rect = wintypes.RECT()
            user32.GetClientRect(self.target_hwnd, ctypes.byref(client_rect))

            # タイトルバーとボーダーのオフセットを計算
            title_height = (window_rect.bottom - window_rect.top) - client_rect.bottom
            border_width = (window_rect.right - window_rect.left - client_rect.right) // 2

            # オーバーレイをクライアント領域に配置
            x = rect.left + border_width
            y = rect.top + title_height - border_width
            width = client_rect.right
            height = client_rect.bottom

            # オーバーレイの位置とサイズを更新
            if self.geometry() != QRect(x, y, width, height):
                self.setGeometry(x, y, width, height)


class OverlayApp(QApplication):
    def __init__(self, argv):
        super().__init__(argv)
        self.overlay = OverlayWidget()
        self.setupTrayIcon()

    def setupTrayIcon(self):
        # システムトレイアイコンを作成
        self.tray_icon = QSystemTrayIcon(self)

        # デフォルトアイコンを設定(利用可能な場合)
        icon = self.style().standardIcon(self.style().StandardPixmap.SP_ComputerIcon)
        self.tray_icon.setIcon(icon)

        # トレイメニューを作成
        tray_menu = QMenu()

        show_action = QAction("Show Overlay", self)
        show_action.triggered.connect(self.overlay.show)
        tray_menu.addAction(show_action)

        hide_action = QAction("Hide Overlay", self)
        hide_action.triggered.connect(self.overlay.hide)
        tray_menu.addAction(hide_action)

        tray_menu.addSeparator()

        quit_action = QAction("Quit", self)
        quit_action.triggered.connect(self.quit)
        tray_menu.addAction(quit_action)

        self.tray_icon.setContextMenu(tray_menu)
        self.tray_icon.show()

        # トレイアイコンにツールチップを設定
        self.tray_icon.setToolTip("Notepad Overlay")

    def run(self):
        self.overlay.show()
        return self.exec()


def main():
    app = OverlayApp(sys.argv)
    sys.exit(app.run())


if __name__ == "__main__":
    main()

4. 動作確認

  1. メモ帳を起動する
  2. スクリプトを実行する
  3. メモ帳を移動・リサイズすると、オーバーレイが即座に追従する
  4. ボタンをクリックすると "Hello World" が出力される
  5. ボタン以外の領域ではメモ帳をクリック・操作できる

5. ハマりどころと解決策

5.1 コールバック関数がGCされる

問題:Hookを設定してもコールバックが呼ばれない、またはクラッシュする

解決:コールバック関数の参照をインスタンス変数として保持する

self.hook_callback = WINEVENTPROC(self.winEventCallback)  # 保持!

5.2 QTimer.singleShot(100, ...)の謎

問題showEventの直後にWin32設定を適用すると失敗する

解決:100ms待つことでウィンドウの初期化を完了させる

def showEvent(self, event):
    super().showEvent(event)
    QTimer.singleShot(100, self.applyWin32Settings)  # 待機が必要

5.3 マウス位置のグローバル/ローカル変換

問題:ボタンの座標をそのまま使うと判定がずれる

解決:ウィジェットのグローバル座標にボタンのローカル座標を加算

button_global_rect = QRect(
    widget_rect.x() + self.button.x(),  # グローバル + ローカル
    widget_rect.y() + self.button.y(),
    self.button.width(),
    self.button.height()
)

5.4 Hookの解放忘れ

問題:アプリを終了してもリソースが残る

解決closeEventで必ずHookを解放

def closeEvent(self, event):
    if self.hook:
        user32.UnhookWinEvent(self.hook)  # 必須!
    super().closeEvent(event)

6. まとめ

今回実装した内容:

  • 部分的クリックスルー:ボタンだけクリック可能、それ以外は透過
  • Windows Hookによるリアルタイム追従:移動に即座に反応

これで実用的なオーバーレイアプリケーションの基礎が完成しました。

というのをClaude先生に教えてもらいました。
5年前と比べていい感じになったのではないでしょうか。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?