diff --git a/native/cursor-overlay-helper/OverlayForm.cs b/native/cursor-overlay-helper/OverlayForm.cs index a285aca..69f37b7 100644 --- a/native/cursor-overlay-helper/OverlayForm.cs +++ b/native/cursor-overlay-helper/OverlayForm.cs @@ -16,6 +16,7 @@ internal sealed class OverlayForm : Form private readonly CrosshairLineForm verticalCrosshair = new(); private DateTime clickPulseStartedAt = DateTime.MinValue; private bool isClickPulse; + private bool isDragActive; private int ringWindowSize = DefaultWindowSize; private PointF cursorCenter = new(DefaultWindowSize / 2.0f, DefaultWindowSize / 2.0f); private Color overlayColor = DefaultOverlayColor; @@ -74,6 +75,7 @@ internal void ShowOverlay(OverlayCommand command) cursorCenter = new PointF(size / 2.0f, size / 2.0f); isClickPulse = string.Equals(command.Event, "click", StringComparison.OrdinalIgnoreCase); + isDragActive = string.Equals(command.Event, "drag", StringComparison.OrdinalIgnoreCase); clickPulseStartedAt = isClickPulse ? DateTime.UtcNow : DateTime.MinValue; if (!RenderLayeredOverlay()) @@ -99,6 +101,7 @@ internal void HideOverlay() hideTimer.Stop(); animationTimer.Stop(); isClickPulse = false; + isDragActive = false; horizontalCrosshair.Hide(); verticalCrosshair.Hide(); Hide(); @@ -149,12 +152,24 @@ private bool RenderLayeredOverlay() float centerY = cursorCenter.Y; float ringX = centerX - ringDiameter / 2.0f; float ringY = centerY - ringDiameter / 2.0f; - - using Pen glow = new(Color.FromArgb(62, overlayColor.R, overlayColor.G, overlayColor.B), outerStroke); - using Pen ring = new(Color.FromArgb(250, overlayColor.R, overlayColor.G, overlayColor.B), ringStroke); + float dragDotDiameter = ringDiameter * 0.22f; + float dragDotX = centerX - dragDotDiameter / 2.0f; + float dragDotY = centerY - dragDotDiameter / 2.0f; + + using Pen glow = new( + Color.FromArgb(isDragActive ? 66 : 62, overlayColor.R, overlayColor.G, overlayColor.B), + isDragActive ? outerStroke * 1.08f : outerStroke); + using Pen ring = new( + Color.FromArgb(250, overlayColor.R, overlayColor.G, overlayColor.B), + isDragActive ? ringStroke + 1.0f : ringStroke); + using SolidBrush dragDot = new(Color.FromArgb(240, overlayColor.R, overlayColor.G, overlayColor.B)); graphics.DrawEllipse(glow, ringX, ringY, ringDiameter, ringDiameter); graphics.DrawEllipse(ring, ringX, ringY, ringDiameter, ringDiameter); + if (isDragActive) + { + graphics.FillEllipse(dragDot, dragDotX, dragDotY, dragDotDiameter, dragDotDiameter); + } } IntPtr screenDc = NativeMethods.GetDC(IntPtr.Zero); diff --git a/src/main/cursor-overlay-helper-client.test.ts b/src/main/cursor-overlay-helper-client.test.ts index 89c022c..3339057 100644 --- a/src/main/cursor-overlay-helper-client.test.ts +++ b/src/main/cursor-overlay-helper-client.test.ts @@ -31,7 +31,7 @@ class FakeHelperProcess extends EventEmitter { class FakeOverlayBackend implements CursorOverlayBackend { readonly events: string[] = []; - show(event: 'move' | 'click', _options: CursorOverlayRenderOptions): void { + show(event: 'move' | 'click' | 'drag', _options: CursorOverlayRenderOptions): void { this.events.push(`show:${event}`); } @@ -123,6 +123,35 @@ describe('NativeWindowsCursorOverlayBackend', () => { }); }); + it('writes drag commands to the helper process', () => { + const helper = new FakeHelperProcess(); + const fallback = new FakeOverlayBackend(); + const backend = new NativeWindowsCursorOverlayBackend({ + helperPath: __filename, + fallback, + getCursorPosition: () => ({ x: 100, y: 200 }), + idleTimeoutMs: 900, + getSettings: () => ({ enabled: true, size: 'medium', visibility: 'onInput', crosshairs: false, color: 'red' }), + resolveSizePixels: () => 128, + spawnProcess: () => helper as never + }); + + backend.show('drag', { + size: 128, + idleTimeoutMs: 900, + crosshairs: false, + persistent: true, + colorRgb: [211, 47, 47] + }); + + expect(JSON.parse(helper.writes[0])).toMatchObject({ + type: 'show', + event: 'drag', + persistent: true, + durationMs: 0 + }); + }); + it('writes persistent show commands without a hide duration', () => { const helper = new FakeHelperProcess(); const fallback = new FakeOverlayBackend(); @@ -217,6 +246,28 @@ describe('NativeWindowsCursorOverlayBackend', () => { expect(failures[0]).toContain('Cursor overlay helper was not found'); }); + it('falls back with the drag event when the helper is missing during drag', () => { + const fallback = new FakeOverlayBackend(); + const backend = new NativeWindowsCursorOverlayBackend({ + helperPath: 'C:\\missing\\SwitchifyCursorOverlay.exe', + fallback, + getCursorPosition: () => ({ x: 100, y: 200 }), + idleTimeoutMs: 900, + getSettings: () => ({ enabled: true, size: 'medium', visibility: 'onInput', crosshairs: false, color: 'red' }), + resolveSizePixels: () => 128 + }); + + backend.show('drag', { + size: 128, + idleTimeoutMs: 900, + crosshairs: false, + persistent: true, + colorRgb: [211, 47, 47] + }); + + expect(fallback.events).toEqual(['show:drag']); + }); + it('falls back after helper errors', () => { const helper = new FakeHelperProcess(); const fallback = new FakeOverlayBackend(); diff --git a/src/main/cursor-overlay-helper-client.ts b/src/main/cursor-overlay-helper-client.ts index c0b2e5d..ebe4974 100644 --- a/src/main/cursor-overlay-helper-client.ts +++ b/src/main/cursor-overlay-helper-client.ts @@ -2,7 +2,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { existsSync } from 'node:fs'; import { resolveCursorOverlayColorRgb, type CursorOverlaySettings } from '../shared/cursor-overlay-settings'; -export type CursorOverlayEvent = 'move' | 'click'; +export type CursorOverlayEvent = 'move' | 'click' | 'drag'; export type CursorOverlayPoint = { x: number; diff --git a/src/main/cursor-overlay.ts b/src/main/cursor-overlay.ts index 7bade86..3740003 100644 --- a/src/main/cursor-overlay.ts +++ b/src/main/cursor-overlay.ts @@ -33,6 +33,7 @@ export class CursorOverlay { private readonly followIntervalMs: number; private settings: CursorOverlaySettings; private followTimer: NodeJS.Timeout | null = null; + private dragActive = false; constructor(private readonly options: CursorOverlayOptions) { this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; @@ -68,7 +69,7 @@ export class CursorOverlay { setSettings(settings: CursorOverlaySettings): void { const previous = this.settings; this.settings = normalizeCursorOverlaySettings(settings); - if (!this.settings.enabled || this.settings.visibility !== 'whileControlling') { + if (!this.settings.enabled || !this.shouldFollowCursor()) { this.stopFollowing(); } if (!this.settings.enabled) { @@ -85,13 +86,31 @@ export class CursorOverlay { } markControlActive(): void { - if (!this.settings.enabled || this.settings.visibility !== 'whileControlling') return; + if (!this.settings.enabled || !this.shouldFollowCursor()) return; this.startFollowing(); } + setDragActive(active: boolean): void { + if (this.dragActive === active) return; + + this.dragActive = active; + if (!this.settings.enabled) return; + + if (this.dragActive) { + this.startFollowing(); + return; + } + + if (this.shouldFollowCursor()) { + this.refreshPersistentOverlay(); + } else { + this.stopFollowing(); + } + } + show(event: CursorOverlayEvent): void { if (!this.settings.enabled) return; - if (this.settings.visibility === 'whileControlling') { + if (this.shouldFollowCursor()) { this.startFollowing(); } this.backend.show(event, this.renderOptions()); @@ -103,6 +122,7 @@ export class CursorOverlay { } endControlSession(): void { + this.dragActive = false; this.hide(); } @@ -112,14 +132,14 @@ export class CursorOverlay { } private startFollowing(): void { - this.refreshPersistentOverlay(); if (this.followTimer) return; + this.refreshPersistentOverlay(); this.followTimer = setInterval(() => { - if (!this.settings.enabled || this.settings.visibility !== 'whileControlling') { + if (!this.settings.enabled || !this.shouldFollowCursor()) { this.hide(); return; } - this.backend.show('move', this.renderOptions()); + this.backend.show(this.currentPersistentEvent(), this.renderOptions()); }, this.followIntervalMs); this.followTimer.unref?.(); } @@ -132,8 +152,8 @@ export class CursorOverlay { } private refreshPersistentOverlay(): void { - if (!this.settings.enabled || this.settings.visibility !== 'whileControlling') return; - this.backend.show('move', this.renderOptions()); + if (!this.settings.enabled || !this.shouldFollowCursor()) return; + this.backend.show(this.currentPersistentEvent(), this.renderOptions()); } private renderOptions(): CursorOverlayRenderOptions { @@ -141,7 +161,7 @@ export class CursorOverlay { size: this.resolveSizePixels(), idleTimeoutMs: this.idleTimeoutMs, crosshairs: this.settings.crosshairs, - persistent: this.settings.visibility === 'whileControlling', + persistent: this.shouldFollowCursor(), colorRgb: resolveCursorOverlayColorRgb(this.settings.color) }; } @@ -149,6 +169,14 @@ export class CursorOverlay { private resolveSizePixels(): number { return resolveCursorOverlaySizePixels(this.settings.size); } + + private shouldFollowCursor(): boolean { + return this.settings.visibility === 'whileControlling' || this.dragActive; + } + + private currentPersistentEvent(): CursorOverlayEvent { + return this.dragActive ? 'drag' : 'move'; + } } type ElectronCursorOverlayBackendOptions = { @@ -313,7 +341,7 @@ function createOverlayEventScript( } ): string { return ` - document.body.className = ${JSON.stringify(event === 'click' ? 'click' : 'move')}; + document.body.className = ${JSON.stringify(event)}; document.body.classList.toggle('crosshairs-enabled', ${JSON.stringify(options.crosshairs)}); document.documentElement.style.setProperty('--overlay-rgb', '${options.colorRgb.join(', ')}'); document.documentElement.style.setProperty('--center-x', '${Math.round(options.centerX)}px'); @@ -405,6 +433,32 @@ function createOverlayHtml(): string { animation: click-pulse 180ms ease-out; } + .drag-dot { + position: absolute; + left: var(--center-x, 64px); + top: var(--center-y, 64px); + display: none; + width: calc(var(--ring-size, 72px) * 0.22); + height: calc(var(--ring-size, 72px) * 0.22); + border-radius: 999px; + background: rgba(var(--overlay-rgb, 211, 47, 47), 0.94); + box-shadow: + 0 0 0 4px rgba(var(--overlay-rgb, 211, 47, 47), 0.18), + 0 0 18px rgba(var(--overlay-rgb, 211, 47, 47), 0.45); + transform: translate(-50%, -50%); + } + + body.drag .ring { + border-width: calc(var(--ring-stroke, 5px) + 1px); + box-shadow: + 0 0 0 calc(var(--glow-stroke, 24px) * 0.5) rgba(var(--overlay-rgb, 211, 47, 47), 0.26), + 0 0 42px rgba(var(--overlay-rgb, 211, 47, 47), 0.54); + } + + body.drag .drag-dot { + display: block; + } + @keyframes click-pulse { 0% { transform: translate(-50%, -50%) scale(0.82); @@ -425,6 +479,7 @@ function createOverlayHtml(): string {
+
`; } diff --git a/src/main/input/command-executor.test.ts b/src/main/input/command-executor.test.ts index 8855ed0..673f6df 100644 --- a/src/main/input/command-executor.test.ts +++ b/src/main/input/command-executor.test.ts @@ -8,14 +8,19 @@ import { DesktopCommandExecutor } from './command-executor'; type RecordedCall = { method: keyof DesktopInputAdapter; args: unknown[] }; class FakeCursorOverlay { - readonly events: Array<'move' | 'click'> = []; + readonly events: Array<'move' | 'click' | 'drag'> = []; + readonly dragActiveChanges: boolean[] = []; activeCount = 0; hideCount = 0; - show(event: 'move' | 'click'): void { + show(event: 'move' | 'click' | 'drag'): void { this.events.push(event); } + setDragActive(active: boolean): void { + this.dragActiveChanges.push(active); + } + markControlActive(): void { this.activeCount += 1; } @@ -306,11 +311,24 @@ describe('DesktopCommandExecutor', () => { { method: 'moveMouseBy', args: [{ dx: 10, dy: 0 }] }, { method: 'setMouseButtonDown', args: ['left', false] } ]); - expect(overlay.events).toEqual(['move', 'move', 'move']); + expect(overlay.events).toEqual(['drag', 'drag', 'move']); + expect(overlay.dragActiveChanges).toEqual([true, false]); expect(overlay.activeCount).toBe(3); expect(overlay.hideCount).toBe(0); }); + it('uses the drag overlay while moving with a held mouse button', async () => { + const { executor, overlay } = createExecutor(); + + await executor.execute(command('mouse.dragStart', { button: 'left' })); + await executor.execute(command('mouse.move', { dx: 5, dy: 0 })); + await executor.execute(command('mouse.dragEnd', { button: 'left' })); + await executor.execute(command('mouse.move', { dx: 5, dy: 0 })); + + expect(overlay.events).toEqual(['drag', 'drag', 'move', 'move']); + expect(overlay.dragActiveChanges).toEqual([true, false]); + }); + it('serializes drag start, movement, and drag end pointer actions', async () => { const { adapter, executor } = createExecutor(); adapter.mouseMoveDelayMs = 10; @@ -356,8 +374,8 @@ describe('DesktopCommandExecutor', () => { ]); }); - it('releases the active drag button during cleanup', async () => { - const { adapter, executor } = createExecutor(); + it('releases the active drag button and clears the drag overlay during cleanup', async () => { + const { adapter, executor, overlay } = createExecutor(); await executor.execute(command('mouse.dragStart', { button: 'left' })); await executor.releaseHeldMouseButtons(); @@ -367,6 +385,8 @@ describe('DesktopCommandExecutor', () => { { method: 'setMouseButtonDown', args: ['left', true] }, { method: 'setMouseButtonDown', args: ['left', false] } ]); + expect(overlay.dragActiveChanges).toEqual([true, false]); + expect(overlay.hideCount).toBe(1); }); it('converts drag adapter errors into structured failures', async () => { diff --git a/src/main/input/command-executor.ts b/src/main/input/command-executor.ts index eab7452..a543446 100644 --- a/src/main/input/command-executor.ts +++ b/src/main/input/command-executor.ts @@ -14,9 +14,10 @@ export type CommandExecutionResult = | { ok: false; code: 'unsupported_command' | 'unsafe_payload' | 'adapter_failure'; message: string }; export type CursorOverlayNotifier = { - show(event: 'move' | 'click'): void; + show(event: 'move' | 'click' | 'drag'): void; hide?(): void; markControlActive?(): void; + setDragActive?(active: boolean): void; }; export class DesktopCommandExecutor { @@ -48,6 +49,8 @@ export class DesktopCommandExecutor { const button = this.activeDragButton; await this.adapter.setMouseButtonDown(button, false); this.activeDragButton = null; + this.cursorOverlay?.setDragActive?.(false); + this.cursorOverlay?.hide?.(); }); this.pointerActionQueue = release.then( () => undefined, @@ -129,14 +132,16 @@ export class DesktopCommandExecutor { assertBoundedNumber(command.payload.dx, MAX_POINTER_DELTA, 'dx'); assertBoundedNumber(command.payload.dy, MAX_POINTER_DELTA, 'dy'); await this.adapter.moveMouseBy(command.payload); - this.cursorOverlay?.show('move'); + this.cursorOverlay?.show(this.activeDragButton ? 'drag' : 'move'); return { ok: true }; case 'mouse.dragStart': await this.startDrag(command.payload.button); - this.cursorOverlay?.show('move'); + this.cursorOverlay?.setDragActive?.(true); + this.cursorOverlay?.show('drag'); return { ok: true }; case 'mouse.dragEnd': await this.endDrag(command.payload.button); + this.cursorOverlay?.setDragActive?.(false); this.cursorOverlay?.show('move'); return { ok: true }; case 'mouse.click':