diff --git a/package-lock.json b/package-lock.json index 1e8936c..7213952 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "AGPL-3.0-or-later", "dependencies": { "@nut-tree-fork/libnut-win32": "^2.7.5", - "electron-updater": "^6.8.9" + "electron-updater": "^6.8.9", + "lucide-react": "^1.21.0" }, "devDependencies": { "@types/node": "^25.9.1", @@ -4643,6 +4644,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5256,7 +5266,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", - "dev": true, "license": "MIT", "peer": true, "engines": { diff --git a/package.json b/package.json index 8bb7e15..c894ab0 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/main/index.ts b/src/main/index.ts index 20f3d18..33a4887 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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'; @@ -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) => { @@ -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(); @@ -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(); diff --git a/src/main/input/libnut-win32-adapter.test.ts b/src/main/input/libnut-win32-adapter.test.ts index 3ce1b91..87bb71a 100644 --- a/src/main/input/libnut-win32-adapter.test.ts +++ b/src/main/input/libnut-win32-adapter.test.ts @@ -3,6 +3,7 @@ import { calculateNativeScrollDelta, calculateDisplayNormalizedMouseTarget, calculateScaledMouseTarget, + inferPointerMovementSize, toLibnutKeyboardKey, toLibnutMouseToggle } from './libnut-win32-adapter'; @@ -10,6 +11,8 @@ 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', () => { @@ -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', () => { diff --git a/src/main/input/libnut-win32-adapter.ts b/src/main/input/libnut-win32-adapter.ts index b7182bd..695ca6b 100644 --- a/src/main/input/libnut-win32-adapter.ts +++ b/src/main/input/libnut-win32-adapter.ts @@ -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'; @@ -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(); @@ -42,7 +69,7 @@ export class LibnutWin32InputAdapter implements DesktopInputAdapter { async moveMouseBy(delta: { dx: number; dy: number }): Promise { 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); } @@ -141,7 +168,8 @@ 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 = @@ -149,7 +177,11 @@ export function calculateDisplayNormalizedMouseTarget( 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), @@ -157,6 +189,13 @@ export function calculateDisplayNormalizedMouseTarget( }; } +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 diff --git a/src/main/input/pointer-profile.test.ts b/src/main/input/pointer-profile.test.ts index c885685..38fb3a8 100644 --- a/src/main/input/pointer-profile.test.ts +++ b/src/main/input/pointer-profile.test.ts @@ -3,7 +3,7 @@ import { MAX_POINTER_DELTA } from '../../shared/protocol'; import { createPointerMovementProfile } from './pointer-profile'; describe('createPointerMovementProfile', () => { - it('creates baseline deltas for a 1280x720 display at 1.5 scale', () => { + it('creates stable baseline deltas for a 1280x720 display at 1.5 scale', () => { const profile = createPointerMovementProfile({ cursor: { x: 100, y: 100 }, display: { @@ -19,7 +19,7 @@ describe('createPointerMovementProfile', () => { maxDelta: MAX_POINTER_DELTA, recommendedDeltas: { small: 32, - medium: 85, + medium: 86, large: 187 }, capabilities: { @@ -39,7 +39,7 @@ describe('createPointerMovementProfile', () => { ); }); - it('returns 1080p baseline deltas on a 1920x1080 display at 1x scale', () => { + it('preserves current feel on a 1920x1080 display at 1x scale', () => { expect( createPointerMovementProfile({ cursor: { x: 100, y: 100 }, @@ -49,13 +49,57 @@ describe('createPointerMovementProfile', () => { } }).recommendedDeltas ).toEqual({ - small: 48, - medium: 128, - large: 280 + small: 49, + medium: 130, + large: 281 }); }); - it('keeps profile deltas stable on a 4K display at 1x scale', () => { + it('keeps profile deltas stable when pointer movement scale changes', () => { + expect( + createPointerMovementProfile({ + cursor: { x: 100, y: 100 }, + display: { + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + scaleFactor: 1 + } + }).recommendedDeltas + ).toEqual({ + small: 49, + medium: 130, + large: 281 + }); + }); + + it('divides stable baseline deltas by scale factor on high-DPI displays', () => { + expect( + createPointerMovementProfile({ + cursor: { x: 100, y: 100 }, + display: { + bounds: { x: 0, y: 0, width: 3840, height: 2160 }, + scaleFactor: 2 + } + }).recommendedDeltas.medium + ).toBe(65); + }); + + it('does not apply movement settings in the pointer profile', () => { + expect( + createPointerMovementProfile({ + cursor: { x: 100, y: 100 }, + display: { + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + scaleFactor: 1 + } + }).recommendedDeltas + ).toEqual({ + small: 49, + medium: 130, + large: 281 + }); + }); + + it('returns stable baseline deltas on a 4K display at 1x scale', () => { expect( createPointerMovementProfile({ cursor: { x: 100, y: 100 }, @@ -65,13 +109,13 @@ describe('createPointerMovementProfile', () => { } }).recommendedDeltas ).toEqual({ - small: 48, - medium: 128, - large: 280 + small: 49, + medium: 130, + large: 281 }); }); - it('keeps profile deltas stable on ultrawide displays', () => { + it('returns stable baseline deltas on ultrawide displays', () => { expect( createPointerMovementProfile({ cursor: { x: 100, y: 100 }, @@ -81,9 +125,9 @@ describe('createPointerMovementProfile', () => { } }).recommendedDeltas ).toEqual({ - small: 48, - medium: 128, - large: 280 + small: 49, + medium: 130, + large: 281 }); }); @@ -98,7 +142,7 @@ describe('createPointerMovementProfile', () => { }).recommendedDeltas ).toEqual({ small: 24, - medium: 64, + medium: 65, large: 140 }); }); diff --git a/src/main/input/pointer-profile.ts b/src/main/input/pointer-profile.ts index 9b8ac3d..464d015 100644 --- a/src/main/input/pointer-profile.ts +++ b/src/main/input/pointer-profile.ts @@ -1,13 +1,21 @@ import { MAX_POINTER_DELTA, NO_ACK_CONTROL_COMMAND_TYPES, type PointerMovementProfile } from '../../shared/protocol'; +import { + pointerMovementFractionFor, + DEFAULT_POINTER_MOVEMENT_SETTINGS, + type PointerMovementSizeKey +} from '../../shared/pointer-movement-settings'; type Point = { x: number; y: number }; type Bounds = { x: number; y: number; width: number; height: number }; -const TARGET_REFERENCE_NATIVE_DELTAS = { - small: 48, - medium: 128, - large: 280 -}; +const REFERENCE_POINTER_SHORT_EDGE = 1080; +const pointerMovementSizeKeys: PointerMovementSizeKey[] = ['small', 'medium', 'large']; +const TARGET_REFERENCE_NATIVE_DELTAS = Object.fromEntries( + pointerMovementSizeKeys.map((size) => [ + size, + REFERENCE_POINTER_SHORT_EDGE * pointerMovementFractionFor(DEFAULT_POINTER_MOVEMENT_SETTINGS, size) + ]) +) as Record; export function createPointerMovementProfile(input: { cursor: Point; diff --git a/src/main/pointer-movement-settings-ipc.test.ts b/src/main/pointer-movement-settings-ipc.test.ts new file mode 100644 index 0000000..5d3873c --- /dev/null +++ b/src/main/pointer-movement-settings-ipc.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + GET_POINTER_MOVEMENT_SETTINGS_CHANNEL, + SET_POINTER_MOVEMENT_SETTINGS_CHANNEL +} from '../shared/ipc-channels'; +import type { PointerMovementSettings } from '../shared/pointer-movement-settings'; +import { registerPointerMovementSettingsIpc } from './pointer-movement-settings-ipc'; +import type { JsonPointerMovementSettingsStore } from './pointer-movement-settings-store'; + +type IpcHandler = (event: Electron.IpcMainInvokeEvent, ...args: unknown[]) => unknown; + +const ipcHandlers = new Map(); + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: IpcHandler) => { + ipcHandlers.set(channel, handler); + }) + } +})); + +describe('registerPointerMovementSettingsIpc', () => { + beforeEach(() => { + ipcHandlers.clear(); + }); + + it('returns stored settings', async () => { + const settings = { scalePercent: 125 }; + const store = createStore(settings); + + registerPointerMovementSettingsIpc(store, vi.fn()); + + await expect(invoke(GET_POINTER_MOVEMENT_SETTINGS_CHANNEL)).resolves.toEqual(settings); + }); + + it('normalizes and saves settings', async () => { + const store = createStore(); + + registerPointerMovementSettingsIpc(store, vi.fn()); + + await expect(invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, { scalePercent: 123 })).resolves.toEqual({ + scalePercent: 125 + }); + expect(store.save).toHaveBeenCalledWith({ scalePercent: 125 }); + }); + + it('notifies when settings change', async () => { + const store = createStore(); + const onSettingsChanged = vi.fn(); + + registerPointerMovementSettingsIpc(store, onSettingsChanged); + await invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, { scalePercent: 75 }); + + expect(onSettingsChanged).toHaveBeenCalledWith({ scalePercent: 75 }); + }); + + it('normalizes migrated percentage settings before saving and notifying', async () => { + const store = createStore(); + const onSettingsChanged = vi.fn(); + + registerPointerMovementSettingsIpc(store, onSettingsChanged); + + await expect( + invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, { + percentages: { small: 9, medium: 24, large: 50 } + }) + ).resolves.toEqual({ scalePercent: 195 }); + expect(store.save).toHaveBeenCalledWith({ scalePercent: 195 }); + expect(onSettingsChanged).toHaveBeenCalledWith({ scalePercent: 195 }); + }); +}); + +function createStore(settings: PointerMovementSettings = { scalePercent: 100 }): JsonPointerMovementSettingsStore { + return { + load: vi.fn(() => settings), + save: vi.fn((nextSettings: PointerMovementSettings) => nextSettings) + } as unknown as JsonPointerMovementSettingsStore; +} + +function invoke(channel: string, ...args: unknown[]): Promise { + const handler = ipcHandlers.get(channel); + if (!handler) throw new Error(`Handler was not registered: ${channel}`); + return Promise.resolve(handler({ sender: {} } as Electron.IpcMainInvokeEvent, ...args)); +} diff --git a/src/main/pointer-movement-settings-ipc.ts b/src/main/pointer-movement-settings-ipc.ts new file mode 100644 index 0000000..4dc5dfc --- /dev/null +++ b/src/main/pointer-movement-settings-ipc.ts @@ -0,0 +1,19 @@ +import { ipcMain } from 'electron'; +import { normalizePointerMovementSettings, type PointerMovementSettings } from '../shared/pointer-movement-settings'; +import { + GET_POINTER_MOVEMENT_SETTINGS_CHANNEL, + SET_POINTER_MOVEMENT_SETTINGS_CHANNEL +} from '../shared/ipc-channels'; +import type { JsonPointerMovementSettingsStore } from './pointer-movement-settings-store'; + +export function registerPointerMovementSettingsIpc( + settingsStore: JsonPointerMovementSettingsStore, + onSettingsChanged: (settings: PointerMovementSettings) => void +): void { + ipcMain.handle(GET_POINTER_MOVEMENT_SETTINGS_CHANNEL, () => settingsStore.load()); + ipcMain.handle(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, (_event, settings: PointerMovementSettings) => { + const normalized = settingsStore.save(normalizePointerMovementSettings(settings)); + onSettingsChanged(normalized); + return normalized; + }); +} diff --git a/src/main/pointer-movement-settings-store.test.ts b/src/main/pointer-movement-settings-store.test.ts new file mode 100644 index 0000000..3b5ddcc --- /dev/null +++ b/src/main/pointer-movement-settings-store.test.ts @@ -0,0 +1,84 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_POINTER_MOVEMENT_SETTINGS } from '../shared/pointer-movement-settings'; +import { JsonPointerMovementSettingsStore } from './pointer-movement-settings-store'; + +describe('JsonPointerMovementSettingsStore', () => { + let tempDir: string | null = null; + let warn: ReturnType; + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warn.mockRestore(); + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('loads defaults when the settings file is missing', () => { + expect(store().load()).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS); + }); + + it('loads and normalizes valid settings', () => { + const settingsFile = settingsPath(); + writeFileSync(settingsFile, JSON.stringify({ scalePercent: 123 }), 'utf8'); + + expect(new JsonPointerMovementSettingsStore(settingsFile).load()).toEqual({ scalePercent: 125 }); + }); + + it('loads and migrates percentage settings', () => { + const settingsFile = settingsPath(); + writeFileSync(settingsFile, JSON.stringify({ percentages: { small: 9, medium: 24, large: 50 } }), 'utf8'); + + expect(new JsonPointerMovementSettingsStore(settingsFile).load()).toEqual({ scalePercent: 195 }); + }); + + it('loads and migrates legacy multiplier settings', () => { + const settingsFile = settingsPath(); + writeFileSync(settingsFile, JSON.stringify({ multipliers: { small: 200, medium: 50, large: 100 } }), 'utf8'); + + expect(new JsonPointerMovementSettingsStore(settingsFile).load()).toEqual({ scalePercent: 115 }); + }); + + it('loads defaults and warns when JSON is invalid', () => { + const settingsFile = settingsPath(); + writeFileSync(settingsFile, '{', 'utf8'); + + expect(new JsonPointerMovementSettingsStore(settingsFile).load()).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS); + expect(warn).toHaveBeenCalledWith('Switchify pointer movement settings could not be loaded. Defaults will be used.'); + }); + + it('saves normalized JSON', () => { + const settingsFile = settingsPath(); + const saved = new JsonPointerMovementSettingsStore(settingsFile).save({ scalePercent: 123 }); + + expect(saved).toEqual({ scalePercent: 125 }); + expect(JSON.parse(readFileSync(settingsFile, 'utf8'))).toEqual(saved); + }); + + it('creates the parent directory when saving', () => { + tempDir = mkdtempSync(join(tmpdir(), 'switchify-pointer-movement-')); + const settingsFile = join(tempDir, 'nested', 'pointer-movement-settings.json'); + + new JsonPointerMovementSettingsStore(settingsFile).save(DEFAULT_POINTER_MOVEMENT_SETTINGS); + + expect(JSON.parse(readFileSync(settingsFile, 'utf8'))).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS); + }); + + function store(): JsonPointerMovementSettingsStore { + return new JsonPointerMovementSettingsStore(settingsPath()); + } + + function settingsPath(): string { + if (!tempDir) { + tempDir = mkdtempSync(join(tmpdir(), 'switchify-pointer-movement-')); + } + return join(tempDir, 'pointer-movement-settings.json'); + } +}); diff --git a/src/main/pointer-movement-settings-store.ts b/src/main/pointer-movement-settings-store.ts new file mode 100644 index 0000000..42c96ac --- /dev/null +++ b/src/main/pointer-movement-settings-store.ts @@ -0,0 +1,41 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { + DEFAULT_POINTER_MOVEMENT_SETTINGS, + normalizePointerMovementSettings, + type PointerMovementSettings +} from '../shared/pointer-movement-settings'; + +export class JsonPointerMovementSettingsStore { + constructor(private readonly filePath: string) {} + + load(): PointerMovementSettings { + try { + const raw = readFileSync(this.filePath, 'utf8'); + return normalizePointerMovementSettings(JSON.parse(raw)); + } catch (error) { + if (isMissingFileError(error)) { + return DEFAULT_POINTER_MOVEMENT_SETTINGS; + } + + console.warn('Switchify pointer movement settings could not be loaded. Defaults will be used.'); + return DEFAULT_POINTER_MOVEMENT_SETTINGS; + } + } + + save(settings: PointerMovementSettings): PointerMovementSettings { + const normalized = normalizePointerMovementSettings(settings); + mkdirSync(dirname(this.filePath), { recursive: true }); + writeFileSync(this.filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8'); + return normalized; + } +} + +function isMissingFileError(error: unknown): boolean { + return ( + error !== null && + typeof error === 'object' && + 'code' in error && + (error as { code?: unknown }).code === 'ENOENT' + ); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 002e6ff..689fa9f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; import type { PairingApprovalDecision, PendingPairingApprovalView } from '../shared/pairing-approval'; import type { PairedDeviceView, PcControlStatus } from '../shared/server-status'; import type { CursorOverlaySettings } from '../shared/cursor-overlay-settings'; +import type { PointerMovementSettings } from '../shared/pointer-movement-settings'; import { APP_WINDOW_CLOSE_CHANNEL, APP_WINDOW_MINIMIZE_CHANNEL, @@ -13,6 +14,7 @@ import { GET_CURSOR_OVERLAY_SETTINGS_CHANNEL, GET_PAIRED_DEVICES_CHANNEL, GET_PENDING_PAIRING_REQUESTS_CHANNEL, + GET_POINTER_MOVEMENT_SETTINGS_CHANNEL, GET_SYSTEM_STARTUP_SETTINGS_CHANNEL, GET_UPDATE_STATE_CHANNEL, INSTALL_DOWNLOADED_UPDATE_CHANNEL, @@ -22,6 +24,7 @@ import { SERVER_STATUS_CHANNEL, SET_CURSOR_OVERLAY_ENABLED_CHANNEL, SET_CURSOR_OVERLAY_SETTINGS_CHANNEL, + SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, SET_START_WITH_SYSTEM_CHANNEL, SHOW_SETTINGS_SECTION_CHANNEL, } from '../shared/ipc-channels'; @@ -47,6 +50,10 @@ contextBridge.exposeInMainWorld('switchifyPc', { getCursorOverlaySettings: (): Promise => ipcRenderer.invoke(GET_CURSOR_OVERLAY_SETTINGS_CHANNEL), setCursorOverlaySettings: (settings: CursorOverlaySettings): Promise => ipcRenderer.invoke(SET_CURSOR_OVERLAY_SETTINGS_CHANNEL, settings), + getPointerMovementSettings: (): Promise => + ipcRenderer.invoke(GET_POINTER_MOVEMENT_SETTINGS_CHANNEL), + setPointerMovementSettings: (settings: PointerMovementSettings): Promise => + ipcRenderer.invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, settings), openSettingsWindow: (section?: SettingsSectionId): Promise => ipcRenderer.invoke(OPEN_SETTINGS_WINDOW_CHANNEL, section), onShowSettingsSection: (handler: (section: SettingsSectionId) => void): (() => void) => { diff --git a/src/renderer/SettingsApp.tsx b/src/renderer/SettingsApp.tsx index 98a6b38..a6483d2 100644 --- a/src/renderer/SettingsApp.tsx +++ b/src/renderer/SettingsApp.tsx @@ -99,10 +99,12 @@ export function SettingsApp(): ReactElement { pairedDevices={status.pairedDevices} serverStatus={status.serverStatus} cursorOverlaySettings={status.cursorOverlaySettings} + pointerMovementSettings={status.pointerMovementSettings} systemStartupSettings={systemStartupSettings} onDisconnect={status.disconnectClients} onForgetPairedDevice={status.forgetPairedDevice} onUpdateCursorOverlaySettings={status.updateCursorOverlaySettings} + onUpdatePointerMovementSettings={status.updatePointerMovementSettings} isUpdatingSystemStartup={isUpdatingSystemStartup} onSetStartWithSystem={setStartWithSystem} updateState={updateState} diff --git a/src/renderer/api.d.ts b/src/renderer/api.d.ts index e140882..86c8a9d 100644 --- a/src/renderer/api.d.ts +++ b/src/renderer/api.d.ts @@ -3,6 +3,7 @@ export {}; import type { PairingApprovalDecision, PendingPairingApprovalView } from '../shared/pairing-approval'; import type { PairedDeviceView, PcControlStatus } from '../shared/server-status'; import type { CursorOverlaySettings } from '../shared/cursor-overlay-settings'; +import type { PointerMovementSettings } from '../shared/pointer-movement-settings'; import type { SettingsSectionId } from '../shared/settings'; import type { SystemStartupSettings } from '../shared/system-startup'; import type { UpdateState } from '../shared/update'; @@ -25,6 +26,8 @@ declare global { setCursorOverlayEnabled: (enabled: boolean) => Promise; getCursorOverlaySettings: () => Promise; setCursorOverlaySettings: (settings: CursorOverlaySettings) => Promise; + getPointerMovementSettings: () => Promise; + setPointerMovementSettings: (settings: PointerMovementSettings) => Promise; openSettingsWindow: (section?: SettingsSectionId) => Promise; onShowSettingsSection: (handler: (section: SettingsSectionId) => void) => () => void; openExternalUrl: (url: string) => Promise<{ ok: boolean }>; diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 069080b..dbc6384 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState, type ReactElement } from 'react'; +import { useEffect, useState, type CSSProperties, type ReactElement } from 'react'; +import { MousePointer2 } from 'lucide-react'; import type { CursorOverlayColor, CursorOverlaySettings, @@ -6,6 +7,14 @@ import type { CursorOverlayVisibility } from '../../shared/cursor-overlay-settings'; import { CURSOR_OVERLAY_COLORS } from '../../shared/cursor-overlay-settings'; +import { + BASE_POINTER_MOVEMENT_PERCENTAGES, + normalizePointerMovementSettings, + pointerMovementPercentageFor, + pointerMovementScalePercentFor, + type PointerMovementSettings, + type PointerMovementSizeKey +} from '../../shared/pointer-movement-settings'; import type { PairedDeviceView, PcControlStatus } from '../../shared/server-status'; import type { SystemStartupSettings } from '../../shared/system-startup'; import type { SettingsSectionId } from '../../shared/settings'; @@ -21,10 +30,12 @@ type SettingsViewProps = { pairedDevices: PairedDeviceView[]; serverStatus: PcControlStatus | null; cursorOverlaySettings: CursorOverlaySettings; + pointerMovementSettings: PointerMovementSettings; systemStartupSettings: SystemStartupSettings | null; onDisconnect: () => Promise; onForgetPairedDevice: (deviceId: string) => Promise<{ ok: boolean; reason?: string }>; onUpdateCursorOverlaySettings: (settings: CursorOverlaySettings) => Promise; + onUpdatePointerMovementSettings: (settings: PointerMovementSettings) => Promise; isUpdatingSystemStartup: boolean; onSetStartWithSystem: (enabled: boolean) => Promise; updateState: UpdateState | null; @@ -53,10 +64,12 @@ export function SettingsView({ pairedDevices, serverStatus, cursorOverlaySettings, + pointerMovementSettings, systemStartupSettings, onDisconnect, onForgetPairedDevice, onUpdateCursorOverlaySettings, + onUpdatePointerMovementSettings, isUpdatingSystemStartup, onSetStartWithSystem, updateState, @@ -108,7 +121,9 @@ export function SettingsView({ {selectedSection === 'pointer' ? ( ) : null} {selectedSection === 'updates' ? ( @@ -190,14 +205,27 @@ function BluetoothSettingsSection({ function PointerSettingsSection({ cursorOverlaySettings, - onUpdateCursorOverlaySettings + pointerMovementSettings, + onUpdateCursorOverlaySettings, + onUpdatePointerMovementSettings }: { cursorOverlaySettings: CursorOverlaySettings; + pointerMovementSettings: PointerMovementSettings; onUpdateCursorOverlaySettings: (settings: CursorOverlaySettings) => Promise; + onUpdatePointerMovementSettings: (settings: PointerMovementSettings) => Promise; }): ReactElement { return (

Pointer

+

Movement distance

+

+ Choose how far each Android pointer step moves on this display. +

+ +

Cursor overlay

Promise; +}): ReactElement { + const normalizedSettings = normalizePointerMovementSettings(settings); + const scalePercent = pointerMovementScalePercentFor(normalizedSettings); + const update = (value: number): void => { + void onChange( + normalizePointerMovementSettings({ + scalePercent: value + }) + ); + }; + + return ( +
+
+
+ {pointerMovementScaleOptions.map((value) => ( + + ))} +
+
+ + +
+ ); +} + +const pointerMovementSizeOptions: Array<{ value: PointerMovementSizeKey; label: string }> = [ + { value: 'small', label: 'Small' }, + { value: 'medium', label: 'Medium' }, + { value: 'large', label: 'Large' } +]; + +const pointerMovementScaleOptions = [50, 75, 100, 125, 150, 175, 200]; + +function PointerMovementPreview({ settings }: { settings: PointerMovementSettings }): ReactElement { + const normalizedSettings = normalizePointerMovementSettings(settings); + return ( +
+ {pointerMovementSizeOptions.map((option) => { + const percentage = pointerMovementPercentageFor(normalizedSettings, option.value); + return ( +
+ {option.label} + + {percentage}% +
+ ); + })} +
+ ); +} + +function PointerMovementTable({ settings }: { settings: PointerMovementSettings }): ReactElement { + const normalizedSettings = normalizePointerMovementSettings(settings); + return ( + + + + + + + + + + {pointerMovementSizeOptions.map((option) => ( + + + + + + ))} + +
StepDefaultCurrent
{option.label}{BASE_POINTER_MOVEMENT_PERCENTAGES[option.value]}%{pointerMovementPercentageFor(normalizedSettings, option.value)}%
+ ); +} + function SavedDevicesSettingsSection({ pairedDevices, onForgetPairedDevice diff --git a/src/renderer/styles.css b/src/renderer/styles.css index bc958f8..6807390 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -813,6 +813,138 @@ p { font-weight: 800; } +.settings-section-note { + max-width: 64ch; + margin: calc(var(--space-xs) * -1) 0 var(--space-s); + color: var(--color-text-muted); + font-size: 0.86rem; + line-height: 1.45; +} + +.pointer-movement-controls { + display: grid; + gap: var(--space-xs); +} + +.pointer-movement-button-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(66px, 1fr)); + gap: 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + padding: 4px; + background: var(--color-surface-muted); +} + +.pointer-movement-button-row button { + min-height: 34px; + border-color: transparent; + border-radius: 7px; + padding: 0 var(--space-xs); + background: transparent; + font-size: 0.84rem; + font-weight: 800; +} + +.pointer-movement-button-row button.selected { + border-color: var(--brand-primary); + background: color-mix(in srgb, var(--brand-primary) 16%, transparent); + color: var(--brand-primary); +} + +.pointer-movement-preview { + display: grid; + gap: var(--space-xs); + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + padding: var(--space-s); + background: var(--color-surface-muted); +} + +.pointer-movement-preview-row { + display: grid; + grid-template-columns: minmax(72px, 0.25fr) minmax(140px, 1fr) minmax(52px, auto); + gap: var(--space-s); + align-items: center; +} + +.pointer-movement-preview-label, +.pointer-movement-preview-value { + color: var(--color-text-muted); + font-size: 0.82rem; + font-weight: 800; +} + +.pointer-movement-preview-value { + justify-self: end; + font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +.pointer-movement-preview-track { + container-type: inline-size; + position: relative; + height: 30px; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + background: var(--color-surface); +} + +.pointer-movement-preview-icon { + position: absolute; + left: 8px; + top: 50%; + width: 16px; + height: 16px; + color: var(--brand-primary); + transform: translateY(-50%); + animation: pointer-preview-forward 1.8s ease-in-out infinite; +} + +@keyframes pointer-preview-forward { + 0%, + 18% { + transform: translateY(-50%) translateX(0); + } + + 78%, + 92% { + transform: translateY(-50%) translateX(calc(var(--pointer-preview-distance) * 1cqw)); + } + + 93%, + 100% { + transform: translateY(-50%) translateX(0); + } +} + +.pointer-movement-table { + width: 100%; + border-collapse: collapse; + font-size: 0.84rem; +} + +.pointer-movement-table th, +.pointer-movement-table td { + border-bottom: 1px solid var(--color-border); + padding: 7px 0; + text-align: left; +} + +.pointer-movement-table th { + color: var(--color-text); + font-weight: 800; +} + +.pointer-movement-table td { + color: var(--color-text-muted); +} + +.pointer-movement-table td:last-child, +.pointer-movement-table th:last-child { + text-align: right; +} + .segmented-control { display: grid; grid-template-columns: repeat(auto-fit, minmax(108px, 1fr)); @@ -1015,6 +1147,14 @@ p { gap: 3px; } + .pointer-movement-preview-row { + grid-template-columns: 1fr; + } + + .pointer-movement-preview-value { + justify-self: start; + } + .settings-layout { grid-template-columns: 1fr; width: 100%; @@ -1027,3 +1167,10 @@ p { } } + +@media (prefers-reduced-motion: reduce) { + .pointer-movement-preview-icon { + animation: none; + transform: translateY(-50%) translateX(calc(var(--pointer-preview-distance) * 1cqw)); + } +} diff --git a/src/renderer/useSwitchifyPcStatus.ts b/src/renderer/useSwitchifyPcStatus.ts index e925c4e..3d524f2 100644 --- a/src/renderer/useSwitchifyPcStatus.ts +++ b/src/renderer/useSwitchifyPcStatus.ts @@ -4,6 +4,11 @@ import { normalizeCursorOverlaySettings, type CursorOverlaySettings } from '../shared/cursor-overlay-settings'; +import { + DEFAULT_POINTER_MOVEMENT_SETTINGS, + normalizePointerMovementSettings, + type PointerMovementSettings +} from '../shared/pointer-movement-settings'; import { deriveDesktopUiState, type DesktopUiState } from '../shared/desktop-ui-state'; import type { PairingApprovalDecision, PendingPairingApprovalView } from '../shared/pairing-approval'; import type { PairedDeviceView, PcControlStatus } from '../shared/server-status'; @@ -16,10 +21,12 @@ export type SwitchifyPcStatusViewModel = { pendingPairingRequests: PendingPairingApprovalView[]; connectedDevices: ConnectedDeviceView[]; cursorOverlaySettings: CursorOverlaySettings; + pointerMovementSettings: PointerMovementSettings; refresh: () => Promise; disconnectClients: () => Promise; forgetPairedDevice: (deviceId: string) => Promise<{ ok: boolean; reason?: string }>; updateCursorOverlaySettings: (settings: CursorOverlaySettings) => Promise; + updatePointerMovementSettings: (settings: PointerMovementSettings) => Promise; respondToPairingRequest: (requestId: string, decision: PairingApprovalDecision) => Promise; }; @@ -30,6 +37,9 @@ export function useSwitchifyPcStatus(bridge: Window['switchifyPc']): SwitchifyPc const [cursorOverlaySettings, setCursorOverlaySettings] = useState( DEFAULT_CURSOR_OVERLAY_SETTINGS ); + const [pointerMovementSettings, setPointerMovementSettings] = useState( + DEFAULT_POINTER_MOVEMENT_SETTINGS + ); const refresh = useCallback(async (): Promise => { const [status, devices, requests] = await Promise.all([ @@ -66,8 +76,10 @@ export function useSwitchifyPcStatus(bridge: Window['switchifyPc']): SwitchifyPc const load = async (): Promise => { const overlayEnabled = await bridge.getCursorOverlayEnabled(); const overlaySettings = await bridge.getCursorOverlaySettings(); + const movementSettings = await bridge.getPointerMovementSettings(); if (!cancelled) { setCursorOverlaySettings(normalizeCursorOverlaySettings({ ...overlaySettings, enabled: overlayEnabled })); + setPointerMovementSettings(normalizePointerMovementSettings(movementSettings)); } await refresh(); }; @@ -90,6 +102,15 @@ export function useSwitchifyPcStatus(bridge: Window['switchifyPc']): SwitchifyPc [bridge] ); + const updatePointerMovementSettings = useCallback( + async (settings: PointerMovementSettings): Promise => { + const normalized = normalizePointerMovementSettings(settings); + setPointerMovementSettings(normalized); + setPointerMovementSettings(await bridge.setPointerMovementSettings(normalized)); + }, + [bridge] + ); + const uiState = deriveDesktopUiState(serverStatus, pairedDevices); const respondToPairingRequest = useCallback( @@ -107,10 +128,12 @@ export function useSwitchifyPcStatus(bridge: Window['switchifyPc']): SwitchifyPc pendingPairingRequests, connectedDevices: toConnectedDeviceViews(serverStatus?.connectedClients ?? [], pairedDevices), cursorOverlaySettings, + pointerMovementSettings, refresh, disconnectClients, forgetPairedDevice, updateCursorOverlaySettings, + updatePointerMovementSettings, respondToPairingRequest }; } diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 23229a5..0f5da96 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -6,6 +6,8 @@ export const GET_CURSOR_OVERLAY_ENABLED_CHANNEL = 'cursor-overlay:get-enabled'; export const SET_CURSOR_OVERLAY_ENABLED_CHANNEL = 'cursor-overlay:set-enabled'; export const GET_CURSOR_OVERLAY_SETTINGS_CHANNEL = 'cursor-overlay:get-settings'; export const SET_CURSOR_OVERLAY_SETTINGS_CHANNEL = 'cursor-overlay:set-settings'; +export const GET_POINTER_MOVEMENT_SETTINGS_CHANNEL = 'pointer-movement:get-settings'; +export const SET_POINTER_MOVEMENT_SETTINGS_CHANNEL = 'pointer-movement:set-settings'; export const GET_PENDING_PAIRING_REQUESTS_CHANNEL = 'pairing-approval:get-pending'; export const RESPOND_TO_PAIRING_REQUEST_CHANNEL = 'pairing-approval:respond'; export const OPEN_SETTINGS_WINDOW_CHANNEL = 'settings:open-window'; diff --git a/src/shared/pointer-movement-settings.test.ts b/src/shared/pointer-movement-settings.test.ts new file mode 100644 index 0000000..d99f3e5 --- /dev/null +++ b/src/shared/pointer-movement-settings.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_POINTER_MOVEMENT_SETTINGS, + normalizePointerMovementSettings, + pointerMovementFractionFor, + pointerMovementPercentageFor, + pointerMovementScalePercentFor +} from './pointer-movement-settings'; + +describe('normalizePointerMovementSettings', () => { + it('uses defaults for missing input', () => { + expect(normalizePointerMovementSettings(null)).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS); + }); + + it('preserves valid scale percentages', () => { + expect(normalizePointerMovementSettings({ scalePercent: 125 })).toEqual({ scalePercent: 125 }); + }); + + it('clamps scale values below the minimum', () => { + expect(normalizePointerMovementSettings({ scalePercent: 10 })).toEqual({ scalePercent: 50 }); + }); + + it('clamps scale values above the maximum', () => { + expect(normalizePointerMovementSettings({ scalePercent: 1000 })).toEqual({ scalePercent: 200 }); + }); + + it('rounds scale values to the nearest five percent', () => { + expect(normalizePointerMovementSettings({ scalePercent: 123 })).toEqual({ scalePercent: 125 }); + }); + + it('falls back for non-finite and non-number values', () => { + expect(normalizePointerMovementSettings({ scalePercent: Number.NaN })).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS); + expect(normalizePointerMovementSettings({ scalePercent: '125' })).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS); + }); + + it('migrates percentage settings to one scale value', () => { + expect( + normalizePointerMovementSettings({ + percentages: { + small: 9, + medium: 24, + large: 50 + } + }) + ).toEqual({ scalePercent: 195 }); + }); + + it('migrates legacy multiplier settings to one scale value', () => { + expect( + normalizePointerMovementSettings({ + multipliers: { + small: 200, + medium: 50, + large: 100 + } + }) + ).toEqual({ scalePercent: 115 }); + }); +}); + +describe('pointer movement setting helpers', () => { + it('returns normalized scale percentages', () => { + expect(pointerMovementScalePercentFor({ scalePercent: 123 })).toBe(125); + }); + + it('returns derived movement percentages', () => { + expect(pointerMovementPercentageFor({ scalePercent: 150 }, 'small')).toBe(7); + expect(pointerMovementPercentageFor({ scalePercent: 150 }, 'medium')).toBe(18); + expect(pointerMovementPercentageFor({ scalePercent: 150 }, 'large')).toBe(39); + }); + + it('returns fractions for derived movement percentages', () => { + expect(pointerMovementFractionFor(DEFAULT_POINTER_MOVEMENT_SETTINGS, 'small')).toBe(0.045); + expect(pointerMovementFractionFor(DEFAULT_POINTER_MOVEMENT_SETTINGS, 'medium')).toBe(0.12); + expect(pointerMovementFractionFor(DEFAULT_POINTER_MOVEMENT_SETTINGS, 'large')).toBe(0.26); + }); +}); diff --git a/src/shared/pointer-movement-settings.ts b/src/shared/pointer-movement-settings.ts new file mode 100644 index 0000000..0d7d9ee --- /dev/null +++ b/src/shared/pointer-movement-settings.ts @@ -0,0 +1,115 @@ +export type PointerMovementSizeKey = 'small' | 'medium' | 'large'; + +export type PointerMovementSettings = { + scalePercent: number; +}; + +export const BASE_POINTER_MOVEMENT_PERCENTAGES: Record = { + small: 4.5, + medium: 12, + large: 26 +}; + +export const DEFAULT_POINTER_MOVEMENT_SETTINGS: PointerMovementSettings = { + scalePercent: 100 +}; + +export const POINTER_MOVEMENT_SCALE_MIN = 50; +export const POINTER_MOVEMENT_SCALE_MAX = 200; +export const POINTER_MOVEMENT_SCALE_STEP = 5; + +const pointerMovementSizeKeys: PointerMovementSizeKey[] = ['small', 'medium', 'large']; +const DISPLAY_PERCENTAGE_MIN = 1; +const DISPLAY_PERCENTAGE_MAX = 50; +const DISPLAY_PERCENTAGE_STEP = 0.5; + +export function normalizePointerMovementSettings(value: unknown): PointerMovementSettings { + if (!value || typeof value !== 'object') { + return { ...DEFAULT_POINTER_MOVEMENT_SETTINGS }; + } + + const candidate = value as Partial & { + percentages?: unknown; + multipliers?: unknown; + }; + + if ('scalePercent' in candidate) { + return { scalePercent: normalizeScale(candidate.scalePercent) }; + } + + if (candidate.percentages && typeof candidate.percentages === 'object') { + return { scalePercent: normalizeScale(scaleFromPercentages(candidate.percentages)) }; + } + + if (candidate.multipliers && typeof candidate.multipliers === 'object') { + return { scalePercent: normalizeScale(scaleFromLegacyMultipliers(candidate.multipliers)) }; + } + + return { ...DEFAULT_POINTER_MOVEMENT_SETTINGS }; +} + +export function pointerMovementScalePercentFor(settings: PointerMovementSettings): number { + return normalizePointerMovementSettings(settings).scalePercent; +} + +export function pointerMovementPercentageFor( + settings: PointerMovementSettings, + size: PointerMovementSizeKey +): number { + const scale = pointerMovementScalePercentFor(settings) / 100; + return normalizeDisplayPercentage(BASE_POINTER_MOVEMENT_PERCENTAGES[size] * scale); +} + +export function pointerMovementFractionFor( + settings: PointerMovementSettings, + size: PointerMovementSizeKey +): number { + return pointerMovementPercentageFor(settings, size) / 100; +} + +function scaleFromPercentages(value: unknown): number { + const percentages = value as Partial>; + const scales = pointerMovementSizeKeys + .map((size) => { + const percentage = percentages[size]; + if (typeof percentage !== 'number' || !Number.isFinite(percentage)) return null; + return (normalizeDisplayPercentage(percentage) / BASE_POINTER_MOVEMENT_PERCENTAGES[size]) * 100; + }) + .filter((scale): scale is number => scale !== null); + + if (scales.length === 0) return DEFAULT_POINTER_MOVEMENT_SETTINGS.scalePercent; + return scales.reduce((sum, scale) => sum + scale, 0) / scales.length; +} + +function scaleFromLegacyMultipliers(value: unknown): number { + const multipliers = value as Partial>; + const scales = pointerMovementSizeKeys + .map((size) => { + const multiplier = multipliers[size]; + return typeof multiplier === 'number' && Number.isFinite(multiplier) ? multiplier : null; + }) + .filter((scale): scale is number => scale !== null); + + if (scales.length === 0) return DEFAULT_POINTER_MOVEMENT_SETTINGS.scalePercent; + return scales.reduce((sum, scale) => sum + scale, 0) / scales.length; +} + +function normalizeScale(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_POINTER_MOVEMENT_SETTINGS.scalePercent; + } + + return clamp(roundToStep(value, POINTER_MOVEMENT_SCALE_STEP), POINTER_MOVEMENT_SCALE_MIN, POINTER_MOVEMENT_SCALE_MAX); +} + +function normalizeDisplayPercentage(value: number): number { + return clamp(roundToStep(value, DISPLAY_PERCENTAGE_STEP), DISPLAY_PERCENTAGE_MIN, DISPLAY_PERCENTAGE_MAX); +} + +function roundToStep(value: number, step: number): number { + return Math.round(value / step) * step; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +}