This commit is contained in:
+59
-16
@@ -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,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user