Skip to content

Commit c44a27f

Browse files
Normalize pointer movement at execution time (#232)
Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent 394a905 commit c44a27f

5 files changed

Lines changed: 171 additions & 41 deletions

File tree

src/main/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,13 @@ if (!gotSingleInstanceLock) {
267267
);
268268
const pairingManager = new PairingManager(pairingStore);
269269
const pairingApprovalManager = new PairingApprovalManager(pairingStore);
270-
const inputAdapter = new LibnutWin32InputAdapter((position) => screen.getDisplayNearestPoint(position).scaleFactor);
270+
const inputAdapter = new LibnutWin32InputAdapter((position) => {
271+
const display = screen.getDisplayNearestPoint(position);
272+
return {
273+
bounds: display.bounds,
274+
scaleFactor: display.scaleFactor
275+
};
276+
});
271277
cursorOverlay = new CursorOverlay({ settings: cursorOverlaySettingsStore.load() });
272278
const commandExecutor = new DesktopCommandExecutor(inputAdapter, cursorOverlay);
273279
releaseHeldMouseButtons = () => commandExecutor.releaseHeldMouseButtons();

src/main/input/libnut-win32-adapter.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22
import {
33
calculateNativeScrollDelta,
4+
calculateDisplayNormalizedMouseTarget,
45
calculateScaledMouseTarget,
56
toLibnutKeyboardKey,
67
toLibnutMouseToggle
@@ -33,6 +34,112 @@ describe('calculateScaledMouseTarget', () => {
3334
});
3435
});
3536

