目的
Windows上でタッチパッドのタッチ位置を捕捉する機能は、.NETでは標準で提供されていませんが、これをWin32のRaw Input APIで.NETから捕捉できるようにします。内容的には、TouchpadGestures AdvancedのC++のコードに基本的にならって、C#のP/Invokeで実行するようにしたものです。
背景
タッチパッド(タッチパネルにあらず)からの入力は、.NETではマウスの動きに変換された後に扱うようになっていて、タッチパッドの入力を直接扱えるようにはなっていません。それで実用的に困ることはないですし、タッチ操作用のデバイスとしてはタッチスクリーンの方が優れているので、タッチパッドにこだわる必要もないです。
ただ、入力デバイスの選択肢としてあって困ることはなく、その一方で先行例が見つからず手が出なかったのですが1、@kamektxさんのTouchpadGestures Advancedを見かけてC++での方法は分かったので、C#でも書けるかなと。
ノートPCのタッチパッドでタブを切り替えるソフトウェアを作りました ~TouchpadGestures Advanced~
その途中で@mfakaneさんのRawInput.Sharpを知り、既にライブラリ化されていたわけですが、いずれにせよこの二つを非常に参考にさせていただきました。
コード
タッチパッドからの入力を捕捉するには、WM_INPUTメッセージが送られてくるように登録が必要ですが、ここは難しくないので省きます。問題はWM_INPUTメッセージを受けて、このデータからタッチ位置などの情報をどう取得するかですが、基本的にTouchpadGestures AdvancedのHidManagerの手順にならっています。
大まかな流れは、以下のようになります。
- WM_INPUTのlParamからRAWINPUT構造体を取得
- RAWINPUT構造体中のRAWINPUTHEADER構造体から、どの種類のデータがあるかを取得
- これを使ってRAWINPUT構造体中のRAWHID構造体から、実際のデータの値を取得
始めに、タッチ位置などのコンタクトの値を格納するためのTouchpadContact構造体を作成。ContactIdはタッチ中に各コンタクトに継続的に振られる番号で、これでどのコンタクトかを判別します。XとYはタッチパッドの左上角を原点とした座標。
public struct TouchpadContact
{
public int ContactId { get; }
public int X { get; }
public int Y { get; }
public TouchpadContact(int contactId, int x, int y) =>
(this.ContactId, this.X, this.Y) = (contactId, x, y);
}
次に、TouchpadContactを生成するためのTouchpadContactCreatorクラスを作成。これはTouchpadContact中の値は一遍に取得できず、順番に一時保存してから生成する必要があるためです。
internal class TouchpadContactCreator
{
public int? ContactId { get; set; }
public int? X { get; set; }
public int? Y { get; set; }
public bool TryCreate(out TouchpadContact contact)
{
if (ContactId.HasValue && X.HasValue && Y.HasValue)
{
contact = new TouchpadContact(ContactId.Value, X.Value, Y.Value);
return true;
}
contact = default;
return false;
}
public void Clear()
{
ContactId = null;
X = null;
Y = null;
}
}
最後に、lParamからTouchpadContactを配列で取得するParseInputメソッド。
internal static class TouchpadHelper
{
public static TouchpadContact[] ParseInput(IntPtr lParam)
{
// Get RAWINPUT.
uint rawInputSize = 0;
uint rawInputHeaderSize = (uint)Marshal.SizeOf<RAWINPUTHEADER>();
if (GetRawInputData(
lParam,
RID_INPUT,
IntPtr.Zero,
ref rawInputSize,
rawInputHeaderSize) != 0)
{
return null;
}
RAWINPUT rawInput;
byte[] rawHidRawData;
IntPtr rawInputPointer = IntPtr.Zero;
try
{
rawInputPointer = Marshal.AllocHGlobal((int)rawInputSize);
if (GetRawInputData(
lParam,
RID_INPUT,
rawInputPointer,
ref rawInputSize,
rawInputHeaderSize) != rawInputSize)
{
return null;
}
rawInput = Marshal.PtrToStructure<RAWINPUT>(rawInputPointer);
var rawInputData = new byte[rawInputSize];
Marshal.Copy(rawInputPointer, rawInputData, 0, rawInputData.Length);
rawHidRawData = new byte[rawInput.Hid.dwSizeHid * rawInput.Hid.dwCount];
int rawInputOffset = (int)rawInputSize - rawHidRawData.Length;
Buffer.BlockCopy(rawInputData, rawInputOffset, rawHidRawData, 0, rawHidRawData.Length);
}
finally
{
Marshal.FreeHGlobal(rawInputPointer);
}
// Parse RAWINPUT.
IntPtr rawHidRawDataPointer = Marshal.AllocHGlobal(rawHidRawData.Length);
Marshal.Copy(rawHidRawData, 0, rawHidRawDataPointer, rawHidRawData.Length);
IntPtr preparsedDataPointer = IntPtr.Zero;
try
{
uint preparsedDataSize = 0;
if (GetRawInputDeviceInfo(
rawInput.Header.hDevice,
RIDI_PREPARSEDDATA,
IntPtr.Zero,
ref preparsedDataSize) != 0)
{
return null;
}
preparsedDataPointer = Marshal.AllocHGlobal((int)preparsedDataSize);
if (GetRawInputDeviceInfo(
rawInput.Header.hDevice,
RIDI_PREPARSEDDATA,
preparsedDataPointer,
ref preparsedDataSize) != preparsedDataSize)
{
return null;
}
if (HidP_GetCaps(
preparsedDataPointer,
out HIDP_CAPS caps) != HIDP_STATUS_SUCCESS)
{
return null;
}
ushort valueCapsLength = caps.NumberInputValueCaps;
var valueCaps = new HIDP_VALUE_CAPS[valueCapsLength];
if (HidP_GetValueCaps(
HIDP_REPORT_TYPE.HidP_Input,
valueCaps,
ref valueCapsLength,
preparsedDataPointer) != HIDP_STATUS_SUCCESS)
{
return null;
}
uint scanTime = 0;
uint contactCount = 0;
TouchpadContactCreator creator = new();
List<TouchpadContact> contacts = new();
foreach (var valueCap in valueCaps.OrderBy(x => x.LinkCollection))
{
if (HidP_GetUsageValue(
HIDP_REPORT_TYPE.HidP_Input,
valueCap.UsagePage,
valueCap.LinkCollection,
valueCap.Usage,
out uint value,
preparsedDataPointer,
rawHidRawDataPointer,
(uint)rawHidRawData.Length) != HIDP_STATUS_SUCCESS)
{
continue;
}
// Usage Page and ID in Windows Precision Touchpad input reports
// https://docs.microsoft.com/en-us/windows-hardware/design/component-guidelines/windows-precision-touchpad-required-hid-top-level-collections#windows-precision-touchpad-input-reports
switch (valueCap.LinkCollection)
{
case 0:
switch (valueCap.UsagePage, valueCap.Usage)
{
case (0x0D, 0x56): // Scan Time
scanTime = value;
break;
case (0x0D, 0x54): // Contact Count
contactCount = value;
break;
}
break;
default:
switch (valueCap.UsagePage, valueCap.Usage)
{
case (0x0D, 0x51): // Contact ID
creator.ContactId = (int)value;
break;
case (0x01, 0x30): // X
creator.X = (int)value;
break;
case (0x01, 0x31): // Y
creator.Y = (int)value;
break;
}
break;
}
if (creator.TryCreate(out TouchpadContact contact))
{
contacts.Add(contact);
if (contacts.Count >= contactCount)
break;
creator.Clear();
}
}
return contacts.ToArray();
}
finally
{
Marshal.FreeHGlobal(rawHidRawDataPointer);
Marshal.FreeHGlobal(preparsedDataPointer);
}
}
[DllImport("User32.dll", SetLastError = true)]
private static extern uint GetRawInputData(
IntPtr hRawInput, // lParam in WM_INPUT
uint uiCommand, // RID_HEADER
IntPtr pData,
ref uint pcbSize,
uint cbSizeHeader);
private const uint RID_INPUT = 0x10000003;
[StructLayout(LayoutKind.Sequential)]
private struct RAWINPUT
{
public RAWINPUTHEADER Header;
public RAWHID Hid;
}
[StructLayout(LayoutKind.Sequential)]
private struct RAWINPUTHEADER
{
public uint dwType; // RIM_TYPEMOUSE or RIM_TYPEKEYBOARD or RIM_TYPEHID
public uint dwSize;
public IntPtr hDevice;
public IntPtr wParam; // wParam in WM_INPUT
}
private const uint RIM_TYPEMOUSE = 0;
private const uint RIM_TYPEKEYBOARD = 1;
private const uint RIM_TYPEHID = 2;
[StructLayout(LayoutKind.Sequential)]
private struct RAWHID
{
public uint dwSizeHid;
public uint dwCount;
public IntPtr bRawData; // This is not for use.
}
[DllImport("User32.dll", SetLastError = true)]
private static extern uint GetRawInputDeviceInfo(
IntPtr hDevice, // hDevice by RAWINPUTHEADER
uint uiCommand, // RIDI_PREPARSEDDATA
IntPtr pData,
ref uint pcbSize);
private const uint RIDI_PREPARSEDDATA = 0x20000005;
[DllImport("Hid.dll", SetLastError = true)]
private static extern uint HidP_GetCaps(
IntPtr PreparsedData,
out HIDP_CAPS Capabilities);
private const uint HIDP_STATUS_SUCCESS = 0x00110000;
[StructLayout(LayoutKind.Sequential)]
private struct HIDP_CAPS
{
public ushort Usage;
public ushort UsagePage;
public ushort InputReportByteLength;
public ushort OutputReportByteLength;
public ushort FeatureReportByteLength;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 17)]
public ushort[] Reserved;
public ushort NumberLinkCollectionNodes;
public ushort NumberInputButtonCaps;
public ushort NumberInputValueCaps;
public ushort NumberInputDataIndices;
public ushort NumberOutputButtonCaps;
public ushort NumberOutputValueCaps;
public ushort NumberOutputDataIndices;
public ushort NumberFeatureButtonCaps;
public ushort NumberFeatureValueCaps;
public ushort NumberFeatureDataIndices;
}
[DllImport("Hid.dll", CharSet = CharSet.Auto)]
private static extern uint HidP_GetValueCaps(
HIDP_REPORT_TYPE ReportType,
[Out] HIDP_VALUE_CAPS[] ValueCaps,
ref ushort ValueCapsLength,
IntPtr PreparsedData);
private enum HIDP_REPORT_TYPE
{
HidP_Input,
HidP_Output,
HidP_Feature
}
[StructLayout(LayoutKind.Sequential)]
private struct HIDP_VALUE_CAPS
{
public ushort UsagePage;
public byte ReportID;
[MarshalAs(UnmanagedType.U1)]
public bool IsAlias;
public ushort BitField;
public ushort LinkCollection;
public ushort LinkUsage;
public ushort LinkUsagePage;
[MarshalAs(UnmanagedType.U1)]
public bool IsRange;
[MarshalAs(UnmanagedType.U1)]
public bool IsStringRange;
[MarshalAs(UnmanagedType.U1)]
public bool IsDesignatorRange;
[MarshalAs(UnmanagedType.U1)]
public bool IsAbsolute;
[MarshalAs(UnmanagedType.U1)]
public bool HasNull;
public byte Reserved;
public ushort BitSize;
public ushort ReportCount;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 5)]
public ushort[] Reserved2;
public uint UnitsExp;
public uint Units;
public int LogicalMin;
public int LogicalMax;
public int PhysicalMin;
public int PhysicalMax;
// Range
public ushort UsageMin;
public ushort UsageMax;
public ushort StringMin;
public ushort StringMax;
public ushort DesignatorMin;
public ushort DesignatorMax;
public ushort DataIndexMin;
public ushort DataIndexMax;
// NotRange
public ushort Usage => UsageMin;
// ushort Reserved1;
public ushort StringIndex => StringMin;
// ushort Reserved2;
public ushort DesignatorIndex => DesignatorMin;
// ushort Reserved3;
public ushort DataIndex => DataIndexMin;
// ushort Reserved4;
}
[DllImport("Hid.dll", CharSet = CharSet.Auto)]
private static extern uint HidP_GetUsageValue(
HIDP_REPORT_TYPE ReportType,
ushort UsagePage,
ushort LinkCollection,
ushort Usage,
out uint UsageValue,
IntPtr PreparsedData,
IntPtr Report,
uint ReportLength);
}
基本的には粛々とP/Invokeを書けばいいのですが、少し難しいのはRAWHID構造体で、Win32の定義は以下のようになっています。
typedef struct tagRAWHID {
DWORD dwSizeHid;
DWORD dwCount;
BYTE bRawData[1];
} RAWHID
この1番目のdwSizeHidは各HID inputのデータの長さ、2番目のdwCountはHID inputの数で、3番目のbRawDataが実際のデータですが、byte配列でありながら長さは1になっています。RemarksによればdwSizeHidとdwCountの積がbRawDataの長さになるということですが、Surface Pro 4で実際に実行してみると、dwSizeHidが30、dwCountが1だったので、この積は30となり、計算が合いません。
ではどういうことかというと、これはWin32では時々ある、構造体の後ろにbyte配列が続いている形式で、bRawDataはこの配列の先頭byteを指しています。つまり、Surface Pro 4の例ではRAWINPUT構造体の長さは62だったので、RAWINPUTHEADERの長さが24(16 + 8)で、dwSizeHidとdwCountの長さがそれぞれ4なので、差し引き62 - 24 - 4 - 4 = 30がbRawDataの本当の長さということになります。これは、dwSizeHidとdwCountの積とも符合します。ちなみに、30でコンタクト5つ分のデータがありました。
もしこの長さが30で固定であれば、[MarshalAs(UnmanagedType.ByValArray, SizeConst = 30)]
を付ければいいわけですが、固定ではないのでしょうから、これはなし。
したがって、RAWINPUT構造体を取得するときに、先にこのサイズを取得した後、IntPtrにメモリを確保し、このIntPtrにGetRawInputDataのデータを格納し、このIntPtrからRAWINPUT構造体に変換してbRawData以外の値を取得し、さらに同じIntPtrから直接byte配列を取得して、この後半のbRawDataに当たる部分を取り出すということをやっています。2
上記のコードのレポジトリはRawInput.Touchpadです。
テスト
テスト環境は以下のとおりです。
- Windows 10 21H1
- Surface Pro 4(+タイプカバーのタッチパッド)
- .NET 5.0のWPF
タッチパッドに指を同時に5本当てたところ。5つのコンタクトで各指のタッチ位置が示されています。
タッチ中は非常に短い間隔でWM_INPUTメッセージが送られてきますが、指が浮くとその指のコンタクトは途切れてしまうので、動きを自然にトレースするには同じIDのコンタクトを追う必要があります。ちなみに、Surface Pro 4の例ではタッチパッドの左上角が原点の0,0で、右下角が1956,997だったので、物理的なサイズ(101 × 53mm)に対比して結構細かいです。
以上のとおり、もし必要が出てくれば使えるのではないかと思います。