From 25bca7c3de00c72acc3fd7a83bf7cd6db51f48f6 Mon Sep 17 00:00:00 2001 From: Iisyourdad Date: Wed, 10 Jun 2026 22:51:01 -0500 Subject: [PATCH] Session UX: tray-controlled recording, no per-shot window hiding Users couldn't click into the app mid-session: every automatic capture hid the window for the shot, so it vanished under the cursor. Under WSLg minimize() is a no-op and isFocused() sticks true, so neither can be used for control. - Sessions now hide the window once at start and show a red tray icon with Capture now / Pause-Resume / Open StepForge (auto-pauses) / Finish; finishing or quitting restores/cleans up properly - Opening the app from the tray pauses capture; resuming tucks the window away again - Automatic captures skip while the cursor is over a visible StepForge window (cursor-based, not focus-based, due to WSLg sticky focus) - Per-shot latency reduced: with the window already hidden the 350 ms hide-repaint wait is skipped entirely - OS notification announces the session; self-tests updated and green Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 +++ app/capture.js | 117 +++++++++++++++++++++++++++++++++++++++++++- app/main.js | 9 ++++ app/renderer/app.js | 6 +-- 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a32ae4..7d4c16e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ Initial release. click detection or global hotkeys are unavailable (e.g. WSLg/Wayland). The REC bar shows the live count and trigger, with Shoot / Auto / Pause / Finish controls. +- Recording sessions tuck the window away once and control everything + from a red tray icon (capture now / pause / open / finish) instead of + hiding the window for every shot — the app stays reachable + mid-session, opening it auto-pauses capture, and per-shot latency + drops because the hide-repaint wait is gone. Automatic captures also + stand down whenever the cursor is over a visible StepForge window. - New captures and newly added steps are now selected in the editor. - The app hides its own window during capture so screenshots show your work, not StepForge; hotkey captures restore the window without diff --git a/app/capture.js b/app/capture.js index 8d6fdc8..2af6238 100644 --- a/app/capture.js +++ b/app/capture.js @@ -2,8 +2,10 @@ const path = require('node:path'); const { spawn, execFileSync } = require('node:child_process'); -const { desktopCapturer, screen, BrowserWindow, nativeImage } = require('electron'); +const { desktopCapturer, screen, BrowserWindow, nativeImage, Tray, Menu, Notification } = require('electron'); const { expandPlaceholders } = require('../core/placeholders'); +const raster = require('../core/raster'); +const { encodePng } = require('../core/png'); /** * Capture service: full-screen, active-window, and region capture via @@ -78,6 +80,84 @@ class CaptureService { if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher(); this.applyInterval(); this.notify('capture:state', this.state()); + + // Tuck the app away once instead of hiding it for every shot — the + // hide/show flicker made the window impossible to click mid-session. + // A tray icon controls the session while the window is hidden. + // (Skipped for the dev screenshot hook, which needs a visible page.) + if (!process.env.STEPFORGE_SCREENSHOT) { + this.createSessionTray(); + const win = this.getWindow(); + if (win && !win.isDestroyed() && win.isVisible()) { + this.hiddenForSession = true; + setTimeout(() => { + // Re-check: the session may have been finished within the delay. + if (this.session && this.hiddenForSession && !win.isDestroyed()) win.hide(); + }, 1200); // let the user read the "session started" toast first + } + try { + new Notification({ + title: 'StepForge is capturing', + body: 'The window tucks away while recording. Use the red tray icon to pause, capture, or finish.', + }).show(); + } catch { /* notifications unavailable on this desktop */ } + } + } + + /** Red-dot tray icon with session controls, shown while recording. */ + createSessionTray() { + this.destroySessionTray(); + try { + const img = raster.createImage(16, 16, [0, 0, 0, 0]); + raster.fillOval(img, 2, 2, 12, 12, [229, 72, 77, 255]); + this.tray = new Tray(nativeImage.createFromBuffer(encodePng(img))); + this.tray.setToolTip('StepForge — capture session running'); + const rebuild = () => { + if (!this.tray || this.tray.isDestroyed()) return; + this.tray.setContextMenu(Menu.buildFromTemplate([ + { label: `Captured ${this.session ? this.session.count : 0} steps`, enabled: false }, + { type: 'separator' }, + { label: 'Capture now', click: () => this.sessionCapture('manual').then(rebuild).catch(() => {}) }, + { + label: this.session && this.session.paused ? 'Resume capturing' : 'Pause capturing', + click: () => { this.togglePause(); rebuild(); }, + }, + { + label: 'Open StepForge (pauses capture)', + click: () => { + this.togglePause(true); + this.showWindow(); + rebuild(); + }, + }, + { type: 'separator' }, + { label: 'Finish session', click: () => this.finishSession() }, + ])); + }; + rebuild(); + this.rebuildTrayMenu = rebuild; + this.tray.on('click', () => { + this.togglePause(true); + this.showWindow(); + rebuild(); + }); + } catch { + this.tray = null; // no tray on this desktop; cursor-over skip still protects clicks + } + } + + destroySessionTray() { + if (this.tray && !this.tray.isDestroyed()) this.tray.destroy(); + this.tray = null; + this.rebuildTrayMenu = null; + } + + showWindow() { + const win = this.getWindow(); + if (win && !win.isDestroyed()) { + win.show(); + win.focus(); + } } setInterval(intervalSec) { @@ -103,7 +183,16 @@ class CaptureService { togglePause(force) { if (!this.session) return; + const wasPaused = this.session.paused; this.session.paused = typeof force === 'boolean' ? force : !this.session.paused; + // Resuming from the app tucks the window away again for clean shots. + if (wasPaused && !this.session.paused && this.hiddenForSession) { + const win = this.getWindow(); + if (win && !win.isDestroyed()) setTimeout(() => { + if (this.session && !this.session.paused && !win.isDestroyed()) win.hide(); + }, 400); + } + if (this.rebuildTrayMenu) this.rebuildTrayMenu(); this.notify('capture:state', this.state()); } @@ -113,13 +202,38 @@ class CaptureService { this.intervalTimer = null; } this.stopClickWatcher(); + this.destroySessionTray(); this.session = null; + if (this.hiddenForSession) { + this.hiddenForSession = false; + this.showWindow(); + } + this.notify('capture:state', this.state()); + } + + /** + * True when the user is interacting with StepForge itself. Deliberately + * based on cursor position over the visible window, not isFocused(): + * some compositors (WSLg) report focus as stuck-true, which would block + * every automatic capture forever. + */ + userIsInApp() { + const win = this.getWindow(); + if (!win || win.isDestroyed() || !win.isVisible() || win.isMinimized()) return false; + const cur = screen.getCursorScreenPoint(); + const b = win.getBounds(); + return cur.x >= b.x && cur.x <= b.x + b.width && cur.y >= b.y && cur.y <= b.y + b.height; } /** One capture inside the active session (hotkey/click/interval/manual). */ async sessionCapture(trigger = 'hotkey') { if (!this.session || this.session.paused) return { ok: false, reason: 'no active capture session' }; if (this.shooting) return { ok: false, reason: 'capture already in progress' }; + // Automatic triggers stand down while the user is in StepForge, so the + // app stays clickable mid-session and never screenshots itself. + if (trigger !== 'manual' && this.userIsInApp()) { + return { ok: false, reason: 'skipped — StepForge is focused' }; + } this.shooting = true; try { const mode = this.settings.get('capture.mode') || 'fullscreen'; @@ -133,6 +247,7 @@ class CaptureService { this.session.count += 1; this.notify('capture:added', { guideId: this.session.guideId, step: result.step, trigger }); this.notify('capture:state', this.state()); + if (this.rebuildTrayMenu) this.rebuildTrayMenu(); // refresh step counter } return result; } finally { diff --git a/app/main.js b/app/main.js index 5b0c42d..44854a8 100644 --- a/app/main.js +++ b/app/main.js @@ -105,6 +105,10 @@ function createWindow() { try { const guide = store.createGuide({ title: 'hotkey selftest' }); capture.startSession(guide.guideId, { intervalSec: 0 }); + // Sessions hide the window while recording; do it immediately here + // instead of waiting out the toast-grace delay. + mainWindow.hide(); + await new Promise((res) => setTimeout(res, 400)); const results = []; for (let i = 0; i < 3; i++) { const r = await capture.hotkeyCapture(); @@ -486,6 +490,11 @@ if (!gotLock) { app.on('will-quit', () => { globalShortcut.unregisterAll(); + if (capture) { + // Targeted cleanup (not finishSession — that re-shows the window). + capture.stopClickWatcher(); + capture.destroySessionTray(); + } // clean preview temp files on close try { for (const entry of fs.readdirSync(store.tempDir)) { diff --git a/app/renderer/app.js b/app/renderer/app.js index c3b767a..dcdb192 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -189,11 +189,11 @@ class StepForgeApp { this.updateCaptureState(state); const hotkey = this.state.settings?.capture?.hotkeyCapture; if (state.clickCapture) { - toast('Capture session started — every click outside StepForge grabs a step.'); + toast('Recording — every click grabs a step. StepForge tucks away; use the red tray icon to pause or finish.'); } else if (state.intervalSec > 0) { - toast(`Capture session started — auto-capturing every ${state.intervalSec}s (use the REC bar to pause or change).`); + toast(`Recording — a step every ${state.intervalSec}s. StepForge tucks away; use the red tray icon to pause or finish.`); } else { - toast(hotkey ? `Capture session started — press ${hotkey} or use Shoot in the REC bar.` : 'Capture session started.'); + toast(hotkey ? `Recording — press ${hotkey} to grab steps. Use the red tray icon to pause or finish.` : 'Capture session started.'); } }