37+
describe('calculateDisplayNormalizedMouseTarget', () => {
38+
it('uses 1080p as the reference display size', () => {
39+
expect(
40+
calculateDisplayNormalizedMouseTarget(
41+
{ x: 100, y: 200 },
42+
{ dx: 128, dy: -64 },
43+
{ bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 }
44+
)
45+
).toEqual({
46+
x: 228,
47+
y: 136
48+
});
49+
});
50+
51+
it('applies larger native movement on a 4K display at 1x scale', () => {
52+
expect(
53+
calculateDisplayNormalizedMouseTarget(
54+
{ x: 100, y: 200 },
55+
{ dx: 128, dy: 0 },
56+
{ bounds: { x: 0, y: 0, width: 3840, height: 2160 }, scaleFactor: 1 }
57+
)
58+
).toEqual({
59+
x: 356,
60+
y: 200
61+
});
62+
});
63+
64+
it('combines high-DPI logical deltas with display resolution normalization', () => {
65+
expect(
66+
calculateDisplayNormalizedMouseTarget(
67+
{ x: 100, y: 200 },
68+
{ dx: 64, dy: 0 },
69+
{ bounds: { x: 0, y: 0, width: 3840, height: 2160 }, scaleFactor: 2 }
70+
)
71+
).toEqual({
72+
x: 356,
73+
y: 200
74+
});
75+
});
76+
77+
it('uses the display short edge for ultrawide displays', () => {
78+
expect(
79+
calculateDisplayNormalizedMouseTarget(
80+
{ x: 100, y: 200 },
81+
{ dx: 128, dy: 0 },
82+
{ bounds: { x: 0, y: 0, width: 3440, height: 1440 }, scaleFactor: 1 }
83+
)
84+
).toEqual({
85+
x: 271,
86+
y: 200
87+
});
88+
});
89+
90+
it('accounts for fractional scale factors', () => {
91+
expect(
92+
calculateDisplayNormalizedMouseTarget(
93+
{ x: 100, y: 200 },
94+
{ dx: 102, dy: 0 },
95+
{ bounds: { x: 0, y: 0, width: 2560, height: 1440 }, scaleFactor: 1.25 }
96+
)
97+
).toEqual({
98+
x: 270,
99+
y: 200
100+
});
101+
});
102+
103+
it('handles displays with negative coordinates', () => {
104+
expect(
105+
calculateDisplayNormalizedMouseTarget(
106+
{ x: -300, y: 100 },
107+
{ dx: 128, dy: 64 },
108+
{ bounds: { x: -3840, y: 0, width: 3840, height: 2160 }, scaleFactor: 1 }
109+
)
110+
).toEqual({
111+
x: -44,
112+
y: 228
113+
});
114+
});
115+
116+
it('falls back to the reference size for invalid bounds', () => {
117+
expect(
118+
calculateDisplayNormalizedMouseTarget(
119+
{ x: 10, y: 20 },
120+
{ dx: 5, dy: 6 },
121+
{ bounds: { x: 0, y: 0, width: 0, height: 2160 }, scaleFactor: 1 }
122+
)
123+
).toEqual({
124+
x: 15,
125+
y: 26
126+
});
127+
});
128+
129+
it('falls back to scale 1 for invalid scale values', () => {
130+
expect(
131+
calculateDisplayNormalizedMouseTarget(
132+
{ x: 10, y: 20 },
133+
{ dx: 5, dy: 6 },
134+
{ bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 0 }
135+
)
136+
).toEqual({
137+
x: 15,
138+
y: 26
139+
});
140+
});
141+
});
142+
36143
describe('calculateNativeScrollDelta', () => {
37144
it('scales vertical scroll by the native multiplier', () => {
38145
expect(calculateNativeScrollDelta({ dx: 0, dy: 5 })).toEqual({

src/main/input/libnut-win32-adapter.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,21 @@ import { createTextInputBackend, type TextInputBackend } from './text-input-help
1616
import { runWindowsWindowControlAction } from './windows-window-control';
1717

1818
type Point = { x: number; y: number };
19-
type PointerScaleProvider = (position: Point) => number;
19+
type PointerDisplay = {
20+
bounds: { x: number; y: number; width: number; height: number };
21+
scaleFactor: number;
22+
};
23+
type PointerDisplayProvider = (position: Point) => PointerDisplay;
2024

2125
export const NATIVE_SCROLL_DELTA_MULTIPLIER = 8;
26+
export const REFERENCE_POINTER_SHORT_EDGE = 1080;
2227

2328
export class LibnutWin32InputAdapter implements DesktopInputAdapter {
2429
constructor(
25-
private readonly getPointerScale: PointerScaleProvider = () => 1,
30+
private readonly getPointerDisplay: PointerDisplayProvider = () => ({
31+
bounds: { x: 0, y: 0, width: REFERENCE_POINTER_SHORT_EDGE, height: REFERENCE_POINTER_SHORT_EDGE },
32+
scaleFactor: 1
33+
}),
2634
private readonly textInputBackend: TextInputBackend = createTextInputBackend()
2735
) {}
2836

@@ -33,8 +41,8 @@ export class LibnutWin32InputAdapter implements DesktopInputAdapter {
3341

3442
async moveMouseBy(delta: { dx: number; dy: number }): Promise<void> {
3543
const current = this.getMousePosition();
36-
const scale = this.getPointerScale(current);
37-
const target = calculateScaledMouseTarget(current, delta, scale);
44+
const display = this.getPointerDisplay(current);
45+
const target = calculateDisplayNormalizedMouseTarget(current, delta, display);
3846
moveMouse(target.x, target.y);
3947
}
4048

@@ -122,6 +130,25 @@ export function calculateScaledMouseTarget(
122130
};
123131
}
124132

133+
export function calculateDisplayNormalizedMouseTarget(
134+
current: Point,
135+
delta: { dx: number; dy: number },
136+
display: PointerDisplay
137+
): Point {
138+
const scaleFactor = Number.isFinite(display.scaleFactor) && display.scaleFactor > 0 ? display.scaleFactor : 1;
139+
const shortEdge =
140+
Number.isFinite(display.bounds.width) && display.bounds.width > 0 &&
141+
Number.isFinite(display.bounds.height) && display.bounds.height > 0
142+
? Math.min(display.bounds.width, display.bounds.height)
143+
: REFERENCE_POINTER_SHORT_EDGE;
144+
const multiplier = scaleFactor * (shortEdge / REFERENCE_POINTER_SHORT_EDGE);
145+
146+
return {
147+
x: Math.round(current.x + delta.dx * multiplier),
148+
y: Math.round(current.y + delta.dy * multiplier)
149+
};
150+
}
151+
125152
export function calculateNativeScrollDelta(
126153
delta: { dx: number; dy: number },
127154
multiplier = NATIVE_SCROLL_DELTA_MULTIPLIER

src/main/input/pointer-profile.test.ts

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { MAX_POINTER_DELTA } from '../../shared/protocol';
33
import { createPointerMovementProfile } from './pointer-profile';
44

55
describe('createPointerMovementProfile', () => {
6-
it('creates display-relative deltas for a 1280x720 display at 1.5 scale', () => {
6+
it('creates baseline deltas for a 1280x720 display at 1.5 scale', () => {
77
const profile = createPointerMovementProfile({
88
cursor: { x: 100, y: 100 },
99
display: {
@@ -18,17 +18,17 @@ describe('createPointerMovementProfile', () => {
1818
bounds: { x: 0, y: 0, width: 1280, height: 720 },
1919
maxDelta: MAX_POINTER_DELTA,
2020
recommendedDeltas: {
21-
small: 21,
22-
medium: 57,
23-
large: 125
21+
small: 32,
22+
medium: 85,
23+
large: 187
2424
},
2525
capabilities: {
2626
noAckMouseMove: true
2727
}
2828
});
2929
});
3030

31-
it('preserves current feel on a 1920x1080 display at 1x scale', () => {
31+
it('returns 1080p baseline deltas on a 1920x1080 display at 1x scale', () => {
3232
expect(
3333
createPointerMovementProfile({
3434
cursor: { x: 100, y: 100 },
@@ -38,13 +38,13 @@ describe('createPointerMovementProfile', () => {
3838
}
3939
}).recommendedDeltas
4040
).toEqual({
41-
small: 49,
42-
medium: 130,
43-
large: 281
41+
small: 48,
42+
medium: 128,
43+
large: 280
4444
});
4545
});
4646

47-
it('returns larger clamped deltas on a 4K display at 1x scale', () => {
47+
it('keeps profile deltas stable on a 4K display at 1x scale', () => {
4848
expect(
4949
createPointerMovementProfile({
5050
cursor: { x: 100, y: 100 },
@@ -54,13 +54,13 @@ describe('createPointerMovementProfile', () => {
5454
}
5555
}).recommendedDeltas
5656
).toEqual({
57-
small: 97,
58-
medium: 259,
59-
large: MAX_POINTER_DELTA
57+
small: 48,
58+
medium: 128,
59+
large: 280
6060
});
6161
});
6262

63-
it('uses the short edge for ultrawide displays', () => {
63+
it('keeps profile deltas stable on ultrawide displays', () => {
6464
expect(
6565
createPointerMovementProfile({
6666
cursor: { x: 100, y: 100 },
@@ -70,9 +70,9 @@ describe('createPointerMovementProfile', () => {
7070
}
7171
}).recommendedDeltas
7272
).toEqual({
73-
small: 65,
74-
medium: 173,
75-
large: 374
73+
small: 48,
74+
medium: 128,
75+
large: 280
7676
});
7777
});
7878

@@ -86,9 +86,9 @@ describe('createPointerMovementProfile', () => {
8686
}
8787
}).recommendedDeltas
8888
).toEqual({
89-
small: 49,
90-
medium: 130,
91-
large: 281
89+
small: 24,
90+
medium: 64,
91+
large: 140
9292
});
9393
});
9494

src/main/input/pointer-profile.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { MAX_POINTER_DELTA, NO_ACK_CONTROL_COMMAND_TYPES, type PointerMovementPr
33
type Point = { x: number; y: number };
44
type Bounds = { x: number; y: number; width: number; height: number };
55

6-
const TARGET_DISPLAY_FRACTIONS = {
7-
small: 0.045,
8-
medium: 0.12,
9-
large: 0.26
6+
const TARGET_REFERENCE_NATIVE_DELTAS = {
7+
small: 48,
8+
medium: 128,
9+
large: 280
1010
};
1111

1212
export function createPointerMovementProfile(input: {
@@ -21,17 +21,16 @@ export function createPointerMovementProfile(input: {
2121
const scaleFactor =
2222
Number.isFinite(input.display.scaleFactor) && input.display.scaleFactor > 0 ? input.display.scaleFactor : 1;
2323
const maxDelta = input.maxDelta ?? MAX_POINTER_DELTA;
24-
const targetNativeDeltas = targetNativeDeltasForDisplay(bounds);
2524

2625
return {
2726
displayId: `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}:${scaleFactor}`,
2827
scaleFactor,
2928
bounds,
3029
maxDelta,
3130
recommendedDeltas: {
32-
small: toLogicalDelta(targetNativeDeltas.small, scaleFactor, maxDelta),
33-
medium: toLogicalDelta(targetNativeDeltas.medium, scaleFactor, maxDelta),
34-
large: toLogicalDelta(targetNativeDeltas.large, scaleFactor, maxDelta)
31+
small: toLogicalDelta(TARGET_REFERENCE_NATIVE_DELTAS.small, scaleFactor, maxDelta),
32+
medium: toLogicalDelta(TARGET_REFERENCE_NATIVE_DELTAS.medium, scaleFactor, maxDelta),
33+
large: toLogicalDelta(TARGET_REFERENCE_NATIVE_DELTAS.large, scaleFactor, maxDelta)
3534
},
3635
capabilities: {
3736
noAckMouseMove: true,
@@ -40,15 +39,6 @@ export function createPointerMovementProfile(input: {
4039
};
4140
}
4241

43-
function targetNativeDeltasForDisplay(bounds: Bounds): { small: number; medium: number; large: number } {
44-
const referenceSize = Math.min(bounds.width, bounds.height);
45-
return {
46-
small: Math.round(referenceSize * TARGET_DISPLAY_FRACTIONS.small),
47-
medium: Math.round(referenceSize * TARGET_DISPLAY_FRACTIONS.medium),
48-
large: Math.round(referenceSize * TARGET_DISPLAY_FRACTIONS.large)
49-
};
50-
}
51-
5242
function normalizeBounds(bounds: Bounds): Bounds {
5343
return {
5444
x: finiteOr(bounds.x, 0),

0 commit comments

Comments
 (0)