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 {
+