Add a 200ms click debounce with extensive behavioral tests
Template tests / tests (push) Waiting to run
Template tests / tests (pull_request) Waiting to run

Per request: clicks of the same button closer together than
capture.clickDebounceMs (default 200ms) now collapse into a single step, so
accidental fast/double clicks don't each become a step. It is a leading-edge
debounce measured from the last *accepted* click, so a run of fast clicks
can't push the next deliberate click out — two clicks spaced beyond the
window (e.g. the reported 400-500ms apart) always register.

Replaces the prior 8ms duplicate-delivery suppression (subsumed by the
window). Configurable; 0 captures every click.

Tests (the point of this change is that it can't silently regress):
- 13 behavioral unit tests in capture.test.js that drive real onOsClick
  calls with controlled timestamps and assert which clicks survive — the
  reported 400/450/500ms cases, sub-window collapse, the 200ms boundary,
  per-button independence, configurability, debounce=0, last-accepted (not
  last-dropped) reference, session reset, and a full onOsClick -> queue ->
  store integration check. No keyword/comment assertions.
- A fourth end-to-end self-test scenario (burst of 40ms clicks collapses to
  1; three 300ms-apart clicks each register => 4 total). The marker/drain
  scenarios set debounce to 0 so they keep stressing the frame pipeline.

147 unit tests + all repo checks pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Iisyourdad
2026-06-12 09:02:51 -05:00
parent 0ab29e4ff0
commit 3d0b753205
5 changed files with 217 additions and 55 deletions
+36 -25
View File
@@ -45,12 +45,13 @@ const { physicalToDip } = require('./coords');
* failures surface as { ok: false, reason } instead of crashing.
*/
// Suppress only *duplicate deliveries* of one physical press (same button,
// same coordinates, a few ms apart). This deliberately replaces the old
// time-only debounce: real humans double-click ~50-100ms apart, and any
// purely temporal cutoff eventually drops a legitimate fast click, which
// reads as "my click didn't register". One hook/watcher event = one click.
const CLICK_EVENT_DUPLICATE_MS = 8;
// Leading-edge click debounce: the first click of a button is captured, and
// further clicks of that button within this window of the last *accepted*
// click are ignored. This collapses accidental fast / double clicks into one
// step, while any two deliberate clicks spaced more than the window apart
// each register. Tunable via capture.clickDebounceMs; this is only the
// default when the setting is absent.
const DEFAULT_CLICK_DEBOUNCE_MS = 200;
// How long a Linux raw button event waits for its regular twin (the
// representation that carries root coordinates) before firing without them.
const LINUX_CLICK_TWIN_MS = 25;
@@ -122,7 +123,7 @@ class CaptureService {
this.frameLoopGrabStartedAt = null;
this.recentFrames = [];
this.shooting = false;
this.lastClickEventByButton = new Map();
this.lastAcceptedClickByButton = new Map();
this.streamBackend = null;
this.streamBackendStarting = false;
}
@@ -946,7 +947,7 @@ public static class SFMouseHook {
this.clickWatcherBuf = '';
this.linuxEvent = null;
this.discardPendingRawClick();
this.lastClickEventByButton.clear();
this.lastAcceptedClickByButton.clear();
}
/**
@@ -1101,15 +1102,22 @@ public static class SFMouseHook {
this.pendingRawClick = null;
}
/** Debounce window in ms (capture.clickDebounceMs, default 200). */
clickDebounceMs() {
const raw = this.settings.get('capture.clickDebounceMs');
const v = Number(raw);
return raw != null && Number.isFinite(v) && v >= 0 ? v : DEFAULT_CLICK_DEBOUNCE_MS;
}
onOsClick(at = Date.now(), osPoint = null, button = 'mouse') {
if (!this.session || this.session.paused) return;
const clickAt = Number.isFinite(at) ? at : Date.now();
// Source-aware dedupe, not a debounce: each hook/watcher event is one
// click however fast it follows the previous one. Only an *identical*
// event a few ms later — duplicate delivery of one physical press — is
// suppressed.
if (this.isDuplicateClickEvent(clickAt, osPoint, button)) {
clog('click@', clickAt, button, 'suppressed as duplicate delivery');
// Leading-edge debounce: ignore a click that lands within the debounce
// window of the last accepted click of the same button. This makes fast
// / accidental repeat clicks register once, while two deliberate clicks
// spaced more than the window apart each register (one step per click).
if (this.isDebouncedClick(clickAt, button)) {
clog('click@', clickAt, button, 'debounced (within', this.clickDebounceMs(), 'ms of last accepted)');
return;
}
// Prefer the position the watcher sampled with the button-down event
@@ -1124,18 +1132,21 @@ public static class SFMouseHook {
this.enqueueClickCapture(clickPos, clickAt, button || 'mouse');
}
isDuplicateClickEvent(at, osPoint, button) {
/**
* Whether this click should be dropped by the debounce. A click is dropped
* only when it follows the last *accepted* click of the same button by
* less than the debounce window — so the window is measured from accepted
* clicks, never from dropped ones, and a run of fast clicks can't push the
* next deliberate click out indefinitely. Accepting a click records it as
* the new reference point. Different buttons debounce independently.
*/
isDebouncedClick(at, button) {
const key = button || 'mouse';
const last = this.lastClickEventByButton.get(key);
this.lastClickEventByButton.set(key, { at, osPoint });
if (!last) return false;
if (at < last.at || at - last.at >= CLICK_EVENT_DUPLICATE_MS) return false;
// Same button within a few ms: duplicate only if it is the *same* event
// (same coordinates, or neither delivery carried coordinates).
if (osPoint && last.osPoint) {
return osPoint.x === last.osPoint.x && osPoint.y === last.osPoint.y;
}
return !osPoint && !last.osPoint;
const windowMs = this.clickDebounceMs();
const last = this.lastAcceptedClickByButton.get(key);
if (last != null && at >= last && at - last < windowMs) return true;
this.lastAcceptedClickByButton.set(key, at);
return false;
}
/**
+40
View File
@@ -104,6 +104,11 @@ function createWindow() {
if (process.env.STEPFORGE_CLICK_SELFTEST) {
setTimeout(async () => {
try {
// The marker/drain scenarios inject clicks faster than the default
// debounce to stress the frame pipeline; turn the debounce off for
// them so every injected click is captured. A dedicated scenario
// at the end re-enables it and verifies the debounce itself.
settings.set('capture.clickDebounceMs', 0);
const guide = store.createGuide({ title: 'click selftest' });
capture.startSession(guide.guideId, { intervalSec: 0 });
// Isolate the test from the user's real mouse: the session starts
@@ -222,6 +227,41 @@ function createWindow() {
console.log('CLICK-SELFTEST arm: first click ->', armStepIds.length,
'step(s)', armPreClick ? '(see margin in [capture] log above)' : 'FAIL — first click lost');
capture.finishSession();
// Fourth scenario: the debounce itself, exercised end to end through
// onOsClick. A fast burst (40ms apart) must collapse to one step,
// and deliberate clicks (300ms apart) must each register.
settings.set('capture.clickDebounceMs', 200);
const dbGuide = store.createGuide({ title: 'debounce selftest' });
mainWindow.show();
await new Promise((res) => setTimeout(res, 200));
capture.startSession(dbGuide.guideId, { intervalSec: 0 });
capture.stopClickWatcher();
capture.clickCaptureAvailable = () => true;
capture.hiddenForSession = true;
capture.togglePause(false);
await capture.startClickFrameBackend();
await new Promise((res) => setTimeout(res, 1500));
const dbPoint = {
x: Math.round(bounds.x + bounds.width * 0.55),
y: Math.round(bounds.y + bounds.height * 0.55),
};
// 4 clicks 40ms apart — accidental fast clicking → expect 1 step.
for (let i = 0; i < 4; i++) {
capture.onOsClick(Date.now(), toPhysical(dbPoint), 'button-1');
await new Promise((res) => setTimeout(res, 40));
}
// 3 deliberate clicks 300ms apart → expect 3 more steps.
for (let i = 0; i < 3; i++) {
await new Promise((res) => setTimeout(res, 300));
capture.onOsClick(Date.now(), toPhysical(dbPoint), 'button-1');
}
await capture.clickQueue;
await new Promise((res) => setTimeout(res, 800));
const dbSteps = store.getGuide(dbGuide.guideId).stepsOrder.length;
console.log('CLICK-SELFTEST debounce:', dbSteps, 'of 4 expected',
dbSteps === 4 ? 'OK — burst collapsed to 1, three deliberate clicks kept' : 'FAIL');
capture.finishSession();
} catch (err) {
console.log('CLICK-SELFTEST ERROR', err.message);
} finally {