Skip to content

Commit b070d9a

Browse files
authored
Indicate active dragging in cursor overlay
Closes #198
1 parent f7826bb commit b070d9a

6 files changed

Lines changed: 169 additions & 23 deletions

File tree

native/cursor-overlay-helper/OverlayForm.cs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ internal sealed class OverlayForm : Form
1616
private readonly CrosshairLineForm verticalCrosshair = new();
1717
private DateTime clickPulseStartedAt = DateTime.MinValue;
1818
private bool isClickPulse;
19+
private bool isDragActive;
1920
private int ringWindowSize = DefaultWindowSize;
2021
private PointF cursorCenter = new(DefaultWindowSize / 2.0f, DefaultWindowSize / 2.0f);
2122
private Color overlayColor = DefaultOverlayColor;
@@ -74,6 +75,7 @@ internal void ShowOverlay(OverlayCommand command)
7475
cursorCenter = new PointF(size / 2.0f, size / 2.0f);
7576

7677
isClickPulse = string.Equals(command.Event, "click", StringComparison.OrdinalIgnoreCase);
78+
isDragActive = string.Equals(command.Event, "drag", StringComparison.OrdinalIgnoreCase);
7779
clickPulseStartedAt = isClickPulse ? DateTime.UtcNow : DateTime.MinValue;
7880

7981
if (!RenderLayeredOverlay())
@@ -99,6 +101,7 @@ internal void HideOverlay()
99101
hideTimer.Stop();
100102
animationTimer.Stop();
101103
isClickPulse = false;
104+
isDragActive = false;
102105
horizontalCrosshair.Hide();
103106
verticalCrosshair.Hide();
104107
Hide();
@@ -149,12 +152,24 @@ private bool RenderLayeredOverlay()
149152
float centerY = cursorCenter.Y;
150153
float ringX = centerX - ringDiameter / 2.0f;
151154
float ringY = centerY - ringDiameter / 2.0f;
152-
153-
using Pen glow = new(Color.FromArgb(62, overlayColor.R, overlayColor.G, overlayColor.B), outerStroke);
154-
using Pen ring = new(Color.FromArgb(250, overlayColor.R, overlayColor.G, overlayColor.B), ringStroke);
155+
float dragDotDiameter = ringDiameter * 0.22f;
156+
float dragDotX = centerX - dragDotDiameter / 2.0f;
157+
float dragDotY = centerY - dragDotDiameter / 2.0f;
158+
159+
using Pen glow = new(
160+
Color.FromArgb(isDragActive ? 66 : 62, overlayColor.R, overlayColor.G, overlayColor.B),
161+
isDragActive ? outerStroke * 1.08f : outerStroke);
162+
using Pen ring = new(
163+
Color.FromArgb(250, overlayColor.R, overlayColor.G, overlayColor.B),
164+
isDragActive ? ringStroke + 1.0f : ringStroke);
165+
using SolidBrush dragDot = new(Color.FromArgb(240, overlayColor.R, overlayColor.G, overlayColor.B));
155166

156167
graphics.DrawEllipse(glow, ringX, ringY, ringDiameter, ringDiameter);
157168
graphics.DrawEllipse(ring, ringX, ringY, ringDiameter, ringDiameter);
169+
if (isDragActive)
170+
{
171+
graphics.FillEllipse(dragDot, dragDotX, dragDotY, dragDotDiameter, dragDotDiameter);
172+
}
158173
}
159174

160175
IntPtr screenDc = NativeMethods.GetDC(IntPtr.Zero);

