Skip to content
Merged
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
13 changes: 11 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
},
"dependencies": {
"@nut-tree-fork/libnut-win32": "^2.7.5",
"electron-updater": "^6.8.9"
"electron-updater": "^6.8.9",
"lucide-react": "^1.21.0"
}
}
12 changes: 11 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { PairingApprovalManager } from './pairing/pairing-approval-manager';
import { registerPairingApprovalIpc } from './pairing/pairing-approval-ipc';
import { JsonPairingStore } from './pairing/pairing-store';
import { PairingManager } from './pairing/pairing-manager';
import { registerPointerMovementSettingsIpc } from './pointer-movement-settings-ipc';
import { JsonPointerMovementSettingsStore } from './pointer-movement-settings-store';
import { registerServerIpc } from './server-ipc';
import { registerSettingsWindowIpc } from './settings-window-ipc';
import { secondInstanceAction } from './single-instance';
Expand Down Expand Up @@ -265,6 +267,10 @@ if (!gotSingleInstanceLock) {
const cursorOverlaySettingsStore = new JsonCursorOverlaySettingsStore(
join(app.getPath('userData'), 'cursor-overlay-settings.json')
);
const pointerMovementSettingsStore = new JsonPointerMovementSettingsStore(
join(app.getPath('userData'), 'pointer-movement-settings.json')
);
let pointerMovementSettings = pointerMovementSettingsStore.load();
const pairingManager = new PairingManager(pairingStore);
const pairingApprovalManager = new PairingApprovalManager(pairingStore);
const inputAdapter = new LibnutWin32InputAdapter((position) => {
Expand All @@ -273,7 +279,7 @@ if (!gotSingleInstanceLock) {
bounds: display.bounds,
scaleFactor: display.scaleFactor
};
});
}, undefined, pointerMovementSettings);
cursorOverlay = new CursorOverlay({ settings: cursorOverlaySettingsStore.load() });
const commandExecutor = new DesktopCommandExecutor(inputAdapter, cursorOverlay);
releaseHeldMouseButtons = () => commandExecutor.releaseHeldMouseButtons();
Expand Down Expand Up @@ -322,6 +328,10 @@ if (!gotSingleInstanceLock) {
});
registerServerIpc(controlService, pairingStore);
registerCursorOverlayIpc(cursorOverlay, cursorOverlaySettingsStore);
registerPointerMovementSettingsIpc(pointerMovementSettingsStore, (settings) => {
pointerMovementSettings = settings;
inputAdapter.setPointerMovementSettings(settings);
});
registerPairingApprovalIpc(controlService);
registerSettingsWindowIpc(showSettingsWindow);
registerAppWindowIpc();
Expand Down
150 changes: 150 additions & 0 deletions src/main/input/libnut-win32-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import {
calculateNativeScrollDelta,
calculateDisplayNormalizedMouseTarget,
calculateScaledMouseTarget,
inferPointerMovementSize,
toLibnutKeyboardKey,
toLibnutMouseToggle
} from './libnut-win32-adapter';
import {
createWindowControlScript,
toWindowsWindowControlStrategy
} from './windows-window-control';
import { createPointerMovementProfile } from './pointer-profile';
import type { PointerMovementSettings } from '../../shared/pointer-movement-settings';

