diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b65d6c0..8837429b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to iPhoneMirror are documented here. The project follows ## [Unreleased] +### Added + +- Add opt-in absolute Bluetooth mouse input in the main and independent previews, with seamless cursor movement between Windows and the mirrored image. + ### Fixed - Improve Chinese IME key forwarding, shortcut press/release recovery and preview focus handling. See the [Sogou verification record](docs/SOGOU_IME_VERIFICATION_2026-10-31.zh-CN.md) for tested scope. diff --git a/CHANGELOG.zh-TW.md b/CHANGELOG.zh-TW.md index c2b4c368..28c1c7b9 100644 --- a/CHANGELOG.zh-TW.md +++ b/CHANGELOG.zh-TW.md @@ -4,6 +4,10 @@ iPhoneMirror 的重要變更均記錄於此。已發布版本遵循[語意化版 ## [Unreleased] +### 新增 + +- 新增可選的絕對座標藍牙滑鼠模式,可在主預覽、獨立投影視窗和 Windows 桌面之間直接移動游標。 + ### 修正 - 改善中文輸入法按鍵轉發、快捷鍵按下與放開恢復,以及預覽焦點處理。 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 3c7780fe..8641b1b4 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -121,12 +121,18 @@ iPhoneMirror 的控制链路与投屏链路分开:电脑蓝牙会以标准 BLE 3. 选择设备后打开左侧“投屏”面板,点击“启用蓝牙反向控制”。可以在开始投屏前完成蓝牙 广播和配对;首次连接时,在蓝牙客户端绑定窗口中确认与当前手机对应的客户端。绑定完成后, 主预览中的鼠标移动、单击、拖拽、滚轮和常用键盘按键会发送到该设备。 -4. 反向控制开启后会隐藏 Windows 本机光标;默认按 `F9` 可释放鼠标并暂停输入,再按一次恢复控制。正常暂停期间保留蓝牙连接,恢复时不重复弹出连接提示;释放输入失败时会关闭连接,下次开启需重新连接。独立窗口可恢复拖动(手动固定的窗口除外)。也可以在 +4. 默认的相对鼠标模式会隐藏 Windows 本机光标;默认按 `F9` 可释放鼠标并暂停输入,再按一次恢复控制。正常暂停期间保留蓝牙连接,恢复时不重复弹出连接提示;释放输入失败时会关闭连接,下次开启需重新连接。独立窗口可恢复拖动(手动固定的窗口除外)。也可以在 “设置快捷键”窗口中改为其他全局快捷键。该窗口还可配置有线投屏、无线投屏、蓝牙控制、 控制中心、通知中心、App 切换器、主屏幕、Boss 键、音量、锁屏、Dock 和 Siri。F12 保留为 不可设置的系统键;F1-F11、右键和中键可以直接录入。滚轮方向和鼠标灵敏度可按竖屏/横屏 分别设置,打开独立预览窗口时,主窗口反向控制会自动关闭。 +如需直接点选投屏画面,请先停止蓝牙反向控制,在“设置 → 反向控制设置”勾选 +“在投屏画面内使用绝对坐标鼠标(无需 F9)”,再启动蓝牙反向控制。主预览和独立窗口 +会把鼠标位置映射到视频内容,移出画面或失去焦点时释放按键。Windows 光标保持可见。 +切换相对与绝对模式会更换 BLE HID 报告描述符。若设备缓存了旧描述符,请在系统蓝牙 +设置中忽略该设备,按连接提示重新配对。设备对绝对坐标鼠标的支持取决于系统版本。 + 控制能力受 iOS 系统鼠标模型限制:主要是单指指针操作,不能注入真正的多指触控或游戏 触摸事件。停止投屏时控制广播会自动关闭;也可以在同一面板手动关闭。客户端断开后不会 自动转移到另一台手机,原客户端重新连接后才会恢复;可在设置中解除单个绑定或清除全部绑定。 diff --git a/src/App.Runtime.Tests/BluetoothInputToggleTests.cs b/src/App.Runtime.Tests/BluetoothInputToggleTests.cs index 2ad0376c..3593660d 100644 --- a/src/App.Runtime.Tests/BluetoothInputToggleTests.cs +++ b/src/App.Runtime.Tests/BluetoothInputToggleTests.cs @@ -18,6 +18,8 @@ private static int RunBluetoothInputTransportTests() { Task.Run(TestBluetoothPauseRouteChangeAsync).GetAwaiter().GetResult(); Task.Run(TestBluetoothMouseAdmissionAsync).GetAwaiter().GetResult(); + Task.Run(TestWindowBluetoothMouseClickAsync).GetAwaiter().GetResult(); + Task.Run(TestAbsoluteWindowMouseAsync).GetAwaiter().GetResult(); Task.Run(TestBluetoothHidSessionAdmissionAsync).GetAwaiter().GetResult(); TestBluetoothNotificationResults(); return 0; @@ -380,6 +382,20 @@ void Ready() KeyboardCall(shell, "ToggleFixedWindow"); Require(Hit() == 2, "unfixing a paused independent window did not restore dragging."); Console.WriteLine("PASS independent Bluetooth window: temporary input hit-testing, restored caption dragging/capture, explicit fixed preference retained."); + Toggle(); + SetKeyboardField(main, "_controlPointerFlushInFlight", 1); + try + { + TestWindowBluetoothMouseHandoff(main, device, shell.Handle, hwnd => foreground = hwnd); + TestWindowBluetoothPointer(main, device); + } + finally + { + SetKeyboardField(main, "_bluetoothWindowMouseEnabled", false); + KeyboardCall(main, "ResetControlRouteState"); + SetKeyboardField(main, "_controlPointerFlushInFlight", 0); + } + Toggle(); AwaitMapping(vm.DisableBluetoothControlAsync()); Require(!vm.IsBluetoothControlEnabled && !vm.BluetoothControlIsInputPaused && vm.BluetoothControlTargetUdid is null, @@ -546,6 +562,216 @@ private static void TestBluetoothNotificationResults() Console.WriteLine("PASS notification result contract: only Success with exact bytes and no protocol error confirms delivery."); } + private static void TestWindowBluetoothMouseHandoff(MainWindow window, DeviceViewModel device, + nint independent, Action setForeground) + { + SetKeyboardField(window, "_bluetoothWindowMouseEnabled", true); + var vm = KeyboardField(window, "_viewModel"); + var service = KeyboardField(vm, "_bluetoothControl"); + var main = new WindowInteropHelper(window).Handle; + var preview = KeyboardField(window, "MainPreviewHost"); + var sends = (HashSet)KeyboardField(window, "_keyboardSends"); + SetKeyboardField(service, "_useAbsoluteMouse", true); + try + { + SetKeyboardField(window, "_controlButtons", (byte)1); + KeyboardCall(window, "ApplyBluetoothControlInputState", false); + InteractionAssert((byte)KeyboardField(window, "_controlButtons") == 0, + "Enabling absolute input must discard previous drag state."); + + foreach (var owner in new[] { main, independent }) + { + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + sends.Add(pending.Task); + try + { + setForeground(owner); + KeyboardCall(window, "FocusControlDevice", device.Udid, owner == main ? (nint)0 : owner); + KeyboardCall(window, "ResetKeyboardOwnership"); + var handoff = (Task)KeyboardField(window, "_keyboardHandoff"); + InteractionAssert(!handoff.IsCompleted && + !(bool)KeyboardField(window, "_rawMouseInputEnabled"), + "Absolute window mode must keep regular mouse input while keyboard input drains."); + pending.SetResult(); + AwaitMapping(handoff); + InteractionAssert(!(bool)KeyboardField(window, "_rawMouseInputEnabled") && + !(bool)KeyboardField(preview, "SuppressLegacyMouseButtons"), + "A delayed keyboard handoff must not suppress normal mouse clicks in either preview."); + } + finally + { + pending.TrySetResult(); + sends.Remove(pending.Task); + } + } + + void Pointer(PreviewPointerKind kind, byte button = 0, nint? keys = null) + { + KeyboardCall(window, "OnIndependentPointerInput", device.Udid, + new PreviewPointerEventArgs(kind, 150, 150, button, 0, 300, 300, 100, 300) + { + WindowsButtons = keys is nint state ? NativePreviewMouse.PressedButtons(state) : null, + }); + } + byte Buttons() => (byte)KeyboardField(window, "_controlButtons"); + Pointer(PreviewPointerKind.Move, keys: 0x01); + InteractionAssert(Buttons() == 0, "Movement must not inherit a Windows button held before activation."); + Pointer(PreviewPointerKind.ButtonDown, button: 1); + Pointer(PreviewPointerKind.ButtonDown, button: 2); + Pointer(PreviewPointerKind.ButtonDown, button: 4); + Pointer(PreviewPointerKind.Move, keys: 0x12); + InteractionAssert(Buttons() == 6, "Movement must clear only the released button and retain right/middle."); + Pointer(PreviewPointerKind.Wheel, keys: unchecked((nint)0xFF880000L)); + InteractionAssert(Buttons() == 0, "A wheel snapshot must clear missed releases even without a scroll tick."); + } + finally + { + SetKeyboardField(service, "_useAbsoluteMouse", false); + } + Console.WriteLine("PASS absolute mouse: both-window handoff, startup drag isolation and missed-release recovery."); + } + + private static void TestWindowBluetoothPointer(MainWindow window, DeviceViewModel device) + { + KeyboardCall(window, "ResetControlRouteState"); + SetKeyboardField(window, "_bluetoothWindowMouseEnabled", true); + KeyboardCall(window, "ApplyBluetoothControlInputState", false); + InteractionAssert(!(bool)KeyboardField(window, "_rawMouseInputEnabled") && + !(bool)KeyboardField(window, "_windowsCursorHidden"), + "Window mouse must leave Windows mouse messages and cursor available."); + + void Send(PreviewPointerKind kind, short x = 150, byte button = 0, int wheel = 0, + int width = 300) => KeyboardCall(window, "OnIndependentPointerInput", device.Udid, + new PreviewPointerEventArgs(kind, x, 150, button, wheel, width, 300, 100, 300)); + byte Buttons() => (byte)KeyboardField(window, "_controlButtons"); + int Delta() => (int)KeyboardField(window, "_pendingControlDx"); + + Send(PreviewPointerKind.ButtonDown, x: 20, button: 1); + InteractionAssert(Buttons() == 0, "Clicks on black bars must stay local."); + Send(PreviewPointerKind.Move, x: 145); + InteractionAssert(Delta() == 0, "Entering the picture must anchor relative movement."); + Send(PreviewPointerKind.ButtonDown, button: 1); + Send(PreviewPointerKind.Move, x: 170); + InteractionAssert(Buttons() == 1 && Delta() != 0, "An in-picture drag must send motion and a held button."); + Send(PreviewPointerKind.Move, x: 210); + InteractionAssert(Buttons() == 0 && Delta() == 0, + "Crossing into a black bar must release the drag and discard queued movement."); + Send(PreviewPointerKind.Move, x: 165); + Send(PreviewPointerKind.ButtonUp, button: 1); + InteractionAssert(Buttons() == 0 && Delta() == 0, + "Re-entry must anchor again without restarting the old drag."); + Send(PreviewPointerKind.Wheel, wheel: 120); + InteractionAssert((int)KeyboardField(window, "_pendingControlWheel") != 0, + "The wheel must reach the device inside the picture."); + Send(PreviewPointerKind.Move, x: 175, width: 320); + InteractionAssert(Delta() == 0, "Resizing must re-anchor without a pointer jump."); + + Send(PreviewPointerKind.ButtonDown, button: 1); + Send(PreviewPointerKind.Reset); // WM_MOUSELEAVE or capture/focus loss + InteractionAssert(Buttons() == 0 && (int)KeyboardField(window, "_pendingControlWheel") == 0, + "Leaving the HWND must release the drag and clear pending wheel input."); + Console.WriteLine("Bluetooth window pointer: legacy Windows input, black bars, drag release, re-entry, wheel and resize passed."); + } + + private static async Task TestAbsoluteWindowMouseAsync() + { + var delivered = new ConcurrentQueue<(byte Id, byte[] Payload)>(); + await using var service = new BluetoothHidMouseService((id, payload, guard) => + { + if (id is 2 or 6 && guard?.Invoke() != false) delivered.Enqueue((id, payload.ToArray())); + return Task.FromResult(true); + }); + const string target = "absolute-window-mouse"; + SetKeyboardField(service, "_useAbsoluteMouse", true); + SetKeyboardField(service, "_targetDeviceUdid", target); + void HoldPump() => SetKeyboardField(service, "_mousePumpRunning", true); + Task Pump() => (Task)KeyboardCall(service, "PumpReportsAsync", + KeyboardField(service, "_mousePumpGeneration"))!; + Task Point(double x, double y, byte buttons, bool reset = false) => + service.SendAbsoluteMouseAsync(x, y, buttons, 0, target, () => true, reset); + static int X(byte[] report) => report[1] | report[2] << 8; + + HoldPump(); + await Point(0, 1, 1); + await Point(0, 1, 0); + await Pump(); + MappingAssert(delivered.Select(report => (report.Id, report.Payload[0], X(report.Payload))) + .SequenceEqual(new (byte, byte, int)[] { (2, 0, 0), (6, 1, 0), (6, 0, 0), (2, 0, 0) }), + "A quick click must position first and preserve both relative button edges."); + delivered.Clear(); + + for (var i = 0; i < 2; i++) + { + HoldPump(); + await Point(0.5, 0.25, 1); + await Point(0.75, 0.25, 0); + await Pump(); + MappingAssert(delivered.Select(report => (report.Id, report.Payload[0], X(report.Payload))) + .SequenceEqual(new (byte, byte, int)[] { (2, 0, 16384), (6, 1, 0), + (6, 1, 256), (6, 0, 0), (2, 0, 24575) }), + "Each consecutive drag must move and release on report 6, then restore the absolute pointer."); + delivered.Clear(); + } + + HoldPump(); + await Point(0.5, 0.25, 1); + await Point(0.75, 0.25, 1); + await Pump(); + delivered.Clear(); + + HoldPump(); + await Point(0.5, 0.25, 0); + await Pump(); + var returnDrag = delivered.ToArray(); + MappingAssert(returnDrag.Select(report => report.Id).SequenceEqual(new byte[] { 6, 6, 2, 2 }) && + returnDrag[1].Payload[0] == 0 && X(returnDrag[2].Payload) != 16384 && + X(returnDrag[3].Payload) == 16384, + "A drag that returns to its original position needs a changed anchor before restoring that position."); + delivered.Clear(); + + HoldPump(); + await Point(0.5, 0.25, 1); + await Pump(); + delivered.Clear(); + + HoldPump(); + await Point(0.9, 0.9, 1); + await Point(0.75, 0.25, 0, reset: true); + await Point(1, 0, 0); + await Pump(); + MappingAssert(delivered.Select(report => (report.Id, report.Payload[0], X(report.Payload))) + .SequenceEqual(new (byte, byte, int)[] { (6, 0, 0), (2, 0, 24575), (2, 0, 32767) }), + "Leaving the surface must release without sending stale drag movement before re-entry."); + delivered.Clear(); + + MappingAssert(await service.PauseInputAsync(), "Absolute mouse pause could not release the device."); + MappingAssert(delivered.Select(report => (report.Id, report.Payload[0], X(report.Payload))) + .SequenceEqual(new (byte, byte, int)[] { (6, 0, 0) }), + "Pausing must release the relative buttons without repositioning."); + Console.WriteLine("PASS hybrid window mouse: click, consecutive drags, re-anchor, re-entry and pause."); + } + + private static async Task TestWindowBluetoothMouseClickAsync() + { + var delivered = new ConcurrentQueue(); + await using var service = new BluetoothHidMouseService((id, payload, guard) => + { + if (id == 2 && guard?.Invoke() != false) delivered.Enqueue(payload[0]); + return Task.FromResult(true); + }); + SetKeyboardField(service, "_targetDeviceUdid", "window-mouse"); + SetKeyboardField(service, "_mousePumpRunning", true); + Task Send(byte buttons, int wheel = 0) => service.SendMouseAsync(0, 0, buttons, wheel, + "window-mouse", preserveButtonTransitions: true); + await Send(1); + await Send(1, wheel: 1); + await Send(0); + await (Task)KeyboardCall(service, "PumpReportsAsync", KeyboardField(service, "_mousePumpGeneration"))!; + MappingAssert(delivered.SequenceEqual(new byte[] { 1, 0 }), + "A fast window click must retain both button transitions while BLE is busy."); + Console.WriteLine("PASS Bluetooth window mouse: queued click edges survive a wheel tick and a busy BLE pump."); + } + private static async Task TestBluetoothMouseAdmissionAsync() { var delivered = new ConcurrentQueue(); diff --git a/src/App/Controls/NativePreviewHost.cs b/src/App/Controls/NativePreviewHost.cs index 3404669c..d209dedb 100644 --- a/src/App/Controls/NativePreviewHost.cs +++ b/src/App/Controls/NativePreviewHost.cs @@ -32,6 +32,8 @@ internal sealed class NativePreviewHost : HwndHost private (int Width, int Height, int Radius, double Curve)? _appliedRegion; internal bool CapturePointerInput { get; set; } + private readonly NativePreviewMouse _windowMouse = new(); + internal bool BluetoothWindowMouseEnabled { get; set; } internal bool CaptureTouchInput { get; set; } internal bool SuppressMouseMove { get; set; } // Raw Input owns button and wheel transitions while Bluetooth control is @@ -146,6 +148,8 @@ internal void SetPresentationVisible(bool visible) { if (_presentationVisible == visible) return; _presentationVisible = visible; + if (!visible && BluetoothWindowMouseEnabled) + PointerInput?.Invoke(this, new PreviewPointerEventArgs(PreviewPointerKind.Reset, 0, 0, 0, 0)); if (_window != 0) _ = ShowWindow(_window, visible ? SwShowNoActivate : SwHide); } @@ -232,12 +236,19 @@ protected override void DestroyWindowCore(HandleRef hwnd) PreviewAttachmentCoordinator.Unregister(hwnd.Handle); if (hwnd.Handle != 0) DestroyWindow(hwnd.Handle); _window = 0; + _windowMouse.OnLeave(); _appliedRegion = null; } protected override nint WndProc(nint hwnd, int message, nint wParam, nint lParam, ref bool handled) { + if (message == NativePreviewMouse.WmMouseLeave) + { + _windowMouse.OnLeave(); + if (BluetoothWindowMouseEnabled) + PointerInput?.Invoke(this, new PreviewPointerEventArgs(PreviewPointerKind.Reset, 0, 0, 0, 0)); + } if (message == NativeTouchInput.WmTouch) { NativeTouchInput.Dispatch(hwnd, wParam, lParam, CapturePointerInput && CaptureTouchInput, @@ -279,7 +290,7 @@ protected override nint WndProc(nint hwnd, int message, nint wParam, nint lParam { // Button-up clears our mask before ReleaseCapture synchronously // sends WM_CAPTURECHANGED. That is a completed click, not a reset. - if (message == 0x0215 && _capturedMouseButtons == 0) + if (message == 0x0215 && _capturedMouseButtons == 0 && !BluetoothWindowMouseEnabled) { handled = true; return 0; @@ -300,16 +311,23 @@ protected override nint WndProc(nint hwnd, int message, nint wParam, nint lParam handled = true; return 0; } - lParam = DrainQueuedMouseMoves(_window, lParam); + if (BluetoothWindowMouseEnabled) _windowMouse.TrackLeave(hwnd); + else lParam = DrainQueuedMouseMoves(_window, lParam); PointerInput?.Invoke(this, new PreviewPointerEventArgs( PreviewPointerKind.Move, GetSignedLowWord(lParam), GetSignedHighWord(lParam), 0, 0, GetClientWidth(), - GetClientHeight())); + GetClientHeight()) + { + WindowsButtons = NativePreviewMouse.PressedButtons(wParam), + }); handled = true; return 0; case 0x0201: // WM_LBUTTONDOWN case 0x0204: // WM_RBUTTONDOWN case 0x0207: // WM_MBUTTONDOWN + case 0x0203 when BluetoothWindowMouseEnabled: // WM_LBUTTONDBLCLK + case 0x0206 when BluetoothWindowMouseEnabled: // WM_RBUTTONDBLCLK + case 0x0209 when BluetoothWindowMouseEnabled: // WM_MBUTTONDBLCLK // STATIC children do not take keyboard focus on click. // Restore both WPF and native focus after a toolbar/editor // interaction before dispatching this preview input. @@ -325,8 +343,12 @@ protected override nint WndProc(nint hwnd, int message, nint wParam, nint lParam handled = true; return 0; } - _capturedMouseButtons |= MouseButtonFromMessage(message); - _ = SetCapture(_window); + if (BluetoothWindowMouseEnabled) _windowMouse.TrackLeave(hwnd); + else + { + _capturedMouseButtons |= MouseButtonFromMessage(message); + _ = SetCapture(_window); + } PointerInput?.Invoke(this, new PreviewPointerEventArgs( PreviewPointerKind.ButtonDown, GetSignedLowWord(lParam), GetSignedHighWord(lParam), MouseButtonFromMessage(message), 0, @@ -346,7 +368,7 @@ protected override nint WndProc(nint hwnd, int message, nint wParam, nint lParam PreviewPointerKind.ButtonUp, GetSignedLowWord(lParam), GetSignedHighWord(lParam), MouseButtonFromMessage(message), 0, GetClientWidth(), GetClientHeight())); - if (_capturedMouseButtons == 0) _ = ReleaseCapture(); + if (!BluetoothWindowMouseEnabled && _capturedMouseButtons == 0) _ = ReleaseCapture(); handled = true; return 0; case 0x020A: // WM_MOUSEWHEEL @@ -355,10 +377,19 @@ protected override nint WndProc(nint hwnd, int message, nint wParam, nint lParam handled = true; return 0; } + if (BluetoothWindowMouseEnabled) + { + if (!NativePreviewMouse.TryGetWheelClientPosition(hwnd, lParam, out lParam)) + break; + _windowMouse.TrackLeave(hwnd); + } PointerInput?.Invoke(this, new PreviewPointerEventArgs( PreviewPointerKind.Wheel, GetSignedLowWord(lParam), GetSignedHighWord(lParam), 0, (short)((long)wParam >> 16), - GetClientWidth(), GetClientHeight())); + GetClientWidth(), GetClientHeight()) + { + WindowsButtons = NativePreviewMouse.PressedButtons(wParam), + }); handled = true; return 0; case 0x0100: // WM_KEYDOWN @@ -402,8 +433,8 @@ protected override nint WndProc(nint hwnd, int message, nint wParam, nint lParam private static short GetSignedHighWord(nint value) => unchecked((short)(((long)value >> 16) & 0xFFFF)); private static byte MouseButtonFromMessage(int message) => message switch { - 0x0201 or 0x0202 => 1, - 0x0204 or 0x0205 => 2, + 0x0201 or 0x0202 or 0x0203 => 1, + 0x0204 or 0x0205 or 0x0206 => 2, _ => 4, }; @@ -533,6 +564,7 @@ internal PreviewPointerEventArgs(PreviewPointerKind kind, int x, int y, internal int X { get; } internal int Y { get; } internal byte Button { get; } + internal byte? WindowsButtons { get; init; } internal int Wheel { get; } internal int SurfaceWidth { get; } internal int SurfaceHeight { get; } diff --git a/src/App/Controls/NativePreviewMouse.cs b/src/App/Controls/NativePreviewMouse.cs new file mode 100644 index 00000000..67bfb339 --- /dev/null +++ b/src/App/Controls/NativePreviewMouse.cs @@ -0,0 +1,73 @@ +using System.Runtime.InteropServices; + +namespace IPhoneMirror.App.Controls; + +internal sealed class NativePreviewMouse +{ + internal const int WmMouseLeave = 0x02A3; + private bool _trackingLeave; + + internal void TrackLeave(nint hwnd) + { + if (_trackingLeave) return; + var request = new MouseTracking + { + Size = (uint)Marshal.SizeOf(), + Flags = 2, // TME_LEAVE + Window = hwnd, + }; + _trackingLeave = TrackMouseEvent(ref request); + } + + internal void OnLeave() => _trackingLeave = false; + + internal static byte PressedButtons(nint mouseKeyState) + { + var flags = mouseKeyState.ToInt64(); + // Win32 left/right use the same bits as HID. MK_MBUTTON (0x10) + // maps to the HID middle-button bit (0x04). + return (byte)((flags & 0x03) | ((flags & 0x10) >> 2)); + } + + internal static bool TryGetWheelClientPosition(nint hwnd, nint screenPosition, out nint clientPosition) + { + // A focused preview can receive wheel messages while the cursor is + // elsewhere. Require the actual hit window before forwarding them. + var point = new Point + { + X = unchecked((short)screenPosition.ToInt64()), + Y = unchecked((short)(screenPosition.ToInt64() >> 16)), + }; + clientPosition = 0; + if (WindowFromPoint(point) != hwnd || !ScreenToClient(hwnd, ref point)) return false; + clientPosition = (nint)((point.X & 0xFFFF) | (point.Y << 16)); + return true; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MouseTracking + { + internal uint Size; + internal uint Flags; + internal nint Window; + internal uint HoverTime; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Point + { + internal int X; + internal int Y; + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool TrackMouseEvent(ref MouseTracking request); + + [DllImport("user32.dll")] + private static extern nint WindowFromPoint(Point point); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ScreenToClient(nint hwnd, ref Point point); +} diff --git a/src/App/Localization/Strings.en-US.xaml b/src/App/Localization/Strings.en-US.xaml index b619fe7b..ff39b25e 100644 --- a/src/App/Localization/Strings.en-US.xaml +++ b/src/App/Localization/Strings.en-US.xaml @@ -233,6 +233,7 @@ Apply video settings Play audio on Windows Playback volume + Use absolute Bluetooth mouse in the preview (no F9) Bluetooth mouse sensitivity Reverse control settings Wheel sensitivity diff --git a/src/App/Localization/Strings.ja-JP.xaml b/src/App/Localization/Strings.ja-JP.xaml index 21ffc99c..9e9cd0a0 100644 --- a/src/App/Localization/Strings.ja-JP.xaml +++ b/src/App/Localization/Strings.ja-JP.xaml @@ -233,6 +233,7 @@ 映像設定を適用 Windowsで音声を再生 再生音量 + プレビュー内で絶対座標マウスを使用(F9 不要) Bluetoothマウスの感度 リモート操作の設定 ホイールの感度 diff --git a/src/App/Localization/Strings.zh-CN.xaml b/src/App/Localization/Strings.zh-CN.xaml index e99e71be..a6cc037a 100644 --- a/src/App/Localization/Strings.zh-CN.xaml +++ b/src/App/Localization/Strings.zh-CN.xaml @@ -233,6 +233,7 @@ 应用画面设置 在 Windows 播放声音 播放音量 + 在投屏画面内使用绝对坐标鼠标(无需 F9) 蓝牙鼠标灵敏度 反向控制设置 滚轮灵敏度 diff --git a/src/App/Localization/Strings.zh-HK.xaml b/src/App/Localization/Strings.zh-HK.xaml index bd5f7eb2..6f73ab03 100644 --- a/src/App/Localization/Strings.zh-HK.xaml +++ b/src/App/Localization/Strings.zh-HK.xaml @@ -233,6 +233,7 @@ 套用畫面設定 在 Windows 播放音訊 播放音量 + 在投影畫面內使用絕對座標滑鼠(無需 F9) 藍牙滑鼠靈敏度 反向控制設定 滾輪靈敏度 diff --git a/src/App/Localization/Strings.zh-TW.xaml b/src/App/Localization/Strings.zh-TW.xaml index 31fa1b77..eceff362 100644 --- a/src/App/Localization/Strings.zh-TW.xaml +++ b/src/App/Localization/Strings.zh-TW.xaml @@ -233,6 +233,7 @@ 套用畫面設定 在 Windows 播放音訊 播放音量 + 在投影畫面內使用絕對座標滑鼠(無需 F9) 藍牙滑鼠靈敏度 反向控制設定 滾輪靈敏度 diff --git a/src/App/MainWindow.KeyboardFocus.cs b/src/App/MainWindow.KeyboardFocus.cs index 7f43a5d2..b9432486 100644 --- a/src/App/MainWindow.KeyboardFocus.cs +++ b/src/App/MainWindow.KeyboardFocus.cs @@ -155,6 +155,7 @@ private void OnMainKeyboardDeactivated(object? sender, EventArgs e) if (_activeControlWindow == 0) { _keyboardFocusSuspended = true; + ResetWindowBluetoothPointer(_viewModel.SelectedDevice?.Udid); HandleControlKeyboardInput(new PreviewKeyboardEventArgs( PreviewKeyboardKind.Reset, 0), _viewModel.SelectedDevice?.Udid); _ = HandleUsbPointerInputAsync(new PreviewPointerEventArgs( @@ -172,6 +173,7 @@ private void OnIndependentKeyboardFocusChanged(string udid, nint hwnd, bool acti if (_activeControlWindow != hwnd || !DeviceViewModel.UdidEquals(_activeControlUdid, udid)) return; _keyboardFocusSuspended = true; + ResetWindowBluetoothPointer(udid); _mouseShortcutButtons.Clear(); _viewModel.SetControlInputDevice(null); UnregisterDeviceHotkeys(); diff --git a/src/App/MainWindow.KeyboardInputMode.cs b/src/App/MainWindow.KeyboardInputMode.cs index ebef07e6..e9fc0934 100644 --- a/src/App/MainWindow.KeyboardInputMode.cs +++ b/src/App/MainWindow.KeyboardInputMode.cs @@ -99,9 +99,9 @@ private async Task FinishKeyboardHandoffAsync(Task previous, Task pending, Task finally { _systemShortcutGate.Release(); } if (!_keyboardRouter.CompleteHandoff(generation)) return; if (!_mappingClosing) - // Mouse INPUTSINK belongs to the active Bluetooth route, - // including independent previews, regardless of keyboard owner. - RegisterRawInput(IsBluetoothControlActive, + // Only relative capture owns Raw Input. Absolute window + // control still needs normal Win32 mouse messages after handoff. + RegisterRawInput(IsBluetoothMouseCaptured, _keyboardRouter.Mode == KeyboardInputMode.Direct && (IsBluetoothControlActive || IsUsbControlActive) && _activeControlWindow == 0); PublishKeyboardInputMode(_keyboardRouter.Mode); diff --git a/src/App/MainWindow.xaml b/src/App/MainWindow.xaml index 161cb73c..c3d93663 100644 --- a/src/App/MainWindow.xaml +++ b/src/App/MainWindow.xaml @@ -1867,6 +1867,10 @@ +