diff --git a/src/App.xaml.cs b/src/App.xaml.cs index f854eec..829467b 100644 --- a/src/App.xaml.cs +++ b/src/App.xaml.cs @@ -20,6 +20,9 @@ public partial class App : System.Windows.Application protected override void OnStartup(StartupEventArgs e) { + // 启用现代指针支持以修复触屏拖拽卡顿并支持多点触控 + AppContext.SetSwitch("Switch.System.Windows.Input.Stylus.EnablePointerSupport", true); + const string appName = @"Global\BASpark_SingleInstance_Mutex"; _mutex = new Mutex(true, appName, out bool createdNew); diff --git a/src/ConfigManager.cs b/src/ConfigManager.cs index 2401780..2c22556 100644 --- a/src/ConfigManager.cs +++ b/src/ConfigManager.cs @@ -44,6 +44,7 @@ public static class ConfigManager public static string FilterProfiles { get; set; } = ""; public static string ActiveProfileId { get; set; } = ""; public static bool IsTouchscreenMode { get; set; } = false; + public static bool EnableMultiTouch { get; set; } = false; public static int ClickTriggerType { get; set; } = 0; // 0:左, 1:右, 2:左右 public static string EnabledScreenIds { get; set; } = ""; @@ -76,6 +77,7 @@ public static void Load() HideInFullscreen = Convert.ToBoolean(key.GetValue("HideInFullscreen", true)); ShowEffectOnDesktop = Convert.ToBoolean(key.GetValue("ShowEffectOnDesktop", true)); IsTouchscreenMode = Convert.ToBoolean(key.GetValue("IsTouchscreenMode", false)); + EnableMultiTouch = Convert.ToBoolean(key.GetValue("EnableMultiTouch", false)); ClickTriggerType = Convert.ToInt32(key.GetValue("ClickTriggerType", 0)); EnabledScreenIds = key.GetValue("EnabledScreenIds", "")?.ToString() ?? ""; @@ -255,6 +257,7 @@ public static void ResetAndClear() ActiveProfileId = ""; _profiles.Clear(); IsTouchscreenMode = false; + EnableMultiTouch = false; ClickTriggerType = 0; EnabledScreenIds = ""; } diff --git a/src/ControlPanelWindow.xaml b/src/ControlPanelWindow.xaml index f87cbde..46bbdca 100644 --- a/src/ControlPanelWindow.xaml +++ b/src/ControlPanelWindow.xaml @@ -233,6 +233,20 @@ + + + + + + + diff --git a/src/ControlPanelWindow.xaml.cs b/src/ControlPanelWindow.xaml.cs index 5400e44..6bcaf4f 100644 --- a/src/ControlPanelWindow.xaml.cs +++ b/src/ControlPanelWindow.xaml.cs @@ -388,6 +388,7 @@ private void LoadSettings() CheckShowEffectOnDesktop.IsChecked = ConfigManager.ShowEffectOnDesktop; CheckRunAsAdmin.IsChecked = ConfigManager.RunAsAdmin; CheckTouchscreenMode.IsChecked = ConfigManager.IsTouchscreenMode; + CheckMultiTouch.IsChecked = ConfigManager.EnableMultiTouch; int mode = ConfigManager.ClickTriggerType; if (mode == 1) RadioRightClick.IsChecked = true; @@ -765,6 +766,7 @@ private void SaveSettings_Click(object sender, RoutedEventArgs e) bool startSilentEnabled = CheckStartSilent.IsChecked ?? false; bool runAsAdminEnabled = CheckRunAsAdmin.IsChecked ?? false; bool isTouchscreenEnabled = CheckTouchscreenMode?.IsChecked ?? false; + bool isMultiTouchEnabled = CheckMultiTouch?.IsChecked ?? false; int clickType = 0; if (RadioRightClick.IsChecked == true) clickType = 1; @@ -776,6 +778,7 @@ private void SaveSettings_Click(object sender, RoutedEventArgs e) ConfigManager.Save("RunAsAdmin", runAsAdminEnabled); ConfigManager.Save("IsTouchscreenMode", isTouchscreenEnabled); + ConfigManager.Save("EnableMultiTouch", isMultiTouchEnabled); ConfigManager.Save("IsEffectEnabled", CheckMasterSwitch.IsChecked ?? true); ConfigManager.Save("AutoStart", autoStartEnabled); ConfigManager.Save("EnableTelemetry", CheckTelemetry.IsChecked ?? false); @@ -814,6 +817,7 @@ private void SaveSettings_Click(object sender, RoutedEventArgs e) App.Overlay?.UpdateTrailRefreshRate(trailRefreshRate); App.Overlay?.RefreshEnvironmentFilterState(); App.Overlay?.UpdateTouchMode(isTouchscreenEnabled); + App.Overlay?.UpdateMultiTouchMode(isMultiTouchEnabled); if (!enabledScreenIds.SetEquals(selectedIds)) { App.Overlay?.RefreshScreenSelection(); diff --git a/src/MainWindow.xaml.cs b/src/MainWindow.xaml.cs index 8f7cd40..67a77ac 100644 --- a/src/MainWindow.xaml.cs +++ b/src/MainWindow.xaml.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Drawing; using System.Globalization; using System.Linq; @@ -72,6 +72,7 @@ private struct RECT private IntPtr _hwnd; private string? _lastReportedInputMode; private bool? _lastReportedAlwaysTrail; + private bool? _lastReportedMultiTouch; private const string InputModeMouse = "mouse"; private const string InputModeTouch = "touch"; @@ -264,15 +265,23 @@ private static bool IsCursorVisible() private string BuildInputContextScript(string inputMode) { bool alwaysTrailEnabled = ConfigManager.EnableAlwaysTrailEffect; - if (_lastReportedInputMode == inputMode && _lastReportedAlwaysTrail == alwaysTrailEnabled) + bool multiTouchEnabled = ConfigManager.EnableMultiTouch; + + if (_lastReportedInputMode == inputMode && + _lastReportedAlwaysTrail == alwaysTrailEnabled && + _lastReportedMultiTouch == multiTouchEnabled) { return string.Empty; } _lastReportedInputMode = inputMode; _lastReportedAlwaysTrail = alwaysTrailEnabled; + _lastReportedMultiTouch = multiTouchEnabled; + string alwaysTrailLiteral = alwaysTrailEnabled ? "true" : "false"; - return $"if(window.setInputContext) window.setInputContext('{inputMode}', {alwaysTrailLiteral});"; + string multiTouchLiteral = multiTouchEnabled ? "true" : "false"; + + return $"if(window.setInputContext) window.setInputContext('{inputMode}', {alwaysTrailLiteral}, {multiTouchLiteral});"; } private void SyncInputContext(string inputMode) @@ -323,6 +332,27 @@ public void EmitUp(bool touchLike) ExecuteWithInputContext(inputMode, "if(window.externalUp) window.externalUp();"); } + public void EmitTouchDown(uint pointerId, int x, int y) + { + if (!TryConvertScreenToOverlayPoint(x, y, out System.Windows.Point clientPoint)) return; + string px = FormatCoordinate(clientPoint.X); + string py = FormatCoordinate(clientPoint.Y); + ExecuteWithInputContext(InputModeTouch, $"if(window.externalTouchDown) window.externalTouchDown({pointerId}, {px}, {py});"); + } + + public void EmitTouchMove(uint pointerId, int x, int y, bool touchLike) + { + if (!TryConvertScreenToOverlayPoint(x, y, out System.Windows.Point clientPoint)) return; + string px = FormatCoordinate(clientPoint.X); + string py = FormatCoordinate(clientPoint.Y); + ExecuteWithInputContext(InputModeTouch, $"if(window.externalTouchMove) window.externalTouchMove({pointerId}, {px}, {py});"); + } + + public void EmitTouchUp(uint pointerId, bool touchLike) + { + ExecuteWithInputContext(InputModeTouch, $"if(window.externalTouchUp) window.externalTouchUp({pointerId});"); + } + private void UpdateOverlayBounds() { Rectangle bounds = GetScreenBounds(); diff --git a/src/OverlayManager.cs b/src/OverlayManager.cs index 52a2a93..9972ecd 100644 --- a/src/OverlayManager.cs +++ b/src/OverlayManager.cs @@ -51,9 +51,12 @@ public sealed class OverlayManager : IDisposable private static extern IntPtr WindowFromPoint(POINT Point); [DllImport("user32.dll")] private static extern IntPtr GetAncestor(IntPtr hwnd, uint gaFlags); + [DllImport("user32.dll")] + private static extern IntPtr GetMessageExtraInfo(); [StructLayout(LayoutKind.Sequential)] private struct POINT { public int x; public int y; } + [StructLayout(LayoutKind.Sequential)] private struct RECT { public int Left; public int Top; public int Right; public int Bottom; } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] @@ -74,10 +77,12 @@ private struct MONITORINFO private readonly Dictionary _overlays = new(StringComparer.OrdinalIgnoreCase); private IKeyboardMouseEvents? _globalHook; + private TouchInputCapture? _touchCapture; private MainWindow? _activePointerOverlay; private long _lastMoveTicks; private long _lastClickTicks; - private long _moveIntervalTicks = 250000; + private long _moveIntervalTicks = 250000; // 默认 40Hz + private long _touchMoveIntervalTicks = 80000; // 触摸专用,约 120Hz,降低拖尾延迟 private bool _isPrimaryPointerDown; private bool _isTouchLikeInput; private bool _isSuppressedByEnvironment; @@ -85,13 +90,87 @@ private struct MONITORINFO private IntPtr _lastForegroundWindow = IntPtr.Zero; private bool _disposed; + private readonly Dictionary _activePointers = new(); + + private class TouchPointState + { + public uint PointerId; + public int LastX, LastY; + public bool IsDown; + public long LastMoveTicks; + public MainWindow? TargetOverlay; + } + public void Start() { RebuildWindows(forceRebuild: true); SetupGlobalHooks(); + SetupTouchCapture(); SystemEvents.DisplaySettingsChanged += HandleDisplaySettingsChanged; } + private void SetupTouchCapture() + { + if (ConfigManager.EnableMultiTouch) + { + _touchCapture = new TouchInputCapture(); + _touchCapture.TouchDown += (id, x, y) => EmitTouchDown(id, x, y); + _touchCapture.TouchMove += (id, x, y) => EmitTouchMove(id, x, y); + _touchCapture.TouchUp += (id) => EmitTouchUp(id); + _touchCapture.Start(); + } + } + + private void HandleRawInput(IntPtr hRawInput) + { + // 已废弃,多点触控改由 TouchInputCapture 处理 + } + + public void EmitTouchDown(uint pointerId, int x, int y) + { + if (!CanRenderEffects()) return; + + MainWindow? target = ResolveTargetOverlay(x, y); + if (target == null) return; + + _activePointers[pointerId] = new TouchPointState + { + PointerId = pointerId, + LastX = x, + LastY = y, + IsDown = true, + TargetOverlay = target + }; + + ConfigManager.TotalClicks++; + target.EmitTouchDown(pointerId, x, y); + } + + public void EmitTouchMove(uint pointerId, int x, int y) + { + if (!_activePointers.TryGetValue(pointerId, out var state) || !state.IsDown) return; + if (state.TargetOverlay == null) return; + + // 每个触控点独立节流,避免多指互相阻塞 + long currentTicks = DateTime.Now.Ticks; + if (currentTicks - state.LastMoveTicks < _touchMoveIntervalTicks) return; + state.LastMoveTicks = currentTicks; + + state.LastX = x; + state.LastY = y; + state.TargetOverlay.EmitTouchMove(pointerId, x, y, true); + } + + public void EmitTouchUp(uint pointerId) + { + if (_activePointers.TryGetValue(pointerId, out var state)) + { + state.IsDown = false; + state.TargetOverlay?.EmitTouchUp(pointerId, true); + _activePointers.Remove(pointerId); + } + } + public void UpdateColor(string color) => ForEachOverlay(w => w.UpdateColor(color)); public void UpdateEffectSettings(double scale, double opacity, double speed) => ForEachOverlay(w => w.UpdateEffectSettings(scale, opacity, speed)); public void UpdateTrailRefreshRate(int hz) @@ -101,6 +180,23 @@ public void UpdateTrailRefreshRate(int hz) ForEachOverlay(w => w.UpdateTrailRefreshRate(hz)); } public void UpdateTouchMode(bool enabled) => ForEachOverlay(w => w.UpdateTouchMode(enabled)); + public void UpdateMultiTouchMode(bool enabled) + { + if (enabled && _touchCapture == null) + { + _touchCapture = new TouchInputCapture(); + _touchCapture.TouchDown += (id, x, y) => EmitTouchDown(id, x, y); + _touchCapture.TouchMove += (id, x, y) => EmitTouchMove(id, x, y); + _touchCapture.TouchUp += (id) => EmitTouchUp(id); + _touchCapture.Start(); + } + else if (!enabled && _touchCapture != null) + { + _touchCapture.Dispose(); + _touchCapture = null; + _activePointers.Clear(); + } + } public bool IsEffectSuppressedByEnvironment() => ShouldSuppressEffects(); public void RefreshEnvironmentFilterState() { @@ -122,10 +218,24 @@ private void SetupGlobalHooks() _globalHook.MouseUpExt += OnMouseUpExt; } + public void EmitDown(int x, int y) + { + if (!CanRenderEffects()) return; + + MainWindow? target = ResolveTargetOverlay(x, y); + if (target == null) return; + + ConfigManager.TotalClicks++; + target.EmitDown(x, y); + } + private void OnMouseDownExt(object? sender, MouseEventExtArgs e) { if (!CanRenderEffects()) return; + // 如果当前正在处理原生多点触控,则跳过合成鼠标事件的按下 + if (_activePointers.Count > 0 && !CursorIsVisible()) return; + bool isLeft = e.Button == MouseButtons.Left; bool isRight = e.Button == MouseButtons.Right; bool shouldTrigger = ConfigManager.ClickTriggerType switch @@ -161,11 +271,16 @@ private void OnMouseMoveExt(object? sender, MouseEventExtArgs e) { if (!CanRenderEffects()) return; + // 如果已有原生触控点在处理,则跳过合成鼠标事件以消除多余延迟 + if (_activePointers.Count > 0 && !CursorIsVisible()) return; + bool cursorVisible = CursorIsVisible(); if (!cursorVisible && !_isPrimaryPointerDown) return; + // 触摸/触控笔事件使用更低的节流阈值,修复拖拽卡顿 long currentTicks = DateTime.Now.Ticks; - if (currentTicks - _lastMoveTicks < _moveIntervalTicks) return; + long interval = (!cursorVisible || _isTouchLikeInput) ? _touchMoveIntervalTicks : _moveIntervalTicks; + if (currentTicks - _lastMoveTicks < interval) return; _lastMoveTicks = currentTicks; if (!TryGetPhysicalCursorPosition(out int cursorX, out int cursorY)) @@ -181,6 +296,8 @@ private void OnMouseMoveExt(object? sender, MouseEventExtArgs e) private void OnMouseUpExt(object? sender, MouseEventExtArgs e) { _ = e; + // 如果已有原生触控点在处理,跳过 + if (_activePointers.Count > 0 && !CursorIsVisible()) return; if (!_isPrimaryPointerDown) { _isTouchLikeInput = false; @@ -207,7 +324,7 @@ private bool CanRenderEffects() return false; } - if (!ConfigManager.IsTouchscreenMode && !CursorIsVisible()) + if (!ConfigManager.IsTouchscreenMode && !CursorIsVisible() && _activePointers.Count == 0) { ReleasePointerState(); return false; @@ -543,6 +660,14 @@ public void Dispose() _globalHook = null; } + if (_touchCapture != null) + { + _touchCapture.Dispose(); + _touchCapture = null; + } + + _activePointers.Clear(); + CloseWindows(); } } diff --git a/src/TouchInputCapture.cs b/src/TouchInputCapture.cs new file mode 100644 index 0000000..7ea94d7 --- /dev/null +++ b/src/TouchInputCapture.cs @@ -0,0 +1,415 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace BASpark +{ + /// + /// 使用 Raw Input (WM_INPUT + RIDEV_INPUTSINK) 全局捕获多点触控输入。 + /// 不需要 UIAccess 权限,可在普通用户模式下工作。 + /// + internal sealed class TouchInputCapture : IDisposable + { + public event Action? TouchDown; // contactId, screenX, screenY + public event Action? TouchMove; // contactId, screenX, screenY + public event Action? TouchUp; // contactId + + private RawInputReceiver? _receiver; + private bool _disposed; + + public void Start() + { + _receiver = new RawInputReceiver(this); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _receiver?.ReleaseHandle(); + _receiver = null; + } + + #region P/Invoke + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool RegisterRawInputDevices(RAWINPUTDEVICE[] pRawInputDevices, uint uiNumDevices, uint cbSize); + + [DllImport("user32.dll")] + private static extern uint GetRawInputData(IntPtr hRawInput, uint uiCommand, IntPtr pData, ref uint pcbSize, uint cbSizeHeader); + + [DllImport("user32.dll")] + private static extern uint GetRawInputDeviceInfo(IntPtr hDevice, uint uiCommand, IntPtr pData, ref uint pcbSize); + + [DllImport("hid.dll")] + private static extern int HidP_GetCaps(IntPtr PreparsedData, out HIDP_CAPS Capabilities); + + [DllImport("hid.dll")] + private static extern int HidP_GetValueCaps(ushort ReportType, [Out] HIDP_VALUE_CAPS[] ValueCaps, ref ushort ValueCapsLength, IntPtr PreparsedData); + + [DllImport("hid.dll")] + private static extern int HidP_GetUsageValue(ushort ReportType, ushort UsagePage, ushort LinkCollection, ushort Usage, out uint UsageValue, IntPtr PreparsedData, byte[] Report, uint ReportLength); + + [DllImport("hid.dll")] + private static extern int HidP_GetUsages(ushort ReportType, ushort UsagePage, ushort LinkCollection, [Out] ushort[] UsageList, ref uint UsageLength, IntPtr PreparsedData, byte[] Report, uint ReportLength); + + private const uint RIDEV_INPUTSINK = 0x00000100; + private const uint RID_INPUT = 0x10000003; + private const uint RIM_TYPEHID = 2; + private const uint RIDI_PREPARSEDDATA = 0x20000005; + private const int WM_INPUT = 0x00FF; + private const ushort HidP_Input = 0; + + // HID Usage 常量 + private const ushort USAGE_PAGE_DIGITIZER = 0x0D; + private const ushort USAGE_PAGE_GENERIC = 0x01; + private const ushort USAGE_TOUCH_SCREEN = 0x04; + private const ushort USAGE_CONTACT_ID = 0x51; + private const ushort USAGE_TIP_SWITCH = 0x42; + private const ushort USAGE_X = 0x30; + private const ushort USAGE_Y = 0x31; + + [StructLayout(LayoutKind.Sequential)] + private struct RAWINPUTDEVICE + { + public ushort usUsagePage; + public ushort usUsage; + public uint dwFlags; + public IntPtr hwndTarget; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RAWINPUTHEADER + { + public uint dwType; + public uint dwSize; + public IntPtr hDevice; + public IntPtr wParam; + } + + [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; + } + + [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; + // Union: Range / NotRange — 只取 NotRange.Usage + public ushort UsageMin_or_Usage; + public ushort UsageMax_or_Reserved; + public ushort StringMin; + public ushort StringMax; + public ushort DesignatorMin; + public ushort DesignatorMax; + public ushort DataIndexMin; + public ushort DataIndexMax; + } + + #endregion + + #region Device Info Cache + + private class TouchDeviceInfo + { + public IntPtr PreparsedData; + public int LogicalMaxX; + public int LogicalMaxY; + public ushort LinkCollectionForContacts; // link collection containing X/Y/ContactID + public bool IsValid; + } + + private readonly Dictionary _deviceCache = new(); + + private TouchDeviceInfo? GetOrCreateDeviceInfo(IntPtr hDevice) + { + if (_deviceCache.TryGetValue(hDevice, out var cached)) + return cached.IsValid ? cached : null; + + var info = BuildDeviceInfo(hDevice); + _deviceCache[hDevice] = info; + return info.IsValid ? info : null; + } + + private TouchDeviceInfo BuildDeviceInfo(IntPtr hDevice) + { + var result = new TouchDeviceInfo(); + uint size = 0; + + // 获取 PreparsedData 大小 + GetRawInputDeviceInfo(hDevice, RIDI_PREPARSEDDATA, IntPtr.Zero, ref size); + if (size == 0) return result; + + IntPtr preparsedData = Marshal.AllocHGlobal((int)size); + try + { + if (GetRawInputDeviceInfo(hDevice, RIDI_PREPARSEDDATA, preparsedData, ref size) == unchecked((uint)-1)) + return result; + + if (HidP_GetCaps(preparsedData, out HIDP_CAPS caps) != 0x00110000) // HIDP_STATUS_SUCCESS + return result; + + // 获取 Value Caps 以找出 X/Y 的逻辑范围和 LinkCollection + ushort numValueCaps = caps.NumberInputValueCaps; + if (numValueCaps == 0) return result; + + var valueCaps = new HIDP_VALUE_CAPS[numValueCaps]; + if (HidP_GetValueCaps(HidP_Input, valueCaps, ref numValueCaps, preparsedData) != 0x00110000) + return result; + + int logMaxX = 0, logMaxY = 0; + ushort linkCol = 0; + bool foundX = false, foundY = false; + + foreach (var vc in valueCaps) + { + ushort usage = vc.IsRange ? vc.UsageMin_or_Usage : vc.UsageMin_or_Usage; + + if (vc.UsagePage == USAGE_PAGE_GENERIC && usage == USAGE_X) + { + logMaxX = vc.LogicalMax; + linkCol = vc.LinkCollection; + foundX = true; + } + else if (vc.UsagePage == USAGE_PAGE_GENERIC && usage == USAGE_Y) + { + logMaxY = vc.LogicalMax; + foundY = true; + } + } + + if (!foundX || !foundY || logMaxX <= 0 || logMaxY <= 0) + return result; + + // 不释放 preparsedData,缓存它(后续解析需要) + result.PreparsedData = preparsedData; + result.LogicalMaxX = logMaxX; + result.LogicalMaxY = logMaxY; + result.LinkCollectionForContacts = linkCol; + result.IsValid = true; + + preparsedData = IntPtr.Zero; // 防止 finally 释放 + return result; + } + finally + { + if (preparsedData != IntPtr.Zero) + Marshal.FreeHGlobal(preparsedData); + } + } + + #endregion + + #region Contact State Tracking + + private readonly Dictionary _contactStates = new(); // contactId -> wasDown + + #endregion + + #region Raw Input Processing + + private void ProcessRawInput(IntPtr hRawInput) + { + uint headerSize = (uint)Marshal.SizeOf(); + uint size = 0; + GetRawInputData(hRawInput, RID_INPUT, IntPtr.Zero, ref size, headerSize); + if (size == 0) return; + + IntPtr pData = Marshal.AllocHGlobal((int)size); + try + { + if (GetRawInputData(hRawInput, RID_INPUT, pData, ref size, headerSize) == unchecked((uint)-1)) + return; + + var header = Marshal.PtrToStructure(pData); + if (header.dwType != RIM_TYPEHID) + return; + + var deviceInfo = GetOrCreateDeviceInfo(header.hDevice); + if (deviceInfo == null) return; + + // 读取 HID 报文:header 之后是 { dwSizeHid, dwCount, bRawData[] } + int hidOffset = (int)headerSize; + // 对齐到 IntPtr 边界 + if (IntPtr.Size == 8) + hidOffset = (hidOffset + 7) & ~7; + + uint dwSizeHid = (uint)Marshal.ReadInt32(pData, hidOffset); + uint dwCount = (uint)Marshal.ReadInt32(pData, hidOffset + 4); + int rawDataOffset = hidOffset + 8; + + if (dwSizeHid == 0 || dwCount == 0) return; + + // 每个 HID report 处理一个触控点 + for (uint i = 0; i < dwCount; i++) + { + byte[] report = new byte[dwSizeHid]; + Marshal.Copy(pData + rawDataOffset + (int)(i * dwSizeHid), report, 0, (int)dwSizeHid); + + ParseSingleContact(report, dwSizeHid, deviceInfo); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[TouchInputCapture] ProcessRawInput error: {ex.Message}"); + } + finally + { + Marshal.FreeHGlobal(pData); + } + } + + private void ParseSingleContact(byte[] report, uint reportLength, TouchDeviceInfo device) + { + // 检查 TipSwitch(是否触摸中) + ushort[] usages = new ushort[16]; + uint usageCount = (uint)usages.Length; + bool isTouching = false; + + int statusBtn = HidP_GetUsages(HidP_Input, USAGE_PAGE_DIGITIZER, device.LinkCollectionForContacts, + usages, ref usageCount, device.PreparsedData, report, reportLength); + + if (statusBtn == 0x00110000) // HIDP_STATUS_SUCCESS + { + for (int j = 0; j < usageCount; j++) + { + if (usages[j] == USAGE_TIP_SWITCH) + { + isTouching = true; + break; + } + } + } + + // 获取 ContactID + int statusCid = HidP_GetUsageValue(HidP_Input, USAGE_PAGE_DIGITIZER, device.LinkCollectionForContacts, + USAGE_CONTACT_ID, out uint contactId, device.PreparsedData, report, reportLength); + if (statusCid != 0x00110000) return; + + // 获取 X, Y(逻辑坐标) + int statusX = HidP_GetUsageValue(HidP_Input, USAGE_PAGE_GENERIC, device.LinkCollectionForContacts, + USAGE_X, out uint logX, device.PreparsedData, report, reportLength); + int statusY = HidP_GetUsageValue(HidP_Input, USAGE_PAGE_GENERIC, device.LinkCollectionForContacts, + USAGE_Y, out uint logY, device.PreparsedData, report, reportLength); + + if (statusX != 0x00110000 || statusY != 0x00110000) return; + + // 逻辑坐标 → 屏幕像素坐标 + // 使用虚拟屏幕(所有显示器的合并区域) + int virtualLeft = SystemInformation.VirtualScreen.Left; + int virtualTop = SystemInformation.VirtualScreen.Top; + int virtualWidth = SystemInformation.VirtualScreen.Width; + int virtualHeight = SystemInformation.VirtualScreen.Height; + + int screenX = virtualLeft + (int)((long)logX * virtualWidth / device.LogicalMaxX); + int screenY = virtualTop + (int)((long)logY * virtualHeight / device.LogicalMaxY); + + // 状态跟踪并发射事件 + bool wasDown = _contactStates.TryGetValue(contactId, out bool prev) && prev; + + if (isTouching) + { + if (!wasDown) + { + _contactStates[contactId] = true; + TouchDown?.Invoke(contactId, screenX, screenY); + } + else + { + TouchMove?.Invoke(contactId, screenX, screenY); + } + } + else + { + if (wasDown) + { + _contactStates[contactId] = false; + TouchUp?.Invoke(contactId); + } + } + } + + #endregion + + #region NativeWindow Receiver + + private class RawInputReceiver : NativeWindow + { + private readonly TouchInputCapture _owner; + + public RawInputReceiver(TouchInputCapture owner) + { + _owner = owner; + CreateHandle(new CreateParams()); + + // 注册接收触摸数字化器的原始输入(RIDEV_INPUTSINK 允许后台接收) + var rid = new RAWINPUTDEVICE[] + { + new RAWINPUTDEVICE + { + usUsagePage = USAGE_PAGE_DIGITIZER, + usUsage = USAGE_TOUCH_SCREEN, + dwFlags = RIDEV_INPUTSINK, + hwndTarget = Handle + } + }; + + bool ok = RegisterRawInputDevices(rid, 1, (uint)Marshal.SizeOf()); + Debug.WriteLine($"[TouchInputCapture] RegisterRawInputDevices: {ok}"); + } + + protected override void WndProc(ref Message m) + { + if (m.Msg == WM_INPUT) + { + _owner.ProcessRawInput(m.LParam); + } + base.WndProc(ref m); + } + } + + #endregion + } +} diff --git a/src/Web/index.html b/src/Web/index.html index 84267aa..c33b1f4 100644 --- a/src/Web/index.html +++ b/src/Web/index.html @@ -28,6 +28,7 @@ this.waves = []; this.sparks = []; this.trail = []; + this.touchPointers = new Map(); // 多指追踪: pointerId -> { trail: [], isDown: bool, lastPos: null } this.isDown = false; this.lastPos = null; this.baseFrameMs = 1000 / 60; @@ -164,65 +165,20 @@ this.lastFrameTime = now; const frameScale = (deltaMs / this.baseFrameMs) * this.speed; - if (this.waves.length > 0 || this.sparks.length > 0 || this.trail.length > 0) { + if (this.waves.length > 0 || this.sparks.length > 0 || this.trail.length > 0 || this.touchPointers.size > 0) { this.ctx.clearRect(0, 0, this.c.width, this.c.height); // 优化:减少全局合成模式的使用 this.ctx.globalCompositeOperation = "lighter"; - for (let i = this.trail.length - 1; i >= 0; i--) { - let t = this.trail[i]; - if (window.effectiveAlwaysTrail) { - t.life -= 0.085 * frameScale; - } else { - t.life -= (this.isDown ? 0.085 : 0.18) * frameScale; - } - if (t.life <= 0) this.trail.splice(i, 1); - } - - if (this.trail.length > 1) { - // this.ctx.beginPath(); - // this.ctx.moveTo(this.trail[0].x, this.trail[0].y); - // for (let i = 1; i < this.trail.length; i++) { - // this.ctx.lineTo(this.trail[i].x, this.trail[i].y); - // } - // this.ctx.lineWidth = 8.0; - - - // this.ctx.strokeStyle = `rgba(${this.color},${this.alpha(0.35)})`; - // this.ctx.stroke(); - - - // 逐段渲染:每段的透明度基于数组索引位置,而非空间位置 - // 防止当鼠标锐角转动时,尾迹变淡后突然出现的问题 - this.ctx.lineWidth = 5.0; - this.ctx.shadowColor = `rgba(${this.color}, 0.6)`; - this.ctx.shadowBlur = 3; - this.ctx.shadowOffsetX = 0; - this.ctx.shadowOffsetY = 0; - - const lastIdx = this.trail.length - 1; - for (let i = 0; i < lastIdx; i++) { - const alphaStart = i / lastIdx; - const alphaEnd = (i + 1) / lastIdx; - const a0 = this.trail[i]; - const a1 = this.trail[i + 1]; - - // 每段使用独立的两点渐变,只覆盖该段自身 - const segGrad = this.ctx.createLinearGradient( - a0.x, a0.y, a1.x, a1.y - ); - segGrad.addColorStop(0, `rgba(${this.color}, ${alphaStart})`); - segGrad.addColorStop(1, `rgba(${this.color}, ${alphaEnd})`); + // 渲染主鼠标拖尾 + this.renderTrail(this.trail, this.isDown, frameScale); - this.ctx.beginPath(); - this.ctx.moveTo(a0.x, a0.y); - this.ctx.lineTo(a1.x, a1.y); - this.ctx.strokeStyle = segGrad; - this.ctx.stroke(); + // 渲染多指拖尾 + for (let [id, pointer] of this.touchPointers.entries()) { + this.renderTrail(pointer.trail, pointer.isDown, frameScale); + if (!pointer.isDown && pointer.trail.length === 0) { + this.touchPointers.delete(id); } - - this.ctx.shadowColor = 'transparent'; - } for (let i = this.waves.length - 1; i >= 0; i--) { @@ -291,6 +247,46 @@ } requestAnimationFrame((nextNow) => this.loop(nextNow)); } + + renderTrail(trailArray, isPointerDown, frameScale) { + for (let i = trailArray.length - 1; i >= 0; i--) { + let t = trailArray[i]; + if (window.effectiveAlwaysTrail) { + t.life -= 0.085 * frameScale; + } else { + t.life -= (isPointerDown ? 0.085 : 0.18) * frameScale; + } + if (t.life <= 0) trailArray.splice(i, 1); + } + + if (trailArray.length > 1) { + this.ctx.lineWidth = 5.0; + this.ctx.shadowColor = `rgba(${this.color}, 0.6)`; + this.ctx.shadowBlur = 3; + this.ctx.shadowOffsetX = 0; + this.ctx.shadowOffsetY = 0; + + const lastIdx = trailArray.length - 1; + for (let i = 0; i < lastIdx; i++) { + const alphaStart = i / lastIdx; + const alphaEnd = (i + 1) / lastIdx; + const a0 = trailArray[i]; + const a1 = trailArray[i + 1]; + + const segGrad = this.ctx.createLinearGradient(a0.x, a0.y, a1.x, a1.y); + segGrad.addColorStop(0, `rgba(${this.color}, ${alphaStart})`); + segGrad.addColorStop(1, `rgba(${this.color}, ${alphaEnd})`); + + this.ctx.beginPath(); + this.ctx.moveTo(a0.x, a0.y); + this.ctx.lineTo(a1.x, a1.y); + this.ctx.strokeStyle = segGrad; + this.ctx.stroke(); + } + + this.ctx.shadowColor = 'transparent'; + } + } } window.spark = new MouseSpark(); @@ -304,14 +300,16 @@ window.currentInputMode = "mouse"; window.enableAlwaysTrailEffect = false; window.effectiveAlwaysTrail = false; +window.enableMultiTouch = false; -window.setInputContext = (mode, alwaysTrailEnabled) => { +window.setInputContext = (mode, alwaysTrailEnabled, multiTouchEnabled) => { window.currentInputMode = mode === "touch" ? "touch" : "mouse"; window.enableAlwaysTrailEffect = Boolean(alwaysTrailEnabled); + window.enableMultiTouch = Boolean(multiTouchEnabled); window.effectiveAlwaysTrail = window.currentInputMode === "mouse" && window.enableAlwaysTrailEffect; }; -window.setInputContext("mouse", false); +window.setInputContext("mouse", false, false); // 接收 C# 传来的物理百分比 (0.0 ~ 1.0),由前端根据真实的画布宽度自行计算 window.externalBoom = (percentX, percentY) => { @@ -338,6 +336,41 @@ window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); }; +// 多点触控接口 +window.externalTouchDown = (pointerId, percentX, percentY) => { + if (!window.spark || !window.enableMultiTouch) return; + let cx = percentX * window.innerWidth; + let cy = percentY * window.innerHeight; + + if (window.spark.touchPointers.size > 5) return; // 限制最多5指 + + let pointer = window.spark.touchPointers.get(pointerId) || { trail: [], isDown: false, lastPos: null }; + pointer.isDown = true; + pointer.lastPos = { x: cx, y: cy }; + window.spark.touchPointers.set(pointerId, pointer); + + window.spark.boom(cx, cy); +}; + +window.externalTouchMove = (pointerId, percentX, percentY) => { + if (!window.spark || !window.enableMultiTouch) return; + let pointer = window.spark.touchPointers.get(pointerId); + if (!pointer || !pointer.isDown) return; + + let cx = percentX * window.innerWidth; + let cy = percentY * window.innerHeight; + + pointer.trail.push({ x: cx, y: cy, life: 1 }); + if (pointer.trail.length > window.spark.maxTrail) pointer.trail.shift(); + pointer.lastPos = { x: cx, y: cy }; +}; + +window.externalTouchUp = (pointerId) => { + if (!window.spark) return; + let pointer = window.spark.touchPointers.get(pointerId); + if (pointer) pointer.isDown = false; +}; + window.updateColor = (rgbString) => { if (window.spark) window.spark.color = rgbString; };