describe('calculateScaledMouseTarget', () => {
it('applies the display scale factor to relative movement', () => {
Expand Down Expand Up @@ -138,6 +141,153 @@ describe('calculateDisplayNormalizedMouseTarget', () => {
y: 26
});
});

it('applies the movement scale to small movement', () => {
expect(
calculateDisplayNormalizedMouseTarget(
{ x: 100, y: 200 },
{ dx: 48, dy: 0 },
{ bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 },
{ scalePercent: 200 }
)
).toEqual({
x: 196,
y: 200
});
});

it('applies the movement scale to medium movement', () => {
expect(
calculateDisplayNormalizedMouseTarget(
{ x: 100, y: 200 },
{ dx: 128, dy: 0 },
{ bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 },
{ scalePercent: 50 }
)
).toEqual({
x: 164,
y: 200
});
});

it('combines display normalization with customized movement scale', () => {
expect(
calculateDisplayNormalizedMouseTarget(
{ x: 100, y: 200 },
{ dx: 128, dy: 0 },
{ bounds: { x: 0, y: 0, width: 3840, height: 2160 }, scaleFactor: 1 },
{ scalePercent: 150 }
)
).toEqual({
x: 484,
y: 200
});
});

it('falls back for invalid display data while applying movement scale', () => {
expect(
calculateDisplayNormalizedMouseTarget(
{ x: 10, y: 20 },
{ dx: 48, dy: 0 },
{ bounds: { x: 0, y: 0, width: 0, height: 2160 }, scaleFactor: 0 },
{ scalePercent: 200 }
)
).toEqual({
x: 106,
y: 20
});
});

it('migrates legacy percentage settings before applying movement scale', () => {
const legacySettings = {
percentages: { small: 9, medium: 24, large: 50 }
} as unknown as PointerMovementSettings;

expect(
calculateDisplayNormalizedMouseTarget(
{ x: 100, y: 200 },
{ dx: 48, dy: 0 },
{ bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 },
legacySettings
)
).toEqual({
x: 196,
y: 200
});
});

it('normalizes a 4K profile delta once at execution time', () => {
const profile = createPointerMovementProfile({
cursor: { x: 100, y: 200 },
display: {
bounds: { x: 0, y: 0, width: 3840, height: 2160 },
scaleFactor: 1
}
});

expect(
calculateDisplayNormalizedMouseTarget(
{ x: 100, y: 200 },
{ dx: profile.recommendedDeltas.medium, dy: 0 },
{ bounds: { x: 0, y: 0, width: 3840, height: 2160 }, scaleFactor: 1 }
)
).toEqual({
x: 360,
y: 200
});
});

it('normalizes a high-DPI profile delta once at execution time', () => {
const profile = createPointerMovementProfile({
cursor: { x: 100, y: 200 },
display: {
bounds: { x: 0, y: 0, width: 3840, height: 2160 },
scaleFactor: 2
}
});

expect(
calculateDisplayNormalizedMouseTarget(
{ x: 100, y: 200 },
{ dx: profile.recommendedDeltas.medium, dy: 0 },
{ bounds: { x: 0, y: 0, width: 3840, height: 2160 }, scaleFactor: 2 }
)
).toEqual({
x: 360,
y: 200
});
});

it('applies configured movement scale during profile delta execution', () => {
const profile = createPointerMovementProfile({
cursor: { x: 100, y: 200 },
display: {
bounds: { x: 0, y: 0, width: 3840, height: 2160 },
scaleFactor: 1
}
});

expect(
calculateDisplayNormalizedMouseTarget(
{ x: 100, y: 200 },
{ dx: profile.recommendedDeltas.medium, dy: 0 },
{ bounds: { x: 0, y: 0, width: 3840, height: 2160 }, scaleFactor: 1 },
{ scalePercent: 150 }
)
).toEqual({
x: 490,
y: 200
});
});
});

describe('inferPointerMovementSize', () => {
it('classifies movement deltas by dominant axis', () => {
expect(inferPointerMovementSize({ dx: 48, dy: 0 })).toBe('small');
expect(inferPointerMovementSize({ dx: 128, dy: 0 })).toBe('medium');
expect(inferPointerMovementSize({ dx: 280, dy: 0 })).toBe('large');
expect(inferPointerMovementSize({ dx: 0, dy: 128 })).toBe('medium');
});
});

