From 095de74d442735c685ffddac9461af3925b076ab Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Sat, 25 Apr 2026 10:52:36 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=A4=9A?= =?UTF-8?q?=E7=82=B9=E8=A7=A6=E6=8E=A7=E6=94=AF=E6=8C=81=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=A7=A6=E5=B1=8F=E6=8B=96=E6=8B=BD=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.xaml.cs | 3 + src/ConfigManager.cs | 3 + src/ControlPanelWindow.xaml | 14 +++ src/ControlPanelWindow.xaml.cs | 3 + src/MainWindow.xaml.cs | 13 ++- src/OverlayManager.cs | 167 +++++++++++++++++++++++++++++++++ src/Web/index.html | 6 +- 7 files changed, 205 insertions(+), 4 deletions(-) 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..f700c0e 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..04e6e0b 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); diff --git a/src/MainWindow.xaml.cs b/src/MainWindow.xaml.cs index 8f7cd40..268b8b1 100644 --- a/src/MainWindow.xaml.cs +++ b/src/MainWindow.xaml.cs @@ -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) diff --git a/src/OverlayManager.cs b/src/OverlayManager.cs index 52a2a93..36dbcd6 100644 --- a/src/OverlayManager.cs +++ b/src/OverlayManager.cs @@ -51,9 +51,61 @@ 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 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 bool RegisterPointerInputTarget(IntPtr hwnd, uint scope); + + private const uint PP_SCOPE_RECENT_INPUT = 1; + private const uint PP_SCOPE_GLOBAL = 2; + + [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 RAWHID + { + public uint dwSizeHid; + public uint dwCount; + public byte bRawData; + } + + [StructLayout(LayoutKind.Explicit)] + private struct RAWINPUT + { + [FieldOffset(0)] + public RAWINPUTHEADER header; + [FieldOffset(16)] // 在 64 位系统上 header 是 24 字节,但在 32 位是 16。 + // .NET 会自动处理,但为了安全我们使用 IntPtr 偏移。 + public RAWHID hid; + } + + private const uint RID_INPUT = 0x10000003; + private const uint RIM_TYPEHID = 2; + private const ushort HID_USAGE_PAGE_DIGITIZER = 0x0D; + private const ushort HID_USAGE_TOUCH_SCREEN = 0x04; + private const uint RIDEV_INPUTSINK = 0x00000100; [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,6 +126,7 @@ private struct MONITORINFO private readonly Dictionary _overlays = new(StringComparer.OrdinalIgnoreCase); private IKeyboardMouseEvents? _globalHook; + private RawInputWindow? _rawInputWindow; private MainWindow? _activePointerOverlay; private long _lastMoveTicks; private long _lastClickTicks; @@ -89,9 +142,106 @@ public void Start() { RebuildWindows(forceRebuild: true); SetupGlobalHooks(); + SetupRawInput(); SystemEvents.DisplaySettingsChanged += HandleDisplaySettingsChanged; } + private void SetupRawInput() + { + if (ConfigManager.EnableMultiTouch) + { + _rawInputWindow = new RawInputWindow(this); + } + } + + private class RawInputWindow : NativeWindow + { + private readonly OverlayManager _owner; + private const int WM_POINTERDOWN = 0x0246; + + [DllImport("user32.dll")] + private static extern bool GetPointerInfo(uint pointerId, ref POINTER_INFO pointerInfo); + + [StructLayout(LayoutKind.Sequential)] + private struct POINTER_INFO + { + public uint pointerType; + public uint pointerId; + public IntPtr frameId; + public uint pointerFlags; + public IntPtr sourceDevice; + public IntPtr hwndTarget; + public POINT ptPixelLocation; + public POINT ptHimetricLocation; + public POINT ptPixelLocationRaw; + public POINT ptHimetricLocationRaw; + public uint dwTime; + public uint historyCount; + public int InputData; + public uint dwKeyStates; + public ulong PerformanceCount; + public int ButtonChangeType; + } + + public RawInputWindow(OverlayManager owner) + { + _owner = owner; + CreateHandle(new CreateParams()); + + // 注册全局指针监听(需要高权限或在 Secure Desktop 运行,但在普通模式下也能捕获部分消息) + RegisterPointerInputTarget(Handle, PP_SCOPE_GLOBAL); + } + + protected override void WndProc(ref Message m) + { + if (m.Msg == WM_POINTERDOWN) + { + uint pointerId = (uint)((ulong)m.WParam & 0xFFFF); + POINTER_INFO pi = new POINTER_INFO(); + if (GetPointerInfo(pointerId, ref pi)) + { + _owner.EmitDown(pi.ptPixelLocation.x, pi.ptPixelLocation.y); + } + } + base.WndProc(ref m); + } + } + + private void HandleRawInput(IntPtr hRawInput) + { + if (!CanRenderEffects()) return; + + uint dwSize = 0; + GetRawInputData(hRawInput, RID_INPUT, IntPtr.Zero, ref dwSize, (uint)Marshal.SizeOf()); + + if (dwSize == 0) return; + + IntPtr pData = Marshal.AllocHGlobal((int)dwSize); + try + { + if (GetRawInputData(hRawInput, RID_INPUT, pData, ref dwSize, (uint)Marshal.SizeOf()) != dwSize) + { + return; + } + + RAWINPUTHEADER header = Marshal.PtrToStructure(pData); + if (header.dwType == RIM_TYPEHID) + { + // 这里由于 HID 报文解析极其复杂且设备各异,BASpark 采用更稳健的策略: + // 当收到原始触摸输入且非主指针(由鼠标钩子处理)时,补全点击。 + // 实际上,对于大多数多点触控应用,我们通过检测当前光标位置是否在特效范围内来优化。 + + // 注意:为了保持性能和兼容性,我们主要通过 PointerSupport 提升 UI 响应, + // 全局多点特效在目前的开源驱动下通常通过模拟并发点击实现。 + // 这里的 RawInput 主要是为了唤醒处于非活动状态的覆盖层。 + } + } + finally + { + Marshal.FreeHGlobal(pData); + } + } + 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) @@ -122,6 +272,17 @@ 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; @@ -543,6 +704,12 @@ public void Dispose() _globalHook = null; } + if (_rawInputWindow != null) + { + _rawInputWindow.ReleaseHandle(); + _rawInputWindow = null; + } + CloseWindows(); } } diff --git a/src/Web/index.html b/src/Web/index.html index 84267aa..9466b6b 100644 --- a/src/Web/index.html +++ b/src/Web/index.html @@ -304,14 +304,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) => { From 7c9e4903b5348993d4d3e829a78b1aa6c4bc0e79 Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Fri, 1 May 2026 08:15:57 +0800 Subject: [PATCH 2/7] =?UTF-8?q?perf:=20=E9=87=8D=E6=9E=84=E8=A7=A6?= =?UTF-8?q?=E6=91=B8=E8=BE=93=E5=85=A5=E6=8D=95=E8=8E=B7=E6=9C=BA=E5=88=B6?= =?UTF-8?q?=EF=BC=8C=E7=BB=95=E8=BF=87=E5=85=A8=E5=B1=80=E9=BC=A0=E6=A0=87?= =?UTF-8?q?=E9=92=A9=E5=AD=90=E4=BB=A5=E4=BF=AE=E5=A4=8D=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/OverlayManager.cs | 114 ++++++++++++++++++++++++++++++------------ 1 file changed, 82 insertions(+), 32 deletions(-) diff --git a/src/OverlayManager.cs b/src/OverlayManager.cs index 36dbcd6..23789f2 100644 --- a/src/OverlayManager.cs +++ b/src/OverlayManager.cs @@ -130,7 +130,8 @@ private struct MONITORINFO 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; @@ -138,6 +139,16 @@ 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 MainWindow? TargetOverlay; + } + public void Start() { RebuildWindows(forceRebuild: true); @@ -150,14 +161,16 @@ private void SetupRawInput() { if (ConfigManager.EnableMultiTouch) { - _rawInputWindow = new RawInputWindow(this); + _rawInputWindow = new PointerInputWindow(this); } } - private class RawInputWindow : NativeWindow + private class PointerInputWindow : NativeWindow { private readonly OverlayManager _owner; + private const int WM_POINTERUPDATE = 0x0245; private const int WM_POINTERDOWN = 0x0246; + private const int WM_POINTERUP = 0x0247; [DllImport("user32.dll")] private static extern bool GetPointerInfo(uint pointerId, ref POINTER_INFO pointerInfo); @@ -183,24 +196,35 @@ private struct POINTER_INFO public int ButtonChangeType; } - public RawInputWindow(OverlayManager owner) + public PointerInputWindow(OverlayManager owner) { _owner = owner; CreateHandle(new CreateParams()); - // 注册全局指针监听(需要高权限或在 Secure Desktop 运行,但在普通模式下也能捕获部分消息) + // 注册全局指针监听(通常需要 UIAccess 权限,若满足条件可直接捕获多点触控) RegisterPointerInputTarget(Handle, PP_SCOPE_GLOBAL); } protected override void WndProc(ref Message m) { - if (m.Msg == WM_POINTERDOWN) + if (m.Msg == WM_POINTERDOWN || m.Msg == WM_POINTERUPDATE || m.Msg == WM_POINTERUP) { uint pointerId = (uint)((ulong)m.WParam & 0xFFFF); POINTER_INFO pi = new POINTER_INFO(); if (GetPointerInfo(pointerId, ref pi)) { - _owner.EmitDown(pi.ptPixelLocation.x, pi.ptPixelLocation.y); + if (m.Msg == WM_POINTERDOWN) + { + _owner.EmitTouchDown(pointerId, pi.ptPixelLocation.x, pi.ptPixelLocation.y); + } + else if (m.Msg == WM_POINTERUPDATE) + { + _owner.EmitTouchMove(pointerId, pi.ptPixelLocation.x, pi.ptPixelLocation.y); + } + else if (m.Msg == WM_POINTERUP) + { + _owner.EmitTouchUp(pointerId); + } } } base.WndProc(ref m); @@ -209,36 +233,50 @@ protected override void WndProc(ref Message m) private void HandleRawInput(IntPtr hRawInput) { - if (!CanRenderEffects()) return; + // 已废弃,多点触控改由 PointerInputWindow 直接处理 WM_POINTER + } - uint dwSize = 0; - GetRawInputData(hRawInput, RID_INPUT, IntPtr.Zero, ref dwSize, (uint)Marshal.SizeOf()); + public void EmitTouchDown(uint pointerId, int x, int y) + { + if (!CanRenderEffects()) return; - if (dwSize == 0) return; + MainWindow? target = ResolveTargetOverlay(x, y); + if (target == null) return; - IntPtr pData = Marshal.AllocHGlobal((int)dwSize); - try + _activePointers[pointerId] = new TouchPointState { - if (GetRawInputData(hRawInput, RID_INPUT, pData, ref dwSize, (uint)Marshal.SizeOf()) != dwSize) - { - return; - } + PointerId = pointerId, + LastX = x, + LastY = y, + IsDown = true, + TargetOverlay = target + }; - RAWINPUTHEADER header = Marshal.PtrToStructure(pData); - if (header.dwType == RIM_TYPEHID) - { - // 这里由于 HID 报文解析极其复杂且设备各异,BASpark 采用更稳健的策略: - // 当收到原始触摸输入且非主指针(由鼠标钩子处理)时,补全点击。 - // 实际上,对于大多数多点触控应用,我们通过检测当前光标位置是否在特效范围内来优化。 - - // 注意:为了保持性能和兼容性,我们主要通过 PointerSupport 提升 UI 响应, - // 全局多点特效在目前的开源驱动下通常通过模拟并发点击实现。 - // 这里的 RawInput 主要是为了唤醒处于非活动状态的覆盖层。 - } - } - finally + 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 - _lastMoveTicks < _touchMoveIntervalTicks) return; + _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)) { - Marshal.FreeHGlobal(pData); + state.IsDown = false; + state.TargetOverlay?.EmitTouchUp(pointerId, true); + _activePointers.Remove(pointerId); } } @@ -287,6 +325,9 @@ 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 @@ -322,11 +363,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)) @@ -342,6 +388,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; @@ -710,6 +758,8 @@ public void Dispose() _rawInputWindow = null; } + _activePointers.Clear(); + CloseWindows(); } } From 8da98a9f0f546b33bf7e71e777a2652cdc9419c2 Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Fri, 1 May 2026 08:16:51 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=85=A8?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E5=A4=9A=E7=82=B9=E8=A7=A6=E6=8E=A7=E8=BF=BD?= =?UTF-8?q?=E8=B8=AA=E4=B8=8E=E5=89=8D=E7=AB=AF=E7=8B=AC=E7=AB=8B=E6=B8=B2?= =?UTF-8?q?=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/MainWindow.xaml.cs | 23 +++++++- src/Web/index.html | 127 ++++++++++++++++++++++++++--------------- 2 files changed, 102 insertions(+), 48 deletions(-) diff --git a/src/MainWindow.xaml.cs b/src/MainWindow.xaml.cs index 268b8b1..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; @@ -332,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/Web/index.html b/src/Web/index.html index 9466b6b..3536020 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,67 +165,64 @@ 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; + // 渲染主鼠标拖尾 + this.renderTrail(this.trail, this.isDown, frameScale); + + // 渲染多指拖尾 + 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); } - 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; + } + 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); + } - // this.ctx.strokeStyle = `rgba(${this.color},${this.alpha(0.35)})`; - // this.ctx.stroke(); - + 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; - // 逐段渲染:每段的透明度基于数组索引位置,而非空间位置 - // 防止当鼠标锐角转动时,尾迹变淡后突然出现的问题 - 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})`); + 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]; - 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'; + 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'; + } + } + for (let i = this.waves.length - 1; i >= 0; i--) { let w = this.waves[i]; w.life += frameScale; @@ -340,6 +338,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; }; From 8f27395633c4d059fbe1e89f80ed24c669f14449 Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Fri, 1 May 2026 08:17:10 +0800 Subject: [PATCH 4/7] =?UTF-8?q?opt:=20=E4=BC=98=E5=8C=96=E5=A4=9A=E7=82=B9?= =?UTF-8?q?=E8=A7=A6=E6=8E=A7=E8=AE=BE=E7=BD=AE=E4=BD=93=E9=AA=8C=E5=8F=8A?= =?UTF-8?q?=E7=9B=B8=E5=85=B3UI=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ControlPanelWindow.xaml | 2 +- src/ControlPanelWindow.xaml.cs | 2 +- src/OverlayManager.cs | 13 +++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/ControlPanelWindow.xaml b/src/ControlPanelWindow.xaml index f700c0e..46bbdca 100644 --- a/src/ControlPanelWindow.xaml +++ b/src/ControlPanelWindow.xaml @@ -245,7 +245,7 @@ - + diff --git a/src/ControlPanelWindow.xaml.cs b/src/ControlPanelWindow.xaml.cs index 04e6e0b..fa9160f 100644 --- a/src/ControlPanelWindow.xaml.cs +++ b/src/ControlPanelWindow.xaml.cs @@ -815,8 +815,8 @@ private void SaveSettings_Click(object sender, RoutedEventArgs e) App.Overlay?.UpdateColor(ConfigManager.ParticleColor); App.Overlay?.UpdateEffectSettings(effectScale, effectOpacity, effectSpeed); 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/OverlayManager.cs b/src/OverlayManager.cs index 23789f2..d014b4a 100644 --- a/src/OverlayManager.cs +++ b/src/OverlayManager.cs @@ -289,6 +289,19 @@ 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 && _rawInputWindow == null) + { + _rawInputWindow = new PointerInputWindow(this); + } + else if (!enabled && _rawInputWindow != null) + { + _rawInputWindow.ReleaseHandle(); + _rawInputWindow = null; + _activePointers.Clear(); + } + } public bool IsEffectSuppressedByEnvironment() => ShouldSuppressEffects(); public void RefreshEnvironmentFilterState() { From 071c3614139d42910349a64e98afaaed254a920b Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Fri, 1 May 2026 08:19:44 +0800 Subject: [PATCH 5/7] =?UTF-8?q?chore:=20=E6=B8=85=E7=90=86=E9=81=97?= =?UTF-8?q?=E7=95=99=E6=97=A0=E6=95=88API=E5=B9=B6=E7=A1=AE=E4=BF=9D?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E5=85=BC=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/OverlayManager.cs | 48 +------------------------------------------ 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/src/OverlayManager.cs b/src/OverlayManager.cs index d014b4a..e4ffd22 100644 --- a/src/OverlayManager.cs +++ b/src/OverlayManager.cs @@ -52,57 +52,11 @@ public sealed class OverlayManager : IDisposable [DllImport("user32.dll")] private static extern IntPtr GetAncestor(IntPtr hwnd, uint gaFlags); [DllImport("user32.dll")] - 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 bool RegisterPointerInputTarget(IntPtr hwnd, uint scope); private const uint PP_SCOPE_RECENT_INPUT = 1; private const uint PP_SCOPE_GLOBAL = 2; - [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 RAWHID - { - public uint dwSizeHid; - public uint dwCount; - public byte bRawData; - } - - [StructLayout(LayoutKind.Explicit)] - private struct RAWINPUT - { - [FieldOffset(0)] - public RAWINPUTHEADER header; - [FieldOffset(16)] // 在 64 位系统上 header 是 24 字节,但在 32 位是 16。 - // .NET 会自动处理,但为了安全我们使用 IntPtr 偏移。 - public RAWHID hid; - } - - private const uint RID_INPUT = 0x10000003; - private const uint RIM_TYPEHID = 2; - private const ushort HID_USAGE_PAGE_DIGITIZER = 0x0D; - private const ushort HID_USAGE_TOUCH_SCREEN = 0x04; - private const uint RIDEV_INPUTSINK = 0x00000100; - [StructLayout(LayoutKind.Sequential)] private struct POINT { public int x; public int y; } @@ -126,7 +80,7 @@ private struct MONITORINFO private readonly Dictionary _overlays = new(StringComparer.OrdinalIgnoreCase); private IKeyboardMouseEvents? _globalHook; - private RawInputWindow? _rawInputWindow; + private PointerInputWindow? _rawInputWindow; private MainWindow? _activePointerOverlay; private long _lastMoveTicks; private long _lastClickTicks; From 1ba5ce2c7e0c35586c3385b4bb041153a692fd96 Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Fri, 1 May 2026 08:23:49 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DrenderTrail?= =?UTF-8?q?=E4=BD=9C=E7=94=A8=E5=9F=9F=E9=94=99=E8=AF=AF=E3=80=81=E5=A4=9A?= =?UTF-8?q?=E6=8C=87=E8=8A=82=E6=B5=81=E4=BA=92=E6=96=A5=E5=8F=8A=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E8=BF=87=E6=BB=A4=E7=8A=B6=E6=80=81=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ControlPanelWindow.xaml.cs | 1 + src/OverlayManager.cs | 8 ++-- src/Web/index.html | 82 +++++++++++++++++----------------- 3 files changed, 46 insertions(+), 45 deletions(-) diff --git a/src/ControlPanelWindow.xaml.cs b/src/ControlPanelWindow.xaml.cs index fa9160f..6bcaf4f 100644 --- a/src/ControlPanelWindow.xaml.cs +++ b/src/ControlPanelWindow.xaml.cs @@ -815,6 +815,7 @@ private void SaveSettings_Click(object sender, RoutedEventArgs e) App.Overlay?.UpdateColor(ConfigManager.ParticleColor); App.Overlay?.UpdateEffectSettings(effectScale, effectOpacity, effectSpeed); App.Overlay?.UpdateTrailRefreshRate(trailRefreshRate); + App.Overlay?.RefreshEnvironmentFilterState(); App.Overlay?.UpdateTouchMode(isTouchscreenEnabled); App.Overlay?.UpdateMultiTouchMode(isMultiTouchEnabled); if (!enabledScreenIds.SetEquals(selectedIds)) diff --git a/src/OverlayManager.cs b/src/OverlayManager.cs index e4ffd22..d389d7d 100644 --- a/src/OverlayManager.cs +++ b/src/OverlayManager.cs @@ -100,6 +100,7 @@ private class TouchPointState public uint PointerId; public int LastX, LastY; public bool IsDown; + public long LastMoveTicks; public MainWindow? TargetOverlay; } @@ -215,9 +216,10 @@ 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 - _lastMoveTicks < _touchMoveIntervalTicks) return; - _lastMoveTicks = currentTicks; + if (currentTicks - state.LastMoveTicks < _touchMoveIntervalTicks) return; + state.LastMoveTicks = currentTicks; state.LastX = x; state.LastY = y; @@ -383,7 +385,7 @@ private bool CanRenderEffects() return false; } - if (!ConfigManager.IsTouchscreenMode && !CursorIsVisible()) + if (!ConfigManager.IsTouchscreenMode && !CursorIsVisible() && _activePointers.Count == 0) { ReleasePointerState(); return false; diff --git a/src/Web/index.html b/src/Web/index.html index 3536020..c33b1f4 100644 --- a/src/Web/index.html +++ b/src/Web/index.html @@ -181,48 +181,6 @@ } } - } - - 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'; - } - } - for (let i = this.waves.length - 1; i >= 0; i--) { let w = this.waves[i]; w.life += frameScale; @@ -289,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(); From f5d2b12281985c7c09521832fb4a6d79bee96e54 Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Fri, 1 May 2026 08:32:36 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E4=BD=BF=E7=94=A8Raw=20Input=20HID?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E9=9C=80UIAccess=E7=9A=84RegisterPointerInpu?= =?UTF-8?q?tTarget=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=A4=9A=E7=82=B9=E8=A7=A6?= =?UTF-8?q?=E6=8E=A7=E4=B8=8D=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/OverlayManager.cs | 105 +++------- src/TouchInputCapture.cs | 415 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 437 insertions(+), 83 deletions(-) create mode 100644 src/TouchInputCapture.cs diff --git a/src/OverlayManager.cs b/src/OverlayManager.cs index d389d7d..9972ecd 100644 --- a/src/OverlayManager.cs +++ b/src/OverlayManager.cs @@ -52,10 +52,7 @@ public sealed class OverlayManager : IDisposable [DllImport("user32.dll")] private static extern IntPtr GetAncestor(IntPtr hwnd, uint gaFlags); [DllImport("user32.dll")] - private static extern bool RegisterPointerInputTarget(IntPtr hwnd, uint scope); - - private const uint PP_SCOPE_RECENT_INPUT = 1; - private const uint PP_SCOPE_GLOBAL = 2; + private static extern IntPtr GetMessageExtraInfo(); [StructLayout(LayoutKind.Sequential)] private struct POINT { public int x; public int y; } @@ -80,7 +77,7 @@ private struct MONITORINFO private readonly Dictionary _overlays = new(StringComparer.OrdinalIgnoreCase); private IKeyboardMouseEvents? _globalHook; - private PointerInputWindow? _rawInputWindow; + private TouchInputCapture? _touchCapture; private MainWindow? _activePointerOverlay; private long _lastMoveTicks; private long _lastClickTicks; @@ -108,87 +105,25 @@ public void Start() { RebuildWindows(forceRebuild: true); SetupGlobalHooks(); - SetupRawInput(); + SetupTouchCapture(); SystemEvents.DisplaySettingsChanged += HandleDisplaySettingsChanged; } - private void SetupRawInput() + private void SetupTouchCapture() { if (ConfigManager.EnableMultiTouch) { - _rawInputWindow = new PointerInputWindow(this); - } - } - - private class PointerInputWindow : NativeWindow - { - private readonly OverlayManager _owner; - private const int WM_POINTERUPDATE = 0x0245; - private const int WM_POINTERDOWN = 0x0246; - private const int WM_POINTERUP = 0x0247; - - [DllImport("user32.dll")] - private static extern bool GetPointerInfo(uint pointerId, ref POINTER_INFO pointerInfo); - - [StructLayout(LayoutKind.Sequential)] - private struct POINTER_INFO - { - public uint pointerType; - public uint pointerId; - public IntPtr frameId; - public uint pointerFlags; - public IntPtr sourceDevice; - public IntPtr hwndTarget; - public POINT ptPixelLocation; - public POINT ptHimetricLocation; - public POINT ptPixelLocationRaw; - public POINT ptHimetricLocationRaw; - public uint dwTime; - public uint historyCount; - public int InputData; - public uint dwKeyStates; - public ulong PerformanceCount; - public int ButtonChangeType; - } - - public PointerInputWindow(OverlayManager owner) - { - _owner = owner; - CreateHandle(new CreateParams()); - - // 注册全局指针监听(通常需要 UIAccess 权限,若满足条件可直接捕获多点触控) - RegisterPointerInputTarget(Handle, PP_SCOPE_GLOBAL); - } - - protected override void WndProc(ref Message m) - { - if (m.Msg == WM_POINTERDOWN || m.Msg == WM_POINTERUPDATE || m.Msg == WM_POINTERUP) - { - uint pointerId = (uint)((ulong)m.WParam & 0xFFFF); - POINTER_INFO pi = new POINTER_INFO(); - if (GetPointerInfo(pointerId, ref pi)) - { - if (m.Msg == WM_POINTERDOWN) - { - _owner.EmitTouchDown(pointerId, pi.ptPixelLocation.x, pi.ptPixelLocation.y); - } - else if (m.Msg == WM_POINTERUPDATE) - { - _owner.EmitTouchMove(pointerId, pi.ptPixelLocation.x, pi.ptPixelLocation.y); - } - else if (m.Msg == WM_POINTERUP) - { - _owner.EmitTouchUp(pointerId); - } - } - } - base.WndProc(ref m); + _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) { - // 已废弃,多点触控改由 PointerInputWindow 直接处理 WM_POINTER + // 已废弃,多点触控改由 TouchInputCapture 处理 } public void EmitTouchDown(uint pointerId, int x, int y) @@ -247,14 +182,18 @@ public void UpdateTrailRefreshRate(int hz) public void UpdateTouchMode(bool enabled) => ForEachOverlay(w => w.UpdateTouchMode(enabled)); public void UpdateMultiTouchMode(bool enabled) { - if (enabled && _rawInputWindow == null) + if (enabled && _touchCapture == null) { - _rawInputWindow = new PointerInputWindow(this); + _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 && _rawInputWindow != null) + else if (!enabled && _touchCapture != null) { - _rawInputWindow.ReleaseHandle(); - _rawInputWindow = null; + _touchCapture.Dispose(); + _touchCapture = null; _activePointers.Clear(); } } @@ -721,10 +660,10 @@ public void Dispose() _globalHook = null; } - if (_rawInputWindow != null) + if (_touchCapture != null) { - _rawInputWindow.ReleaseHandle(); - _rawInputWindow = null; + _touchCapture.Dispose(); + _touchCapture = null; } _activePointers.Clear(); 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 + } +}