src/main/cursor-overlay-helper-client.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class FakeHelperProcess extends EventEmitter {
3131
class FakeOverlayBackend implements CursorOverlayBackend {
3232
readonly events: string[] = [];
3333

34-
show(event: 'move' | 'click', _options: CursorOverlayRenderOptions): void {
34+
show(event: 'move' | 'click' | 'drag', _options: CursorOverlayRenderOptions): void {
3535
this.events.push(`show:${event}`);
3636
}
3737

@@ -123,6 +123,35 @@ describe('NativeWindowsCursorOverlayBackend', () => {
123123
});
124124
});
125125

126+
it('writes drag commands to the helper process', () => {
127+
const helper = new FakeHelperProcess();
128+
const fallback = new FakeOverlayBackend();
129+
const backend = new NativeWindowsCursorOverlayBackend({
130+
helperPath: __filename,
131+
fallback,
132+
getCursorPosition: () => ({ x: 100, y: 200 }),
133+
idleTimeoutMs: 900,
134+
getSettings: () => ({ enabled: true, size: 'medium', visibility: 'onInput', crosshairs: false, color: 'red' }),
135+
resolveSizePixels: () => 128,
136+
spawnProcess: () => helper as never
137+
});
138+
139+
backend.show('drag', {
140+
size: 128,
141+
idleTimeoutMs: 900,
142+
crosshairs: false,
143+
persistent: true,
144+
colorRgb: [211, 47, 47]
145+
});
146+
147+
expect(JSON.parse(helper.writes[0])).toMatchObject({
148+
type: 'show',
149+
event: 'drag',
150+
persistent: true,
151+
durationMs: 0
152+
});
153+
});
154+
126155
it('writes persistent show commands without a hide duration', () => {
127156
const helper = new FakeHelperProcess();
128157
const fallback = new FakeOverlayBackend();
@@ -217,6 +246,28 @@ describe('NativeWindowsCursorOverlayBackend', () => {
217246
expect(failures[0]).toContain('Cursor overlay helper was not found');
218247
});
219248

249+
it('falls back with the drag event when the helper is missing during drag', () => {
250+
const fallback = new FakeOverlayBackend();
251+
const backend = new NativeWindowsCursorOverlayBackend({
252+
helperPath: 'C:\\missing\\SwitchifyCursorOverlay.exe',
253+
fallback,
254+
getCursorPosition: () => ({ x: 100, y: 200 }),
255+
idleTimeoutMs: 900,
256+
getSettings: () => ({ enabled: true, size: 'medium', visibility: 'onInput', crosshairs: false, color: 'red' }),
257+
resolveSizePixels: () => 128
258+
});
259+
260+
backend.show('drag', {
261+
size: 128,
262+
idleTimeoutMs: 900,
263+
crosshairs: false,
264+
persistent: true,
265+
colorRgb: [211, 47, 47]
266+
});
267+
268+
expect(fallback.events).toEqual(['show:drag']);
269+
});
270+
220271
it('falls back after helper errors', () => {
221272
const helper = new FakeHelperProcess();
222273
const fallback = new FakeOverlayBackend();

src/main/cursor-overlay-helper-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
22
import { existsSync } from 'node:fs';
33
import { resolveCursorOverlayColorRgb, type CursorOverlaySettings } from '../shared/cursor-overlay-settings';
44

5-
export type CursorOverlayEvent = 'move' | 'click';
5+
export type CursorOverlayEvent = 'move' | 'click' | 'drag';
66

77
export type CursorOverlayPoint = {
88
x: number;

src/main/cursor-overlay.ts

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export class CursorOverlay {
3333
private readonly followIntervalMs: number;
3434
private settings: CursorOverlaySettings;
3535
private followTimer: NodeJS.Timeout | null = null;
36+
private dragActive = false;
3637

3738
constructor(private readonly options: CursorOverlayOptions) {
3839
this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
@@ -68,7 +69,7 @@ export class CursorOverlay {
6869
setSettings(settings: CursorOverlaySettings): void {
6970
const previous = this.settings;
7071
this.settings = normalizeCursorOverlaySettings(settings);
71-
if (!this.settings.enabled || this.settings.visibility !== 'whileControlling') {
72+
if (!this.settings.enabled || !this.shouldFollowCursor()) {
7273
this.stopFollowing();
7374
}
7475
if (!this.settings.enabled) {
@@ -85,13 +86,31 @@ export class CursorOverlay {
8586
}
8687

8788
markControlActive(): void {
88-
if (!this.settings.enabled || this.settings.visibility !== 'whileControlling') return;
89+
if (!this.settings.enabled || !this.shouldFollowCursor()) return;
8990
this.startFollowing();
9091
}
9192

93+
setDragActive(active: boolean): void {
94+
if (this.dragActive === active) return;
95+
96+
this.dragActive = active;
97+
if (!this.settings.enabled) return;
98+
99+
if (this.dragActive) {
100+
this.startFollowing();
101+
return;
102+
}
103+
104+
if (this.shouldFollowCursor()) {
105+
this.refreshPersistentOverlay();
106+
} else {
107+
this.stopFollowing();
108+
}
109+
}
110+
92111
show(event: CursorOverlayEvent): void {
93112
if (!this.settings.enabled) return;
94-
if (this.settings.visibility === 'whileControlling') {
113+
if (this.shouldFollowCursor()) {
95114
this.startFollowing();
96115
}
97116
this.backend.show(event, this.renderOptions());
@@ -103,6 +122,7 @@ export class CursorOverlay {
103122
}
104123

105124
endControlSession(): void {
125+
this.dragActive = false;
106126
this.hide();
107127
}
108128

@@ -112,14 +132,14 @@ export class CursorOverlay {
112132
}
113133

114134
private startFollowing(): void {
115-
this.refreshPersistentOverlay();
116135
if (this.followTimer) return;
136+
this.refreshPersistentOverlay();
117137
this.followTimer = setInterval(() => {
118-
if (!this.settings.enabled || this.settings.visibility !== 'whileControlling') {
138+
if (!this.settings.enabled || !this.shouldFollowCursor()) {
119139
this.hide();
120140
return;
121141
}
122-
this.backend.show('move', this.renderOptions());
142+
this.backend.show(this.currentPersistentEvent(), this.renderOptions());
123143
}, this.followIntervalMs);
124144
this.followTimer.unref?.();
125145
}
@@ -132,23 +152,31 @@ export class CursorOverlay {
132152
}
133153

134154
private refreshPersistentOverlay(): void {
135-
if (!this.settings.enabled || this.settings.visibility !== 'whileControlling') return;
136-
this.backend.show('move', this.renderOptions());
155+
if (!this.settings.enabled || !this.shouldFollowCursor()) return;
156+
this.backend.show(this.currentPersistentEvent(), this.renderOptions());
137157
}
138158

139159
private renderOptions(): CursorOverlayRenderOptions {
140160
return {
141161
size: this.resolveSizePixels(),
142162
idleTimeoutMs: this.idleTimeoutMs,
143163
crosshairs: this.settings.crosshairs,
144-
persistent: this.settings.visibility === 'whileControlling',
164+
persistent: this.shouldFollowCursor(),
145165
colorRgb: resolveCursorOverlayColorRgb(this.settings.color)
146166
};
147167
}
148168

149169
private resolveSizePixels(): number {
150170
return resolveCursorOverlaySizePixels(this.settings.size);
151171
}
172+
173+
private shouldFollowCursor(): boolean {
174+
return this.settings.visibility === 'whileControlling' || this.dragActive;
175+
}
176+
177+
private currentPersistentEvent(): CursorOverlayEvent {
178+
return this.dragActive ? 'drag' : 'move';
179+
}
152180
}
153181

154182
type ElectronCursorOverlayBackendOptions = {
@@ -313,7 +341,7 @@ function createOverlayEventScript(
313341
}
314342
): string {
315343
return `
316-
document.body.className = ${JSON.stringify(event === 'click' ? 'click' : 'move')};
344+
document.body.className = ${JSON.stringify(event)};
317345
document.body.classList.toggle('crosshairs-enabled', ${JSON.stringify(options.crosshairs)});
318346
document.documentElement.style.setProperty('--overlay-rgb', '${options.colorRgb.join(', ')}');
319347
document.documentElement.style.setProperty('--center-x', '${Math.round(options.centerX)}px');
@@ -405,6 +433,32 @@ function createOverlayHtml(): string {
405433
animation: click-pulse 180ms ease-out;
406434
}
407435
436+
.drag-dot {
437+
position: absolute;
438+
left: var(--center-x, 64px);
439+
top: var(--center-y, 64px);
440+
display: none;
441+
width: calc(var(--ring-size, 72px) * 0.22);
442+
height: calc(var(--ring-size, 72px) * 0.22);
443+
border-radius: 999px;
444+
background: rgba(var(--overlay-rgb, 211, 47, 47), 0.94);
445+
box-shadow:
446+
0 0 0 4px rgba(var(--overlay-rgb, 211, 47, 47), 0.18),
447+
0 0 18px rgba(var(--overlay-rgb, 211, 47, 47), 0.45);
448+
transform: translate(-50%, -50%);
449+
}
450+
451+
body.drag .ring {
452+
border-width: calc(var(--ring-stroke, 5px) + 1px);
453+
box-shadow:
454+
0 0 0 calc(var(--glow-stroke, 24px) * 0.5) rgba(var(--overlay-rgb, 211, 47, 47), 0.26),
455+
0 0 42px rgba(var(--overlay-rgb, 211, 47, 47), 0.54);
456+
}
457+
458+
body.drag .drag-dot {
459+
display: block;
460+
}
461+
408462
@keyframes click-pulse {
409463
0% {
410464
transform: translate(-50%, -50%) scale(0.82);
@@ -425,6 +479,7 @@ function createOverlayHtml(): string {
425479
<div class="crosshair crosshair-horizontal"></div>
426480
<div class="crosshair crosshair-vertical"></div>
427481
<div class="ring"></div>
482+
<div class="drag-dot"></div>
428483
</body>
429484
</html>`;
430485
}

src/main/input/command-executor.test.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,19 @@ import { DesktopCommandExecutor } from './command-executor';
88
type RecordedCall = { method: keyof DesktopInputAdapter; args: unknown[] };
99

1010
class FakeCursorOverlay {
11-
readonly events: Array<'move' | 'click'> = [];
11+
readonly events: Array<'move' | 'click' | 'drag'> = [];
12+
readonly dragActiveChanges: boolean[] = [];
1213
activeCount = 0;
1314
hideCount = 0;
1415

15-
show(event: 'move' | 'click'): void {
16+
show(event: 'move' | 'click' | 'drag'): void {
1617
this.events.push(event);
1718
}
1819

20+
setDragActive(active: boolean): void {
21+
this.dragActiveChanges.push(active);
22+
}
23+
1924
markControlActive(): void {
2025
this.activeCount += 1;
2126
}
@@ -306,11 +311,24 @@ describe('DesktopCommandExecutor', () => {
306311
{ method: 'moveMouseBy', args: [{ dx: 10, dy: 0 }] },
307312
{ method: 'setMouseButtonDown', args: ['left', false] }
308313
]);
309-
expect(overlay.events).toEqual(['move', 'move', 'move']);
314+
expect(overlay.events).toEqual(['drag', 'drag', 'move']);
315+
expect(overlay.dragActiveChanges).toEqual([true, false]);
310316
expect(overlay.activeCount).toBe(3);
311317
expect(overlay.hideCount).toBe(0);
312318
});
313319

320+
it('uses the drag overlay while moving with a held mouse button', async () => {
321+
const { executor, overlay } = createExecutor();
322+
323+
await executor.execute(command('mouse.dragStart', { button: 'left' }));
324+
await executor.execute(command('mouse.move', { dx: 5, dy: 0 }));
325+
await executor.execute(command('mouse.dragEnd', { button: 'left' }));
326+
await executor.execute(command('mouse.move', { dx: 5, dy: 0 }));
327+
328+
expect(overlay.events).toEqual(['drag', 'drag', 'move', 'move']);
329+
expect(overlay.dragActiveChanges).toEqual([true, false]);
330+
});
331+
314332
it('serializes drag start, movement, and drag end pointer actions', async () => {
315333
const { adapter, executor } = createExecutor();
316334
adapter.mouseMoveDelayMs = 10;
@@ -356,8 +374,8 @@ describe('DesktopCommandExecutor', () => {
356374
]);
357375
});
358376

359-
it('releases the active drag button during cleanup', async () => {
360-
const { adapter, executor } = createExecutor();
377+
it('releases the active drag button and clears the drag overlay during cleanup', async () => {
378+
const { adapter, executor, overlay } = createExecutor();
361379

362380
await executor.execute(command('mouse.dragStart', { button: 'left' }));
363381
await executor.releaseHeldMouseButtons();
@@ -367,6 +385,8 @@ describe('DesktopCommandExecutor', () => {
367385
{ method: 'setMouseButtonDown', args: ['left', true] },
368386
{ method: 'setMouseButtonDown', args: ['left', false] }
369387
]);
388+
expect(overlay.dragActiveChanges).toEqual([true, false]);
389+
expect(overlay.hideCount).toBe(1);
370390
});
371391

372392
it('converts drag adapter errors into structured failures', async () => {

0 commit comments

Comments
 (0)