VSTOアドインでキーボード操作を見たい


## はじめに

動機はタイトルにある通りで，キーボード関係のイベントを使えばいいだろうと始めてしまったもののそう簡単ではなかったという話になります。
私はPowerPointでやっていましたが，Word等でも状況は同じであろうと推察されます。

VSTOアドインの作成方法を解説するという趣旨の記事ではありませんので，VSTOについては[公式のページ](https://learn.microsoft.com/en-us/visualstudio/vsto/getting-started-programming-vsto-add-ins?view=vs-2022)を参照してください。

### 問題は何か

キーボード操作を簡単に監視できる`KeyDown`とか`KeyUp`とかがVSTOでは使えない。

### ではどうするか

KeyHookでキーストロークイベントを監視して**頑張る**。

- [Hooks Overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/winmsg/about-hooks)

## 実装例

泥臭い(?)処理になるので，面倒なことは`KeyHook`クラスに押し付けて簡単に呼べるように試みます。

```cs
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Windows.Forms;

namespace Qiita;

#nullable enable

internal static class KeyHook
{
    private const int WH_KEYBOARD = 0x0002;
    private const int KF_ALTDOWN = 0x2000;

    #region KeyHook関連 P/Invoke

    [DllImport("kernel32", CharSet = CharSet.Auto, SetLastError = true)]
    private static extern int GetCurrentThreadId();

    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    private static extern IntPtr SetWindowsHookEx(int idHook, KeyboardProc lpfn, IntPtr hMod, uint dwThreadId);

    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool UnhookWindowsHookEx(IntPtr hhk);

    [DllImport("user32.dll")]
    static extern uint MapVirtualKey(uint uCode, uint uMapType);

    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, uint wParam, uint lParam);

    #endregion KeyHook関連 P/Invoke

    private delegate IntPtr KeyboardProc(int nCode, uint wParam, uint lParam);

    private static KeyboardProc? proc;
    private static IntPtr hookId = IntPtr.Zero;

    // 1ストロークに対して大量のメッセージが飛んでくるのでキーごとに時刻を覚えておく
    private static readonly Dictionary<Keys, DateTime> monitoring = [];

    // ↑の時刻と現在時刻のインターバル閾値(ミリ秒)。だいたい500 msくらいでなんとかなる
    internal static int KeyEventInterval { get; set; } = 500;

    // アドイン本体側で見るイベント
    internal static event KeyEventHandler? KeyDownCallback;

    // 監視を始める
    internal static void Hook()
    {
        if (hookId != IntPtr.Zero) return;

        proc = HookProcedure;
        var threadId = (uint)GetCurrentThreadId();
        hookId = SetWindowsHookEx(WH_KEYBOARD, proc, IntPtr.Zero, threadId);
    } // internal void Hook ()

    internal static void Unhook()
    {
        UnhookWindowsHookEx(hookId);
        hookId = IntPtr.Zero;
    } // internal void Unhook ()

    // キーストロークごとにイベントが飛んでくる場所
    internal static IntPtr HookProcedure(int nCode, uint wParam, uint lParam)
    {   
        if (nCode >= 0)
        {
            var key = (Keys)MapVirtualKey(wParam, 2);

            // Alt キーは変なフラグがある
            var flags = lParam >> 0x10;
            var alt = (flags & KF_ALTDOWN) == KF_ALTDOWN;
            if (alt) key |= Keys.Alt;

            InvokeKeyDownCallback(key);
        }

        return CallNextHookEx(hookId, nCode, wParam, lParam);
    } // internal static IntPtr HookProcedure (int, uint, uint)

    private static void InvokeKeyDownCallback(Keys key)
    {
        if (!monitoring.ContainsKey(key)) return;

        // 連続して飛んでくるので一定時間(KeyEventInterval)以内は無視する。
        var last = monitoring[key];
        var now = monitoring[key] = DateTime.Now;
        if ((now - last).TotalMilliseconds <= KeyEventInterval) return;

        var e = new KeyEventArgs(key);
        KeyDownCallback?.Invoke(null, e);
    } // private static void InvokeKeyDownCallback (Keys)

    // 監視するKeyを登録する
    internal static void Monitor(Keys key)
        => monitoring.Add(key, DateTime.MinValue);
        
    // 監視を解除する
    internal static void Unmonitor(Keys key)
        => monitoring.Remove(key);
} // internal static class KeyHook
```

KeyHookの実装自体はググれば例が落ちていますし，[公式](https://learn.microsoft.com/en-us/windows/win32/inputdev/keyboard-input)を見ながら試行錯誤でも行けるでしょう。
ここで注意すべきは**1回しかキーを押していなくても大量にメッセージが飛んでくる**ことです。
単純に思いつく解決策としてはキーが押された時刻を覚えておいて，同じキーが一定時間内に連打されても無視するというものになるでしょう。
ただし，全てのキーに対して時刻を覚えておこうとするとコストが高いので，監視したいキーを登録して対象だけについて時刻を覚えておくようにしました。

利用側は

```cs
namespace Qiita;

public partial class ThisAddIn
{
    private void ThisAddIn_Startup(object sender, EventArgs e)
    {
        KeyHook.Monitor(Keys.X | Keys.Alt);
        KeyHook.Hook();
        KeyHook.KeyDownCallback += (sender, e) => { /*  */ };
    }

    private void ThisAddIn_Shutdown(object sender, EventArgs e)
    {
        KeyHook.Unhook();
    }

    private void InternalStartup()
    {
        Startup += ThisAddIn_Startup;
        Shutdown += ThisAddIn_Shutdown;
    }
}
```

と言った感じになります。

## さいごに

軽い気持ちでキーボードを監視しようとしたことを少し後悔しました(´･_･`)