Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions native/cursor-overlay-helper/OverlayForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())
Expand All @@ -99,6 +101,7 @@ internal void HideOverlay()
hideTimer.Stop();
animationTimer.Stop();
isClickPulse = false;
isDragActive = false;
horizontalCrosshair.Hide();
verticalCrosshair.Hide();
Hide();
Expand Down Expand Up @@ -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);
Expand Down
53 changes: 52 additions & 1 deletion src/main/cursor-overlay-helper-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/main/cursor-overlay-helper-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
75 changes: 65 additions & 10 deletions src/main/cursor-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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());
Expand All @@ -103,6 +122,7 @@ export class CursorOverlay {
}

endControlSession(): void {
this.dragActive = false;
this.hide();
}

Expand All @@ -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?.();
}
Expand All @@ -132,23 +152,31 @@ 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 {
return {
size: this.resolveSizePixels(),
idleTimeoutMs: this.idleTimeoutMs,
crosshairs: this.settings.crosshairs,
persistent: this.settings.visibility === 'whileControlling',
persistent: this.shouldFollowCursor(),
colorRgb: resolveCursorOverlayColorRgb(this.settings.color)
};
}

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 = {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand All @@ -425,6 +479,7 @@ function createOverlayHtml(): string {
<div class="crosshair crosshair-horizontal"></div>
<div class="crosshair crosshair-vertical"></div>
<div class="ring"></div>
<div class="drag-dot"></div>
</body>
</html>`;
}
30 changes: 25 additions & 5 deletions src/main/input/command-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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 () => {
Expand Down
Loading