Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/adr/0003-selection-overlay.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
},
},
},
Expand Down
37 changes: 33 additions & 4 deletions src/main/capture/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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');
Expand Down Expand Up @@ -59,6 +71,23 @@ function timestampedFilename(format: 'png' | 'jpg'): string {
}

export async function takeScreenshot(options: CaptureOptions): Promise<CaptureResult> {
// 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 });
Expand All @@ -68,7 +97,7 @@ export async function takeScreenshot(options: CaptureOptions): Promise<CaptureRe
const delaySeconds = Math.max(0, Math.round((options.delayMs ?? 0) / 1000));
// Default silent unless the user explicitly opts in via prefs (handled by callers).
const silent = options.silent !== false;
const args = buildArgs(options.mode, format, delaySeconds, outFile, silent);
const args = buildArgs(options.mode, format, delaySeconds, outFile, silent, region);

logger.info('capture: spawning screencapture', { args });

Expand Down
2 changes: 2 additions & 0 deletions src/main/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { showHudWithImage } from '@main/windows/hud';
import { markFirstRunDone, relaunchApp } from '@main/windows/firstRun';
import { chooseSaveDirectory } from '@main/windows/settings';
import { registerHistoryHandlers } from '@main/ipc/historyHandlers';
import { registerSelectionHandlers } from '@main/selection/overlay';
import { registerGlobalShortcuts } from '@main/shortcuts/index';
import { registerHudHandlers } from '@main/ipc/hudHandlers';
import { IPC } from '@shared/ipc';
Expand Down Expand Up @@ -59,6 +60,7 @@ export function registerIpcHandlers(): void {

registerHudHandlers();
registerHistoryHandlers();
registerSelectionHandlers();

ipcMain.handle(IPC.firstRun.markDone, () => markFirstRunDone());
ipcMain.handle(IPC.firstRun.relaunch, () => relaunchApp());
Expand Down
108 changes: 108 additions & 0 deletions src/main/selection/geometry.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading