VBでマウスジェスチャ(フック)


最近Operaというブラウザを使ってるんですが、
これの「マウスジェスチャ」機能ってのがなかなかいいもので、
慣れると他のアプリにも欲しくなる機能です。(っていうか間違ってマウスをまわしちまうw)

ということで、VBでやってみました(^^;


マウスジェスチャを行うには、マウスの動きを検出する必要があります。
マウスの位置(X,Y)がどういう風に変化したかをみて判断するわけです。

VBではマウスの動きをMouseMoveイベントなどで取得することができますが、
右ボタンを押した位置のコントロールでイベントが発生するだけです。
色々なコントロールを配置したフォーム内では、
それぞれのイベントプロシジャにコードを書くのは現実的でないですし、
クラスとか使ってまとめて処理しても複雑になるだけです。
キー入力ならばKeyPreview=Trueにすればフォームで一括処理できるのですが、
マウスの場合はそれができないのも残念です。

そこでフック(Hook)というテクニックを使うことにします。
SetWindowsHookEx(ほか数点)というAPIを使って、
フォームにフックプロシジャを登録しそこでマウスの動きを取得します。


フックの仕組み

簡単にフックの仕組みを説明します。

まずWindowsというOSは「メッセージ」というものをやり取りして
マウスの動きやキー入力などをOSからアプリケーションに伝えています。
またテキストボックスやリストボックスといったシステムが用意しているコントロール
(特殊なウィンドウ)のスタイルを変更するにも、
そのウィンドウに対してメッセージを送ることにより行います。

かなり端折った説明になりますが、
マウスなどのドライバがマウスの動きを感知すると、
それをOSが用意したAPIでOSに伝え、
OSがマウスカーソルなどを移動させた後、
アプリケーションにメッセージとして送ると思えばいいでしょう。

ウィンドウには「ウィンドウプロシジャ」というモジュール(関数)が必要です。
このウィンドウプロシジャがメッセージを受け取るわけです。
VBではランタイムの中にウィンドウプロシジャがあり、
イベントとしてユーザプログラムに伝えられます。

OS


(メッセージ)

VBのランタイム
(ウィンドウプロシジャ)


(イベント)

ユーザコード

このときすべてのメッセージに対するイベントが用意されているわけではありません。
このためにVBでは出来ないことが出てくるわけです。

で、フックをかけるとどうなるかというと、

OS


(メッセージ)

ユーザコード
(フックプロシジャ)


(メッセージ)

VBのランタイム
(ウィンドウプロシジャ)


(イベント)

ユーザコード

OSとウィンドウプロシジャの間にユーザコードのフックプロシジャを割り込ませることができます。
これにより、ウィンドウプロシジャがOSからのメッセージを受け取る前に
自前のフックプロシジャがメッセージを受け取って、
VBでは不可能なアクションをとることが出来るわけです。
またVBのランタイムに渡すメッセージを変更してしまうことも可能です。

(実際にはデフォルトのメッセージ処理のプロシジャに飛んでたりもしますが、無視してます)


実際の使い方はこちらのサンプルをもとに説明したいと思います。

このサンプルを起動すると以下のような画面がでます。

マウスジェスチャを有効にする」というチェックボックスをチェックするとマウスジェスチャ機能が有効になります。
マウスの右ボタンを押しながら上下左右に動かしてみましょう。
最新の動き」「入力されたコマンド」のところに、動きにあわせた矢印がでます。
右ボタンを離すと「コマンド履歴」に登録されます。
ウィンドウの何も無いところだけでなく、テキストボックス上でも入力が可能です。
また、マウスの動きが小さい場合は反応しないようになってます。

 

サンプルは以下の構成になってます。

名称 ファイル名 内容
Form1 Form1.frm サンプルフォーム
mMouseHook mMouseHook.bas フック用モジュール
cMouseHookEvent cMouseHookEvent.cls イベント生成用クラス

 

動作に沿って説明していきます。

まずサンプルプロジェクトのスタートアップがForm1になっているので
起動時にForm1がロードされます。

'マウスジェスチャのイベントを受け取るためのイベントハンドラ

Private WithEvents mMouseEvent As cMouseHookEvent



'フォームロード時

Private Sub Form_Load()

    

End Sub

モジュールセクションにcMouseHookEventクラスの変数をWithEventsを指定して定義しています。
cMouseHookEventクラスはフックには関係なく、
フックプロシジャでキャッチしたメッセージを元にマウスの動きを検知し、
イベントを起こさせるための物です。

'マウスジェスチャのON/OFF

Private Sub chk_Enable_Click()

    

    If chk_Enable.Value Then

        'ON

        Set mMouseEvent = SetHook(Me.hwnd)

    Else

        'OFF

        ResetHook

        Set mMouseEvent = Nothing

    End If

    

End Sub

マウスジェスチャを有効にする」のチェックボックスをクリックイベントプロシジャです。
ここで上で定義したmMouseEventにcMouseHookEventクラスのインスタンスを入れています。
cMouseHookEventクラスのインスタンスを作っているのはmMouseHookモジュール内の
SetHook()関数です。

'////////////////////////////////////////////////////////////

'フック開始

Public Function SetHook(ByVal hwnd As Long) As cMouseHookEvent

    

    '前回のフックが残っていれば解放

    ResetHook

    

    'インスタンスハンドルとスレッドIDの取得

    Dim hInst&, tid&

    hInst = GetWindowLong(hwnd, GWL_HINSTANCE)

    If hInst = 0 Then Exit Function

    tid = GetCurrentThreadId()

    

    'フック

    mHook = SetWindowsHookEx(WH_MOUSE, AddressOf HookProc, hInst, tid)

    

    'イベントハンドラの準備

    Set mHookEvent = New cMouseHookEvent

    Set SetHook = mHookEvent

    

    Debug.Print "Start hook : " + Hex$(mHook)

    

End Function

SetHook()関数ではフックをかけて、
イベントを起こさせるためのcMouseHookEventインスタンスを作成し返します。
cMouseHookEventインスタンスはフックプロシジャから呼び出すので
モジュールセクションで定義した変数に保存しておきます。

フックプロシジャを登録するAPI()はSetWindowsHookEx()です。
これの引数は、
  フックの種類
  フックプロシジャのアドレス
  フックプロシジャのインスタンスハンドル
  フックをかけるスレッドのID
になります。

フックの種類は以下のようなものがあります。

定数 フックの内容
WH_MOUSE マウス関係のメッセージをフックします。
WH_KEYBOARD キーボード関係のメッセージをフックします。
WH_CBT ComputerBasedTrainingに有効なメッセージをフックします。
ウィンドウが表示される時、アクティブになった時、ウィンドウが閉じられたときなどを検知できます。
WH_CALLWNDPROC ウィンドウプロシジャに送られるメッセージをフックします。
WH_GETMESSAGE PostMessage()APIでウィンドウプロシジャに送られたメッセージをフックします。
WH_MSGFILTER  ダイアログ ボックス、 メッセージ ボックス、 メニュー、 スクロールバーを
操作したときに生成されるメッセージをフックします。

他にジャーナル関係、シェル関係などがあります。(詳しくはヘルプを参照してください)
今回はマウスだけなのでWH_MOUSEを使います。

フックプロシジャのアドレスはAddressOf演算子でフックプロシジャを指定すれば取得できます。
ただしAddressOf演算子で取得できる関数のアドレスは
標準モジュールにある必要があります。
このために標準モジュールが必須となります。

フックプロシジャのインスタンスハンドルと、フックをかけるスレッドのIDは
フォームのウィンドウハンドル(hWndプロパティ)から
GetWindowLong()、GetCurrentThreadId()のAPIを使えば取得できます。

SetWindowsHookEx()APIの戻り値はフックハンドルになります。
これはフックを解除するときに必要になるので保存しておきます。

登録されるフックプロシジャは下記になります。

'////////////////////////////////////////////////////////////

'フックプロシジャ

Private Function HookProc(ByVal nCode As Long, ByVal wParam As Long, ByVal lParam As Long) As Long

    

    If nCode = HC_ACTION Then

        If Not mHookEvent Is Nothing Then

            Dim mm As tMouseHookStruct

            CopyMemory mm.Point.x, ByVal lParam, LenB(mm)

            

            If mHookEvent.FireGetMessage(wParam, mm.Point.x, mm.Point.y, mm.hwnd, mm.HitTestCode) Then

                HookProc = 1

                Exit Function

            End If

        End If

    End If

    HookProc = CallNextHookEx(mHook, nCode, wParam, lParam)

End Function

引数のnCodeにはHC_ACTION(0)又はHC_NOREMOVE(3)がセットされます。
マウスの動きを見るときはHC_ACTIONの時だけで十分です。

実際どんなメッセージが送られてきたかは、第2引数のwParamに入っています。
またマウスの位置や送られるべきウィンドウのハンドルなどは
第3引数のlParamが示すアドレス(MOUSEHOOKSTRUCT構造体のアドレス)に入っています。
VBでは直接メモリを参照できないので、
下記のように定義した構造体にコピーして参照します。

Public Type tPoint

    x As Long

    y As Long

End Type



Public Type tMouseHookStruct

    Point As tPoint

    hwnd As Long

    HitTestCode As Long

    ExtraInfo As Long

End Type

Pointにマウスの位置、hWndにウィンドウハンドルなどが入ってます。

例えばマウスを動かした場合、
wParamにWM_MOUSEMOVE(&H200)、
tMouseHookStructのPointにその座標(スクリーン座標)が入っていることになります。

最後のCallNextHookEx()APIは次のフックプロシジャを呼び出します。
フックプロシジャは多重に登録できます。
後で登録されたプロシジャがシステムから呼び出されますが、
そこで終わりにしないで前の(次の)フックプロシジャを呼び出してあげないと
前に登録されたフックプロシジャがなにも出来なくなる訳です。
ただ絶対呼び出さなければならない訳ではありません。
フックプロシジャで捕まえたメッセージは
イベント生成用のクラスインスタンスmHookEventのFireGetMessageに引き渡して
そこで動きの検知などをしていますが、
そこでメッセージを破棄すべき時などは戻り値に1をセットして
次のフックプロシジャを呼び出さないで帰ります。

CallNextHookExを呼び出さなければ、前に登録したフックプロシジャを無効にすることもできますが、
あまり行儀のよいプログラムとはいえないです。
(常駐してフックを行うプログラムなどで、他の同様の常駐プログラムと相性が悪いなんてのは
 この辺が影響していることが多いです。)

'////////////////////////////////////////////////////////////

'フックプロシジャからの呼び出し

Public Function FireGetMessage(ByVal MsgID As Long, ByVal x As Long, ByVal y As Long, ByVal hwnd As Long, ByVal HitTestCode As Long) As Boolean

    FireGetMessage = True

    

    Dim Cancel As Boolean

    Cancel = False

    RaiseEvent OnGetMessage(MsgID, x, y, hwnd, HitTestCode, Cancel)

    If Cancel Then

        Exit Function

    End If

    

    If CheckAction(MsgID, x, y) Then

        Exit Function

    End If

    

    FireGetMessage = False

End Function



'動作の検出

Private Function CheckAction(ByVal MsgID As Long, ByVal x As Long, ByVal y As Long) As Boolean

    CheckAction = False

    

    Dim dx&, dy&, dd&

    Dim sx&, sy&

    

    Select Case MsgID

        Case &H200  'MOUSEMOVE

            If mNowAction Then

                dx = x - mLastPosX

                dy = y - mLastPosY

                sx = Sgn(dx)

                sy = Sgn(dy)

                If Sqr(dx * dx + dy * dy) > mMinMove Then

                    dx = Abs(dx)

                    dy = Abs(dy)

                    If dx > dy / 3 Then

                        sy = 0

                    End If

                    If dy > dx / 3 Then

                        sx = 0

                    End If

                    

                    AddAction sx, sy

                    mLastPosX = x

                    mLastPosY = y

                End If

                

                CheckAction = True

            End If

        

        Case &H204  'RIGHTDOWN

            If mMessageSkip Then

            Else

                mNowAction = True

                mLastPosX = x

                mLastPosY = y

                mStartPosX = x

                mStartPosY = y

                mActionIndex = 0

                CheckAction = True

            End If

        

        Case &H205  'RIGHTUP

            If mMessageSkip Then

                mMessageSkip = False

            Else

                mEndPosX = x

                mEndPosY = y

                mNowAction = False

                If mActionIndex Then

                    RaiseEvent OnActionEnd(mActionIndex)

                Else

                    'アクションなし

                    mMessageSkip = True

                    mouse_event MOUSEEVENTF_ABSOLUTE Or MOUSEEVENTF_RIGHTDOWN, mStartPosX, mStartPosY, 0, 0

                    mouse_event MOUSEEVENTF_ABSOLUTE Or MOUSEEVENTF_RIGHTUP, mEndPosX, mEndPosY, 0, 0

                End If

            End If

            

    End Select

    

End Function

いきなり長いですが、上がフックプロシジャからメッセージを受け取って
マウスの動きを検知している所です。

フックプロシジャから直接呼び出されるFireGetMessageは
OnGetMessageイベントを発生させてますが、サンプルでは使ってません。

マウスジェスチャの開始は右ボタンが押下されたときから始まります。
右ボタンが押下されたときはMsgIDが&H204(WM_RBUTTONDOWN)になります(緑の部分)。
移動量を測るためのメンバ変数にクリックされたときの位置を保存し
ジェスチャ中であることを示す変数(mNowAction)をセットしています。
そしてそのメッセージは無視させるように戻り値をTrueにします。
これによりこのメッセージはVBに渡りません。(MouseDownイベントが発生しない)
またmMessageSkipがTrueの時は処理しません。(後述)


マウスが動かされたときはMsgIDが&H200(WM_MOUSEMOVE)になります(青の部分)。
ジェスチャ中(mNowAction=TRUE)であるときだけ動きの検知を行います。
実は動きの検知は結構いい加減です(^^;

(1)
移動距離(左右、前後の増減分の二乗を加えた値の平方根)が
最低移動距離mMinMoveより大きいかを調べ、
最低移動距離以下の場合は無視します。

(2)
横の移動量が縦の移動量の3倍よりも大きい場合、縦の移動量は0とします。
縦の移動量が横の移動量の3倍よりも大きい場合、横の移動量は0とします。
これにより斜めの移動は移動量0となります。(大概ね)

(3)
縦、横の移動量から方向を求めます。
縦、横の移動量とも0(=斜めの時)ならば無視します。

検知はこれだけです。
動きがあると判断されたときはAddAction関数内でアクションバッファに登録し、
OnActionStart/OnActionMoveイベントを発生させます。


マウスの右ボタンが離されたときはMsgIDが&H205(WM_RBUTTONUP)になります(黄色の部分)。
ここでもmMessageSkip=Trueの時はmMessageSkipをクリアするだけです。
アクションバッファにデータがあれば(mActionIndex>0の時)、
なんらかの動きがあったのでOnActionEndイベントを発生させます。
バッファにデータがない場合、アクションがなかったので
通常の右クリック処理を起こさせます。
右クリック処理はmouse_event()APIで右ボタン押す→右ボタン離すをさせることで実現してます。
このとき自分自身にLBUTTONDOWN/LBUTTONUPがまた発生してしまいます。
そのメッセージは無視するようにmMessageSkipをTrueにします。


動きがなかったときに右クリックを起こさせるのは
テキストボックスなどで右クリックメニューを通常と同じように起こさせる為です。
これをやらないとフック中はテキストボックスでポップアップメニュがでなくなってしまいます。


以上でcMouseHookEventのインスタンスmMouseEventをWithEventsで宣言したForm1内で
mMouseEvent_OnActionStart:最初のアクションの時
mMouseEvent_OnActionMove:新しい動きがあった時
mMouseEvent_OnActionEnd:アクションが終わった時
のイベントが発生します。

通常はmMouseEvent_OnActionEndイベントプロシジャ内で
ActionMoveプロパティの中身をみて処理を行います。

なおフックを終了するときは

'フック解除

Public Sub ResetHook()

    If mHook Then

        UnhookWindowsHookEx mHook

        mHook = 0

        Set mHookEvent = Nothing

        Debug.Print "End hook"

    End If

End Sub

ResetHook()関数を呼び出します。
この中でフックプロシジャを解除するUnhookWindowsHookEx()APIを呼び出してます。


マウスジェスチャの説明というよりフックの説明でした(^^;

ちなみにSetWindowsHookExによるフックではなく、
いわゆる「サブクラス化」では子ウィンドウのメッセージを簡単に取得できないので
こういう場合はフックを使った方がいいかと思います。(適材適所)

フックにはもう一つ大事なことがあります。
フックプロシジャは自プロセスのウィンドウ(スレッド)だけでなく、
他のプロセスのウィンドウ(スレッド)に登録することができるのです。
また特定のプロセスではなく、全プロセスにフックをかけることもできます。

これは「グローバルフック(またはシステムフック)」と呼ばれます。
今回使用したのは自分自身のウィンドウにフックをかけた「ローカルフック」というものです。

グローバルフックをすると、面白いことができます。
他のプロセス(プログラム)でのキー入力や
別プログラムがアクティブになった瞬間などを取得することもできます。
ゲームの画面で特定のキーを1つ押すだけで複数の入力を行ったようにしたり、
あるプログラムで特定の画面を出した瞬間になにかをするとかができます。
私も愛用させていただいている「どこでもホイール」のようなこともできます。
(おそらくどこでもホイールはフックでやっているかと思われます)

ただし、VBでは無理です(^^;
グローバルフックを行うときは、フックプロシジャをDLL内におかなければなりません。
(すべてのプロセス空間にロードされる為です)
VBではActiveXDLLを作成することはできますが、
フックプロシジャを入れる純粋なDLLを作成することが出来ないためです。
ちょっと残念ですよね〜


上に戻る