Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion win/OpenTrackIR.WinUI.Tests/TrackIRUiLogicTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ public void Normalize_clamps_and_fills_hotkey_defaults()
IsVideoFlipVerticalEnabled: false,
VideoRotationDegrees: -90.0,
VideoFramesPerSecond: 999,
MouseToggleHotkeyText: " "
MouseToggleHotkeyText: " ",
RecenterHotkeyText: " ",
MouseOverrideDelayMilliseconds: 500,
IsMouseButtonOverrideEnabled: true
);

TrackIRControlState normalized = TrackIRUiLogic.Normalize(state);
Expand All @@ -63,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]
Expand Down Expand Up @@ -379,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]
Expand Down
150 changes: 147 additions & 3 deletions win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
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 WinRT.Interop;

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;
Expand All @@ -35,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);

Expand All @@ -55,6 +65,8 @@ public MainWindow()
AppWindow.Closing += OnAppWindowClosing;
RootView.ViewModel.PropertyChanged += OnRootViewModelPropertyChanged;
UpdateMouseToggleHotkeyRegistration();
UpdateRecenterHotkeyRegistration();
UserMouseMonitor.Install();
_trayService.Initialize(ShowWindowFromTray, ExitApplication);
UpdateRuntimePresentationState();
}
Expand Down Expand Up @@ -124,6 +136,11 @@ private void OnRootViewModelPropertyChanged(object? sender, PropertyChangedEvent
{
UpdateMouseToggleHotkeyRegistration();
}

if (e.PropertyName == nameof(RootView.ViewModel.RecenterHotkeyText))
{
UpdateRecenterHotkeyRegistration();
}
}

private void UpdateMouseToggleHotkeyRegistration()
Expand Down Expand Up @@ -185,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))
Expand Down Expand Up @@ -220,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)
Expand All @@ -231,6 +362,9 @@ private void DisposeWindowResources()
_isDisposed = true;
RootView.ViewModel.PropertyChanged -= OnRootViewModelPropertyChanged;
UnregisterMouseToggleHotkey();
UnregisterRecenterHotkey();
CommitRecenter();
UserMouseMonitor.Uninstall();
SetWindowLongPtr(_windowHandle, WindowLongWindowProcedure, _previousWindowProcedure);
RootView.Dispose();
_trayService.Dispose();
Expand Down Expand Up @@ -292,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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ public sealed record TrackIRControlState(
bool IsVideoFlipVerticalEnabled,
double VideoRotationDegrees,
double VideoFramesPerSecond,
string MouseToggleHotkeyText
string MouseToggleHotkeyText,
string RecenterHotkeyText,
int MouseOverrideDelayMilliseconds,
bool IsMouseButtonOverrideEnabled
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -29,7 +35,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(
Expand Down Expand Up @@ -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)
);
}
}
}
20 changes: 17 additions & 3 deletions win/OpenTrackIR.WinUI/OpenTrackIR.WinUI/Models/TrackIRUiLogic.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ public static TrackIRControlState CreateDefaultControlState()
IsVideoFlipVerticalEnabled: false,
VideoRotationDegrees: 0.0,
VideoFramesPerSecond: 60.0,
MouseToggleHotkeyText: "Shift+F7"
MouseToggleHotkeyText: "Shift+F7",
RecenterHotkeyText: "Ctrl+Shift+8",
MouseOverrideDelayMilliseconds: 500,
IsMouseButtonOverrideEnabled: true
);
}

Expand All @@ -39,9 +42,13 @@ 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, 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),
Expand All @@ -50,7 +57,9 @@ 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,
RecenterHotkeyText = recenterHotkeyText,
MouseOverrideDelayMilliseconds = Math.Clamp(state.MouseOverrideDelayMilliseconds, 0, 5000)
};
}

Expand Down Expand Up @@ -183,6 +192,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";
Expand Down
Loading