describe('calculateNativeScrollDelta', () => {
Expand Down
57 changes: 48 additions & 9 deletions src/main/input/libnut-win32-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import {
} from '@nut-tree-fork/libnut-win32';
import { clipboard } from 'electron';
import type { KeyboardKey, MediaAction, MouseButton, ShortcutKey, WindowControlAction } from '../../shared/protocol';
import {
DEFAULT_POINTER_MOVEMENT_SETTINGS,
normalizePointerMovementSettings,
pointerMovementFractionFor,
type PointerMovementSettings,
type PointerMovementSizeKey
} from '../../shared/pointer-movement-settings';
import type { DesktopInputAdapter } from './desktop-input-adapter';
import { DesktopInputError } from './desktop-input-adapter';
import { insertText } from './text-inserter';
Expand All @@ -24,15 +31,35 @@ type PointerDisplayProvider = (position: Point) => PointerDisplay;

export const NATIVE_SCROLL_DELTA_MULTIPLIER = 8;
export const REFERENCE_POINTER_SHORT_EDGE = 1080;
const BASELINE_POINTER_DELTAS = {
small: 48,
medium: 128,
large: 280
};
const SMALL_MEDIUM_POINTER_BOUNDARY = (BASELINE_POINTER_DELTAS.small + BASELINE_POINTER_DELTAS.medium) / 2;
const MEDIUM_LARGE_POINTER_BOUNDARY = (BASELINE_POINTER_DELTAS.medium + BASELINE_POINTER_DELTAS.large) / 2;

function defaultPointerDisplayProvider(): PointerDisplay {
return {
bounds: { x: 0, y: 0, width: REFERENCE_POINTER_SHORT_EDGE, height: REFERENCE_POINTER_SHORT_EDGE },
scaleFactor: 1
};
}

export class LibnutWin32InputAdapter implements DesktopInputAdapter {
private pointerMovementSettings: PointerMovementSettings;

constructor(
private readonly getPointerDisplay: PointerDisplayProvider = () => ({
bounds: { x: 0, y: 0, width: REFERENCE_POINTER_SHORT_EDGE, height: REFERENCE_POINTER_SHORT_EDGE },
scaleFactor: 1
}),
private readonly textInputBackend: TextInputBackend = createTextInputBackend()
) {}
private readonly getPointerDisplay: PointerDisplayProvider = defaultPointerDisplayProvider,
private readonly textInputBackend: TextInputBackend = createTextInputBackend(),
pointerMovementSettings: PointerMovementSettings = DEFAULT_POINTER_MOVEMENT_SETTINGS
) {
this.pointerMovementSettings = normalizePointerMovementSettings(pointerMovementSettings);
}

setPointerMovementSettings(settings: PointerMovementSettings): void {
this.pointerMovementSettings = normalizePointerMovementSettings(settings);
}

getMousePosition(): { x: number; y: number } {
const current = getMousePos();
Expand All @@ -42,7 +69,7 @@ export class LibnutWin32InputAdapter implements DesktopInputAdapter {
async moveMouseBy(delta: { dx: number; dy: number }): Promise<void> {
const current = this.getMousePosition();
const display = this.getPointerDisplay(current);
const target = calculateDisplayNormalizedMouseTarget(current, delta, display);
const target = calculateDisplayNormalizedMouseTarget(current, delta, display, this.pointerMovementSettings);
moveMouse(target.x, target.y);
}

Expand Down Expand Up @@ -141,22 +168,34 @@ export function calculateScaledMouseTarget(
export function calculateDisplayNormalizedMouseTarget(
current: Point,
delta: { dx: number; dy: number },
display: PointerDisplay
display: PointerDisplay,
movementSettings: PointerMovementSettings = DEFAULT_POINTER_MOVEMENT_SETTINGS
): Point {
const scaleFactor = Number.isFinite(display.scaleFactor) && display.scaleFactor > 0 ? display.scaleFactor : 1;
const shortEdge =
Number.isFinite(display.bounds.width) && display.bounds.width > 0 &&
Number.isFinite(display.bounds.height) && display.bounds.height > 0
? Math.min(display.bounds.width, display.bounds.height)
: REFERENCE_POINTER_SHORT_EDGE;
const multiplier = scaleFactor * (shortEdge / REFERENCE_POINTER_SHORT_EDGE);
const settings = normalizePointerMovementSettings(movementSettings);
const size = inferPointerMovementSize(delta);
const baselineFraction = pointerMovementFractionFor(DEFAULT_POINTER_MOVEMENT_SETTINGS, size);
const movementScale = pointerMovementFractionFor(settings, size) / baselineFraction;
const multiplier = scaleFactor * (shortEdge / REFERENCE_POINTER_SHORT_EDGE) * movementScale;

return {
x: Math.round(current.x + delta.dx * multiplier),
y: Math.round(current.y + delta.dy * multiplier)
};
}

export function inferPointerMovementSize(delta: { dx: number; dy: number }): PointerMovementSizeKey {
const magnitude = Math.max(Math.abs(delta.dx), Math.abs(delta.dy));
if (magnitude <= SMALL_MEDIUM_POINTER_BOUNDARY) return 'small';
if (magnitude <= MEDIUM_LARGE_POINTER_BOUNDARY) return 'medium';
return 'large';
}

export function calculateNativeScrollDelta(
delta: { dx: number; dy: number },
multiplier = NATIVE_SCROLL_DELTA_MULTIPLIER
Expand Down
Loading