From b67bea56510e443872b734bbf9dddbe3c3e7f5e6 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 23 Jun 2026 15:53:37 +0100 Subject: [PATCH 01/13] Add adjustable pointer movement sizes --- src/main/index.ts | 15 ++- src/main/input/libnut-win32-adapter.test.ts | 66 +++++++++++ src/main/input/libnut-win32-adapter.ts | 56 +++++++-- src/main/input/pointer-profile.test.ts | 65 ++++++++++ src/main/input/pointer-profile.ts | 18 ++- .../pointer-movement-settings-ipc.test.ts | 89 ++++++++++++++ src/main/pointer-movement-settings-ipc.ts | 19 +++ .../pointer-movement-settings-store.test.ts | 88 ++++++++++++++ src/main/pointer-movement-settings-store.ts | 41 +++++++ src/preload/index.ts | 7 ++ src/renderer/SettingsApp.tsx | 2 + src/renderer/api.d.ts | 3 + src/renderer/components/SettingsPanel.tsx | 110 ++++++++++++++++- src/renderer/styles.css | 112 ++++++++++++++++++ src/renderer/useSwitchifyPcStatus.ts | 21 ++++ src/shared/ipc-channels.ts | 2 + src/shared/pointer-movement-settings.test.ts | 101 ++++++++++++++++ src/shared/pointer-movement-settings.ts | 77 ++++++++++++ 18 files changed, 876 insertions(+), 16 deletions(-) create mode 100644 src/main/pointer-movement-settings-ipc.test.ts create mode 100644 src/main/pointer-movement-settings-ipc.ts create mode 100644 src/main/pointer-movement-settings-store.test.ts create mode 100644 src/main/pointer-movement-settings-store.ts create mode 100644 src/shared/pointer-movement-settings.test.ts create mode 100644 src/shared/pointer-movement-settings.ts diff --git a/src/main/index.ts b/src/main/index.ts index 20f3d18..d6e0832 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(); @@ -294,7 +300,8 @@ if (!gotSingleInstanceLock) { display: { bounds: display.bounds, scaleFactor: display.scaleFactor - } + }, + movementSettings: pointerMovementSettings }); }, onStatusChange: (status) => { @@ -322,6 +329,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..0ed8623 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'; @@ -138,6 +139,71 @@ describe('calculateDisplayNormalizedMouseTarget', () => { y: 26 }); }); + + it('applies the small movement multiplier', () => { + expect( + calculateDisplayNormalizedMouseTarget( + { x: 100, y: 200 }, + { dx: 48, dy: 0 }, + { bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 }, + { multipliers: { small: 200, medium: 100, large: 100 } } + ) + ).toEqual({ + x: 196, + y: 200 + }); + }); + + it('applies the medium movement multiplier', () => { + expect( + calculateDisplayNormalizedMouseTarget( + { x: 100, y: 200 }, + { dx: 128, dy: 0 }, + { bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 }, + { multipliers: { small: 100, medium: 50, large: 100 } } + ) + ).toEqual({ + x: 164, + y: 200 + }); + }); + + it('combines display normalization with customized movement multipliers', () => { + expect( + calculateDisplayNormalizedMouseTarget( + { x: 100, y: 200 }, + { dx: 128, dy: 0 }, + { bounds: { x: 0, y: 0, width: 3840, height: 2160 }, scaleFactor: 1 }, + { multipliers: { small: 100, medium: 150, large: 100 } } + ) + ).toEqual({ + x: 484, + y: 200 + }); + }); + + it('falls back for invalid display data while applying movement multipliers', () => { + expect( + calculateDisplayNormalizedMouseTarget( + { x: 10, y: 20 }, + { dx: 48, dy: 0 }, + { bounds: { x: 0, y: 0, width: 0, height: 2160 }, scaleFactor: 0 }, + { multipliers: { small: 200, medium: 100, large: 100 } } + ) + ).toEqual({ + x: 106, + y: 20 + }); + }); +}); + +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..efc7011 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, + pointerMovementScaleFor, + 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,10 @@ 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 movementScale = pointerMovementScaleFor(settings, size); + const multiplier = scaleFactor * (shortEdge / REFERENCE_POINTER_SHORT_EDGE) * movementScale; return { x: Math.round(current.x + delta.dx * multiplier), @@ -157,6 +188,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..d3e0634 100644 --- a/src/main/input/pointer-profile.test.ts +++ b/src/main/input/pointer-profile.test.ts @@ -55,6 +55,71 @@ describe('createPointerMovementProfile', () => { }); }); + it('applies pointer movement multipliers to recommended deltas', () => { + expect( + createPointerMovementProfile({ + cursor: { x: 100, y: 100 }, + display: { + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + scaleFactor: 1 + }, + movementSettings: { + multipliers: { + small: 50, + medium: 125, + large: 200 + } + } + }).recommendedDeltas + ).toEqual({ + small: 24, + medium: 160, + large: 500 + }); + }); + + it('divides customized 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 + }, + movementSettings: { + multipliers: { + small: 100, + medium: 150, + large: 100 + } + } + }).recommendedDeltas.medium + ).toBe(96); + }); + + it('normalizes invalid movement settings to defaults', () => { + expect( + createPointerMovementProfile({ + cursor: { x: 100, y: 100 }, + display: { + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + scaleFactor: 1 + }, + movementSettings: { + multipliers: { + small: Number.NaN, + medium: Number.POSITIVE_INFINITY, + large: 0 + } + } + }).recommendedDeltas + ).toEqual({ + small: 48, + medium: 128, + large: 140 + }); + }); + it('keeps profile deltas stable on a 4K display at 1x scale', () => { expect( createPointerMovementProfile({ diff --git a/src/main/input/pointer-profile.ts b/src/main/input/pointer-profile.ts index 9b8ac3d..79209af 100644 --- a/src/main/input/pointer-profile.ts +++ b/src/main/input/pointer-profile.ts @@ -1,4 +1,9 @@ import { MAX_POINTER_DELTA, NO_ACK_CONTROL_COMMAND_TYPES, type PointerMovementProfile } from '../../shared/protocol'; +import { + normalizePointerMovementSettings, + pointerMovementScaleFor, + type PointerMovementSettings +} from '../../shared/pointer-movement-settings'; type Point = { x: number; y: number }; type Bounds = { x: number; y: number; width: number; height: number }; @@ -15,12 +20,19 @@ export function createPointerMovementProfile(input: { bounds: Bounds; scaleFactor: number; }; + movementSettings?: PointerMovementSettings; maxDelta?: number; }): PointerMovementProfile { const bounds = normalizeBounds(input.display.bounds); const scaleFactor = Number.isFinite(input.display.scaleFactor) && input.display.scaleFactor > 0 ? input.display.scaleFactor : 1; const maxDelta = input.maxDelta ?? MAX_POINTER_DELTA; + const movementSettings = normalizePointerMovementSettings(input.movementSettings); + const targetNativeDeltas = { + small: TARGET_REFERENCE_NATIVE_DELTAS.small * pointerMovementScaleFor(movementSettings, 'small'), + medium: TARGET_REFERENCE_NATIVE_DELTAS.medium * pointerMovementScaleFor(movementSettings, 'medium'), + large: TARGET_REFERENCE_NATIVE_DELTAS.large * pointerMovementScaleFor(movementSettings, 'large') + }; return { displayId: `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}:${scaleFactor}`, @@ -28,9 +40,9 @@ export function createPointerMovementProfile(input: { bounds, maxDelta, recommendedDeltas: { - small: toLogicalDelta(TARGET_REFERENCE_NATIVE_DELTAS.small, scaleFactor, maxDelta), - medium: toLogicalDelta(TARGET_REFERENCE_NATIVE_DELTAS.medium, scaleFactor, maxDelta), - large: toLogicalDelta(TARGET_REFERENCE_NATIVE_DELTAS.large, scaleFactor, maxDelta) + small: toLogicalDelta(targetNativeDeltas.small, scaleFactor, maxDelta), + medium: toLogicalDelta(targetNativeDeltas.medium, scaleFactor, maxDelta), + large: toLogicalDelta(targetNativeDeltas.large, scaleFactor, maxDelta) }, capabilities: { noAckMouseMove: true, 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..8d5bbf3 --- /dev/null +++ b/src/main/pointer-movement-settings-ipc.test.ts @@ -0,0 +1,89 @@ +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 = { multipliers: { small: 75, medium: 100, large: 150 } }; + 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, { + multipliers: { small: 10, medium: 123, large: 1000 } + }) + ).resolves.toEqual({ + multipliers: { + small: 50, + medium: 125, + large: 200 + } + }); + expect(store.save).toHaveBeenCalledWith({ + multipliers: { + small: 50, + medium: 125, + large: 200 + } + }); + }); + + it('notifies when settings change', async () => { + const store = createStore(); + const onSettingsChanged = vi.fn(); + + registerPointerMovementSettingsIpc(store, onSettingsChanged); + await invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, { multipliers: { small: 75 } }); + + expect(onSettingsChanged).toHaveBeenCalledWith({ + multipliers: { + small: 75, + medium: 100, + large: 100 + } + }); + }); +}); + +function createStore(settings: PointerMovementSettings = { multipliers: { small: 100, medium: 100, large: 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..d591542 --- /dev/null +++ b/src/main/pointer-movement-settings-store.test.ts @@ -0,0 +1,88 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +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({ multipliers: { small: 75, medium: 123, large: 300 } }), 'utf8'); + + expect(new JsonPointerMovementSettingsStore(settingsFile).load()).toEqual({ + multipliers: { + small: 75, + medium: 125, + large: 200 + } + }); + }); + + 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({ + multipliers: { + small: 10, + medium: 123, + large: 250 + } + }); + + expect(saved).toEqual({ + multipliers: { + small: 50, + medium: 125, + large: 200 + } + }); + 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..744f7f4 --- /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 { multipliers: { ...DEFAULT_POINTER_MOVEMENT_SETTINGS.multipliers } }; + } + + console.warn('Switchify pointer movement settings could not be loaded. Defaults will be used.'); + return { multipliers: { ...DEFAULT_POINTER_MOVEMENT_SETTINGS.multipliers } }; + } + } + + 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..c270c23 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactElement } from 'react'; +import { useEffect, useState, type CSSProperties, type ReactElement } from 'react'; import type { CursorOverlayColor, CursorOverlaySettings, @@ -6,6 +6,15 @@ import type { CursorOverlayVisibility } from '../../shared/cursor-overlay-settings'; import { CURSOR_OVERLAY_COLORS } from '../../shared/cursor-overlay-settings'; +import { + POINTER_MOVEMENT_MULTIPLIER_MAX, + POINTER_MOVEMENT_MULTIPLIER_MIN, + POINTER_MOVEMENT_MULTIPLIER_STEP, + normalizePointerMovementSettings, + pointerMovementMultiplierFor, + 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,24 @@ 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 sizes

+ +

Cursor overlay

Promise; +}): ReactElement { + const normalizedSettings = normalizePointerMovementSettings(settings); + const update = (size: PointerMovementSizeKey, value: number): void => { + void onChange( + normalizePointerMovementSettings({ + multipliers: { + ...normalizedSettings.multipliers, + [size]: value + } + }) + ); + }; + + return ( +
+
+ {pointerMovementSizeOptions.map((option) => { + const value = pointerMovementMultiplierFor(normalizedSettings, option.value); + return ( + + ); + })} +
+ +
+ ); +} + +function PointerMovementPreview({ settings }: { settings: PointerMovementSettings }): ReactElement { + const normalizedSettings = normalizePointerMovementSettings(settings); + return ( +
+ {pointerMovementSizeOptions.map((option, index) => { + const multiplier = pointerMovementMultiplierFor(normalizedSettings, option.value); + return ( +
+ {option.label} + + {multiplier}% +
+ ); + })} +
+ ); +} + +const pointerMovementSizeOptions: Array<{ value: PointerMovementSizeKey; label: string }> = [ + { value: 'small', label: 'Small' }, + { value: 'medium', label: 'Medium' }, + { value: 'large', label: 'Large' } +]; + function SavedDevicesSettingsSection({ pairedDevices, onForgetPairedDevice diff --git a/src/renderer/styles.css b/src/renderer/styles.css index bc958f8..5af567b 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -813,6 +813,100 @@ p { font-weight: 800; } +.pointer-movement-controls { + display: grid; + gap: var(--space-xs); +} + +.pointer-movement-row { + display: grid; + grid-template-columns: minmax(72px, 0.25fr) minmax(120px, 1fr) minmax(48px, auto); + gap: var(--space-s); + align-items: center; +} + +.pointer-movement-label, +.pointer-movement-value { + color: var(--color-text); + font-size: 0.88rem; + font-weight: 800; +} + +.pointer-movement-value { + justify-self: end; + font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +.pointer-movement-slider { + width: 100%; + accent-color: var(--brand-primary); +} + +.pointer-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-preview-row { + display: grid; + grid-template-columns: minmax(72px, 0.24fr) minmax(120px, 1fr) minmax(48px, auto); + gap: var(--space-s); + align-items: center; +} + +.pointer-preview-row span, +.pointer-preview-row strong { + color: var(--color-text-muted); + font-size: 0.82rem; +} + +.pointer-preview-row strong { + justify-self: end; + font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +.pointer-preview-track { + container-type: inline-size; + position: relative; + height: 28px; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + background: var(--color-surface); +} + +.pointer-preview-dot { + position: absolute; + top: 50%; + left: 8px; + width: 12px; + height: 12px; + border-radius: var(--radius-pill); + background: var(--brand-primary); + transform: translateY(-50%); + animation: pointer-preview-travel var(--pointer-preview-duration, 2s) ease-in-out infinite; +} + +@keyframes pointer-preview-travel { + 0%, + 15% { + transform: translateY(-50%) translateX(0); + } + + 65%, + 85% { + transform: translateY(-50%) translateX(calc(var(--pointer-preview-scale) * 34cqw)); + } + + 100% { + transform: translateY(-50%) translateX(0); + } +} + .segmented-control { display: grid; grid-template-columns: repeat(auto-fit, minmax(108px, 1fr)); @@ -1015,6 +1109,17 @@ p { gap: 3px; } + .pointer-movement-row, + .pointer-preview-row { + grid-template-columns: 1fr; + gap: 4px; + } + + .pointer-movement-value, + .pointer-preview-row strong { + justify-self: start; + } + .settings-layout { grid-template-columns: 1fr; width: 100%; @@ -1027,3 +1132,10 @@ p { } } + +@media (prefers-reduced-motion: reduce) { + .pointer-preview-dot { + animation: none; + transform: translateY(-50%) translateX(calc(var(--pointer-preview-scale) * 34cqw)); + } +} diff --git a/src/renderer/useSwitchifyPcStatus.ts b/src/renderer/useSwitchifyPcStatus.ts index e925c4e..6dd1ac0 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,13 @@ export function useSwitchifyPcStatus(bridge: Window['switchifyPc']): SwitchifyPc [bridge] ); + const updatePointerMovementSettings = useCallback( + async (settings: PointerMovementSettings): Promise => { + setPointerMovementSettings(await bridge.setPointerMovementSettings(settings)); + }, + [bridge] + ); + const uiState = deriveDesktopUiState(serverStatus, pairedDevices); const respondToPairingRequest = useCallback( @@ -107,10 +126,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..a48ac57 --- /dev/null +++ b/src/shared/pointer-movement-settings.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_POINTER_MOVEMENT_SETTINGS, + normalizePointerMovementSettings, + pointerMovementMultiplierFor, + pointerMovementScaleFor +} from './pointer-movement-settings'; + +describe('normalizePointerMovementSettings', () => { + it('uses defaults for missing input', () => { + expect(normalizePointerMovementSettings(null)).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS); + }); + + it('preserves valid multipliers', () => { + expect( + normalizePointerMovementSettings({ + multipliers: { + small: 75, + medium: 125, + large: 175 + } + }) + ).toEqual({ + multipliers: { + small: 75, + medium: 125, + large: 175 + } + }); + }); + + it('fills missing sizes with defaults', () => { + expect( + normalizePointerMovementSettings({ + multipliers: { + small: 125 + } + }) + ).toEqual({ + multipliers: { + small: 125, + medium: 100, + large: 100 + } + }); + }); + + it('clamps values below the minimum', () => { + expect( + normalizePointerMovementSettings({ + multipliers: { + small: 10 + } + }).multipliers.small + ).toBe(50); + }); + + it('clamps values above the maximum', () => { + expect( + normalizePointerMovementSettings({ + multipliers: { + large: 1000 + } + }).multipliers.large + ).toBe(200); + }); + + it('rounds values to the nearest five percent', () => { + expect( + normalizePointerMovementSettings({ + multipliers: { + medium: 123 + } + }).multipliers.medium + ).toBe(125); + }); + + it('falls back for non-finite and non-number values', () => { + expect( + normalizePointerMovementSettings({ + multipliers: { + small: Number.NaN, + medium: '150', + large: Number.POSITIVE_INFINITY + } + }) + ).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS); + }); +}); + +describe('pointer movement setting helpers', () => { + it('returns normalized multipliers', () => { + expect(pointerMovementMultiplierFor({ multipliers: { small: 49, medium: 101, large: 202 } }, 'small')).toBe(50); + }); + + it('returns scale factors for normalized multipliers', () => { + expect(pointerMovementScaleFor({ multipliers: { small: 50, medium: 100, large: 200 } }, 'small')).toBe(0.5); + expect(pointerMovementScaleFor({ multipliers: { small: 50, medium: 100, large: 200 } }, 'medium')).toBe(1); + expect(pointerMovementScaleFor({ multipliers: { small: 50, medium: 100, large: 200 } }, 'large')).toBe(2); + }); +}); diff --git a/src/shared/pointer-movement-settings.ts b/src/shared/pointer-movement-settings.ts new file mode 100644 index 0000000..11cfd73 --- /dev/null +++ b/src/shared/pointer-movement-settings.ts @@ -0,0 +1,77 @@ +export type PointerMovementSizeKey = 'small' | 'medium' | 'large'; + +export type PointerMovementSettings = { + multipliers: Record; +}; + +export const DEFAULT_POINTER_MOVEMENT_SETTINGS: PointerMovementSettings = { + multipliers: { + small: 100, + medium: 100, + large: 100 + } +}; + +export const POINTER_MOVEMENT_MULTIPLIER_MIN = 50; +export const POINTER_MOVEMENT_MULTIPLIER_MAX = 200; +export const POINTER_MOVEMENT_MULTIPLIER_STEP = 5; + +const pointerMovementSizeKeys: PointerMovementSizeKey[] = ['small', 'medium', 'large']; + +export function normalizePointerMovementSettings(value: unknown): PointerMovementSettings { + if (!value || typeof value !== 'object') { + return cloneDefaultSettings(); + } + + const candidate = value as Partial; + const multipliers = candidate.multipliers; + if (!multipliers || typeof multipliers !== 'object') { + return cloneDefaultSettings(); + } + + return { + multipliers: { + small: normalizeMultiplier((multipliers as Partial>).small, 'small'), + medium: normalizeMultiplier((multipliers as Partial>).medium, 'medium'), + large: normalizeMultiplier((multipliers as Partial>).large, 'large') + } + }; +} + +export function pointerMovementMultiplierFor( + settings: PointerMovementSettings, + size: PointerMovementSizeKey +): number { + return normalizePointerMovementSettings(settings).multipliers[size]; +} + +export function pointerMovementScaleFor( + settings: PointerMovementSettings, + size: PointerMovementSizeKey +): number { + return pointerMovementMultiplierFor(settings, size) / 100; +} + +function normalizeMultiplier(value: unknown, size: PointerMovementSizeKey): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_POINTER_MOVEMENT_SETTINGS.multipliers[size]; + } + + return clamp(roundToStep(value), POINTER_MOVEMENT_MULTIPLIER_MIN, POINTER_MOVEMENT_MULTIPLIER_MAX); +} + +function roundToStep(value: number): number { + return Math.round(value / POINTER_MOVEMENT_MULTIPLIER_STEP) * POINTER_MOVEMENT_MULTIPLIER_STEP; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function cloneDefaultSettings(): PointerMovementSettings { + return { + multipliers: Object.fromEntries( + pointerMovementSizeKeys.map((size) => [size, DEFAULT_POINTER_MOVEMENT_SETTINGS.multipliers[size]]) + ) as Record + }; +} From 30a1c8446bebe26c461d246cf750973859e1ece4 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 23 Jun 2026 21:04:59 +0100 Subject: [PATCH 02/13] Clarify pointer movement settings --- src/renderer/components/SettingsPanel.tsx | 18 ++++++++---- src/renderer/styles.css | 34 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index c270c23..dbce10e 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -218,6 +218,10 @@ function PointerSettingsSection({

Pointer

Movement sizes

+

+ Adjust how far each Android pointer step moves the cursor. 100% is the default. Reconnect Android or reopen + control to refresh its movement profile. +

update(option.value, Number(event.currentTarget.value))} /> {value}% @@ -281,7 +285,11 @@ function PointerMovementSettingsControls({ function PointerMovementPreview({ settings }: { settings: PointerMovementSettings }): ReactElement { const normalizedSettings = normalizePointerMovementSettings(settings); return ( -
+
+ {pointerMovementSizeOptions.map((option, index) => { const multiplier = pointerMovementMultiplierFor(normalizedSettings, option.value); return ( @@ -307,9 +315,9 @@ function PointerMovementPreview({ settings }: { settings: PointerMovementSetting } const pointerMovementSizeOptions: Array<{ value: PointerMovementSizeKey; label: string }> = [ - { value: 'small', label: 'Small' }, - { value: 'medium', label: 'Medium' }, - { value: 'large', label: 'Large' } + { value: 'small', label: 'Small step' }, + { value: 'medium', label: 'Medium step' }, + { value: 'large', label: 'Large step' } ]; function SavedDevicesSettingsSection({ diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 5af567b..9b230e3 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -813,6 +813,14 @@ 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); @@ -851,6 +859,25 @@ p { background: var(--color-surface-muted); } +.pointer-preview-scale { + display: grid; + grid-template-columns: minmax(72px, 0.24fr) minmax(120px, 1fr) minmax(48px, auto); + gap: var(--space-s); + align-items: center; + color: var(--color-text-muted); + font-size: 0.76rem; + font-weight: 700; +} + +.pointer-preview-scale span:first-child { + grid-column: 2; +} + +.pointer-preview-scale span:last-child { + grid-column: 3; + justify-self: end; +} + .pointer-preview-row { display: grid; grid-template-columns: minmax(72px, 0.24fr) minmax(120px, 1fr) minmax(48px, auto); @@ -1110,11 +1137,18 @@ p { } .pointer-movement-row, + .pointer-preview-scale, .pointer-preview-row { grid-template-columns: 1fr; gap: 4px; } + .pointer-preview-scale span:first-child, + .pointer-preview-scale span:last-child { + grid-column: auto; + justify-self: start; + } + .pointer-movement-value, .pointer-preview-row strong { justify-self: start; From 02ba9b3afa899ec1d61e3f7ee9f5a9b25e0ba0ae Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 23 Jun 2026 21:33:19 +0100 Subject: [PATCH 03/13] Use screen percentages for pointer movement settings --- src/main/input/libnut-win32-adapter.test.ts | 16 +-- src/main/input/libnut-win32-adapter.ts | 5 +- src/main/input/pointer-profile.test.ts | 74 +++++------ src/main/input/pointer-profile.ts | 15 +-- .../pointer-movement-settings-ipc.test.ts | 32 ++--- .../pointer-movement-settings-store.test.ts | 41 +++--- src/main/pointer-movement-settings-store.ts | 4 +- src/renderer/components/SettingsPanel.tsx | 74 +++-------- src/renderer/styles.css | 117 +----------------- src/shared/pointer-movement-settings.test.ts | 94 ++++++++------ src/shared/pointer-movement-settings.ts | 74 +++++++---- 11 files changed, 231 insertions(+), 315 deletions(-) diff --git a/src/main/input/libnut-win32-adapter.test.ts b/src/main/input/libnut-win32-adapter.test.ts index 0ed8623..bbae748 100644 --- a/src/main/input/libnut-win32-adapter.test.ts +++ b/src/main/input/libnut-win32-adapter.test.ts @@ -140,13 +140,13 @@ describe('calculateDisplayNormalizedMouseTarget', () => { }); }); - it('applies the small movement multiplier', () => { + it('applies the small movement percentage', () => { expect( calculateDisplayNormalizedMouseTarget( { x: 100, y: 200 }, { dx: 48, dy: 0 }, { bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 }, - { multipliers: { small: 200, medium: 100, large: 100 } } + { percentages: { small: 9, medium: 12, large: 26 } } ) ).toEqual({ x: 196, @@ -154,13 +154,13 @@ describe('calculateDisplayNormalizedMouseTarget', () => { }); }); - it('applies the medium movement multiplier', () => { + it('applies the medium movement percentage', () => { expect( calculateDisplayNormalizedMouseTarget( { x: 100, y: 200 }, { dx: 128, dy: 0 }, { bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 }, - { multipliers: { small: 100, medium: 50, large: 100 } } + { percentages: { small: 4.5, medium: 6, large: 26 } } ) ).toEqual({ x: 164, @@ -168,13 +168,13 @@ describe('calculateDisplayNormalizedMouseTarget', () => { }); }); - it('combines display normalization with customized movement multipliers', () => { + it('combines display normalization with customized movement percentages', () => { expect( calculateDisplayNormalizedMouseTarget( { x: 100, y: 200 }, { dx: 128, dy: 0 }, { bounds: { x: 0, y: 0, width: 3840, height: 2160 }, scaleFactor: 1 }, - { multipliers: { small: 100, medium: 150, large: 100 } } + { percentages: { small: 4.5, medium: 18, large: 26 } } ) ).toEqual({ x: 484, @@ -182,13 +182,13 @@ describe('calculateDisplayNormalizedMouseTarget', () => { }); }); - it('falls back for invalid display data while applying movement multipliers', () => { + it('falls back for invalid display data while applying movement percentages', () => { expect( calculateDisplayNormalizedMouseTarget( { x: 10, y: 20 }, { dx: 48, dy: 0 }, { bounds: { x: 0, y: 0, width: 0, height: 2160 }, scaleFactor: 0 }, - { multipliers: { small: 200, medium: 100, large: 100 } } + { percentages: { small: 9, medium: 12, large: 26 } } ) ).toEqual({ x: 106, diff --git a/src/main/input/libnut-win32-adapter.ts b/src/main/input/libnut-win32-adapter.ts index efc7011..695ca6b 100644 --- a/src/main/input/libnut-win32-adapter.ts +++ b/src/main/input/libnut-win32-adapter.ts @@ -12,7 +12,7 @@ import type { KeyboardKey, MediaAction, MouseButton, ShortcutKey, WindowControlA import { DEFAULT_POINTER_MOVEMENT_SETTINGS, normalizePointerMovementSettings, - pointerMovementScaleFor, + pointerMovementFractionFor, type PointerMovementSettings, type PointerMovementSizeKey } from '../../shared/pointer-movement-settings'; @@ -179,7 +179,8 @@ export function calculateDisplayNormalizedMouseTarget( : REFERENCE_POINTER_SHORT_EDGE; const settings = normalizePointerMovementSettings(movementSettings); const size = inferPointerMovementSize(delta); - const movementScale = pointerMovementScaleFor(settings, size); + const baselineFraction = pointerMovementFractionFor(DEFAULT_POINTER_MOVEMENT_SETTINGS, size); + const movementScale = pointerMovementFractionFor(settings, size) / baselineFraction; const multiplier = scaleFactor * (shortEdge / REFERENCE_POINTER_SHORT_EDGE) * movementScale; return { diff --git a/src/main/input/pointer-profile.test.ts b/src/main/input/pointer-profile.test.ts index d3e0634..96fcc33 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 display-relative deltas for a 1280x720 display at 1.5 scale', () => { const profile = createPointerMovementProfile({ cursor: { x: 100, y: 100 }, display: { @@ -18,9 +18,9 @@ describe('createPointerMovementProfile', () => { bounds: { x: 0, y: 0, width: 1280, height: 720 }, maxDelta: MAX_POINTER_DELTA, recommendedDeltas: { - small: 32, - medium: 85, - large: 187 + small: 22, + medium: 58, + large: 125 }, capabilities: { noAckMouseMove: true @@ -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,13 @@ describe('createPointerMovementProfile', () => { } }).recommendedDeltas ).toEqual({ - small: 48, - medium: 128, - large: 280 + small: 49, + medium: 130, + large: 281 }); }); - it('applies pointer movement multipliers to recommended deltas', () => { + it('applies pointer movement percentages to recommended deltas', () => { expect( createPointerMovementProfile({ cursor: { x: 100, y: 100 }, @@ -64,21 +64,21 @@ describe('createPointerMovementProfile', () => { scaleFactor: 1 }, movementSettings: { - multipliers: { - small: 50, - medium: 125, - large: 200 + percentages: { + small: 2, + medium: 15, + large: 50 } } }).recommendedDeltas ).toEqual({ - small: 24, - medium: 160, + small: 22, + medium: 162, large: 500 }); }); - it('divides customized deltas by scale factor on high-DPI displays', () => { + it('divides percentage deltas by scale factor on high-DPI displays', () => { expect( createPointerMovementProfile({ cursor: { x: 100, y: 100 }, @@ -87,14 +87,14 @@ describe('createPointerMovementProfile', () => { scaleFactor: 2 }, movementSettings: { - multipliers: { - small: 100, - medium: 150, - large: 100 + percentages: { + small: 4.5, + medium: 12, + large: 26 } } }).recommendedDeltas.medium - ).toBe(96); + ).toBe(130); }); it('normalizes invalid movement settings to defaults', () => { @@ -106,21 +106,21 @@ describe('createPointerMovementProfile', () => { scaleFactor: 1 }, movementSettings: { - multipliers: { + percentages: { small: Number.NaN, medium: Number.POSITIVE_INFINITY, - large: 0 + large: '0' } } }).recommendedDeltas ).toEqual({ - small: 48, - medium: 128, - large: 140 + small: 49, + medium: 130, + large: 281 }); }); - it('keeps profile deltas stable on a 4K display at 1x scale', () => { + it('returns larger deltas on a 4K display at 1x scale', () => { expect( createPointerMovementProfile({ cursor: { x: 100, y: 100 }, @@ -130,13 +130,13 @@ describe('createPointerMovementProfile', () => { } }).recommendedDeltas ).toEqual({ - small: 48, - medium: 128, - large: 280 + small: 97, + medium: 259, + large: 500 }); }); - it('keeps profile deltas stable on ultrawide displays', () => { + it('uses the short edge for ultrawide displays', () => { expect( createPointerMovementProfile({ cursor: { x: 100, y: 100 }, @@ -146,9 +146,9 @@ describe('createPointerMovementProfile', () => { } }).recommendedDeltas ).toEqual({ - small: 48, - medium: 128, - large: 280 + small: 65, + medium: 173, + large: 374 }); }); @@ -162,9 +162,9 @@ describe('createPointerMovementProfile', () => { } }).recommendedDeltas ).toEqual({ - small: 24, - medium: 64, - large: 140 + small: 49, + medium: 130, + large: 281 }); }); diff --git a/src/main/input/pointer-profile.ts b/src/main/input/pointer-profile.ts index 79209af..4fdc2c2 100644 --- a/src/main/input/pointer-profile.ts +++ b/src/main/input/pointer-profile.ts @@ -1,19 +1,13 @@ import { MAX_POINTER_DELTA, NO_ACK_CONTROL_COMMAND_TYPES, type PointerMovementProfile } from '../../shared/protocol'; import { normalizePointerMovementSettings, - pointerMovementScaleFor, + pointerMovementFractionFor, type PointerMovementSettings } 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 -}; - export function createPointerMovementProfile(input: { cursor: Point; display: { @@ -28,10 +22,11 @@ export function createPointerMovementProfile(input: { Number.isFinite(input.display.scaleFactor) && input.display.scaleFactor > 0 ? input.display.scaleFactor : 1; const maxDelta = input.maxDelta ?? MAX_POINTER_DELTA; const movementSettings = normalizePointerMovementSettings(input.movementSettings); + const referenceSize = Math.min(bounds.width, bounds.height); const targetNativeDeltas = { - small: TARGET_REFERENCE_NATIVE_DELTAS.small * pointerMovementScaleFor(movementSettings, 'small'), - medium: TARGET_REFERENCE_NATIVE_DELTAS.medium * pointerMovementScaleFor(movementSettings, 'medium'), - large: TARGET_REFERENCE_NATIVE_DELTAS.large * pointerMovementScaleFor(movementSettings, 'large') + small: referenceSize * pointerMovementFractionFor(movementSettings, 'small'), + medium: referenceSize * pointerMovementFractionFor(movementSettings, 'medium'), + large: referenceSize * pointerMovementFractionFor(movementSettings, 'large') }; return { diff --git a/src/main/pointer-movement-settings-ipc.test.ts b/src/main/pointer-movement-settings-ipc.test.ts index 8d5bbf3..7e596e2 100644 --- a/src/main/pointer-movement-settings-ipc.test.ts +++ b/src/main/pointer-movement-settings-ipc.test.ts @@ -25,7 +25,7 @@ describe('registerPointerMovementSettingsIpc', () => { }); it('returns stored settings', async () => { - const settings = { multipliers: { small: 75, medium: 100, large: 150 } }; + const settings = { percentages: { small: 3, medium: 12, large: 30 } }; const store = createStore(settings); registerPointerMovementSettingsIpc(store, vi.fn()); @@ -40,20 +40,20 @@ describe('registerPointerMovementSettingsIpc', () => { await expect( invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, { - multipliers: { small: 10, medium: 123, large: 1000 } + percentages: { small: 0.2, medium: 12.3, large: 100 } }) ).resolves.toEqual({ - multipliers: { - small: 50, - medium: 125, - large: 200 + percentages: { + small: 1, + medium: 12.5, + large: 50 } }); expect(store.save).toHaveBeenCalledWith({ - multipliers: { - small: 50, - medium: 125, - large: 200 + percentages: { + small: 1, + medium: 12.5, + large: 50 } }); }); @@ -63,19 +63,19 @@ describe('registerPointerMovementSettingsIpc', () => { const onSettingsChanged = vi.fn(); registerPointerMovementSettingsIpc(store, onSettingsChanged); - await invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, { multipliers: { small: 75 } }); + await invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, { percentages: { small: 3 } }); expect(onSettingsChanged).toHaveBeenCalledWith({ - multipliers: { - small: 75, - medium: 100, - large: 100 + percentages: { + small: 3, + medium: 12, + large: 26 } }); }); }); -function createStore(settings: PointerMovementSettings = { multipliers: { small: 100, medium: 100, large: 100 } }): JsonPointerMovementSettingsStore { +function createStore(settings: PointerMovementSettings = { percentages: { small: 4.5, medium: 12, large: 26 } }): JsonPointerMovementSettingsStore { return { load: vi.fn(() => settings), save: vi.fn((nextSettings: PointerMovementSettings) => nextSettings) diff --git a/src/main/pointer-movement-settings-store.test.ts b/src/main/pointer-movement-settings-store.test.ts index d591542..14ba768 100644 --- a/src/main/pointer-movement-settings-store.test.ts +++ b/src/main/pointer-movement-settings-store.test.ts @@ -1,6 +1,6 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; 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'; @@ -27,13 +27,26 @@ describe('JsonPointerMovementSettingsStore', () => { it('loads and normalizes valid settings', () => { const settingsFile = settingsPath(); - writeFileSync(settingsFile, JSON.stringify({ multipliers: { small: 75, medium: 123, large: 300 } }), 'utf8'); + writeFileSync(settingsFile, JSON.stringify({ percentages: { small: 3, medium: 12.3, large: 100 } }), 'utf8'); + + expect(new JsonPointerMovementSettingsStore(settingsFile).load()).toEqual({ + percentages: { + small: 3, + medium: 12.5, + large: 50 + } + }); + }); + + 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({ - multipliers: { - small: 75, - medium: 125, - large: 200 + percentages: { + small: 9, + medium: 6, + large: 26 } }); }); @@ -49,18 +62,18 @@ describe('JsonPointerMovementSettingsStore', () => { it('saves normalized JSON', () => { const settingsFile = settingsPath(); const saved = new JsonPointerMovementSettingsStore(settingsFile).save({ - multipliers: { - small: 10, - medium: 123, - large: 250 + percentages: { + small: 0.2, + medium: 12.3, + large: 100 } }); expect(saved).toEqual({ - multipliers: { - small: 50, - medium: 125, - large: 200 + percentages: { + small: 1, + medium: 12.5, + large: 50 } }); expect(JSON.parse(readFileSync(settingsFile, 'utf8'))).toEqual(saved); diff --git a/src/main/pointer-movement-settings-store.ts b/src/main/pointer-movement-settings-store.ts index 744f7f4..42c96ac 100644 --- a/src/main/pointer-movement-settings-store.ts +++ b/src/main/pointer-movement-settings-store.ts @@ -15,11 +15,11 @@ export class JsonPointerMovementSettingsStore { return normalizePointerMovementSettings(JSON.parse(raw)); } catch (error) { if (isMissingFileError(error)) { - return { multipliers: { ...DEFAULT_POINTER_MOVEMENT_SETTINGS.multipliers } }; + return DEFAULT_POINTER_MOVEMENT_SETTINGS; } console.warn('Switchify pointer movement settings could not be loaded. Defaults will be used.'); - return { multipliers: { ...DEFAULT_POINTER_MOVEMENT_SETTINGS.multipliers } }; + return DEFAULT_POINTER_MOVEMENT_SETTINGS; } } diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index dbce10e..d976fed 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type CSSProperties, type ReactElement } from 'react'; +import { useEffect, useState, type ReactElement } from 'react'; import type { CursorOverlayColor, CursorOverlaySettings, @@ -7,11 +7,11 @@ import type { } from '../../shared/cursor-overlay-settings'; import { CURSOR_OVERLAY_COLORS } from '../../shared/cursor-overlay-settings'; import { - POINTER_MOVEMENT_MULTIPLIER_MAX, - POINTER_MOVEMENT_MULTIPLIER_MIN, - POINTER_MOVEMENT_MULTIPLIER_STEP, + POINTER_MOVEMENT_PERCENTAGE_MAX, + POINTER_MOVEMENT_PERCENTAGE_MIN, + POINTER_MOVEMENT_PERCENTAGE_STEP, normalizePointerMovementSettings, - pointerMovementMultiplierFor, + pointerMovementPercentageFor, type PointerMovementSettings, type PointerMovementSizeKey } from '../../shared/pointer-movement-settings'; @@ -217,10 +217,9 @@ function PointerSettingsSection({ return (

Pointer

-

Movement sizes

+

Movement distance

- Adjust how far each Android pointer step moves the cursor. 100% is the default. Reconnect Android or reopen - control to refresh its movement profile. + Set how far each Android pointer step moves, as a percentage of the active screen size.

{ void onChange( normalizePointerMovementSettings({ - multipliers: { - ...normalizedSettings.multipliers, + percentages: { + ...normalizedSettings.percentages, [size]: value } }) @@ -258,66 +257,33 @@ function PointerMovementSettingsControls({
{pointerMovementSizeOptions.map((option) => { - const value = pointerMovementMultiplierFor(normalizedSettings, option.value); + const value = pointerMovementPercentageFor(normalizedSettings, option.value); return ( ); })}
- -
- ); -} - -function PointerMovementPreview({ settings }: { settings: PointerMovementSettings }): ReactElement { - const normalizedSettings = normalizePointerMovementSettings(settings); - return ( -
- - {pointerMovementSizeOptions.map((option, index) => { - const multiplier = pointerMovementMultiplierFor(normalizedSettings, option.value); - return ( -
- {option.label} - - {multiplier}% -
- ); - })}
); } const pointerMovementSizeOptions: Array<{ value: PointerMovementSizeKey; label: string }> = [ - { value: 'small', label: 'Small step' }, - { value: 'medium', label: 'Medium step' }, - { value: 'large', label: 'Large step' } + { value: 'small', label: 'Small' }, + { value: 'medium', label: 'Medium' }, + { value: 'large', label: 'Large' } ]; function SavedDevicesSettingsSection({ diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 9b230e3..f08cb94 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -828,110 +828,25 @@ p { .pointer-movement-row { display: grid; - grid-template-columns: minmax(72px, 0.25fr) minmax(120px, 1fr) minmax(48px, auto); + grid-template-columns: minmax(72px, 0.25fr) minmax(96px, 140px) minmax(84px, auto); gap: var(--space-s); align-items: center; } -.pointer-movement-label, -.pointer-movement-value { +.pointer-movement-label { color: var(--color-text); font-size: 0.88rem; font-weight: 800; } -.pointer-movement-value { - justify-self: end; - font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace; -} - -.pointer-movement-slider { +.pointer-movement-number { width: 100%; - accent-color: var(--brand-primary); -} - -.pointer-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-preview-scale { - display: grid; - grid-template-columns: minmax(72px, 0.24fr) minmax(120px, 1fr) minmax(48px, auto); - gap: var(--space-s); - align-items: center; - color: var(--color-text-muted); - font-size: 0.76rem; - font-weight: 700; -} - -.pointer-preview-scale span:first-child { - grid-column: 2; -} - -.pointer-preview-scale span:last-child { - grid-column: 3; - justify-self: end; } -.pointer-preview-row { - display: grid; - grid-template-columns: minmax(72px, 0.24fr) minmax(120px, 1fr) minmax(48px, auto); - gap: var(--space-s); - align-items: center; -} - -.pointer-preview-row span, -.pointer-preview-row strong { +.pointer-movement-unit { color: var(--color-text-muted); font-size: 0.82rem; -} - -.pointer-preview-row strong { - justify-self: end; - font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace; -} - -.pointer-preview-track { - container-type: inline-size; - position: relative; - height: 28px; - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-pill); - background: var(--color-surface); -} - -.pointer-preview-dot { - position: absolute; - top: 50%; - left: 8px; - width: 12px; - height: 12px; - border-radius: var(--radius-pill); - background: var(--brand-primary); - transform: translateY(-50%); - animation: pointer-preview-travel var(--pointer-preview-duration, 2s) ease-in-out infinite; -} - -@keyframes pointer-preview-travel { - 0%, - 15% { - transform: translateY(-50%) translateX(0); - } - - 65%, - 85% { - transform: translateY(-50%) translateX(calc(var(--pointer-preview-scale) * 34cqw)); - } - - 100% { - transform: translateY(-50%) translateX(0); - } + font-weight: 700; } .segmented-control { @@ -1136,24 +1051,11 @@ p { gap: 3px; } - .pointer-movement-row, - .pointer-preview-scale, - .pointer-preview-row { + .pointer-movement-row { grid-template-columns: 1fr; gap: 4px; } - .pointer-preview-scale span:first-child, - .pointer-preview-scale span:last-child { - grid-column: auto; - justify-self: start; - } - - .pointer-movement-value, - .pointer-preview-row strong { - justify-self: start; - } - .settings-layout { grid-template-columns: 1fr; width: 100%; @@ -1166,10 +1068,3 @@ p { } } - -@media (prefers-reduced-motion: reduce) { - .pointer-preview-dot { - animation: none; - transform: translateY(-50%) translateX(calc(var(--pointer-preview-scale) * 34cqw)); - } -} diff --git a/src/shared/pointer-movement-settings.test.ts b/src/shared/pointer-movement-settings.test.ts index a48ac57..e52159b 100644 --- a/src/shared/pointer-movement-settings.test.ts +++ b/src/shared/pointer-movement-settings.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_POINTER_MOVEMENT_SETTINGS, normalizePointerMovementSettings, - pointerMovementMultiplierFor, - pointerMovementScaleFor + pointerMovementFractionFor, + pointerMovementPercentageFor } from './pointer-movement-settings'; describe('normalizePointerMovementSettings', () => { @@ -11,20 +11,20 @@ describe('normalizePointerMovementSettings', () => { expect(normalizePointerMovementSettings(null)).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS); }); - it('preserves valid multipliers', () => { + it('preserves valid percentages', () => { expect( normalizePointerMovementSettings({ - multipliers: { - small: 75, - medium: 125, - large: 175 + percentages: { + small: 3, + medium: 12.5, + large: 30 } }) ).toEqual({ - multipliers: { - small: 75, - medium: 125, - large: 175 + percentages: { + small: 3, + medium: 12.5, + large: 30 } }); }); @@ -32,15 +32,15 @@ describe('normalizePointerMovementSettings', () => { it('fills missing sizes with defaults', () => { expect( normalizePointerMovementSettings({ - multipliers: { - small: 125 + percentages: { + small: 6 } }) ).toEqual({ - multipliers: { - small: 125, - medium: 100, - large: 100 + percentages: { + small: 6, + medium: 12, + large: 26 } }); }); @@ -48,54 +48,72 @@ describe('normalizePointerMovementSettings', () => { it('clamps values below the minimum', () => { expect( normalizePointerMovementSettings({ - multipliers: { - small: 10 + percentages: { + small: 0.2 } - }).multipliers.small - ).toBe(50); + }).percentages.small + ).toBe(1); }); it('clamps values above the maximum', () => { expect( normalizePointerMovementSettings({ - multipliers: { - large: 1000 + percentages: { + large: 100 } - }).multipliers.large - ).toBe(200); + }).percentages.large + ).toBe(50); }); - it('rounds values to the nearest five percent', () => { + it('rounds values to the nearest half percent', () => { expect( normalizePointerMovementSettings({ - multipliers: { - medium: 123 + percentages: { + medium: 12.3 } - }).multipliers.medium - ).toBe(125); + }).percentages.medium + ).toBe(12.5); }); it('falls back for non-finite and non-number values', () => { expect( normalizePointerMovementSettings({ - multipliers: { + percentages: { small: Number.NaN, - medium: '150', + medium: '12', large: Number.POSITIVE_INFINITY } }) ).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS); }); + + it('migrates legacy multiplier settings to screen percentages', () => { + expect( + normalizePointerMovementSettings({ + multipliers: { + small: 200, + medium: 50, + large: 100 + } + }) + ).toEqual({ + percentages: { + small: 9, + medium: 6, + large: 26 + } + }); + }); }); describe('pointer movement setting helpers', () => { - it('returns normalized multipliers', () => { - expect(pointerMovementMultiplierFor({ multipliers: { small: 49, medium: 101, large: 202 } }, 'small')).toBe(50); + it('returns normalized percentages', () => { + expect(pointerMovementPercentageFor({ percentages: { small: 0.2, medium: 12, large: 26 } }, 'small')).toBe(1); }); - it('returns scale factors for normalized multipliers', () => { - expect(pointerMovementScaleFor({ multipliers: { small: 50, medium: 100, large: 200 } }, 'small')).toBe(0.5); - expect(pointerMovementScaleFor({ multipliers: { small: 50, medium: 100, large: 200 } }, 'medium')).toBe(1); - expect(pointerMovementScaleFor({ multipliers: { small: 50, medium: 100, large: 200 } }, 'large')).toBe(2); + it('returns fractions for normalized 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 index 11cfd73..60fbe00 100644 --- a/src/shared/pointer-movement-settings.ts +++ b/src/shared/pointer-movement-settings.ts @@ -1,20 +1,20 @@ export type PointerMovementSizeKey = 'small' | 'medium' | 'large'; export type PointerMovementSettings = { - multipliers: Record; + percentages: Record; }; export const DEFAULT_POINTER_MOVEMENT_SETTINGS: PointerMovementSettings = { - multipliers: { - small: 100, - medium: 100, - large: 100 + percentages: { + small: 4.5, + medium: 12, + large: 26 } }; -export const POINTER_MOVEMENT_MULTIPLIER_MIN = 50; -export const POINTER_MOVEMENT_MULTIPLIER_MAX = 200; -export const POINTER_MOVEMENT_MULTIPLIER_STEP = 5; +export const POINTER_MOVEMENT_PERCENTAGE_MIN = 1; +export const POINTER_MOVEMENT_PERCENTAGE_MAX = 50; +export const POINTER_MOVEMENT_PERCENTAGE_STEP = 0.5; const pointerMovementSizeKeys: PointerMovementSizeKey[] = ['small', 'medium', 'large']; @@ -24,44 +24,72 @@ export function normalizePointerMovementSettings(value: unknown): PointerMovemen } const candidate = value as Partial; - const multipliers = candidate.multipliers; + const percentages = candidate.percentages; + if (percentages && typeof percentages === 'object') { + return { + percentages: { + small: normalizePercentage((percentages as Partial>).small, 'small'), + medium: normalizePercentage((percentages as Partial>).medium, 'medium'), + large: normalizePercentage((percentages as Partial>).large, 'large') + } + }; + } + + const multipliers = (candidate as { multipliers?: unknown }).multipliers; if (!multipliers || typeof multipliers !== 'object') { return cloneDefaultSettings(); } return { - multipliers: { - small: normalizeMultiplier((multipliers as Partial>).small, 'small'), - medium: normalizeMultiplier((multipliers as Partial>).medium, 'medium'), - large: normalizeMultiplier((multipliers as Partial>).large, 'large') + percentages: { + small: normalizeLegacyMultiplier( + (multipliers as Partial>).small, + 'small' + ), + medium: normalizeLegacyMultiplier( + (multipliers as Partial>).medium, + 'medium' + ), + large: normalizeLegacyMultiplier( + (multipliers as Partial>).large, + 'large' + ) } }; } -export function pointerMovementMultiplierFor( +export function pointerMovementPercentageFor( settings: PointerMovementSettings, size: PointerMovementSizeKey ): number { - return normalizePointerMovementSettings(settings).multipliers[size]; + return normalizePointerMovementSettings(settings).percentages[size]; } -export function pointerMovementScaleFor( +export function pointerMovementFractionFor( settings: PointerMovementSettings, size: PointerMovementSizeKey ): number { - return pointerMovementMultiplierFor(settings, size) / 100; + return pointerMovementPercentageFor(settings, size) / 100; +} + +function normalizePercentage(value: unknown, size: PointerMovementSizeKey): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_POINTER_MOVEMENT_SETTINGS.percentages[size]; + } + + return clamp(roundToStep(value), POINTER_MOVEMENT_PERCENTAGE_MIN, POINTER_MOVEMENT_PERCENTAGE_MAX); } -function normalizeMultiplier(value: unknown, size: PointerMovementSizeKey): number { +function normalizeLegacyMultiplier(value: unknown, size: PointerMovementSizeKey): number { if (typeof value !== 'number' || !Number.isFinite(value)) { - return DEFAULT_POINTER_MOVEMENT_SETTINGS.multipliers[size]; + return DEFAULT_POINTER_MOVEMENT_SETTINGS.percentages[size]; } - return clamp(roundToStep(value), POINTER_MOVEMENT_MULTIPLIER_MIN, POINTER_MOVEMENT_MULTIPLIER_MAX); + return normalizePercentage((DEFAULT_POINTER_MOVEMENT_SETTINGS.percentages[size] * value) / 100, size); } function roundToStep(value: number): number { - return Math.round(value / POINTER_MOVEMENT_MULTIPLIER_STEP) * POINTER_MOVEMENT_MULTIPLIER_STEP; + return Math.round(value / POINTER_MOVEMENT_PERCENTAGE_STEP) * POINTER_MOVEMENT_PERCENTAGE_STEP; } function clamp(value: number, min: number, max: number): number { @@ -70,8 +98,8 @@ function clamp(value: number, min: number, max: number): number { function cloneDefaultSettings(): PointerMovementSettings { return { - multipliers: Object.fromEntries( - pointerMovementSizeKeys.map((size) => [size, DEFAULT_POINTER_MOVEMENT_SETTINGS.multipliers[size]]) + percentages: Object.fromEntries( + pointerMovementSizeKeys.map((size) => [size, DEFAULT_POINTER_MOVEMENT_SETTINGS.percentages[size]]) ) as Record }; } From e5babf836d7b6d010f990ce0c0f73b27c2d1b7d4 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 23 Jun 2026 21:58:10 +0100 Subject: [PATCH 04/13] Use ordered snapping sliders for pointer movement --- src/main/input/libnut-win32-adapter.test.ts | 14 +++ src/main/input/pointer-profile.test.ts | 19 +++ .../pointer-movement-settings-ipc.test.ts | 33 ++++++ .../pointer-movement-settings-store.test.ts | 22 +++- src/renderer/components/SettingsPanel.tsx | 37 +++++- src/renderer/styles.css | 21 ++-- src/shared/pointer-movement-settings.test.ts | 110 +++++++++++++++++- src/shared/pointer-movement-settings.ts | 92 +++++++++++---- 8 files changed, 310 insertions(+), 38 deletions(-) diff --git a/src/main/input/libnut-win32-adapter.test.ts b/src/main/input/libnut-win32-adapter.test.ts index bbae748..7ca51e5 100644 --- a/src/main/input/libnut-win32-adapter.test.ts +++ b/src/main/input/libnut-win32-adapter.test.ts @@ -195,6 +195,20 @@ describe('calculateDisplayNormalizedMouseTarget', () => { y: 20 }); }); + + it('normalizes unordered movement settings before applying movement percentages', () => { + expect( + calculateDisplayNormalizedMouseTarget( + { x: 100, y: 200 }, + { dx: 48, dy: 0 }, + { bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 }, + { percentages: { small: 20, medium: 12, large: 26 } } + ) + ).toEqual({ + x: 223, + y: 200 + }); + }); }); describe('inferPointerMovementSize', () => { diff --git a/src/main/input/pointer-profile.test.ts b/src/main/input/pointer-profile.test.ts index 96fcc33..ff62e6c 100644 --- a/src/main/input/pointer-profile.test.ts +++ b/src/main/input/pointer-profile.test.ts @@ -120,6 +120,25 @@ describe('createPointerMovementProfile', () => { }); }); + it('normalizes unordered movement settings before generating deltas', () => { + expect( + createPointerMovementProfile({ + cursor: { x: 100, y: 100 }, + display: { + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + scaleFactor: 1 + }, + movementSettings: { + percentages: { + small: 20, + medium: 12, + large: 26 + } + } + }).recommendedDeltas.small + ).toBe(124); + }); + it('returns larger deltas on a 4K display at 1x scale', () => { expect( createPointerMovementProfile({ diff --git a/src/main/pointer-movement-settings-ipc.test.ts b/src/main/pointer-movement-settings-ipc.test.ts index 7e596e2..f16ef0d 100644 --- a/src/main/pointer-movement-settings-ipc.test.ts +++ b/src/main/pointer-movement-settings-ipc.test.ts @@ -73,6 +73,39 @@ describe('registerPointerMovementSettingsIpc', () => { } }); }); + + it('normalizes unordered 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: 20, medium: 12, large: 26 } + }) + ).resolves.toEqual({ + percentages: { + small: 11.5, + medium: 12, + large: 26 + } + }); + expect(store.save).toHaveBeenCalledWith({ + percentages: { + small: 11.5, + medium: 12, + large: 26 + } + }); + expect(onSettingsChanged).toHaveBeenCalledWith({ + percentages: { + small: 11.5, + medium: 12, + large: 26 + } + }); + }); }); function createStore(settings: PointerMovementSettings = { percentages: { small: 4.5, medium: 12, large: 26 } }): JsonPointerMovementSettingsStore { diff --git a/src/main/pointer-movement-settings-store.test.ts b/src/main/pointer-movement-settings-store.test.ts index 14ba768..bee1e56 100644 --- a/src/main/pointer-movement-settings-store.test.ts +++ b/src/main/pointer-movement-settings-store.test.ts @@ -45,7 +45,7 @@ describe('JsonPointerMovementSettingsStore', () => { expect(new JsonPointerMovementSettingsStore(settingsFile).load()).toEqual({ percentages: { small: 9, - medium: 6, + medium: 9.5, large: 26 } }); @@ -79,6 +79,26 @@ describe('JsonPointerMovementSettingsStore', () => { expect(JSON.parse(readFileSync(settingsFile, 'utf8'))).toEqual(saved); }); + it('saves unordered settings in ordered normalized form', () => { + const settingsFile = settingsPath(); + const saved = new JsonPointerMovementSettingsStore(settingsFile).save({ + percentages: { + small: 20, + medium: 12, + large: 26 + } + }); + + expect(saved).toEqual({ + percentages: { + small: 11.5, + medium: 12, + large: 26 + } + }); + 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'); diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index d976fed..1ece6f5 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -9,6 +9,7 @@ import { CURSOR_OVERLAY_COLORS } from '../../shared/cursor-overlay-settings'; import { POINTER_MOVEMENT_PERCENTAGE_MAX, POINTER_MOVEMENT_PERCENTAGE_MIN, + POINTER_MOVEMENT_PERCENTAGE_MIN_GAP, POINTER_MOVEMENT_PERCENTAGE_STEP, normalizePointerMovementSettings, pointerMovementPercentageFor, @@ -258,20 +259,21 @@ function PointerMovementSettingsControls({
{pointerMovementSizeOptions.map((option) => { const value = pointerMovementPercentageFor(normalizedSettings, option.value); + const bounds = pointerMovementSliderBounds(normalizedSettings, option.value); return ( ); })} @@ -286,6 +288,31 @@ const pointerMovementSizeOptions: Array<{ value: PointerMovementSizeKey; label: { value: 'large', label: 'Large' } ]; +function pointerMovementSliderBounds( + settings: PointerMovementSettings, + size: PointerMovementSizeKey +): { min: number; max: number } { + const percentages = normalizePointerMovementSettings(settings).percentages; + if (size === 'small') { + return { + min: POINTER_MOVEMENT_PERCENTAGE_MIN, + max: percentages.medium - POINTER_MOVEMENT_PERCENTAGE_MIN_GAP + }; + } + + if (size === 'medium') { + return { + min: percentages.small + POINTER_MOVEMENT_PERCENTAGE_MIN_GAP, + max: percentages.large - POINTER_MOVEMENT_PERCENTAGE_MIN_GAP + }; + } + + return { + min: percentages.medium + POINTER_MOVEMENT_PERCENTAGE_MIN_GAP, + max: POINTER_MOVEMENT_PERCENTAGE_MAX + }; +} + function SavedDevicesSettingsSection({ pairedDevices, onForgetPairedDevice diff --git a/src/renderer/styles.css b/src/renderer/styles.css index f08cb94..3edde7f 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -828,25 +828,26 @@ p { .pointer-movement-row { display: grid; - grid-template-columns: minmax(72px, 0.25fr) minmax(96px, 140px) minmax(84px, auto); + grid-template-columns: minmax(72px, 0.25fr) minmax(140px, 1fr) minmax(52px, auto); gap: var(--space-s); align-items: center; } -.pointer-movement-label { +.pointer-movement-label, +.pointer-movement-value { color: var(--color-text); font-size: 0.88rem; font-weight: 800; } -.pointer-movement-number { - width: 100%; +.pointer-movement-value { + justify-self: end; + font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace; } -.pointer-movement-unit { - color: var(--color-text-muted); - font-size: 0.82rem; - font-weight: 700; +.pointer-movement-slider { + width: 100%; + accent-color: var(--brand-primary); } .segmented-control { @@ -1056,6 +1057,10 @@ p { gap: 4px; } + .pointer-movement-value { + justify-self: start; + } + .settings-layout { grid-template-columns: 1fr; width: 100%; diff --git a/src/shared/pointer-movement-settings.test.ts b/src/shared/pointer-movement-settings.test.ts index e52159b..a1fc30d 100644 --- a/src/shared/pointer-movement-settings.test.ts +++ b/src/shared/pointer-movement-settings.test.ts @@ -65,6 +65,114 @@ describe('normalizePointerMovementSettings', () => { ).toBe(50); }); + it('enforces ordered percentages with a minimum half percent gap', () => { + expect( + normalizePointerMovementSettings({ + percentages: { + small: 12, + medium: 12, + large: 12 + } + }) + ).toEqual({ + percentages: { + small: 11.5, + medium: 12, + large: 12.5 + } + }); + }); + + it('clamps small below medium', () => { + expect( + normalizePointerMovementSettings({ + percentages: { + small: 20, + medium: 12, + large: 26 + } + }) + ).toEqual({ + percentages: { + small: 11.5, + medium: 12, + large: 26 + } + }); + }); + + it('clamps medium above small', () => { + expect( + normalizePointerMovementSettings({ + percentages: { + small: 4.5, + medium: 3, + large: 26 + } + }) + ).toEqual({ + percentages: { + small: 4.5, + medium: 5, + large: 26 + } + }); + }); + + it('clamps medium below large', () => { + expect( + normalizePointerMovementSettings({ + percentages: { + small: 4.5, + medium: 40, + large: 26 + } + }) + ).toEqual({ + percentages: { + small: 4.5, + medium: 25.5, + large: 26 + } + }); + }); + + it('clamps large above medium', () => { + expect( + normalizePointerMovementSettings({ + percentages: { + small: 4.5, + medium: 12, + large: 10 + } + }) + ).toEqual({ + percentages: { + small: 4.5, + medium: 12, + large: 12.5 + } + }); + }); + + it('shifts values down from the top when order would exceed the maximum', () => { + expect( + normalizePointerMovementSettings({ + percentages: { + small: 49.8, + medium: 50, + large: 50 + } + }) + ).toEqual({ + percentages: { + small: 49, + medium: 49.5, + large: 50 + } + }); + }); + it('rounds values to the nearest half percent', () => { expect( normalizePointerMovementSettings({ @@ -99,7 +207,7 @@ describe('normalizePointerMovementSettings', () => { ).toEqual({ percentages: { small: 9, - medium: 6, + medium: 9.5, large: 26 } }); diff --git a/src/shared/pointer-movement-settings.ts b/src/shared/pointer-movement-settings.ts index 60fbe00..d4fe5a9 100644 --- a/src/shared/pointer-movement-settings.ts +++ b/src/shared/pointer-movement-settings.ts @@ -15,6 +15,7 @@ export const DEFAULT_POINTER_MOVEMENT_SETTINGS: PointerMovementSettings = { export const POINTER_MOVEMENT_PERCENTAGE_MIN = 1; export const POINTER_MOVEMENT_PERCENTAGE_MAX = 50; export const POINTER_MOVEMENT_PERCENTAGE_STEP = 0.5; +export const POINTER_MOVEMENT_PERCENTAGE_MIN_GAP = POINTER_MOVEMENT_PERCENTAGE_STEP; const pointerMovementSizeKeys: PointerMovementSizeKey[] = ['small', 'medium', 'large']; @@ -26,13 +27,11 @@ export function normalizePointerMovementSettings(value: unknown): PointerMovemen const candidate = value as Partial; const percentages = candidate.percentages; if (percentages && typeof percentages === 'object') { - return { - percentages: { - small: normalizePercentage((percentages as Partial>).small, 'small'), - medium: normalizePercentage((percentages as Partial>).medium, 'medium'), - large: normalizePercentage((percentages as Partial>).large, 'large') - } - }; + return normalizeOrderedPercentages({ + small: normalizePercentage((percentages as Partial>).small, 'small'), + medium: normalizePercentage((percentages as Partial>).medium, 'medium'), + large: normalizePercentage((percentages as Partial>).large, 'large') + }); } const multipliers = (candidate as { multipliers?: unknown }).multipliers; @@ -40,22 +39,14 @@ export function normalizePointerMovementSettings(value: unknown): PointerMovemen return cloneDefaultSettings(); } - return { - percentages: { - small: normalizeLegacyMultiplier( - (multipliers as Partial>).small, - 'small' - ), - medium: normalizeLegacyMultiplier( - (multipliers as Partial>).medium, - 'medium' - ), - large: normalizeLegacyMultiplier( - (multipliers as Partial>).large, - 'large' - ) - } - }; + return normalizeOrderedPercentages({ + small: normalizeLegacyMultiplier((multipliers as Partial>).small, 'small'), + medium: normalizeLegacyMultiplier( + (multipliers as Partial>).medium, + 'medium' + ), + large: normalizeLegacyMultiplier((multipliers as Partial>).large, 'large') + }); } export function pointerMovementPercentageFor( @@ -88,6 +79,61 @@ function normalizeLegacyMultiplier(value: unknown, size: PointerMovementSizeKey) return normalizePercentage((DEFAULT_POINTER_MOVEMENT_SETTINGS.percentages[size] * value) / 100, size); } +function normalizeOrderedPercentages( + percentages: Record +): PointerMovementSettings { + let small = percentages.small; + let medium = percentages.medium; + let large = percentages.large; + + for (let pass = 0; pass < 4; pass += 1) { + [small, medium] = orderPair('small', small, 'medium', medium); + [medium, large] = orderPair('medium', medium, 'large', large); + } + + return { + percentages: { + small: clamp(roundToStep(small), POINTER_MOVEMENT_PERCENTAGE_MIN, POINTER_MOVEMENT_PERCENTAGE_MAX), + medium: clamp(roundToStep(medium), POINTER_MOVEMENT_PERCENTAGE_MIN, POINTER_MOVEMENT_PERCENTAGE_MAX), + large: clamp(roundToStep(large), POINTER_MOVEMENT_PERCENTAGE_MIN, POINTER_MOVEMENT_PERCENTAGE_MAX) + } + }; +} + +function orderPair( + lowerSize: PointerMovementSizeKey, + lowerValue: number, + upperSize: PointerMovementSizeKey, + upperValue: number +): [number, number] { + if (lowerValue + POINTER_MOVEMENT_PERCENTAGE_MIN_GAP <= upperValue) { + return [lowerValue, upperValue]; + } + + const lowerDeviation = Math.abs(lowerValue - DEFAULT_POINTER_MOVEMENT_SETTINGS.percentages[lowerSize]); + const upperDeviation = Math.abs(upperValue - DEFAULT_POINTER_MOVEMENT_SETTINGS.percentages[upperSize]); + + if (lowerDeviation >= upperDeviation) { + return [ + clamp( + upperValue - POINTER_MOVEMENT_PERCENTAGE_MIN_GAP, + POINTER_MOVEMENT_PERCENTAGE_MIN, + POINTER_MOVEMENT_PERCENTAGE_MAX + ), + upperValue + ]; + } + + return [ + lowerValue, + clamp( + lowerValue + POINTER_MOVEMENT_PERCENTAGE_MIN_GAP, + POINTER_MOVEMENT_PERCENTAGE_MIN, + POINTER_MOVEMENT_PERCENTAGE_MAX + ) + ]; +} + function roundToStep(value: number): number { return Math.round(value / POINTER_MOVEMENT_PERCENTAGE_STEP) * POINTER_MOVEMENT_PERCENTAGE_STEP; } From 120a068a4df50a14327fdef7bccce59087aa9372 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 23 Jun 2026 22:14:41 +0100 Subject: [PATCH 05/13] Simplify pointer movement scale settings --- src/main/input/libnut-win32-adapter.test.ts | 22 +- src/main/input/pointer-profile.test.ts | 40 ++-- .../pointer-movement-settings-ipc.test.ts | 64 ++---- .../pointer-movement-settings-store.test.ts | 63 ++---- src/renderer/components/SettingsPanel.tsx | 100 ++++----- src/renderer/styles.css | 27 +++ src/shared/pointer-movement-settings.test.ts | 206 +++--------------- src/shared/pointer-movement-settings.ts | 168 ++++++-------- 8 files changed, 219 insertions(+), 471 deletions(-) diff --git a/src/main/input/libnut-win32-adapter.test.ts b/src/main/input/libnut-win32-adapter.test.ts index 7ca51e5..410fa65 100644 --- a/src/main/input/libnut-win32-adapter.test.ts +++ b/src/main/input/libnut-win32-adapter.test.ts @@ -140,13 +140,13 @@ describe('calculateDisplayNormalizedMouseTarget', () => { }); }); - it('applies the small movement percentage', () => { + 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 }, - { percentages: { small: 9, medium: 12, large: 26 } } + { scalePercent: 200 } ) ).toEqual({ x: 196, @@ -154,13 +154,13 @@ describe('calculateDisplayNormalizedMouseTarget', () => { }); }); - it('applies the medium movement percentage', () => { + 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 }, - { percentages: { small: 4.5, medium: 6, large: 26 } } + { scalePercent: 50 } ) ).toEqual({ x: 164, @@ -168,13 +168,13 @@ describe('calculateDisplayNormalizedMouseTarget', () => { }); }); - it('combines display normalization with customized movement percentages', () => { + 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 }, - { percentages: { small: 4.5, medium: 18, large: 26 } } + { scalePercent: 150 } ) ).toEqual({ x: 484, @@ -182,13 +182,13 @@ describe('calculateDisplayNormalizedMouseTarget', () => { }); }); - it('falls back for invalid display data while applying movement percentages', () => { + 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 }, - { percentages: { small: 9, medium: 12, large: 26 } } + { scalePercent: 200 } ) ).toEqual({ x: 106, @@ -196,16 +196,16 @@ describe('calculateDisplayNormalizedMouseTarget', () => { }); }); - it('normalizes unordered movement settings before applying movement percentages', () => { + it('migrates legacy percentage settings before applying movement scale', () => { expect( calculateDisplayNormalizedMouseTarget( { x: 100, y: 200 }, { dx: 48, dy: 0 }, { bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 }, - { percentages: { small: 20, medium: 12, large: 26 } } + { percentages: { small: 9, medium: 24, large: 50 } } ) ).toEqual({ - x: 223, + x: 196, y: 200 }); }); diff --git a/src/main/input/pointer-profile.test.ts b/src/main/input/pointer-profile.test.ts index ff62e6c..55c4b3e 100644 --- a/src/main/input/pointer-profile.test.ts +++ b/src/main/input/pointer-profile.test.ts @@ -55,7 +55,7 @@ describe('createPointerMovementProfile', () => { }); }); - it('applies pointer movement percentages to recommended deltas', () => { + it('applies pointer movement scale to recommended deltas', () => { expect( createPointerMovementProfile({ cursor: { x: 100, y: 100 }, @@ -64,21 +64,17 @@ describe('createPointerMovementProfile', () => { scaleFactor: 1 }, movementSettings: { - percentages: { - small: 2, - medium: 15, - large: 50 - } + scalePercent: 150 } }).recommendedDeltas ).toEqual({ - small: 22, - medium: 162, - large: 500 + small: 76, + medium: 194, + large: 421 }); }); - it('divides percentage deltas by scale factor on high-DPI displays', () => { + it('divides scaled percentage deltas by scale factor on high-DPI displays', () => { expect( createPointerMovementProfile({ cursor: { x: 100, y: 100 }, @@ -87,14 +83,10 @@ describe('createPointerMovementProfile', () => { scaleFactor: 2 }, movementSettings: { - percentages: { - small: 4.5, - medium: 12, - large: 26 - } + scalePercent: 150 } }).recommendedDeltas.medium - ).toBe(130); + ).toBe(194); }); it('normalizes invalid movement settings to defaults', () => { @@ -106,11 +98,7 @@ describe('createPointerMovementProfile', () => { scaleFactor: 1 }, movementSettings: { - percentages: { - small: Number.NaN, - medium: Number.POSITIVE_INFINITY, - large: '0' - } + scalePercent: Number.NaN } }).recommendedDeltas ).toEqual({ @@ -120,7 +108,7 @@ describe('createPointerMovementProfile', () => { }); }); - it('normalizes unordered movement settings before generating deltas', () => { + it('migrates legacy percentage settings before generating deltas', () => { expect( createPointerMovementProfile({ cursor: { x: 100, y: 100 }, @@ -130,13 +118,13 @@ describe('createPointerMovementProfile', () => { }, movementSettings: { percentages: { - small: 20, - medium: 12, - large: 26 + small: 9, + medium: 24, + large: 50 } } }).recommendedDeltas.small - ).toBe(124); + ).toBe(97); }); it('returns larger deltas on a 4K display at 1x scale', () => { diff --git a/src/main/pointer-movement-settings-ipc.test.ts b/src/main/pointer-movement-settings-ipc.test.ts index f16ef0d..5d3873c 100644 --- a/src/main/pointer-movement-settings-ipc.test.ts +++ b/src/main/pointer-movement-settings-ipc.test.ts @@ -25,7 +25,7 @@ describe('registerPointerMovementSettingsIpc', () => { }); it('returns stored settings', async () => { - const settings = { percentages: { small: 3, medium: 12, large: 30 } }; + const settings = { scalePercent: 125 }; const store = createStore(settings); registerPointerMovementSettingsIpc(store, vi.fn()); @@ -38,24 +38,10 @@ describe('registerPointerMovementSettingsIpc', () => { registerPointerMovementSettingsIpc(store, vi.fn()); - await expect( - invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, { - percentages: { small: 0.2, medium: 12.3, large: 100 } - }) - ).resolves.toEqual({ - percentages: { - small: 1, - medium: 12.5, - large: 50 - } - }); - expect(store.save).toHaveBeenCalledWith({ - percentages: { - small: 1, - medium: 12.5, - large: 50 - } + 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 () => { @@ -63,18 +49,12 @@ describe('registerPointerMovementSettingsIpc', () => { const onSettingsChanged = vi.fn(); registerPointerMovementSettingsIpc(store, onSettingsChanged); - await invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, { percentages: { small: 3 } }); - - expect(onSettingsChanged).toHaveBeenCalledWith({ - percentages: { - small: 3, - medium: 12, - large: 26 - } - }); + await invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, { scalePercent: 75 }); + + expect(onSettingsChanged).toHaveBeenCalledWith({ scalePercent: 75 }); }); - it('normalizes unordered settings before saving and notifying', async () => { + it('normalizes migrated percentage settings before saving and notifying', async () => { const store = createStore(); const onSettingsChanged = vi.fn(); @@ -82,33 +62,15 @@ describe('registerPointerMovementSettingsIpc', () => { await expect( invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, { - percentages: { small: 20, medium: 12, large: 26 } + percentages: { small: 9, medium: 24, large: 50 } }) - ).resolves.toEqual({ - percentages: { - small: 11.5, - medium: 12, - large: 26 - } - }); - expect(store.save).toHaveBeenCalledWith({ - percentages: { - small: 11.5, - medium: 12, - large: 26 - } - }); - expect(onSettingsChanged).toHaveBeenCalledWith({ - percentages: { - small: 11.5, - medium: 12, - large: 26 - } - }); + ).resolves.toEqual({ scalePercent: 195 }); + expect(store.save).toHaveBeenCalledWith({ scalePercent: 195 }); + expect(onSettingsChanged).toHaveBeenCalledWith({ scalePercent: 195 }); }); }); -function createStore(settings: PointerMovementSettings = { percentages: { small: 4.5, medium: 12, large: 26 } }): JsonPointerMovementSettingsStore { +function createStore(settings: PointerMovementSettings = { scalePercent: 100 }): JsonPointerMovementSettingsStore { return { load: vi.fn(() => settings), save: vi.fn((nextSettings: PointerMovementSettings) => nextSettings) diff --git a/src/main/pointer-movement-settings-store.test.ts b/src/main/pointer-movement-settings-store.test.ts index bee1e56..3b5ddcc 100644 --- a/src/main/pointer-movement-settings-store.test.ts +++ b/src/main/pointer-movement-settings-store.test.ts @@ -27,28 +27,23 @@ describe('JsonPointerMovementSettingsStore', () => { it('loads and normalizes valid settings', () => { const settingsFile = settingsPath(); - writeFileSync(settingsFile, JSON.stringify({ percentages: { small: 3, medium: 12.3, large: 100 } }), 'utf8'); - - expect(new JsonPointerMovementSettingsStore(settingsFile).load()).toEqual({ - percentages: { - small: 3, - medium: 12.5, - large: 50 - } - }); + 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({ - percentages: { - small: 9, - medium: 9.5, - large: 26 - } - }); + expect(new JsonPointerMovementSettingsStore(settingsFile).load()).toEqual({ scalePercent: 115 }); }); it('loads defaults and warns when JSON is invalid', () => { @@ -61,41 +56,9 @@ describe('JsonPointerMovementSettingsStore', () => { it('saves normalized JSON', () => { const settingsFile = settingsPath(); - const saved = new JsonPointerMovementSettingsStore(settingsFile).save({ - percentages: { - small: 0.2, - medium: 12.3, - large: 100 - } - }); - - expect(saved).toEqual({ - percentages: { - small: 1, - medium: 12.5, - large: 50 - } - }); - expect(JSON.parse(readFileSync(settingsFile, 'utf8'))).toEqual(saved); - }); + const saved = new JsonPointerMovementSettingsStore(settingsFile).save({ scalePercent: 123 }); - it('saves unordered settings in ordered normalized form', () => { - const settingsFile = settingsPath(); - const saved = new JsonPointerMovementSettingsStore(settingsFile).save({ - percentages: { - small: 20, - medium: 12, - large: 26 - } - }); - - expect(saved).toEqual({ - percentages: { - small: 11.5, - medium: 12, - large: 26 - } - }); + expect(saved).toEqual({ scalePercent: 125 }); expect(JSON.parse(readFileSync(settingsFile, 'utf8'))).toEqual(saved); }); diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 1ece6f5..e61efe8 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -7,12 +7,13 @@ import type { } from '../../shared/cursor-overlay-settings'; import { CURSOR_OVERLAY_COLORS } from '../../shared/cursor-overlay-settings'; import { - POINTER_MOVEMENT_PERCENTAGE_MAX, - POINTER_MOVEMENT_PERCENTAGE_MIN, - POINTER_MOVEMENT_PERCENTAGE_MIN_GAP, - POINTER_MOVEMENT_PERCENTAGE_STEP, + BASE_POINTER_MOVEMENT_PERCENTAGES, + POINTER_MOVEMENT_SCALE_MAX, + POINTER_MOVEMENT_SCALE_MIN, + POINTER_MOVEMENT_SCALE_STEP, normalizePointerMovementSettings, pointerMovementPercentageFor, + pointerMovementScalePercentFor, type PointerMovementSettings, type PointerMovementSizeKey } from '../../shared/pointer-movement-settings'; @@ -220,7 +221,8 @@ function PointerSettingsSection({

Pointer

Movement distance

- Set how far each Android pointer step moves, as a percentage of the active screen size. + Adjust all Android pointer steps together. The table shows each movement distance as a percentage of the + active screen size.

Promise; }): ReactElement { const normalizedSettings = normalizePointerMovementSettings(settings); - const update = (size: PointerMovementSizeKey, value: number): void => { + const scalePercent = pointerMovementScalePercentFor(normalizedSettings); + const update = (value: number): void => { void onChange( normalizePointerMovementSettings({ - percentages: { - ...normalizedSettings.percentages, - [size]: value - } + scalePercent: value }) ); }; @@ -257,27 +257,22 @@ function PointerMovementSettingsControls({ return (
- {pointerMovementSizeOptions.map((option) => { - const value = pointerMovementPercentageFor(normalizedSettings, option.value); - const bounds = pointerMovementSliderBounds(normalizedSettings, option.value); - return ( - - ); - })} +
+
); } @@ -288,29 +283,28 @@ const pointerMovementSizeOptions: Array<{ value: PointerMovementSizeKey; label: { value: 'large', label: 'Large' } ]; -function pointerMovementSliderBounds( - settings: PointerMovementSettings, - size: PointerMovementSizeKey -): { min: number; max: number } { - const percentages = normalizePointerMovementSettings(settings).percentages; - if (size === 'small') { - return { - min: POINTER_MOVEMENT_PERCENTAGE_MIN, - max: percentages.medium - POINTER_MOVEMENT_PERCENTAGE_MIN_GAP - }; - } - - if (size === 'medium') { - return { - min: percentages.small + POINTER_MOVEMENT_PERCENTAGE_MIN_GAP, - max: percentages.large - POINTER_MOVEMENT_PERCENTAGE_MIN_GAP - }; - } - - return { - min: percentages.medium + POINTER_MOVEMENT_PERCENTAGE_MIN_GAP, - max: POINTER_MOVEMENT_PERCENTAGE_MAX - }; +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({ diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 3edde7f..f6e775f 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -850,6 +850,33 @@ p { accent-color: var(--brand-primary); } +.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)); diff --git a/src/shared/pointer-movement-settings.test.ts b/src/shared/pointer-movement-settings.test.ts index a1fc30d..d99f3e5 100644 --- a/src/shared/pointer-movement-settings.test.ts +++ b/src/shared/pointer-movement-settings.test.ts @@ -3,7 +3,8 @@ import { DEFAULT_POINTER_MOVEMENT_SETTINGS, normalizePointerMovementSettings, pointerMovementFractionFor, - pointerMovementPercentageFor + pointerMovementPercentageFor, + pointerMovementScalePercentFor } from './pointer-movement-settings'; describe('normalizePointerMovementSettings', () => { @@ -11,191 +12,40 @@ describe('normalizePointerMovementSettings', () => { expect(normalizePointerMovementSettings(null)).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS); }); - it('preserves valid percentages', () => { - expect( - normalizePointerMovementSettings({ - percentages: { - small: 3, - medium: 12.5, - large: 30 - } - }) - ).toEqual({ - percentages: { - small: 3, - medium: 12.5, - large: 30 - } - }); - }); - - it('fills missing sizes with defaults', () => { - expect( - normalizePointerMovementSettings({ - percentages: { - small: 6 - } - }) - ).toEqual({ - percentages: { - small: 6, - medium: 12, - large: 26 - } - }); - }); - - it('clamps values below the minimum', () => { - expect( - normalizePointerMovementSettings({ - percentages: { - small: 0.2 - } - }).percentages.small - ).toBe(1); + it('preserves valid scale percentages', () => { + expect(normalizePointerMovementSettings({ scalePercent: 125 })).toEqual({ scalePercent: 125 }); }); - it('clamps values above the maximum', () => { - expect( - normalizePointerMovementSettings({ - percentages: { - large: 100 - } - }).percentages.large - ).toBe(50); - }); - - it('enforces ordered percentages with a minimum half percent gap', () => { - expect( - normalizePointerMovementSettings({ - percentages: { - small: 12, - medium: 12, - large: 12 - } - }) - ).toEqual({ - percentages: { - small: 11.5, - medium: 12, - large: 12.5 - } - }); - }); - - it('clamps small below medium', () => { - expect( - normalizePointerMovementSettings({ - percentages: { - small: 20, - medium: 12, - large: 26 - } - }) - ).toEqual({ - percentages: { - small: 11.5, - medium: 12, - large: 26 - } - }); + it('clamps scale values below the minimum', () => { + expect(normalizePointerMovementSettings({ scalePercent: 10 })).toEqual({ scalePercent: 50 }); }); - it('clamps medium above small', () => { - expect( - normalizePointerMovementSettings({ - percentages: { - small: 4.5, - medium: 3, - large: 26 - } - }) - ).toEqual({ - percentages: { - small: 4.5, - medium: 5, - large: 26 - } - }); + it('clamps scale values above the maximum', () => { + expect(normalizePointerMovementSettings({ scalePercent: 1000 })).toEqual({ scalePercent: 200 }); }); - it('clamps medium below large', () => { - expect( - normalizePointerMovementSettings({ - percentages: { - small: 4.5, - medium: 40, - large: 26 - } - }) - ).toEqual({ - percentages: { - small: 4.5, - medium: 25.5, - large: 26 - } - }); + it('rounds scale values to the nearest five percent', () => { + expect(normalizePointerMovementSettings({ scalePercent: 123 })).toEqual({ scalePercent: 125 }); }); - it('clamps large above medium', () => { - expect( - normalizePointerMovementSettings({ - percentages: { - small: 4.5, - medium: 12, - large: 10 - } - }) - ).toEqual({ - percentages: { - small: 4.5, - medium: 12, - large: 12.5 - } - }); + 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('shifts values down from the top when order would exceed the maximum', () => { + it('migrates percentage settings to one scale value', () => { expect( normalizePointerMovementSettings({ percentages: { - small: 49.8, - medium: 50, + small: 9, + medium: 24, large: 50 } }) - ).toEqual({ - percentages: { - small: 49, - medium: 49.5, - large: 50 - } - }); + ).toEqual({ scalePercent: 195 }); }); - it('rounds values to the nearest half percent', () => { - expect( - normalizePointerMovementSettings({ - percentages: { - medium: 12.3 - } - }).percentages.medium - ).toBe(12.5); - }); - - it('falls back for non-finite and non-number values', () => { - expect( - normalizePointerMovementSettings({ - percentages: { - small: Number.NaN, - medium: '12', - large: Number.POSITIVE_INFINITY - } - }) - ).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS); - }); - - it('migrates legacy multiplier settings to screen percentages', () => { + it('migrates legacy multiplier settings to one scale value', () => { expect( normalizePointerMovementSettings({ multipliers: { @@ -204,22 +54,22 @@ describe('normalizePointerMovementSettings', () => { large: 100 } }) - ).toEqual({ - percentages: { - small: 9, - medium: 9.5, - large: 26 - } - }); + ).toEqual({ scalePercent: 115 }); }); }); describe('pointer movement setting helpers', () => { - it('returns normalized percentages', () => { - expect(pointerMovementPercentageFor({ percentages: { small: 0.2, medium: 12, large: 26 } }, 'small')).toBe(1); + 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 normalized percentages', () => { + 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 index d4fe5a9..0d7d9ee 100644 --- a/src/shared/pointer-movement-settings.ts +++ b/src/shared/pointer-movement-settings.ts @@ -1,59 +1,63 @@ export type PointerMovementSizeKey = 'small' | 'medium' | 'large'; export type PointerMovementSettings = { - percentages: Record; + scalePercent: number; +}; + +export const BASE_POINTER_MOVEMENT_PERCENTAGES: Record = { + small: 4.5, + medium: 12, + large: 26 }; export const DEFAULT_POINTER_MOVEMENT_SETTINGS: PointerMovementSettings = { - percentages: { - small: 4.5, - medium: 12, - large: 26 - } + scalePercent: 100 }; -export const POINTER_MOVEMENT_PERCENTAGE_MIN = 1; -export const POINTER_MOVEMENT_PERCENTAGE_MAX = 50; -export const POINTER_MOVEMENT_PERCENTAGE_STEP = 0.5; -export const POINTER_MOVEMENT_PERCENTAGE_MIN_GAP = POINTER_MOVEMENT_PERCENTAGE_STEP; +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 cloneDefaultSettings(); + return { ...DEFAULT_POINTER_MOVEMENT_SETTINGS }; + } + + const candidate = value as Partial & { + percentages?: unknown; + multipliers?: unknown; + }; + + if ('scalePercent' in candidate) { + return { scalePercent: normalizeScale(candidate.scalePercent) }; } - const candidate = value as Partial; - const percentages = candidate.percentages; - if (percentages && typeof percentages === 'object') { - return normalizeOrderedPercentages({ - small: normalizePercentage((percentages as Partial>).small, 'small'), - medium: normalizePercentage((percentages as Partial>).medium, 'medium'), - large: normalizePercentage((percentages as Partial>).large, 'large') - }); + if (candidate.percentages && typeof candidate.percentages === 'object') { + return { scalePercent: normalizeScale(scaleFromPercentages(candidate.percentages)) }; } - const multipliers = (candidate as { multipliers?: unknown }).multipliers; - if (!multipliers || typeof multipliers !== 'object') { - return cloneDefaultSettings(); + if (candidate.multipliers && typeof candidate.multipliers === 'object') { + return { scalePercent: normalizeScale(scaleFromLegacyMultipliers(candidate.multipliers)) }; } - return normalizeOrderedPercentages({ - small: normalizeLegacyMultiplier((multipliers as Partial>).small, 'small'), - medium: normalizeLegacyMultiplier( - (multipliers as Partial>).medium, - 'medium' - ), - large: normalizeLegacyMultiplier((multipliers as Partial>).large, 'large') - }); + return { ...DEFAULT_POINTER_MOVEMENT_SETTINGS }; +} + +export function pointerMovementScalePercentFor(settings: PointerMovementSettings): number { + return normalizePointerMovementSettings(settings).scalePercent; } export function pointerMovementPercentageFor( settings: PointerMovementSettings, size: PointerMovementSizeKey ): number { - return normalizePointerMovementSettings(settings).percentages[size]; + const scale = pointerMovementScalePercentFor(settings) / 100; + return normalizeDisplayPercentage(BASE_POINTER_MOVEMENT_PERCENTAGES[size] * scale); } export function pointerMovementFractionFor( @@ -63,89 +67,49 @@ export function pointerMovementFractionFor( return pointerMovementPercentageFor(settings, size) / 100; } -function normalizePercentage(value: unknown, size: PointerMovementSizeKey): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return DEFAULT_POINTER_MOVEMENT_SETTINGS.percentages[size]; - } - - return clamp(roundToStep(value), POINTER_MOVEMENT_PERCENTAGE_MIN, POINTER_MOVEMENT_PERCENTAGE_MAX); +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 normalizeLegacyMultiplier(value: unknown, size: PointerMovementSizeKey): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return DEFAULT_POINTER_MOVEMENT_SETTINGS.percentages[size]; - } - - return normalizePercentage((DEFAULT_POINTER_MOVEMENT_SETTINGS.percentages[size] * value) / 100, size); +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 normalizeOrderedPercentages( - percentages: Record -): PointerMovementSettings { - let small = percentages.small; - let medium = percentages.medium; - let large = percentages.large; - - for (let pass = 0; pass < 4; pass += 1) { - [small, medium] = orderPair('small', small, 'medium', medium); - [medium, large] = orderPair('medium', medium, 'large', large); +function normalizeScale(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_POINTER_MOVEMENT_SETTINGS.scalePercent; } - return { - percentages: { - small: clamp(roundToStep(small), POINTER_MOVEMENT_PERCENTAGE_MIN, POINTER_MOVEMENT_PERCENTAGE_MAX), - medium: clamp(roundToStep(medium), POINTER_MOVEMENT_PERCENTAGE_MIN, POINTER_MOVEMENT_PERCENTAGE_MAX), - large: clamp(roundToStep(large), POINTER_MOVEMENT_PERCENTAGE_MIN, POINTER_MOVEMENT_PERCENTAGE_MAX) - } - }; + return clamp(roundToStep(value, POINTER_MOVEMENT_SCALE_STEP), POINTER_MOVEMENT_SCALE_MIN, POINTER_MOVEMENT_SCALE_MAX); } -function orderPair( - lowerSize: PointerMovementSizeKey, - lowerValue: number, - upperSize: PointerMovementSizeKey, - upperValue: number -): [number, number] { - if (lowerValue + POINTER_MOVEMENT_PERCENTAGE_MIN_GAP <= upperValue) { - return [lowerValue, upperValue]; - } - - const lowerDeviation = Math.abs(lowerValue - DEFAULT_POINTER_MOVEMENT_SETTINGS.percentages[lowerSize]); - const upperDeviation = Math.abs(upperValue - DEFAULT_POINTER_MOVEMENT_SETTINGS.percentages[upperSize]); - - if (lowerDeviation >= upperDeviation) { - return [ - clamp( - upperValue - POINTER_MOVEMENT_PERCENTAGE_MIN_GAP, - POINTER_MOVEMENT_PERCENTAGE_MIN, - POINTER_MOVEMENT_PERCENTAGE_MAX - ), - upperValue - ]; - } - - return [ - lowerValue, - clamp( - lowerValue + POINTER_MOVEMENT_PERCENTAGE_MIN_GAP, - POINTER_MOVEMENT_PERCENTAGE_MIN, - POINTER_MOVEMENT_PERCENTAGE_MAX - ) - ]; +function normalizeDisplayPercentage(value: number): number { + return clamp(roundToStep(value, DISPLAY_PERCENTAGE_STEP), DISPLAY_PERCENTAGE_MIN, DISPLAY_PERCENTAGE_MAX); } -function roundToStep(value: number): number { - return Math.round(value / POINTER_MOVEMENT_PERCENTAGE_STEP) * POINTER_MOVEMENT_PERCENTAGE_STEP; +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)); } - -function cloneDefaultSettings(): PointerMovementSettings { - return { - percentages: Object.fromEntries( - pointerMovementSizeKeys.map((size) => [size, DEFAULT_POINTER_MOVEMENT_SETTINGS.percentages[size]]) - ) as Record - }; -} From 688ed96b7c765475eeb1daf0269e818e61a92577 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 24 Jun 2026 09:12:51 +0100 Subject: [PATCH 06/13] Make pointer scale slider responsive --- src/renderer/useSwitchifyPcStatus.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/renderer/useSwitchifyPcStatus.ts b/src/renderer/useSwitchifyPcStatus.ts index 6dd1ac0..3d524f2 100644 --- a/src/renderer/useSwitchifyPcStatus.ts +++ b/src/renderer/useSwitchifyPcStatus.ts @@ -104,7 +104,9 @@ export function useSwitchifyPcStatus(bridge: Window['switchifyPc']): SwitchifyPc const updatePointerMovementSettings = useCallback( async (settings: PointerMovementSettings): Promise => { - setPointerMovementSettings(await bridge.setPointerMovementSettings(settings)); + const normalized = normalizePointerMovementSettings(settings); + setPointerMovementSettings(normalized); + setPointerMovementSettings(await bridge.setPointerMovementSettings(normalized)); }, [bridge] ); From 6e486a808e094994bdf056054285b683e596356c Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 24 Jun 2026 09:24:24 +0100 Subject: [PATCH 07/13] Use percentage buttons for pointer scale --- src/renderer/components/SettingsPanel.tsx | 32 ++++++++--------- src/renderer/styles.css | 43 ++++++++++------------- 2 files changed, 33 insertions(+), 42 deletions(-) diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index e61efe8..76b6366 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -8,9 +8,6 @@ import type { import { CURSOR_OVERLAY_COLORS } from '../../shared/cursor-overlay-settings'; import { BASE_POINTER_MOVEMENT_PERCENTAGES, - POINTER_MOVEMENT_SCALE_MAX, - POINTER_MOVEMENT_SCALE_MIN, - POINTER_MOVEMENT_SCALE_STEP, normalizePointerMovementSettings, pointerMovementPercentageFor, pointerMovementScalePercentFor, @@ -257,20 +254,19 @@ function PointerMovementSettingsControls({ return (
- +
+ {pointerMovementScaleOptions.map((value) => ( + + ))} +
@@ -283,6 +279,8 @@ const pointerMovementSizeOptions: Array<{ value: PointerMovementSizeKey; label: { value: 'large', label: 'Large' } ]; +const pointerMovementScaleOptions = [50, 75, 100, 125, 150, 175, 200]; + function PointerMovementTable({ settings }: { settings: PointerMovementSettings }): ReactElement { const normalizedSettings = normalizePointerMovementSettings(settings); return ( diff --git a/src/renderer/styles.css b/src/renderer/styles.css index f6e775f..4d00869 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -826,28 +826,30 @@ p { gap: var(--space-xs); } -.pointer-movement-row { +.pointer-movement-button-row { display: grid; - grid-template-columns: minmax(72px, 0.25fr) minmax(140px, 1fr) minmax(52px, auto); - gap: var(--space-s); - align-items: center; + 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-label, -.pointer-movement-value { - color: var(--color-text); - font-size: 0.88rem; +.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-value { - justify-self: end; - font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace; -} - -.pointer-movement-slider { - width: 100%; - accent-color: var(--brand-primary); +.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-table { @@ -1079,15 +1081,6 @@ p { gap: 3px; } - .pointer-movement-row { - grid-template-columns: 1fr; - gap: 4px; - } - - .pointer-movement-value { - justify-self: start; - } - .settings-layout { grid-template-columns: 1fr; width: 100%; From 4a931aa92f10042934b6b4d22140354ff205066d Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 24 Jun 2026 09:54:47 +0100 Subject: [PATCH 08/13] Add pointer movement cursor preview --- package-lock.json | 13 +++- package.json | 3 +- src/renderer/components/SettingsPanel.tsx | 31 ++++++++- src/renderer/styles.css | 80 +++++++++++++++++++++++ 4 files changed, 121 insertions(+), 6 deletions(-) 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/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 76b6366..e317b72 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, @@ -218,8 +219,7 @@ function PointerSettingsSection({

Pointer

Movement distance

- Adjust all Android pointer steps together. The table shows each movement distance as a percentage of the - active screen size. + Choose how slow or fast Android pointer steps feel on this display.

+
); @@ -281,6 +282,30 @@ const pointerMovementSizeOptions: Array<{ value: PointerMovementSizeKey; label: 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); + const distance = Math.min(percentage * 2.4, 88); + return ( +
+ {option.label} + + {percentage}% +
+ ); + })} +
+ ); +} + function PointerMovementTable({ settings }: { settings: PointerMovementSettings }): ReactElement { const normalizedSettings = normalizePointerMovementSettings(settings); return ( diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 4d00869..9548912 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -852,6 +852,71 @@ p { 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 { + 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(var(--pointer-preview-distance)); + } + + 93%, + 100% { + transform: translateY(-50%) translateX(0); + } +} + .pointer-movement-table { width: 100%; border-collapse: collapse; @@ -1081,6 +1146,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%; @@ -1093,3 +1166,10 @@ p { } } + +@media (prefers-reduced-motion: reduce) { + .pointer-movement-preview-icon { + animation: none; + transform: translateY(-50%) translateX(var(--pointer-preview-distance)); + } +} From 35378420f9f44576973aa59c34b050c924329d49 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 24 Jun 2026 10:03:13 +0100 Subject: [PATCH 09/13] Fix pointer preview travel distance --- src/renderer/components/SettingsPanel.tsx | 2 +- src/renderer/styles.css | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index e317b72..bc42507 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -295,7 +295,7 @@ function PointerMovementPreview({ settings }: { settings: PointerMovementSetting {percentage}% diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 9548912..6807390 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -881,6 +881,7 @@ p { } .pointer-movement-preview-track { + container-type: inline-size; position: relative; height: 30px; overflow: hidden; @@ -908,7 +909,7 @@ p { 78%, 92% { - transform: translateY(-50%) translateX(var(--pointer-preview-distance)); + transform: translateY(-50%) translateX(calc(var(--pointer-preview-distance) * 1cqw)); } 93%, @@ -1170,6 +1171,6 @@ p { @media (prefers-reduced-motion: reduce) { .pointer-movement-preview-icon { animation: none; - transform: translateY(-50%) translateX(var(--pointer-preview-distance)); + transform: translateY(-50%) translateX(calc(var(--pointer-preview-distance) * 1cqw)); } } From 1552f48f2e67e52b374326fc16072f0aec980eb2 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 24 Jun 2026 10:10:53 +0100 Subject: [PATCH 10/13] Map pointer preview to display percentage --- src/renderer/components/SettingsPanel.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index bc42507..4e61911 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -288,14 +288,13 @@ function PointerMovementPreview({ settings }: { settings: PointerMovementSetting
{pointerMovementSizeOptions.map((option) => { const percentage = pointerMovementPercentageFor(normalizedSettings, option.value); - const distance = Math.min(percentage * 2.4, 88); return (
{option.label} {percentage}% From 873b62e615ff8ffedb412dbd1b308f5771b6fb93 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 24 Jun 2026 10:15:13 +0100 Subject: [PATCH 11/13] Clarify pointer scale copy --- src/renderer/components/SettingsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 4e61911..dbc6384 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -219,7 +219,7 @@ function PointerSettingsSection({

Pointer

Movement distance

- Choose how slow or fast Android pointer steps feel on this display. + Choose how far each Android pointer step moves on this display.

Date: Wed, 24 Jun 2026 10:26:14 +0100 Subject: [PATCH 12/13] Fix pointer profile movement normalization --- src/main/index.ts | 3 +- src/main/input/libnut-win32-adapter.test.ts | 65 +++++++++++++++++++ src/main/input/pointer-profile.test.ts | 72 +++++++-------------- src/main/input/pointer-profile.ts | 27 ++++---- 4 files changed, 102 insertions(+), 65 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index d6e0832..33a4887 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -300,8 +300,7 @@ if (!gotSingleInstanceLock) { display: { bounds: display.bounds, scaleFactor: display.scaleFactor - }, - movementSettings: pointerMovementSettings + } }); }, onStatusChange: (status) => { diff --git a/src/main/input/libnut-win32-adapter.test.ts b/src/main/input/libnut-win32-adapter.test.ts index 410fa65..0f1cc21 100644 --- a/src/main/input/libnut-win32-adapter.test.ts +++ b/src/main/input/libnut-win32-adapter.test.ts @@ -11,6 +11,7 @@ import { createWindowControlScript, toWindowsWindowControlStrategy } from './windows-window-control'; +import { createPointerMovementProfile } from './pointer-profile'; describe('calculateScaledMouseTarget', () => { it('applies the display scale factor to relative movement', () => { @@ -209,6 +210,70 @@ describe('calculateDisplayNormalizedMouseTarget', () => { 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', () => { diff --git a/src/main/input/pointer-profile.test.ts b/src/main/input/pointer-profile.test.ts index 55c4b3e..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 display-relative 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: { @@ -18,9 +18,9 @@ describe('createPointerMovementProfile', () => { bounds: { x: 0, y: 0, width: 1280, height: 720 }, maxDelta: MAX_POINTER_DELTA, recommendedDeltas: { - small: 22, - medium: 58, - large: 125 + small: 32, + medium: 86, + large: 187 }, capabilities: { noAckMouseMove: true @@ -55,50 +55,41 @@ describe('createPointerMovementProfile', () => { }); }); - it('applies pointer movement scale to recommended deltas', () => { + 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 - }, - movementSettings: { - scalePercent: 150 } }).recommendedDeltas ).toEqual({ - small: 76, - medium: 194, - large: 421 + small: 49, + medium: 130, + large: 281 }); }); - it('divides scaled percentage deltas by scale factor on high-DPI displays', () => { + 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 - }, - movementSettings: { - scalePercent: 150 } }).recommendedDeltas.medium - ).toBe(194); + ).toBe(65); }); - it('normalizes invalid movement settings to defaults', () => { + 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 - }, - movementSettings: { - scalePercent: Number.NaN } }).recommendedDeltas ).toEqual({ @@ -108,26 +99,7 @@ describe('createPointerMovementProfile', () => { }); }); - it('migrates legacy percentage settings before generating deltas', () => { - expect( - createPointerMovementProfile({ - cursor: { x: 100, y: 100 }, - display: { - bounds: { x: 0, y: 0, width: 1920, height: 1080 }, - scaleFactor: 1 - }, - movementSettings: { - percentages: { - small: 9, - medium: 24, - large: 50 - } - } - }).recommendedDeltas.small - ).toBe(97); - }); - - it('returns larger deltas on a 4K display at 1x scale', () => { + it('returns stable baseline deltas on a 4K display at 1x scale', () => { expect( createPointerMovementProfile({ cursor: { x: 100, y: 100 }, @@ -137,13 +109,13 @@ describe('createPointerMovementProfile', () => { } }).recommendedDeltas ).toEqual({ - small: 97, - medium: 259, - large: 500 + small: 49, + medium: 130, + large: 281 }); }); - it('uses the short edge for ultrawide displays', () => { + it('returns stable baseline deltas on ultrawide displays', () => { expect( createPointerMovementProfile({ cursor: { x: 100, y: 100 }, @@ -153,9 +125,9 @@ describe('createPointerMovementProfile', () => { } }).recommendedDeltas ).toEqual({ - small: 65, - medium: 173, - large: 374 + small: 49, + medium: 130, + large: 281 }); }); @@ -169,9 +141,9 @@ describe('createPointerMovementProfile', () => { } }).recommendedDeltas ).toEqual({ - small: 49, - medium: 130, - large: 281 + small: 24, + medium: 65, + large: 140 }); }); diff --git a/src/main/input/pointer-profile.ts b/src/main/input/pointer-profile.ts index 4fdc2c2..464d015 100644 --- a/src/main/input/pointer-profile.ts +++ b/src/main/input/pointer-profile.ts @@ -1,33 +1,34 @@ import { MAX_POINTER_DELTA, NO_ACK_CONTROL_COMMAND_TYPES, type PointerMovementProfile } from '../../shared/protocol'; import { - normalizePointerMovementSettings, pointerMovementFractionFor, - type PointerMovementSettings + 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 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; display: { bounds: Bounds; scaleFactor: number; }; - movementSettings?: PointerMovementSettings; maxDelta?: number; }): PointerMovementProfile { const bounds = normalizeBounds(input.display.bounds); const scaleFactor = Number.isFinite(input.display.scaleFactor) && input.display.scaleFactor > 0 ? input.display.scaleFactor : 1; const maxDelta = input.maxDelta ?? MAX_POINTER_DELTA; - const movementSettings = normalizePointerMovementSettings(input.movementSettings); - const referenceSize = Math.min(bounds.width, bounds.height); - const targetNativeDeltas = { - small: referenceSize * pointerMovementFractionFor(movementSettings, 'small'), - medium: referenceSize * pointerMovementFractionFor(movementSettings, 'medium'), - large: referenceSize * pointerMovementFractionFor(movementSettings, 'large') - }; return { displayId: `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}:${scaleFactor}`, @@ -35,9 +36,9 @@ export function createPointerMovementProfile(input: { bounds, maxDelta, recommendedDeltas: { - small: toLogicalDelta(targetNativeDeltas.small, scaleFactor, maxDelta), - medium: toLogicalDelta(targetNativeDeltas.medium, scaleFactor, maxDelta), - large: toLogicalDelta(targetNativeDeltas.large, scaleFactor, maxDelta) + small: toLogicalDelta(TARGET_REFERENCE_NATIVE_DELTAS.small, scaleFactor, maxDelta), + medium: toLogicalDelta(TARGET_REFERENCE_NATIVE_DELTAS.medium, scaleFactor, maxDelta), + large: toLogicalDelta(TARGET_REFERENCE_NATIVE_DELTAS.large, scaleFactor, maxDelta) }, capabilities: { noAckMouseMove: true, From 09a1b68e714273a630d7db0d8b1ba9fbf5dd67e2 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 24 Jun 2026 10:28:18 +0100 Subject: [PATCH 13/13] Fix pointer movement test typing --- src/main/input/libnut-win32-adapter.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/input/libnut-win32-adapter.test.ts b/src/main/input/libnut-win32-adapter.test.ts index 0f1cc21..87bb71a 100644 --- a/src/main/input/libnut-win32-adapter.test.ts +++ b/src/main/input/libnut-win32-adapter.test.ts @@ -12,6 +12,7 @@ import { 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', () => { @@ -198,12 +199,16 @@ describe('calculateDisplayNormalizedMouseTarget', () => { }); 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 }, - { percentages: { small: 9, medium: 24, large: 50 } } + legacySettings ) ).toEqual({ x: 196,