はじめに
動機はタイトルにある通りで,キーボード関係のイベントを使えばいいだろうと始めてしまったもののそう簡単ではなかったという話になります。
私はPowerPointでやっていましたが,Word等でも状況は同じであろうと推察されます。
VSTOアドインの作成方法を解説するという趣旨の記事ではありませんので,VSTOについては公式のページを参照してください。
問題は何か
キーボード操作を簡単に監視できるKeyDown
とかKeyUp
とかがVSTOでは使えない。
ではどうするか
KeyHookでキーストロークイベントを監視して頑張る。
実装例
泥臭い(?)処理になるので,面倒なことはKeyHook
クラスに押し付けて簡単に呼べるように試みます。
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の実装自体はググれば例が落ちていますし,公式を見ながら試行錯誤でも行けるでしょう。
ここで注意すべきは1回しかキーを押していなくても大量にメッセージが飛んでくることです。
単純に思いつく解決策としてはキーが押された時刻を覚えておいて,同じキーが一定時間内に連打されても無視するというものになるでしょう。
ただし,全てのキーに対して時刻を覚えておこうとするとコストが高いので,監視したいキーを登録して対象だけについて時刻を覚えておくようにしました。
利用側は
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;
}
}
と言った感じになります。
さいごに
軽い気持ちでキーボードを監視しようとしたことを少し後悔しました(´・_・`)