Make capture sessions continuous: click-capture + interval auto-capture
Template tests / tests (push) Failing after 29s

The session previously only listened for the global hotkey, which is
unreliable under WSLg/Wayland — users got one screenshot and nothing
more. Sessions now layer three triggers:

- click-capture via OS adapters (xinput test-xi2 on X11, PowerShell
  GetAsyncKeyState polling on Windows), debounced, ignoring clicks on
  StepForge itself
- interval auto-capture (3/5/10 s) as the always-works fallback,
  enabled by default when click detection is unavailable
- the existing global hotkey, plus a manual Shoot button

The REC bar now shows live count + active trigger with Shoot / Auto /
Pause / Finish. New captures and added steps are selected in the
editor (explicit reload(stepId) wins over a surviving selection).
Capture self-test hook (STEPFORGE_CAPTURE_SELFTEST) verifies 3x
hotkey-path captures and interval capture end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Iisyourdad
2026-06-10 22:33:12 -05:00
parent 6e790832f5
commit 52fd516a5d
7 changed files with 257 additions and 37 deletions
+39 -10
View File
@@ -81,6 +81,7 @@ class StepForgeApp {
async onCaptureAdded(payload) {
if (!payload || !payload.guideId) return;
this.updateCaptureState(await api.capture.state());
if (this.state.view === 'editor' && this.editor.guideId === payload.guideId) {
await this.editor.reload(payload.step && payload.step.stepId ? payload.step.stepId : this.editor.selectedStepId);
return;
@@ -187,7 +188,13 @@ class StepForgeApp {
const state = await api.capture.session({ action: 'start', guideId: guide.guideId });
this.updateCaptureState(state);
const hotkey = this.state.settings?.capture?.hotkeyCapture;
toast(hotkey ? `Capture session started — press ${hotkey} to grab a step.` : 'Capture session started.');
if (state.clickCapture) {
toast('Capture session started — every click outside StepForge grabs a step.');
} else if (state.intervalSec > 0) {
toast(`Capture session started — auto-capturing every ${state.intervalSec}s (use the REC bar to pause or change).`);
} else {
toast(hotkey ? `Capture session started — press ${hotkey} or use Shoot in the REC bar.` : 'Capture session started.');
}
}
async openExistingWorkspace() {
@@ -229,21 +236,43 @@ class StepForgeApp {
return;
}
this.captureStatus.classList.remove('hidden');
const s = this.captureState;
const send = (payload) => api.capture.session(payload).then((next) => this.updateCaptureState(next));
// What is currently triggering captures, so the user knows what to do.
const trigger = s.paused ? 'paused'
: s.clickCapture ? 'on click'
: s.intervalSec > 0 ? `every ${s.intervalSec}s`
: 'hotkey only';
const shootBtn = el('button', {
type: 'button',
title: 'Capture a step now (the app hides itself for the shot)',
onClick: () => send({ action: 'shoot' }),
}, 'Shoot');
// Cycle interval auto-capture: off -> 3s -> 5s -> 10s -> off.
const nextInterval = { 0: 3, 3: 5, 5: 10, 10: 0 }[s.intervalSec ?? 0] ?? 3;
const autoBtn = el('button', {
type: 'button',
title: 'Automatically capture a step on a timer',
onClick: () => send({ action: 'interval', intervalSec: nextInterval }),
}, s.intervalSec > 0 ? `Auto ${s.intervalSec}s` : 'Auto off');
const pauseBtn = el('button', {
type: 'button',
onClick: () => {
const action = this.captureState.paused ? 'resume' : 'pause';
api.capture.session({ action, guideId: this.editorMeta?.guide?.id || this.editorMeta?.guide?.guideId || null })
.then((next) => this.updateCaptureState(next));
},
}, this.captureState.paused ? 'Resume' : 'Pause');
onClick: () => send({ action: s.paused ? 'resume' : 'pause' }),
}, s.paused ? 'Resume' : 'Pause');
const finishBtn = el('button', {
type: 'button',
onClick: () => api.capture.session({ action: 'finish', guideId: this.editorMeta?.guide?.id || this.editorMeta?.guide?.guideId || null })
.then((next) => this.updateCaptureState(next)),
onClick: () => send({ action: 'finish' }),
}, 'Finish');
this.captureStatus.append(
el('span', {}, `Capture ${this.captureState.count || 0}`),
el('span', { title: `Capture session — ${trigger}` }, `REC ${s.count || 0} · ${trigger}`),
shootBtn,
autoBtn,
pauseBtn,
finishBtn,
);
+6 -2
View File
@@ -125,8 +125,12 @@ class GuideEditor {
this.steps = steps;
this.stepMap = new Map(steps.map((step) => [step.stepId, step]));
if (!this.shellMounted) this.mountShell();
if (!this.selectedStepId || !this.stepMap.has(this.selectedStepId)) {
this.selectedStepId = stepId && this.stepMap.has(stepId) ? stepId : (steps[0] && steps[0].stepId) || null;
// An explicitly requested step (new capture, added step, restored
// neighbour) wins; otherwise keep the current selection if it survived.
if (stepId && this.stepMap.has(stepId)) {
this.selectedStepId = stepId;
} else if (!this.selectedStepId || !this.stepMap.has(this.selectedStepId)) {
this.selectedStepId = (steps[0] && steps[0].stepId) || null;
}
this.selectedAnnotationId = null;
this.renderAll();