From 7de5763ec5da30f69f9a89a87b47de8b2cf6fbb4 Mon Sep 17 00:00:00 2001 From: Arya Sadeghi Date: Sun, 10 May 2026 00:24:16 +0200 Subject: [PATCH] feat(selection): homegrown region overlay for area capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a per-display transparent BrowserWindow that drives region selection ourselves, then hands the rect to `screencapture -R`. Replaces the OS `screencapture -i` path for "Capture Area" behind a new `useCustomSelectionOverlay` pref (default on, kept as a safety valve). Unblocks "Capture Previous Area" (PR2) and v0.4 region recording, both of which need to know the chosen rectangle (which `-i` does not expose). - New `selection` IPC namespace + SnaporaApi bridge - `src/main/selection/{overlay,geometry}.ts` — windows + pure math - `src/renderer/{selection.html,selection.tsx,components/Selection.tsx}` - `screencapture -R` arg path with region passthrough - Geometry unit tests (17 cases) - ADR 0003 records the decision Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/adr/0003-selection-overlay.md | 51 +++++++ electron.vite.config.ts | 1 + src/main/capture/screenshot.ts | 37 ++++- src/main/ipc/handlers.ts | 2 + src/main/selection/geometry.ts | 108 +++++++++++++++ src/main/selection/overlay.ts | 191 ++++++++++++++++++++++++++ src/preload/index.ts | 19 ++- src/renderer/components/Selection.tsx | 134 ++++++++++++++++++ src/renderer/selection.html | 27 ++++ src/renderer/selection.tsx | 12 ++ src/shared/ipc.ts | 32 +++++ src/shared/types.ts | 27 ++++ tests/unit/selection-geometry.test.ts | 159 +++++++++++++++++++++ 13 files changed, 795 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0003-selection-overlay.md create mode 100644 src/main/selection/geometry.ts create mode 100644 src/main/selection/overlay.ts create mode 100644 src/renderer/components/Selection.tsx create mode 100644 src/renderer/selection.html create mode 100644 src/renderer/selection.tsx create mode 100644 tests/unit/selection-geometry.test.ts diff --git a/docs/adr/0003-selection-overlay.md b/docs/adr/0003-selection-overlay.md new file mode 100644 index 0000000..7aa442d --- /dev/null +++ b/docs/adr/0003-selection-overlay.md @@ -0,0 +1,51 @@ +# 0003 — Homegrown selection overlay (per-display BrowserWindows) + +**Date:** 2026-05-10 +**Status:** Accepted + +## Context + +`screencapture -i` (the macOS system selection HUD) does not expose the rectangle the user picked — it just writes a file. That's a hard blocker for two ROADMAP items: + +- **v0.2 — Capture Previous Area.** Re-fire the last region without re-dragging. Impossible without remembering bounds. +- **v0.4 — Region screen recording.** ffmpeg's `avfoundation` input takes pixel coordinates, so we must know the rect before invoking it. + +We therefore need to drive selection ourselves. The capture pipeline shifts from `screencapture -i path` to `screencapture -R x,y,w,h path` once we know the rect. + +## Decision + +Snapora drives region selection through a **homegrown selection overlay**: one frameless transparent `BrowserWindow` per display, shown above the menu bar (`alwaysOnTop: 'screen-saver'`). The user drags inside an overlay, the renderer normalizes the rect, and main converts it to global DIPs and feeds `screencapture -R`. + +Key shape: + +- **One window per display**, positioned at that display's `bounds`. A single window spanning the global desktop produces clipping and Mission Control problems on macOS Electron 33. +- **Interactive, not click-through.** `setIgnoreMouseEvents(true)` breaks drag detection on macOS; we own input and use a near-transparent fill (~18% black via CSS) as the visual. +- **DIPs everywhere.** `screencapture -R`, `Display.bounds`, and our stored regions are all in display-independent pixels. Retina is handled inside `screencapture` itself — we never multiply by `scaleFactor`. +- **Pure-math geometry helpers** live in `src/main/selection/geometry.ts` so the unit tests don't import `electron`. + +A pref flag `useCustomSelectionOverlay` (default `true`) keeps the legacy `screencapture -i` path one release as a safety valve. Removed in v0.3 once the overlay has soak time. + +## Consequences + +**Enables:** + +- "Capture Previous Area" (v0.2 PR2 follow-on). +- Region recording in v0.4 reuses the same overlay — one investment, two milestones. +- Future polish (magnifier loupe, keyboard nudging, snap-to-window) lives entirely in our renderer rather than fighting an OS HUD. + +**Costs:** + +- More code than `-i`. We own ESC, multi-display, display-removed mid-selection, drag normalization, etc. +- Visual differs from CleanShot/Shottr's selection. Acceptable — visitors expect a polished selection from a screenshot app, and we control the look. +- One additional renderer entry (`selection.html`). + +## Alternatives considered + +- **`AppleScript` to query the selection rect from `screencapture`** — there is no such API. The system tool keeps its rect internal. +- **Screenshot the entire screen, then crop in Node** — works for "Capture Area" but not "Capture Previous Area" (still need the rect from somewhere) and doubles I/O. Rejected. +- **A single fullscreen window spanning all displays** — Electron + macOS produces clipping and weird input routing across virtual displays. Rejected after testing. +- **Click-through overlay using `setIgnoreMouseEvents`** — pointer events go _through_ the window to the desktop below; we lose drag detection. Rejected. + +## Revisit when + +Apple ships a public API that returns the bounds chosen by `screencapture -i`, or when ScreenCaptureKit's region-picker becomes scriptable from a non-native process. Either would let us shed the overlay code and trust the OS HUD instead. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d179a35..258d9e0 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ firstRun: resolve('src/renderer/first-run.html'), hud: resolve('src/renderer/hud.html'), history: resolve('src/renderer/history.html'), + selection: resolve('src/renderer/selection.html'), }, }, }, diff --git a/src/main/capture/screenshot.ts b/src/main/capture/screenshot.ts index 1fa05e8..2bb8b44 100644 --- a/src/main/capture/screenshot.ts +++ b/src/main/capture/screenshot.ts @@ -3,8 +3,10 @@ import { mkdir, stat } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { app, clipboard, nativeImage } from 'electron'; import logger from '@main/logger'; +import { pickRegion } from '@main/selection/overlay'; +import { getPreferences } from '@main/storage/prefs'; import { insertCapture } from '@main/storage/db'; -import type { CaptureMode, CaptureOptions, CaptureResult } from '@shared/types'; +import type { CaptureMode, CaptureOptions, CaptureResult, SelectionRect } from '@shared/types'; const SCREENCAPTURE_BIN = '/usr/sbin/screencapture'; @@ -14,21 +16,31 @@ const SCREENCAPTURE_BIN = '/usr/sbin/screencapture'; * Reference: `man screencapture`. Flags we use: * -i interactive (mouse selection) * -W start in window-selection mode (only with -i) + * -R rect x,y,w,h in DIPs (skips -i; we drove our own selection) * -t format (png|jpg|...) * -o do not include window shadow when in window mode * -x do not play sound * -T delay in seconds before capture (full-screen only) */ -function buildArgs( +export function buildArgs( mode: CaptureMode, format: 'png' | 'jpg', delaySeconds: number, outFile: string, silent: boolean, -) { + region?: SelectionRect, +): string[] { // -x silences the shutter sound; omit it when the user wants the sound. const args: string[] = silent ? ['-x', '-t', format] : ['-t', format]; + // When we already know the rect (from our overlay or "previous area"), + // -R replaces the interactive HUD entirely. + if (region) { + args.push('-R', `${region.x},${region.y},${region.width},${region.height}`); + args.push(outFile); + return args; + } + switch (mode) { case 'area': args.push('-i'); @@ -59,6 +71,23 @@ function timestampedFilename(format: 'png' | 'jpg'): string { } export async function takeScreenshot(options: CaptureOptions): Promise { + // For "area" mode, route through our homegrown overlay when the pref is on. + // The overlay returns a global-DIP rect that we hand to `screencapture -R`. + let region = options.region; + if (!region && options.mode === 'area' && getPreferences().useCustomSelectionOverlay) { + const result = await pickRegion(); + if (result.cancelled || !result.rect) { + return { + filePath: null, + capturedAt: new Date().toISOString(), + width: null, + height: null, + cancelled: true, + }; + } + region = result.rect; + } + const format: 'png' | 'jpg' = options.format ?? 'png'; const saveDir = defaultSaveDir(); await mkdir(saveDir, { recursive: true }); @@ -68,7 +97,7 @@ export async function takeScreenshot(options: CaptureOptions): Promise markFirstRunDone()); ipcMain.handle(IPC.firstRun.relaunch, () => relaunchApp()); diff --git a/src/main/selection/geometry.ts b/src/main/selection/geometry.ts new file mode 100644 index 0000000..97a99c0 --- /dev/null +++ b/src/main/selection/geometry.ts @@ -0,0 +1,108 @@ +import type { SelectionRect } from '@shared/types'; + +/** + * Subset of `Electron.Display` we care about. Spelled out so this module can + * be unit-tested without importing `electron`. + */ +export interface DisplayLike { + id: number; + bounds: { x: number; y: number; width: number; height: number }; + scaleFactor: number; +} + +/** A rect plus the displayId it was captured on, for staleness checks. */ +export interface StoredRegion extends SelectionRect { + displayId?: number; +} + +/** + * Convert a CSS-pixel rect inside an overlay window (which covers a single + * display) to a global DIP rect that `screencapture -R` can consume. + * + * Both sides use DIPs (not raw pixels) — Retina scaling is handled by + * `screencapture` itself, so we never multiply by scaleFactor. + */ +export function localToGlobalDips( + local: SelectionRect, + origin: { bounds: { x: number; y: number } }, +): SelectionRect { + return { + x: origin.bounds.x + local.x, + y: origin.bounds.y + local.y, + width: local.width, + height: local.height, + }; +} + +/** True if rect has positive area >= the minimum drag threshold. */ +export function isRectMeaningful(rect: SelectionRect, minDip = 4): boolean { + return rect.width >= minDip && rect.height >= minDip; +} + +/** + * Normalize a drag from anchor → current pointer into a positive-width rect, + * regardless of which direction the user dragged. + */ +export function rectFromDrag( + anchor: { x: number; y: number }, + current: { x: number; y: number }, +): SelectionRect { + const x = Math.min(anchor.x, current.x); + const y = Math.min(anchor.y, current.y); + const width = Math.abs(current.x - anchor.x); + const height = Math.abs(current.y - anchor.y); + return { x, y, width, height }; +} + +/** Clamp a rect into a single display's bounds. Returns null if no overlap. */ +export function clampToDisplay(rect: SelectionRect, display: DisplayLike): SelectionRect | null { + const dx1 = display.bounds.x; + const dy1 = display.bounds.y; + const dx2 = display.bounds.x + display.bounds.width; + const dy2 = display.bounds.y + display.bounds.height; + const rx1 = Math.max(rect.x, dx1); + const ry1 = Math.max(rect.y, dy1); + const rx2 = Math.min(rect.x + rect.width, dx2); + const ry2 = Math.min(rect.y + rect.height, dy2); + if (rx2 <= rx1 || ry2 <= ry1) return null; + return { x: rx1, y: ry1, width: rx2 - rx1, height: ry2 - ry1 }; +} + +/** + * Decide whether a previously-stored region is still valid against the + * current set of displays. Used to enable/disable "Capture Previous Area". + * + * Strategy: + * 1. Try to find the original `displayId` — if it still exists and the + * region fits inside it, we're good (no clamp needed). + * 2. Otherwise look for any display whose bounds overlap the region's + * centroid; clamp into that display. + * 3. If nothing overlaps, the region is stale — return null. + */ +export function reconcileRegion( + region: StoredRegion, + displays: DisplayLike[], +): SelectionRect | null { + if (displays.length === 0) return null; + + if (region.displayId != null) { + const exact = displays.find((d) => d.id === region.displayId); + if (exact) { + const clamped = clampToDisplay(region, exact); + if (clamped && isRectMeaningful(clamped)) return clamped; + } + } + + const cx = region.x + region.width / 2; + const cy = region.y + region.height / 2; + const containing = displays.find( + (d) => + cx >= d.bounds.x && + cx < d.bounds.x + d.bounds.width && + cy >= d.bounds.y && + cy < d.bounds.y + d.bounds.height, + ); + if (!containing) return null; + const clamped = clampToDisplay(region, containing); + return clamped && isRectMeaningful(clamped) ? clamped : null; +} diff --git a/src/main/selection/overlay.ts b/src/main/selection/overlay.ts new file mode 100644 index 0000000..76d8158 --- /dev/null +++ b/src/main/selection/overlay.ts @@ -0,0 +1,191 @@ +import { BrowserWindow, ipcMain, screen } from 'electron'; +import { join } from 'node:path'; +import logger from '@main/logger'; +import { IPC, type SelectionInitPayload } from '@shared/ipc'; +import type { SelectionRect } from '@shared/types'; +import { isRectMeaningful, localToGlobalDips } from '@main/selection/geometry'; + +export interface SelectionResult { + cancelled: boolean; + /** Global-DIP rect (origin = primary display top-left). Null when cancelled. */ + rect: SelectionRect | null; + /** Display the user actually drew on, when not cancelled. */ + displayId: number | null; +} + +type OverlayState = 'idle' | 'open' | 'closing'; + +interface PendingSelection { + resolve: (result: SelectionResult) => void; + windows: BrowserWindow[]; + /** Map windowId → SelectionInitPayload, so `selection:request` can answer. */ + inits: Map; + state: OverlayState; +} + +let pending: PendingSelection | null = null; + +function rendererUrl(file: string): string { + if (process.env.ELECTRON_RENDERER_URL) { + return `${process.env.ELECTRON_RENDERER_URL}/${file}`; + } + return `file://${join(__dirname, `../renderer/${file}`)}`; +} + +function createOverlayForDisplay(display: Electron.Display): BrowserWindow { + const win = new BrowserWindow({ + x: display.bounds.x, + y: display.bounds.y, + width: display.bounds.width, + height: display.bounds.height, + frame: false, + transparent: true, + hasShadow: false, + resizable: false, + movable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + focusable: true, + roundedCorners: false, + backgroundColor: '#00000000', + show: false, + enableLargerThanScreen: true, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }); + + // 'screen-saver' is required to sit above the menu bar and the Dock. + // 'floating' (used by the HUD) is not high enough. + win.setAlwaysOnTop(true, 'screen-saver'); + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + win.setWindowButtonVisibility?.(false); + + if (process.env.ELECTRON_RENDERER_URL && process.env.SNAPORA_DEV_TOOLS === '1') { + win.webContents.on('did-finish-load', () => { + win.webContents.openDevTools({ mode: 'detach' }); + }); + } + + void win.loadURL(rendererUrl('selection.html')); + return win; +} + +function tearDown(result: SelectionResult): void { + if (!pending) return; + if (pending.state === 'closing') return; + pending.state = 'closing'; + const { windows, resolve } = pending; + pending = null; + for (const w of windows) { + if (!w.isDestroyed()) w.close(); + } + resolve(result); +} + +function onCommit(_evt: unknown, payload: { displayId: number; rect: SelectionRect }): void { + if (!pending || pending.state !== 'open') return; + const init = [...pending.inits.values()].find((i) => i.displayId === payload.displayId); + if (!init) { + logger.warn('selection: commit from unknown displayId', payload); + tearDown({ cancelled: true, rect: null, displayId: null }); + return; + } + if (!isRectMeaningful(payload.rect)) { + logger.info('selection: commit too small, treating as cancel', payload.rect); + tearDown({ cancelled: true, rect: null, displayId: null }); + return; + } + const global = localToGlobalDips(payload.rect, init); + logger.info('selection: committed', { displayId: payload.displayId, rect: global }); + tearDown({ cancelled: false, rect: global, displayId: payload.displayId }); +} + +function onCancel(): void { + if (!pending || pending.state !== 'open') return; + logger.info('selection: cancelled by renderer'); + tearDown({ cancelled: true, rect: null, displayId: null }); +} + +function onRequest(evt: Electron.IpcMainInvokeEvent): SelectionInitPayload | null { + if (!pending) return null; + const win = BrowserWindow.fromWebContents(evt.sender); + if (!win) return null; + return pending.inits.get(win.id) ?? null; +} + +/** + * Register the IPC listeners that ferry user input back from the overlay + * renderers. Call once at startup from `registerIpcHandlers`. + */ +export function registerSelectionHandlers(): void { + ipcMain.on(IPC.selection.commit, onCommit); + ipcMain.on(IPC.selection.cancel, onCancel); + ipcMain.handle(IPC.selection.request, onRequest); + screen.on('display-removed', () => { + if (pending && pending.state === 'open') { + logger.warn('selection: display removed mid-selection, cancelling'); + tearDown({ cancelled: true, rect: null, displayId: null }); + } + }); +} + +/** + * Show the selection overlay (one transparent window per display) and + * resolve once the user commits a rect or cancels with ESC. + * + * Concurrent calls reject — only one selection at a time. + */ +export function pickRegion(): Promise { + if (pending) { + return Promise.resolve({ cancelled: true, rect: null, displayId: null }); + } + + const displays = screen.getAllDisplays(); + const primaryId = screen.getPrimaryDisplay().id; + const inits = new Map(); + const windows: BrowserWindow[] = []; + + return new Promise((resolve) => { + pending = { resolve, windows, inits, state: 'open' }; + + for (const display of displays) { + const win = createOverlayForDisplay(display); + windows.push(win); + const init: SelectionInitPayload = { + displayId: display.id, + bounds: display.bounds, + scaleFactor: display.scaleFactor, + isPrimary: display.id === primaryId, + }; + inits.set(win.id, init); + + const sendInit = (): void => { + if (win.isDestroyed()) return; + win.webContents.send(IPC.selection.init, init); + win.show(); + win.focus(); + }; + if (win.webContents.isLoading()) { + win.webContents.once('did-finish-load', sendInit); + } else { + sendInit(); + } + + win.on('closed', () => { + // If a window dies unexpectedly while we're still open, cancel. + if (pending && pending.state === 'open') { + const stillAlive = pending.windows.some((w) => !w.isDestroyed()); + if (!stillAlive) { + tearDown({ cancelled: true, rect: null, displayId: null }); + } + } + }); + } + }); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index ca2d739..dd02339 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,13 +6,30 @@ import type { CaptureResult, Permission, PermissionState, + SelectionRect, } from '@shared/types'; -import type { SnaporaApi } from '@shared/ipc'; +import type { SelectionInitPayload, SnaporaApi } from '@shared/ipc'; const api: SnaporaApi = { capture: (options: CaptureOptions): Promise => ipcRenderer.invoke(IPC.capture.start, options), cancelCapture: (): Promise => ipcRenderer.invoke(IPC.capture.cancel), + selection: { + onInit: (handler: (init: SelectionInitPayload) => void) => { + const listener = (_evt: unknown, init: SelectionInitPayload): void => handler(init); + ipcRenderer.on(IPC.selection.init, listener); + return () => ipcRenderer.removeListener(IPC.selection.init, listener); + }, + request: (): Promise => ipcRenderer.invoke(IPC.selection.request), + commit: (displayId: number, rect: SelectionRect): Promise => { + ipcRenderer.send(IPC.selection.commit, { displayId, rect }); + return Promise.resolve(); + }, + cancel: (): Promise => { + ipcRenderer.send(IPC.selection.cancel); + return Promise.resolve(); + }, + }, permissions: { list: (): Promise => ipcRenderer.invoke(IPC.permissions.list), request: (p: Permission): Promise => diff --git a/src/renderer/components/Selection.tsx b/src/renderer/components/Selection.tsx new file mode 100644 index 0000000..bdf72e8 --- /dev/null +++ b/src/renderer/components/Selection.tsx @@ -0,0 +1,134 @@ +import { useEffect, useRef, useState } from 'react'; +import type { SelectionInitPayload } from '@shared/ipc'; +import type { SelectionRect } from '@shared/types'; + +const MIN_DRAG_DIP = 4; + +function rectFromPoints( + anchor: { x: number; y: number }, + current: { x: number; y: number }, +): SelectionRect { + const x = Math.min(anchor.x, current.x); + const y = Math.min(anchor.y, current.y); + const width = Math.abs(current.x - anchor.x); + const height = Math.abs(current.y - anchor.y); + return { x, y, width, height }; +} + +export function SelectionOverlay() { + const [init, setInit] = useState(null); + const [rect, setRect] = useState(null); + const anchorRef = useRef<{ x: number; y: number } | null>(null); + const draggingRef = useRef(false); + + // Wire init: prefer a fresh request, but also subscribe in case main pushes. + useEffect(() => { + let cancelled = false; + void window.snapora.selection.request().then((payload) => { + if (!cancelled && payload) setInit(payload); + }); + const off = window.snapora.selection.onInit((payload) => { + if (!cancelled) setInit(payload); + }); + return () => { + cancelled = true; + off(); + }; + }, []); + + // ESC cancels. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + void window.snapora.selection.cancel(); + } else if ( + e.key === 'Enter' && + rect && + rect.width >= MIN_DRAG_DIP && + rect.height >= MIN_DRAG_DIP && + init + ) { + e.preventDefault(); + void window.snapora.selection.commit(init.displayId, rect); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [rect, init]); + + const onPointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + anchorRef.current = { x: e.clientX, y: e.clientY }; + draggingRef.current = true; + setRect({ x: e.clientX, y: e.clientY, width: 0, height: 0 }); + (e.target as Element).setPointerCapture?.(e.pointerId); + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!draggingRef.current || !anchorRef.current) return; + setRect(rectFromPoints(anchorRef.current, { x: e.clientX, y: e.clientY })); + }; + + const onPointerUp = (e: React.PointerEvent) => { + if (!draggingRef.current || !anchorRef.current || !init) return; + draggingRef.current = false; + const final = rectFromPoints(anchorRef.current, { x: e.clientX, y: e.clientY }); + anchorRef.current = null; + if (final.width < MIN_DRAG_DIP || final.height < MIN_DRAG_DIP) { + void window.snapora.selection.cancel(); + return; + } + void window.snapora.selection.commit(init.displayId, final); + }; + + // While we wait for init, render a transparent surface that still owns + // input — that way ESC works even if init never arrives. + return ( +
+ {rect && rect.width > 0 && rect.height > 0 ? ( + <> +
+
+ {Math.round(rect.width)} × {Math.round(rect.height)} +
+ + ) : null} +
+ ); +} diff --git a/src/renderer/selection.html b/src/renderer/selection.html new file mode 100644 index 0000000..e1457d6 --- /dev/null +++ b/src/renderer/selection.html @@ -0,0 +1,27 @@ + + + + + + Snapora — Selection + + + +
+ + + diff --git a/src/renderer/selection.tsx b/src/renderer/selection.tsx new file mode 100644 index 0000000..59277d3 --- /dev/null +++ b/src/renderer/selection.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { SelectionOverlay } from './components/Selection'; +import './styles/globals.css'; + +const container = document.getElementById('root'); +if (!container) throw new Error('Missing #root'); +createRoot(container).render( + + + , +); diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index f5a18b2..b8f375e 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -4,8 +4,22 @@ import type { CaptureResult, PermissionState, AppPreferences, + SelectionRect, } from './types'; +/** + * Per-overlay init payload. The main process sends one of these to each + * selection overlay window so the renderer can map local CSS coords to + * global DIPs without round-tripping for every mouse event. + */ +export interface SelectionInitPayload { + displayId: number; + /** Display bounds in DIPs (top-left origin = primary display top-left). */ + bounds: { x: number; y: number; width: number; height: number }; + scaleFactor: number; + isPrimary: boolean; +} + export interface HistoryItem { id: number; filePath: string; @@ -25,6 +39,16 @@ export const IPC = { start: 'capture:start', cancel: 'capture:cancel', }, + selection: { + /** main → renderer (per-overlay): SelectionInitPayload */ + init: 'selection:init', + /** renderer → main: re-request the init payload after `did-finish-load` */ + request: 'selection:request', + /** renderer → main: { displayId, rect } — user committed a region */ + commit: 'selection:commit', + /** renderer → main: ESC pressed or click without drag */ + cancel: 'selection:cancel', + }, permissions: { list: 'permissions:list', request: 'permissions:request', @@ -72,6 +96,14 @@ export const IPC = { export interface SnaporaApi { capture(options: CaptureOptions): Promise; cancelCapture(): Promise; + selection: { + /** Subscribe to per-window init payload. Returns an unsubscribe fn. */ + onInit(handler: (init: SelectionInitPayload) => void): () => void; + /** Pull the init payload synchronously after mount. */ + request(): Promise; + commit(displayId: number, rect: SelectionRect): Promise; + cancel(): Promise; + }; permissions: { list(): Promise; request(permission: PermissionState['permission']): Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index 58da9a4..e2322db 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2,6 +2,18 @@ export type CaptureMode = 'area' | 'window' | 'fullscreen'; export type CaptureFormat = 'png' | 'jpg'; +/** + * A rectangle in global DIPs (display-independent pixels), the same unit + * `screencapture -R` accepts and `electron.screen.getAllDisplays()[i].bounds` + * reports. Origin is top-left of the primary display. + */ +export interface SelectionRect { + x: number; + y: number; + width: number; + height: number; +} + export interface CaptureOptions { mode: CaptureMode; format?: CaptureFormat; @@ -13,6 +25,12 @@ export interface CaptureOptions { hideDesktopIcons?: boolean; /** Suppress the macOS shutter sound. Default true. Set to false to play it. */ silent?: boolean; + /** + * Pre-resolved rectangle to capture. When set, bypasses `screencapture -i` + * and uses `-R x,y,w,h` directly. Used by the homegrown selection overlay + * (and by "Capture Previous Area" once that ships in PR2). + */ + region?: SelectionRect; } export interface CaptureResult { @@ -57,6 +75,14 @@ export interface AppPreferences { hideDesktopIcons: boolean; /** Delay before a full-screen capture fires (gives you time to set up). 0 = no timer. */ selfTimerSeconds: 0 | 3 | 5 | 10; + /** + * Route "Capture Area" through Snapora's homegrown selection overlay + * (transparent fullscreen window per display) instead of `screencapture -i`. + * Default true. Kept as a flag for one release as a safety valve in case + * the overlay misbehaves on user setups we haven't tested. Slated for + * removal in v0.3 once recording (v0.4) also uses the overlay. + */ + useCustomSelectionOverlay: boolean; // ----- Shortcuts ----- /** Global hotkey strings (Electron accelerator format) per capture mode. */ @@ -73,6 +99,7 @@ export const DEFAULT_PREFERENCES: AppPreferences = { autoCopyToClipboard: true, hideDesktopIcons: false, selfTimerSeconds: 0, + useCustomSelectionOverlay: true, hotkeys: { area: 'CommandOrControl+Shift+2', window: 'CommandOrControl+Shift+3', diff --git a/tests/unit/selection-geometry.test.ts b/tests/unit/selection-geometry.test.ts new file mode 100644 index 0000000..906690e --- /dev/null +++ b/tests/unit/selection-geometry.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; +import { + clampToDisplay, + isRectMeaningful, + localToGlobalDips, + rectFromDrag, + reconcileRegion, + type DisplayLike, +} from '@main/selection/geometry'; + +const primary: DisplayLike = { + id: 1, + bounds: { x: 0, y: 0, width: 1440, height: 900 }, + scaleFactor: 2, +}; + +const secondaryRight: DisplayLike = { + id: 2, + bounds: { x: 1440, y: 0, width: 1920, height: 1080 }, + scaleFactor: 1, +}; + +const secondaryAbove: DisplayLike = { + id: 3, + bounds: { x: 0, y: -1080, width: 1920, height: 1080 }, + scaleFactor: 1, +}; + +describe('rectFromDrag', () => { + it('normalizes a left-to-right + top-to-bottom drag', () => { + expect(rectFromDrag({ x: 10, y: 20 }, { x: 50, y: 80 })).toEqual({ + x: 10, + y: 20, + width: 40, + height: 60, + }); + }); + + it('normalizes a right-to-left + bottom-to-top drag', () => { + expect(rectFromDrag({ x: 100, y: 100 }, { x: 30, y: 40 })).toEqual({ + x: 30, + y: 40, + width: 70, + height: 60, + }); + }); +}); + +describe('isRectMeaningful', () => { + it('rejects zero-size rects', () => { + expect(isRectMeaningful({ x: 0, y: 0, width: 0, height: 0 })).toBe(false); + }); + + it('rejects rects below the 4 DIP threshold', () => { + expect(isRectMeaningful({ x: 0, y: 0, width: 3, height: 100 })).toBe(false); + }); + + it('accepts rects at or above the threshold', () => { + expect(isRectMeaningful({ x: 0, y: 0, width: 4, height: 4 })).toBe(true); + }); +}); + +describe('localToGlobalDips', () => { + it('passes through on the primary display (origin 0,0)', () => { + const local = { x: 100, y: 50, width: 200, height: 150 }; + expect(localToGlobalDips(local, primary)).toEqual(local); + }); + + it('shifts by display.bounds.x for displays to the right', () => { + const local = { x: 100, y: 50, width: 200, height: 150 }; + expect(localToGlobalDips(local, secondaryRight)).toEqual({ + x: 1540, + y: 50, + width: 200, + height: 150, + }); + }); + + it('uses negative y for displays above the primary', () => { + const local = { x: 0, y: 0, width: 100, height: 100 }; + expect(localToGlobalDips(local, secondaryAbove)).toEqual({ + x: 0, + y: -1080, + width: 100, + height: 100, + }); + }); + + it('does not multiply by scaleFactor (Retina is handled by screencapture)', () => { + const local = { x: 100, y: 100, width: 200, height: 200 }; + const result = localToGlobalDips(local, primary); + expect(result.width).toBe(200); + expect(result.height).toBe(200); + }); +}); + +describe('clampToDisplay', () => { + it('returns the rect unchanged when fully inside the display', () => { + const rect = { x: 100, y: 100, width: 200, height: 200 }; + expect(clampToDisplay(rect, primary)).toEqual(rect); + }); + + it('clamps a rect that overflows the right edge', () => { + const rect = { x: 1300, y: 100, width: 500, height: 200 }; + expect(clampToDisplay(rect, primary)).toEqual({ + x: 1300, + y: 100, + width: 140, + height: 200, + }); + }); + + it('returns null when the rect is fully outside the display', () => { + const rect = { x: 5000, y: 5000, width: 100, height: 100 }; + expect(clampToDisplay(rect, primary)).toBeNull(); + }); +}); + +describe('reconcileRegion', () => { + it('returns null when there are no displays', () => { + expect(reconcileRegion({ x: 0, y: 0, width: 100, height: 100 }, [])).toBeNull(); + }); + + it('uses the original displayId when it still exists and the rect fits', () => { + const region = { x: 100, y: 100, width: 200, height: 200, displayId: 1 }; + expect(reconcileRegion(region, [primary, secondaryRight])).toEqual({ + x: 100, + y: 100, + width: 200, + height: 200, + }); + }); + + it('falls back to centroid containment when the original display is gone', () => { + const region = { x: 1500, y: 200, width: 100, height: 100, displayId: 99 }; + // Centroid (1550, 250) is inside secondaryRight. + expect(reconcileRegion(region, [primary, secondaryRight])).toEqual({ + x: 1500, + y: 200, + width: 100, + height: 100, + }); + }); + + it('returns null when no display contains the centroid', () => { + const region = { x: 5000, y: 5000, width: 100, height: 100 }; + expect(reconcileRegion(region, [primary])).toBeNull(); + }); + + it('clamps a region whose original display shrank around it', () => { + const region = { x: 1200, y: 100, width: 500, height: 200, displayId: 1 }; + expect(reconcileRegion(region, [primary])).toEqual({ + x: 1200, + y: 100, + width: 240, + height: 200, + }); + }); +});