From 855c15b66a2446b5eddad97e54b2648e8b9860e5 Mon Sep 17 00:00:00 2001 From: Jeff Miller Date: Fri, 3 Apr 2026 16:34:23 -0500 Subject: [PATCH 1/2] Added Mouse Override Delay feature Increased MouseMovementSpeed max to 20 --- .../TrackIRUiLogicTests.cs | 4 +- .../OpenTrackIR.WinUI/MainWindow.xaml.cs | 3 + .../Models/TrackIRControlState.cs | 4 +- .../Models/TrackIRMouseRuntimeLogic.cs | 2 +- .../Models/TrackIRUiLogic.cs | 14 +- .../Runtime/NativeTrackIRRuntimeController.cs | 31 ++++- .../Runtime/UserMouseMonitor.cs | 125 ++++++++++++++++++ .../ViewModels/MainShellViewModel.cs | 31 +++++ .../Views/MainShellView.xaml | 12 +- 9 files changed, 209 insertions(+), 17 deletions(-) create mode 100644 win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/UserMouseMonitor.cs diff --git a/win/OpenTrackIR.WinUI.Tests/TrackIRUiLogicTests.cs b/win/OpenTrackIR.WinUI.Tests/TrackIRUiLogicTests.cs index 6d7044e..26b9c2a 100644 --- a/win/OpenTrackIR.WinUI.Tests/TrackIRUiLogicTests.cs +++ b/win/OpenTrackIR.WinUI.Tests/TrackIRUiLogicTests.cs @@ -48,7 +48,9 @@ public void Normalize_clamps_and_fills_hotkey_defaults() IsVideoFlipVerticalEnabled: false, VideoRotationDegrees: -90.0, VideoFramesPerSecond: 999, - MouseToggleHotkeyText: " " + MouseToggleHotkeyText: " ", + MouseOverrideDelayMilliseconds: 500, + IsMouseButtonOverrideEnabled: true ); TrackIRControlState normalized = TrackIRUiLogic.Normalize(state); diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/MainWindow.xaml.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/MainWindow.xaml.cs index 224adb5..8ad615d 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/MainWindow.xaml.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/MainWindow.xaml.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Runtime.InteropServices; using OpenTrackIR.WinUI.Models; +using OpenTrackIR.WinUI.Runtime; using WinRT.Interop; namespace OpenTrackIR.WinUI @@ -55,6 +56,7 @@ public MainWindow() AppWindow.Closing += OnAppWindowClosing; RootView.ViewModel.PropertyChanged += OnRootViewModelPropertyChanged; UpdateMouseToggleHotkeyRegistration(); + UserMouseMonitor.Install(); _trayService.Initialize(ShowWindowFromTray, ExitApplication); UpdateRuntimePresentationState(); } @@ -231,6 +233,7 @@ private void DisposeWindowResources() _isDisposed = true; RootView.ViewModel.PropertyChanged -= OnRootViewModelPropertyChanged; UnregisterMouseToggleHotkey(); + UserMouseMonitor.Uninstall(); SetWindowLongPtr(_windowHandle, WindowLongWindowProcedure, _previousWindowProcedure); RootView.Dispose(); _trayService.Dispose(); diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRControlState.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRControlState.cs index f6fde27..9d6630f 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRControlState.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRControlState.cs @@ -19,6 +19,8 @@ public sealed record TrackIRControlState( bool IsVideoFlipVerticalEnabled, double VideoRotationDegrees, double VideoFramesPerSecond, - string MouseToggleHotkeyText + string MouseToggleHotkeyText, + int MouseOverrideDelayMilliseconds, + bool IsMouseButtonOverrideEnabled ); } diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRMouseRuntimeLogic.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRMouseRuntimeLogic.cs index 6b249e9..99c639f 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRMouseRuntimeLogic.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRMouseRuntimeLogic.cs @@ -29,7 +29,7 @@ public static double EffectiveMouseSpeed( public static double MouseBackendSpeed(double controlSpeed) { - return Math.Clamp(controlSpeed, 0.1, 5.0) * 10.0; + return Math.Clamp(controlSpeed, 0.1, 20.0) * 10.0; } public static bool ShouldFireKeepAwake( diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRUiLogic.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRUiLogic.cs index f12463c..a13bcd7 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRUiLogic.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRUiLogic.cs @@ -29,7 +29,9 @@ public static TrackIRControlState CreateDefaultControlState() IsVideoFlipVerticalEnabled: false, VideoRotationDegrees: 0.0, VideoFramesPerSecond: 60.0, - MouseToggleHotkeyText: "Shift+F7" + MouseToggleHotkeyText: "Shift+F7", + MouseOverrideDelayMilliseconds: 500, + IsMouseButtonOverrideEnabled: true ); } @@ -41,7 +43,7 @@ public static TrackIRControlState Normalize(TrackIRControlState state) return state with { - MouseMovementSpeed = Math.Clamp(state.MouseMovementSpeed, 0.1, 5.0), + MouseMovementSpeed = Math.Clamp(state.MouseMovementSpeed, 0.1, 20.0), MouseSmoothing = Math.Clamp(state.MouseSmoothing, 1, 10), MouseDeadzone = Math.Clamp(state.MouseDeadzone, 0.0, 1.0), MouseJumpThresholdPixels = Math.Max(state.MouseJumpThresholdPixels, 1), @@ -50,7 +52,8 @@ public static TrackIRControlState Normalize(TrackIRControlState state) TimeoutSeconds = Math.Max(state.TimeoutSeconds, 1), VideoRotationDegrees = NormalizeRotationDegrees(state.VideoRotationDegrees), VideoFramesPerSecond = Math.Clamp(state.VideoFramesPerSecond, 0.0, 125.0), - MouseToggleHotkeyText = hotkeyText + MouseToggleHotkeyText = hotkeyText, + MouseOverrideDelayMilliseconds = Math.Clamp(state.MouseOverrideDelayMilliseconds, 0, 5000) }; } @@ -183,6 +186,11 @@ public static string MouseDeadzoneValueLabel(double deadzone) return deadzone.ToString("0.00", CultureInfo.InvariantCulture); } + public static string MouseOverrideDelayValueLabel(int milliseconds) + { + return milliseconds <= 0 ? "Disabled" : $"{milliseconds} ms"; + } + public static string VideoRotationValueLabel(double rotationDegrees) { return Math.Round(NormalizeRotationDegrees(rotationDegrees)).ToString("0", CultureInfo.InvariantCulture) + "\u00B0"; diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/NativeTrackIRRuntimeController.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/NativeTrackIRRuntimeController.cs index 66f040a..ff2b9d1 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/NativeTrackIRRuntimeController.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/NativeTrackIRRuntimeController.cs @@ -20,6 +20,7 @@ public sealed class NativeTrackIRRuntimeController : ITrackIRRuntimeController, private bool _nativeRuntimeUnavailable; private readonly WindowsMouseBridge _mouseBridge = new(); private readonly XKeysFootPedalMonitor _xKeysFootPedalMonitor = new(); + private bool _wasUserMouseOverrideActive; private DateTimeOffset _lastMouseMovementTime = DateTimeOffset.UtcNow; private DateTimeOffset? _lastTelemetryPublishTime; private TrackIRSnapshot? _lastPublishedTelemetrySnapshot; @@ -418,14 +419,32 @@ out TrackIRNativeMethods.NativeTrackIRSessionSnapshot nativeSnapshot _lastTelemetryPublishTime = now; } - didMoveMouse = _mouseBridge.TryApplyTrackingDelta( - nativeSnapshot.HasCentroid != 0, - nativeSnapshot.CentroidX, - nativeSnapshot.CentroidY, - controlState, - effectiveMouseSpeed + bool isUserMouseOverrideActive = UserMouseMonitor.IsUserMouseOverrideActive( + controlState.MouseOverrideDelayMilliseconds, + controlState.IsMouseButtonOverrideEnabled ); + if (isUserMouseOverrideActive) + { + _wasUserMouseOverrideActive = true; + } + else + { + if (_wasUserMouseOverrideActive) + { + _wasUserMouseOverrideActive = false; + _mouseBridge.Reset(); + } + + didMoveMouse = _mouseBridge.TryApplyTrackingDelta( + nativeSnapshot.HasCentroid != 0, + nativeSnapshot.CentroidX, + nativeSnapshot.CentroidY, + controlState, + effectiveMouseSpeed + ); + } + bool shouldPublishPreview = TrackIRRuntimeLogic.ShouldPublishPreview( controlState, presentationState, diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/UserMouseMonitor.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/UserMouseMonitor.cs new file mode 100644 index 0000000..c33094c --- /dev/null +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/UserMouseMonitor.cs @@ -0,0 +1,125 @@ +using System.Runtime.InteropServices; + +namespace OpenTrackIR.WinUI.Runtime +{ + internal static class UserMouseMonitor + { + private const int WhMouseLl = 14; + private const uint WmMouseMove = 0x0200; + private const uint WmLButtonDown = 0x0201; + private const uint WmLButtonUp = 0x0202; + private const uint WmRButtonDown = 0x0204; + private const uint WmRButtonUp = 0x0205; + private const uint WmMButtonDown = 0x0207; + private const uint WmMButtonUp = 0x0208; + private const uint WmMouseWheel = 0x020A; + private const uint LlmhfInjected = 0x00000001; + + private static readonly LowLevelMouseProc HookCallback = LowLevelMouseProcCallback; + private static nint _hookHandle; + private static long _lastUserMouseMoveTick; + private static long _isAnyMouseButtonPressed; + + public static void Install() + { + if (_hookHandle != 0) + { + return; + } + + _hookHandle = SetWindowsHookEx(WhMouseLl, HookCallback, 0, 0); + } + + public static void Uninstall() + { + if (_hookHandle == 0) + { + return; + } + + UnhookWindowsHookEx(_hookHandle); + _hookHandle = 0; + _isAnyMouseButtonPressed = 0; + } + + public static bool IsUserMouseOverrideActive(int delayMilliseconds, bool mouseButtonOverrideEnabled) + { + // If any button is pressed and button override is enabled, override TrackIR + if (mouseButtonOverrideEnabled && Interlocked.Read(ref _isAnyMouseButtonPressed) != 0) + { + return true; + } + + if (delayMilliseconds <= 0) + { + return false; + } + + long lastTick = Interlocked.Read(ref _lastUserMouseMoveTick); + if (lastTick == 0) + { + return false; + } + + return Environment.TickCount64 - lastTick < delayMilliseconds; + } + + private static nint LowLevelMouseProcCallback(int nCode, nint wParam, nint lParam) + { + if (nCode >= 0) + { + uint message = (uint)wParam; + + // Track button press/release + switch (message) + { + case WmLButtonDown: + case WmRButtonDown: + case WmMButtonDown: + Interlocked.Exchange(ref _isAnyMouseButtonPressed, 1); + break; + + case WmLButtonUp: + case WmRButtonUp: + case WmMButtonUp: + Interlocked.Exchange(ref _isAnyMouseButtonPressed, 0); + break; + + case WmMouseWheel: + case WmMouseMove: + MsllHookStruct hookStruct = Marshal.PtrToStructure(lParam); + if ((hookStruct.Flags & LlmhfInjected) == 0) + { + Interlocked.Exchange(ref _lastUserMouseMoveTick, Environment.TickCount64); + } + break; + } + } + + return CallNextHookEx(_hookHandle, nCode, wParam, lParam); + } + + private delegate nint LowLevelMouseProc(int nCode, nint wParam, nint lParam); + + [StructLayout(LayoutKind.Sequential)] + private struct MsllHookStruct + { + public int X; + public int Y; + public uint MouseData; + public uint Flags; + public uint Time; + public nuint ExtraInfo; + } + + [DllImport("user32.dll", EntryPoint = "SetWindowsHookExW", SetLastError = true)] + private static extern nint SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, nint hMod, uint dwThreadId); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UnhookWindowsHookEx(nint hhk); + + [DllImport("user32.dll")] + private static extern nint CallNextHookEx(nint hhk, int nCode, nint wParam, nint lParam); + } +} diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/ViewModels/MainShellViewModel.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/ViewModels/MainShellViewModel.cs index 2324fa3..7206448 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/ViewModels/MainShellViewModel.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/ViewModels/MainShellViewModel.cs @@ -224,6 +224,18 @@ public string MouseToggleHotkeyText set => UpdateControlState(_controlState with { MouseToggleHotkeyText = value }); } + public double MouseOverrideDelayMilliseconds + { + get => _controlState.MouseOverrideDelayMilliseconds; + set => UpdateControlState(_controlState with { MouseOverrideDelayMilliseconds = (int)Math.Round(value) }); + } + + public bool IsMouseButtonOverrideEnabled + { + get => _controlState.IsMouseButtonOverrideEnabled; + set => UpdateControlState(_controlState with { IsMouseButtonOverrideEnabled = value }); + } + public string TrackIRStatusText => IsTrackIREnabled ? "TrackIR On" : "TrackIR Off"; public string TrackIRStatusValue => TrackIRUiLogic.ToggleStateLabel(IsTrackIREnabled, "On", "Off"); @@ -264,6 +276,10 @@ public string MouseToggleHotkeyText public string MouseDeadzoneLabel => TrackIRUiLogic.MouseDeadzoneValueLabel(MouseDeadzone); + public string MouseOverrideDelayLabel => TrackIRUiLogic.MouseOverrideDelayValueLabel(_controlState.MouseOverrideDelayMilliseconds); + + public string MouseOverrideDelayDescription => "Pause head tracking when you move the mouse. Tracking resumes after this delay."; + public string VideoRotationLabel => TrackIRUiLogic.VideoRotationValueLabel(VideoRotationDegrees); public string PacketTypeLabel => TrackIRUiLogic.PacketTypeLabel(_snapshot.PacketType); @@ -559,6 +575,16 @@ private void OnControlStateChanged(TrackIRControlState previousControlState) _controlState.MouseToggleHotkeyText, nameof(MouseToggleHotkeyText) ); + NotifyIfChanged( + previousControlState.MouseOverrideDelayMilliseconds, + _controlState.MouseOverrideDelayMilliseconds, + nameof(MouseOverrideDelayMilliseconds) + ); + NotifyIfChanged( + previousControlState.IsMouseButtonOverrideEnabled, + _controlState.IsMouseButtonOverrideEnabled, + nameof(IsMouseButtonOverrideEnabled) + ); if (previousControlState.IsTrackIREnabled != _controlState.IsTrackIREnabled) { @@ -631,6 +657,11 @@ private void OnControlStateChanged(TrackIRControlState previousControlState) OnPropertyChanged(nameof(TrackIRFramesPerSecondLabel)); OnPropertyChanged(nameof(FramesPerSecondSummary)); } + + if (previousControlState.MouseOverrideDelayMilliseconds != _controlState.MouseOverrideDelayMilliseconds) + { + OnPropertyChanged(nameof(MouseOverrideDelayLabel)); + } } private void OnSnapshotChanged(TrackIRSnapshot previousSnapshot) diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/MainShellView.xaml b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/MainShellView.xaml index a5525c7..0c9c46b 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/MainShellView.xaml +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/MainShellView.xaml @@ -142,14 +142,16 @@ - + - - - - + + + + + + From afe52011a6bd38310892923a1255d4c6b71455ee Mon Sep 17 00:00:00 2001 From: Jeff Miller Date: Sat, 4 Apr 2026 13:26:06 -0500 Subject: [PATCH 2/2] Absolute cursor positioning mode with recenter calibration --- .../TrackIRUiLogicTests.cs | 37 +++++ .../OpenTrackIR.WinUI/MainWindow.xaml.cs | 149 +++++++++++++++++- .../Models/TrackIRControlState.cs | 1 + .../Models/TrackIRMouseRuntimeLogic.cs | 38 +++++ .../Models/TrackIRUiLogic.cs | 6 + .../Runtime/ITrackIRRuntimeController.cs | 2 + .../Runtime/MockTrackIRRuntimeController.cs | 4 + .../Runtime/NativeTrackIRRuntimeController.cs | 10 ++ .../Runtime/WindowsMouseBridge.cs | 61 ++++++- .../ViewModels/MainShellViewModel.cs | 21 +++ .../Views/MainShellView.xaml | 4 +- .../Views/MainShellView.xaml.cs | 32 ++-- .../Views/RecenterOverlayWindow.xaml | 36 +++++ .../Views/RecenterOverlayWindow.xaml.cs | 49 ++++++ 14 files changed, 433 insertions(+), 17 deletions(-) create mode 100644 win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/RecenterOverlayWindow.xaml create mode 100644 win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/RecenterOverlayWindow.xaml.cs diff --git a/win/OpenTrackIR.WinUI.Tests/TrackIRUiLogicTests.cs b/win/OpenTrackIR.WinUI.Tests/TrackIRUiLogicTests.cs index 26b9c2a..3a3697b 100644 --- a/win/OpenTrackIR.WinUI.Tests/TrackIRUiLogicTests.cs +++ b/win/OpenTrackIR.WinUI.Tests/TrackIRUiLogicTests.cs @@ -49,6 +49,7 @@ public void Normalize_clamps_and_fills_hotkey_defaults() VideoRotationDegrees: -90.0, VideoFramesPerSecond: 999, MouseToggleHotkeyText: " ", + RecenterHotkeyText: " ", MouseOverrideDelayMilliseconds: 500, IsMouseButtonOverrideEnabled: true ); @@ -65,6 +66,8 @@ public void Normalize_clamps_and_fills_hotkey_defaults() Assert.Equal(270.0, normalized.VideoRotationDegrees); Assert.Equal(125.0, normalized.VideoFramesPerSecond); Assert.Equal("Shift+F7", normalized.MouseToggleHotkeyText); + Assert.Equal("Ctrl+Shift+8", normalized.RecenterHotkeyText); + Assert.Equal(500, normalized.MouseOverrideDelayMilliseconds); } [Fact] @@ -381,6 +384,40 @@ public void TrackIRMouseRuntimeLogic_computes_backend_speed_keep_awake_and_subpi Assert.Equal(0, dispatch.DeltaY); Assert.Equal(0.75, dispatch.RemainingX, 10); Assert.Equal(-0.4, dispatch.RemainingY, 10); + + AbsoluteCenterCalibration calibration = new( + CentroidX: 320.0, + CentroidY: 240.0, + CursorAnchorX: 960, + CursorAnchorY: 540 + ); + + // At center: cursor stays at anchor + AbsoluteCursorTarget absoluteCenter = + TrackIRMouseRuntimeLogic.AbsoluteCursorTargetForCentroid( + 320.0, 240.0, calibration, 2.0, + scaleX: 1.0, scaleY: 1.0, rotationDegrees: 0.0 + ); + Assert.Equal(960, absoluteCenter.X); + Assert.Equal(540, absoluteCenter.Y); + + // Blob moves right (+X) in camera = head moved left = cursor moves left (-X) + AbsoluteCursorTarget absoluteOffset = + TrackIRMouseRuntimeLogic.AbsoluteCursorTargetForCentroid( + 330.0, 245.0, calibration, 2.0, + scaleX: 1.0, scaleY: 1.0, rotationDegrees: 0.0 + ); + Assert.Equal(960 - 200, absoluteOffset.X); + Assert.Equal(540 + 100, absoluteOffset.Y); + + // Horizontal flip inverts the mirror compensation + AbsoluteCursorTarget absoluteFlippedH = + TrackIRMouseRuntimeLogic.AbsoluteCursorTargetForCentroid( + 330.0, 245.0, calibration, 2.0, + scaleX: -1.0, scaleY: 1.0, rotationDegrees: 0.0 + ); + Assert.Equal(960 + 200, absoluteFlippedH.X); + Assert.Equal(540 + 100, absoluteFlippedH.Y); } [Fact] diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/MainWindow.xaml.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/MainWindow.xaml.cs index 8ad615d..c53b16d 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/MainWindow.xaml.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/MainWindow.xaml.cs @@ -1,12 +1,13 @@ -using Microsoft.UI.Windowing; using Microsoft.UI.Dispatching; +using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; +using OpenTrackIR.WinUI.Models; +using OpenTrackIR.WinUI.Runtime; using OpenTrackIR.WinUI.Services; -using System.IO; +using OpenTrackIR.WinUI.Views; using System.ComponentModel; +using System.IO; using System.Runtime.InteropServices; -using OpenTrackIR.WinUI.Models; -using OpenTrackIR.WinUI.Runtime; using WinRT.Interop; namespace OpenTrackIR.WinUI @@ -14,6 +15,7 @@ namespace OpenTrackIR.WinUI public sealed partial class MainWindow : Window { private const int GlobalMouseToggleHotkeyId = 0x4F544952; + private const int GlobalRecenterHotkeyId = 0x4F544953; private const uint WindowMessageHotkey = 0x0312; private const uint WindowMessageActivate = 0x0006; private const uint WindowMessageSize = 0x0005; @@ -36,6 +38,13 @@ public sealed partial class MainWindow : Window private bool _isExitRequested; private bool _isDisposed; private RegisteredHotkey? _registeredMouseToggleHotkey; + private RegisteredHotkey? _registeredRecenterHotkey; + private RecenterOverlayWindow? _recenterOverlay; + private DispatcherTimer? _recenterReleaseTimer; + private int _recenterScreenCenterX; + private int _recenterScreenCenterY; + private int _recenterVirtualKey; + private bool _wasMouseMovementEnabledBeforeRecenter; private delegate nint WindowProcedure(nint windowHandle, uint message, nint wParam, nint lParam); @@ -56,6 +65,7 @@ public MainWindow() AppWindow.Closing += OnAppWindowClosing; RootView.ViewModel.PropertyChanged += OnRootViewModelPropertyChanged; UpdateMouseToggleHotkeyRegistration(); + UpdateRecenterHotkeyRegistration(); UserMouseMonitor.Install(); _trayService.Initialize(ShowWindowFromTray, ExitApplication); UpdateRuntimePresentationState(); @@ -126,6 +136,11 @@ private void OnRootViewModelPropertyChanged(object? sender, PropertyChangedEvent { UpdateMouseToggleHotkeyRegistration(); } + + if (e.PropertyName == nameof(RootView.ViewModel.RecenterHotkeyText)) + { + UpdateRecenterHotkeyRegistration(); + } } private void UpdateMouseToggleHotkeyRegistration() @@ -187,6 +202,12 @@ private nint WindowProcedureHook(nint windowHandle, uint message, nint wParam, n return 0; } + if (message == WindowMessageHotkey && wParam == GlobalRecenterHotkeyId) + { + BeginRecenterHold(); + return 0; + } + if (message == WindowMessageActivate || message == WindowMessageShowWindow || (message == WindowMessageSize && wParam == SizeMinimized)) @@ -222,6 +243,114 @@ Content is not null ); } + private void UpdateRecenterHotkeyRegistration() + { + RegisteredHotkey? previousHotkey = _registeredRecenterHotkey; + if (!HotkeyCaptureLogic.TryParseHotkeyText( + RootView.ViewModel.RecenterHotkeyText, + out RegisteredHotkey nextHotkey)) + { + UnregisterRecenterHotkey(); + _registeredRecenterHotkey = null; + return; + } + + if (previousHotkey.HasValue && previousHotkey.Value.Equals(nextHotkey)) + { + return; + } + + UnregisterRecenterHotkey(); + if (TryRegisterRecenterHotkey(nextHotkey)) + { + _registeredRecenterHotkey = nextHotkey; + return; + } + + if (previousHotkey.HasValue && TryRegisterRecenterHotkey(previousHotkey.Value)) + { + _registeredRecenterHotkey = previousHotkey.Value; + return; + } + + _registeredRecenterHotkey = null; + } + + private bool TryRegisterRecenterHotkey(RegisteredHotkey hotkey) + { + return RegisterHotKey( + _windowHandle, + GlobalRecenterHotkeyId, + HotkeyModifierFlags(hotkey), + (uint)hotkey.VirtualKeyCode + ); + } + + private void UnregisterRecenterHotkey() + { + if (_registeredRecenterHotkey.HasValue) + { + UnregisterHotKey(_windowHandle, GlobalRecenterHotkeyId); + } + } + + private void BeginRecenterHold() + { + if (_recenterOverlay is not null || !_registeredRecenterHotkey.HasValue) + { + return; + } + + _recenterVirtualKey = _registeredRecenterHotkey.Value.VirtualKeyCode; + _recenterScreenCenterX = (GetSystemMetrics(0) / 2) + 36; + _recenterScreenCenterY = GetSystemMetrics(1) / 2; + + _wasMouseMovementEnabledBeforeRecenter = RootView.ViewModel.IsMouseMovementEnabled; + RootView.ViewModel.IsMouseMovementEnabled = false; + + SetCursorPos(_recenterScreenCenterX, _recenterScreenCenterY); + + _recenterOverlay = new RecenterOverlayWindow(); + _recenterOverlay.Activate(); + + _recenterReleaseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(30) }; + _recenterReleaseTimer.Tick += OnRecenterReleaseTimerTick; + _recenterReleaseTimer.Start(); + } + + private void OnRecenterReleaseTimerTick(object? sender, object e) + { + // Hold cursor at screen center while key is pressed + SetCursorPos(_recenterScreenCenterX, _recenterScreenCenterY); + + if ((GetAsyncKeyState(_recenterVirtualKey) & 0x8000) != 0) + { + return; + } + + CommitRecenter(); + } + + private void CommitRecenter() + { + _recenterReleaseTimer?.Stop(); + _recenterReleaseTimer = null; + + // Clear calibration so the next poll frame auto-calibrates with the live centroid + RootView.ViewModel.RecenterCursorCommand.Execute(null); + + // Pin cursor to screen center so auto-calibration anchors here + SetCursorPos(_recenterScreenCenterX, _recenterScreenCenterY); + + if (_wasMouseMovementEnabledBeforeRecenter) + { + RootView.ViewModel.IsMouseMovementEnabled = true; + } + + _recenterOverlay?.Close(); + _recenterOverlay = null; + } + private void DisposeWindowResources() { if (_isDisposed) @@ -233,6 +362,8 @@ private void DisposeWindowResources() _isDisposed = true; RootView.ViewModel.PropertyChanged -= OnRootViewModelPropertyChanged; UnregisterMouseToggleHotkey(); + UnregisterRecenterHotkey(); + CommitRecenter(); UserMouseMonitor.Uninstall(); SetWindowLongPtr(_windowHandle, WindowLongWindowProcedure, _previousWindowProcedure); RootView.Dispose(); @@ -295,5 +426,15 @@ nint lParam [DllImport("user32.dll")] private static extern nint GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern short GetAsyncKeyState(int virtualKeyCode); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetCursorPos(int x, int y); + + [DllImport("user32.dll")] + private static extern int GetSystemMetrics(int index); } } diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRControlState.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRControlState.cs index 9d6630f..94735dc 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRControlState.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRControlState.cs @@ -20,6 +20,7 @@ public sealed record TrackIRControlState( double VideoRotationDegrees, double VideoFramesPerSecond, string MouseToggleHotkeyText, + string RecenterHotkeyText, int MouseOverrideDelayMilliseconds, bool IsMouseButtonOverrideEnabled ); diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRMouseRuntimeLogic.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRMouseRuntimeLogic.cs index 99c639f..f001879 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRMouseRuntimeLogic.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRMouseRuntimeLogic.cs @@ -2,6 +2,12 @@ namespace OpenTrackIR.WinUI.Models { public readonly record struct KeepAwakeNudge(int DeltaX, int DeltaY); public readonly record struct AbsoluteCursorTarget(int X, int Y); + public readonly record struct AbsoluteCenterCalibration( + double CentroidX, + double CentroidY, + int CursorAnchorX, + int CursorAnchorY + ); public readonly record struct RelativeMouseDispatch( int DeltaX, @@ -79,5 +85,37 @@ int deltaY Y: currentCursorY + deltaY ); } + + public static AbsoluteCursorTarget AbsoluteCursorTargetForCentroid( + double centroidX, + double centroidY, + AbsoluteCenterCalibration calibration, + double effectiveMouseSpeed, + double scaleX, + double scaleY, + double rotationDegrees + ) + { + double speed = Math.Clamp(effectiveMouseSpeed, 0.1, 20.0) * 10.0; + // Negate X to compensate for camera mirror (head left = blob right) + double rawOffsetX = -(centroidX - calibration.CentroidX); + double rawOffsetY = centroidY - calibration.CentroidY; + + // Apply flip transforms + rawOffsetX *= scaleX; + rawOffsetY *= scaleY; + + // Apply rotation + double radians = rotationDegrees * Math.PI / 180.0; + double cos = Math.Cos(radians); + double sin = Math.Sin(radians); + double rotatedX = rawOffsetX * cos - rawOffsetY * sin; + double rotatedY = rawOffsetX * sin + rawOffsetY * cos; + + return new AbsoluteCursorTarget( + X: calibration.CursorAnchorX + (int)Math.Round(rotatedX * speed), + Y: calibration.CursorAnchorY + (int)Math.Round(rotatedY * speed) + ); + } } } diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRUiLogic.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRUiLogic.cs index a13bcd7..8703478 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRUiLogic.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRUiLogic.cs @@ -30,6 +30,7 @@ public static TrackIRControlState CreateDefaultControlState() VideoRotationDegrees: 0.0, VideoFramesPerSecond: 60.0, MouseToggleHotkeyText: "Shift+F7", + RecenterHotkeyText: "Ctrl+Shift+8", MouseOverrideDelayMilliseconds: 500, IsMouseButtonOverrideEnabled: true ); @@ -41,6 +42,10 @@ public static TrackIRControlState Normalize(TrackIRControlState state) ? "Shift+F7" : state.MouseToggleHotkeyText.Trim(); + string recenterHotkeyText = string.IsNullOrWhiteSpace(state.RecenterHotkeyText) + ? "Ctrl+Shift+8" + : state.RecenterHotkeyText.Trim(); + return state with { MouseMovementSpeed = Math.Clamp(state.MouseMovementSpeed, 0.1, 20.0), @@ -53,6 +58,7 @@ public static TrackIRControlState Normalize(TrackIRControlState state) VideoRotationDegrees = NormalizeRotationDegrees(state.VideoRotationDegrees), VideoFramesPerSecond = Math.Clamp(state.VideoFramesPerSecond, 0.0, 125.0), MouseToggleHotkeyText = hotkeyText, + RecenterHotkeyText = recenterHotkeyText, MouseOverrideDelayMilliseconds = Math.Clamp(state.MouseOverrideDelayMilliseconds, 0, 5000) }; } diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/ITrackIRRuntimeController.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/ITrackIRRuntimeController.cs index 20695f0..d57780e 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/ITrackIRRuntimeController.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/ITrackIRRuntimeController.cs @@ -16,6 +16,8 @@ public interface ITrackIRRuntimeController void UpdatePresentationState(TrackIRPresentationState presentationState); + void RecenterCursor(); + void Refresh(); void Stop(); diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/MockTrackIRRuntimeController.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/MockTrackIRRuntimeController.cs index c3e876f..acd53bd 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/MockTrackIRRuntimeController.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/MockTrackIRRuntimeController.cs @@ -34,6 +34,10 @@ public void UpdatePresentationState(TrackIRPresentationState presentationState) PublishNextSnapshot(); } + public void RecenterCursor() + { + } + public void Refresh() { PublishNextSnapshot(); diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/NativeTrackIRRuntimeController.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/NativeTrackIRRuntimeController.cs index ff2b9d1..a76f9d7 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/NativeTrackIRRuntimeController.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/NativeTrackIRRuntimeController.cs @@ -69,6 +69,16 @@ public void UpdatePresentationState(TrackIRPresentationState presentationState) } } + public void RecenterCursor() + { + if (_isStopped) + { + return; + } + + _mouseBridge.ClearAbsoluteCalibration(); + } + public void Refresh() { if (_isStopped) diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/WindowsMouseBridge.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/WindowsMouseBridge.cs index ecbdc70..33807cb 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/WindowsMouseBridge.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Runtime/WindowsMouseBridge.cs @@ -13,6 +13,7 @@ internal sealed class WindowsMouseBridge private readonly Input[] _sendInputBuffer = new Input[1]; private double _pendingDeltaX; private double _pendingDeltaY; + private AbsoluteCenterCalibration? _absoluteCalibration; public WindowsMouseBridge() { @@ -25,6 +26,22 @@ public void Reset() TrackIRNativeMethods.TrackIRMouseTrackerReset(ref _trackerState); _pendingDeltaX = 0.0; _pendingDeltaY = 0.0; + _absoluteCalibration = null; + } + + public void RecenterAbsolute(double centroidX, double centroidY) + { + if (!GetCursorPos(out Point currentCursorPosition)) + { + return; + } + + _absoluteCalibration = new AbsoluteCenterCalibration( + CentroidX: centroidX, + CentroidY: centroidY, + CursorAnchorX: currentCursorPosition.X, + CursorAnchorY: currentCursorPosition.Y + ); } public bool TryApplyTrackingDelta( @@ -35,6 +52,16 @@ public bool TryApplyTrackingDelta( double effectiveMouseSpeed ) { + if (controlState.IsWindowsAbsoluteMousePositioningEnabled && controlState.IsMouseMovementEnabled && hasCentroid) + { + return TryApplyAbsolutePosition( + centroidX, + centroidY, + effectiveMouseSpeed, + controlState + ); + } + TrackIRNativeMethods.NativeTrackIRMouseStep mouseStep = TrackIRNativeMethods.TrackIRMouseTrackerUpdate( ref _trackerState, @@ -66,7 +93,32 @@ double effectiveMouseSpeed return false; } - return TryMoveCursor(dispatch.DeltaX, dispatch.DeltaY, controlState); + return SendRelativeMouseInput(dispatch.DeltaX, dispatch.DeltaY); + } + + private bool TryApplyAbsolutePosition( + double centroidX, + double centroidY, + double effectiveMouseSpeed, + TrackIRControlState controlState + ) + { + if (_absoluteCalibration is not { } calibration) + { + RecenterAbsolute(centroidX, centroidY); + calibration = _absoluteCalibration!.Value; + } + + AbsoluteCursorTarget target = TrackIRMouseRuntimeLogic.AbsoluteCursorTargetForCentroid( + centroidX, + centroidY, + calibration, + effectiveMouseSpeed, + TrackIRUiLogic.PreviewAxisScale(controlState.IsVideoFlipHorizontalEnabled), + TrackIRUiLogic.PreviewAxisScale(controlState.IsVideoFlipVerticalEnabled), + TrackIRUiLogic.NormalizeRotationDegrees(controlState.VideoRotationDegrees) + ); + return SetCursorPos(target.X, target.Y); } public bool TryNudge(TrackIRControlState controlState) @@ -98,6 +150,13 @@ private bool TryMoveCursor(int deltaX, int deltaY, TrackIRControlState controlSt return SendRelativeMouseInput(deltaX, deltaY); } + public bool HasAbsoluteCalibration => _absoluteCalibration is not null; + + public void ClearAbsoluteCalibration() + { + _absoluteCalibration = null; + } + private static TrackIRNativeMethods.NativeTrackIRMouseTrackerState CreateTrackerState() { return new TrackIRNativeMethods.NativeTrackIRMouseTrackerState diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/ViewModels/MainShellViewModel.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/ViewModels/MainShellViewModel.cs index 7206448..c283e79 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/ViewModels/MainShellViewModel.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/ViewModels/MainShellViewModel.cs @@ -64,6 +64,7 @@ ITrayService trayService _previewImageSource = null; RefreshCommand = new RelayCommand(Refresh); + RecenterCursorCommand = new RelayCommand(RecenterCursor); _runtimeController.SnapshotChanged += OnRuntimeSnapshotChanged; _runtimeController.PreviewFrameChanged += OnRuntimePreviewFrameChanged; ApplyControlState(_controlState, persist: false); @@ -72,6 +73,8 @@ ITrayService trayService public ICommand RefreshCommand { get; } + public ICommand RecenterCursorCommand { get; } + public string Title => "OpenTrackIR"; public string Subtitle => "Windows TrackIR preview and controls"; @@ -82,6 +85,8 @@ ITrayService trayService public string MouseOutputModeDescription => "Use absolute cursor positioning to bypass Windows mouse acceleration. This takes more direct control of the pointer."; + public string RecenterCursorDescription => "Set the current head position as the center point for absolute cursor positioning. The cursor will return to this position when you look here again."; + public string HotkeyHelperText => "Click the field and press a shortcut. It stays active while OpenTrackIR is running, even when the window is hidden."; public string BlobDetectionDescription => "Filter tiny blobs and keep the previous-regularized centroid mode for steadier results."; @@ -224,6 +229,12 @@ public string MouseToggleHotkeyText set => UpdateControlState(_controlState with { MouseToggleHotkeyText = value }); } + public string RecenterHotkeyText + { + get => _controlState.RecenterHotkeyText; + set => UpdateControlState(_controlState with { RecenterHotkeyText = value }); + } + public double MouseOverrideDelayMilliseconds { get => _controlState.MouseOverrideDelayMilliseconds; @@ -375,6 +386,11 @@ private void Refresh() _runtimeController.Refresh(); } + private void RecenterCursor() + { + _runtimeController.RecenterCursor(); + } + private void UpdateControlState(TrackIRControlState controlState) { ApplyControlState(controlState, persist: true); @@ -575,6 +591,11 @@ private void OnControlStateChanged(TrackIRControlState previousControlState) _controlState.MouseToggleHotkeyText, nameof(MouseToggleHotkeyText) ); + NotifyIfChanged( + previousControlState.RecenterHotkeyText, + _controlState.RecenterHotkeyText, + nameof(RecenterHotkeyText) + ); NotifyIfChanged( previousControlState.MouseOverrideDelayMilliseconds, _controlState.MouseOverrideDelayMilliseconds, diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/MainShellView.xaml b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/MainShellView.xaml index 0c9c46b..90a155b 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/MainShellView.xaml +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/MainShellView.xaml @@ -150,7 +150,7 @@ - + @@ -182,5 +182,5 @@ - + diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/MainShellView.xaml.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/MainShellView.xaml.cs index 77ce571..5441944 100644 --- a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/MainShellView.xaml.cs +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/MainShellView.xaml.cs @@ -31,22 +31,40 @@ public void Dispose() } private void MouseToggleHotkeyCaptureBox_KeyDown(object sender, KeyRoutedEventArgs e) + { + string? hotkeyText = CaptureHotkeyFromKeyDown(e); + if (hotkeyText is not null) + { + ViewModel.MouseToggleHotkeyText = hotkeyText; + } + } + + private void RecenterHotkeyCaptureBox_KeyDown(object sender, KeyRoutedEventArgs e) + { + string? hotkeyText = CaptureHotkeyFromKeyDown(e); + if (hotkeyText is not null) + { + ViewModel.RecenterHotkeyText = hotkeyText; + } + } + + private static string? CaptureHotkeyFromKeyDown(KeyRoutedEventArgs e) { if (e.Key == VirtualKey.Tab) { - return; + return null; } if (e.Key == VirtualKey.Escape) { e.Handled = true; - return; + return null; } if (HotkeyCaptureLogic.IsModifierKey((int)e.Key)) { e.Handled = true; - return; + return null; } string? keyToken = HotkeyCaptureLogic.KeyTokenForVirtualKey((int)e.Key); @@ -58,14 +76,8 @@ private void MouseToggleHotkeyCaptureBox_KeyDown(object sender, KeyRoutedEventAr keyToken ); - if (hotkeyText is null) - { - e.Handled = true; - return; - } - - ViewModel.MouseToggleHotkeyText = hotkeyText; e.Handled = true; + return hotkeyText; } private static bool IsKeyDown(VirtualKey key) diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/RecenterOverlayWindow.xaml b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/RecenterOverlayWindow.xaml new file mode 100644 index 0000000..4edb307 --- /dev/null +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/RecenterOverlayWindow.xaml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + diff --git a/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/RecenterOverlayWindow.xaml.cs b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/RecenterOverlayWindow.xaml.cs new file mode 100644 index 0000000..34f53e6 --- /dev/null +++ b/win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Views/RecenterOverlayWindow.xaml.cs @@ -0,0 +1,49 @@ +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using System.Runtime.InteropServices; +using WinRT.Interop; + +namespace OpenTrackIR.WinUI.Views +{ + public sealed partial class RecenterOverlayWindow : Window + { + private const int OverlayWidth = 120; + private const int OverlayHeight = 140; + + public RecenterOverlayWindow() + { + InitializeComponent(); + ConfigureOverlay(); + } + + private void ConfigureOverlay() + { + AppWindow appWindow = AppWindow; + if (appWindow.Presenter is OverlappedPresenter presenter) + { + presenter.IsResizable = false; + presenter.IsMinimizable = false; + presenter.IsMaximizable = false; + presenter.SetBorderAndTitleBar(false, false); + presenter.IsAlwaysOnTop = true; + } + + GetMonitorCenter(out int centerX, out int centerY); + appWindow.MoveAndResize(new Windows.Graphics.RectInt32( + centerX - OverlayWidth / 2, + centerY - OverlayHeight / 2, + OverlayWidth, + OverlayHeight + )); + } + + private static void GetMonitorCenter(out int centerX, out int centerY) + { + centerX = GetSystemMetrics(0) / 2; + centerY = GetSystemMetrics(1) / 2; + } + + [DllImport("user32.dll")] + private static extern int GetSystemMetrics(int index); + } +}