Fixed clicking location part 2
Template tests / tests (push) Successful in 1m48s

This commit is contained in:
Iisyourdad
2026-06-11 16:12:09 -05:00
parent 646d6c4716
commit e7b91f8c4c
2 changed files with 138 additions and 17 deletions
+59 -16
View File
@@ -67,7 +67,9 @@ class CaptureService {
this.frameWaiters = []; this.frameWaiters = [];
this.latestFrame = null; this.latestFrame = null;
this.lastClickCapture = 0; this.lastClickCapture = 0;
this.clickWatcherButtonDown = false; this.clickWatcherBuf = '';
this.clickWatcherPendingPress = false;
this.clickQueue = Promise.resolve();
this.frameLoopInFlight = false; this.frameLoopInFlight = false;
this.shooting = false; this.shooting = false;
} }
@@ -421,7 +423,8 @@ class CaptureService {
startClickWatcher() { startClickWatcher() {
this.stopClickWatcher(); this.stopClickWatcher();
try { try {
this.clickWatcherButtonDown = false; this.clickWatcherBuf = '';
this.clickWatcherPendingPress = false;
if (process.platform === 'linux' && hasBinary('xinput')) { if (process.platform === 'linux' && hasBinary('xinput')) {
// Stream raw button events from the X server; one capture per press. // Stream raw button events from the X server; one capture per press.
// xinput block-buffers stdout when piped, so a press event can sit // xinput block-buffers stdout when piped, so a press event can sit
@@ -434,7 +437,7 @@ class CaptureService {
: ['xinput', 'test-xi2', '--root']; : ['xinput', 'test-xi2', '--root'];
this.clickWatcher = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'ignore'] }); this.clickWatcher = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'ignore'] });
this.clickWatcher.stdout.on('data', (chunk) => { this.clickWatcher.stdout.on('data', (chunk) => {
this.processClickWatcherData(chunk.toString(), 'linux'); this.ingestClickWatcherChunk(chunk.toString(), 'linux');
}); });
} else if (process.platform === 'win32') { } else if (process.platform === 'win32') {
// Poll the left mouse button via GetAsyncKeyState; print one line per click. // Poll the left mouse button via GetAsyncKeyState; print one line per click.
@@ -449,7 +452,7 @@ while ($true) {
}`; }`;
this.clickWatcher = spawn('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: ['ignore', 'pipe', 'ignore'] }); this.clickWatcher = spawn('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: ['ignore', 'pipe', 'ignore'] });
this.clickWatcher.stdout.on('data', (chunk) => { this.clickWatcher.stdout.on('data', (chunk) => {
this.processClickWatcherData(chunk.toString(), 'win32'); this.ingestClickWatcherChunk(chunk.toString(), 'win32');
}); });
} }
if (this.clickWatcher) { if (this.clickWatcher) {
@@ -466,23 +469,50 @@ while ($true) {
try { this.clickWatcher.kill(); } catch { /* already gone */ } try { this.clickWatcher.kill(); } catch { /* already gone */ }
this.clickWatcher = null; this.clickWatcher = null;
} }
this.clickWatcherButtonDown = false; this.clickWatcherBuf = '';
this.clickWatcherPendingPress = false;
}
/**
* Buffer stdout chunks and only parse complete lines: a chunk boundary
* can split an event line in half, which used to corrupt press/release
* parsing and swallow clicks.
*/
ingestClickWatcherChunk(chunk, platform = process.platform) {
this.clickWatcherBuf += String(chunk);
const cut = this.clickWatcherBuf.lastIndexOf('\n');
if (cut === -1) return;
const complete = this.clickWatcherBuf.slice(0, cut);
this.clickWatcherBuf = this.clickWatcherBuf.slice(cut + 1);
this.processClickWatcherData(complete, platform);
} }
processClickWatcherData(text, platform = process.platform) { processClickWatcherData(text, platform = process.platform) {
const lines = String(text).split(/\r?\n/); const lines = String(text).split(/\r?\n/);
if (platform === 'linux') { if (platform === 'linux') {
// xinput prints each event as a multi-line block: an "EVENT type …
// (RawButtonPress)" header followed by a "detail: N" line carrying the
// button number. Fire on the detail line so scroll-wheel ticks (X11
// reports them as buttons 4-7) neither create steps nor debounce away
// the real clicks that follow them.
for (const line of lines) { for (const line of lines) {
if (!line) continue; if (!line) continue;
// xinput batches multiple events into one chunk, so parse line by
// line and track press/release state instead of collapsing the chunk.
if (/RawButtonPress|ButtonPress/.test(line)) { if (/RawButtonPress|ButtonPress/.test(line)) {
if (!this.clickWatcherButtonDown) { if (this.clickWatcherPendingPress) this.onOsClick();
this.clickWatcherButtonDown = true; this.clickWatcherPendingPress = true;
this.onOsClick(); continue;
} }
} else if (/RawButtonRelease|ButtonRelease/.test(line)) { if (!this.clickWatcherPendingPress) continue;
this.clickWatcherButtonDown = false; const detail = line.match(/detail:\s*(\d+)/);
if (detail) {
this.clickWatcherPendingPress = false;
const button = Number(detail[1]);
if (button < 4 || button > 7) this.onOsClick();
} else if (line.includes('EVENT type')) {
// Next event arrived without a detail line in between — treat the
// pending press as a plain click rather than dropping it.
this.clickWatcherPendingPress = false;
this.onOsClick();
} }
} }
return; return;
@@ -496,15 +526,28 @@ while ($true) {
onOsClick(at = Date.now()) { onOsClick(at = Date.now()) {
if (!this.session || this.session.paused) return; if (!this.session || this.session.paused) return;
// Ignore clicks on StepForge itself (pausing, finishing, editing).
if (BrowserWindow.getFocusedWindow()) return;
if (at - this.lastClickCapture < CLICK_DEBOUNCE_MS) return; if (at - this.lastClickCapture < CLICK_DEBOUNCE_MS) return;
this.lastClickCapture = at; this.lastClickCapture = at;
// Grab the cursor position synchronously, right when the click is // Grab the cursor position synchronously, right when the click is
// detected, so the marker lands exactly where the user clicked even if // detected, so the marker lands exactly where the user clicked even if
// the shot itself takes a moment to grab. // the shot itself takes a moment to grab. (Clicks on StepForge itself
// are filtered by the cursor-position check in sessionCapture, not by
// window focus — WSLg reports focus unreliably.)
const clickPos = screen.getCursorScreenPoint(); const clickPos = screen.getCursorScreenPoint();
this.sessionCapture('click', clickPos).catch(() => {}); this.enqueueClickCapture(clickPos);
}
/**
* Serialize click captures: a click that lands while an earlier capture is
* still being stored queues behind it instead of being dropped by the
* "capture already in progress" guard. The marker position was already
* read at click time, so a queued step still circles the right spot.
*/
enqueueClickCapture(clickPos) {
this.clickQueue = this.clickQueue
.then(() => this.sessionCapture('click', clickPos))
.catch(() => {});
return this.clickQueue;
} }
async captureCurrentFrame(mode, capturePoint = null) { async captureCurrentFrame(mode, capturePoint = null) {
+78
View File
@@ -78,6 +78,84 @@ test('rapid click watcher bursts are parsed one click at a time', () => {
assert.equal(clicks, 2); assert.equal(clicks, 2);
}); });
test('button presses fire on the detail line; scroll-wheel ticks are ignored', () => {
const service = makeService();
let clicks = 0;
service.onOsClick = () => {
clicks += 1;
};
service.processClickWatcherData([
'EVENT type 15 (RawButtonPress)',
' device: 11 (11)',
' detail: 1',
' valuators:',
'EVENT type 15 (RawButtonPress)', // scroll-wheel tick
' device: 11 (11)',
' detail: 4',
'EVENT type 15 (RawButtonPress)', // horizontal scroll
' device: 11 (11)',
' detail: 7',
'EVENT type 15 (RawButtonPress)',
' device: 11 (11)',
' detail: 3',
].join('\n'), 'linux');
assert.equal(clicks, 2, 'buttons 4-7 are scroll ticks, not clicks');
});
test('motion events with detail lines do not fire clicks', () => {
const service = makeService();
let clicks = 0;
service.onOsClick = () => {
clicks += 1;
};
service.processClickWatcherData([
'EVENT type 17 (RawMotion)',
' device: 11 (11)',
' detail: 0',
' valuators:',
].join('\n'), 'linux');
assert.equal(clicks, 0);
});
test('event lines split across stdout chunks are reassembled before parsing', () => {
const service = makeService();
let clicks = 0;
service.onOsClick = () => {
clicks += 1;
};
service.ingestClickWatcherChunk('EVENT type 15 (RawButt', 'linux');
assert.equal(clicks, 0, 'a partial line must not be parsed yet');
service.ingestClickWatcherChunk('onPress)\n detail: 1\n', 'linux');
assert.equal(clicks, 1);
});
test('clicks queue behind an in-progress capture instead of being dropped', async () => {
const service = makeService();
const order = [];
let releaseFirst;
const firstGate = new Promise((r) => { releaseFirst = r; });
service.sessionCapture = async (trigger, clickPos) => {
order.push(`start-${clickPos.x}`);
if (clickPos.x === 1) await firstGate;
order.push(`done-${clickPos.x}`);
return { ok: true };
};
service.enqueueClickCapture({ x: 1, y: 1 });
const second = service.enqueueClickCapture({ x: 2, y: 2 });
releaseFirst();
await second;
assert.deepEqual(order, ['start-1', 'done-1', 'start-2', 'done-2'],
'the second click must run after the first, not be dropped');
});
test('windows click watcher output is counted line by line', () => { test('windows click watcher output is counted line by line', () => {
const service = makeService(); const service = makeService();
let clicks = 0; let clicks = 0;