diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8a4eedd63..0b0a6a6d4 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -27,7 +27,7 @@ interface NativeCaptureDiagnostics { phase: "availability" | "start" | "stop" | "mux"; timestamp: string; sourceId?: string | null; - sourceType?: "screen" | "window" | "unknown"; + sourceType?: "screen" | "window" | "region" | "unknown"; displayId?: number | null; displayBounds?: { x: number; y: number; width: number; height: number } | null; windowHandle?: number | null; @@ -223,6 +223,7 @@ interface Window { switchToEditor: () => Promise; openSourceSelector: () => Promise; selectSource: (source: ProcessedDesktopSource) => Promise; + selectCaptureRegion: () => Promise; showSourceHighlight: (source: ProcessedDesktopSource) => Promise<{ success: boolean }>; getSelectedSource: () => Promise; onSelectedSourceChanged: ( @@ -948,9 +949,21 @@ interface ProcessedDesktopSource { thumbnail: string | null; appIcon: string | null; originalName?: string; - sourceType?: "screen" | "window"; + sourceType?: "screen" | "window" | "region"; appName?: string; windowTitle?: string; + captureRegion?: { + x: number; + y: number; + width: number; + height: number; + displayBounds: { x: number; y: number; width: number; height: number }; + scaleFactor: number; + pixelX: number; + pixelY: number; + pixelWidth: number; + pixelHeight: number; + }; } interface CursorTelemetryPoint { diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index ebedfe72a..92b0f8556 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -4,6 +4,7 @@ import { CURSOR_TELEMETRY_VERSION, MAX_CURSOR_SAMPLES, } from "../constants"; +import { normalizePointWithinRegion } from "../regionSelectionGeometry"; import { activeCursorSamples, currentCursorVisualType, @@ -182,6 +183,16 @@ export function getNormalizedCursorPoint() { ? { x: linuxCursorCache.x / primarySf, y: linuxCursorCache.y / primarySf } : fallbackCursor; + const captureRegion = selectedSource?.captureRegion; + if (captureRegion) { + return normalizePointWithinRegion(cursor, { + x: captureRegion.displayBounds.x + captureRegion.x, + y: captureRegion.displayBounds.y + captureRegion.y, + width: captureRegion.width, + height: captureRegion.height, + }); + } + const windowBounds = selectedSource?.id?.startsWith("window:") ? selectedWindowBounds : null; if (windowBounds) { const sf = diff --git a/electron/ipc/regionSelection.ts b/electron/ipc/regionSelection.ts new file mode 100644 index 000000000..cf54a212c --- /dev/null +++ b/electron/ipc/regionSelection.ts @@ -0,0 +1,273 @@ +import { BrowserWindow, type Display } from "electron"; +import { + normalizeCaptureRegion, + type Rectangle, + toPixelCaptureRegion, +} from "./regionSelectionGeometry"; +import type { CaptureRegion, SelectedSource } from "./types"; +import { getScreen } from "./utils"; + +const MIN_USER_CAPTURE_SIZE = 64; +let activeRegionSelection: Promise | null = null; + +function buildInitialRegion(display: Display): Rectangle { + const scaleFactor = Math.max(1, display.scaleFactor || 1); + const targetWidth = Math.min(display.bounds.width * 0.8, 1280 / scaleFactor); + const targetHeight = Math.min(display.bounds.height * 0.8, 720 / scaleFactor); + return { + x: Math.round((display.bounds.width - targetWidth) / 2), + y: Math.round((display.bounds.height - targetHeight) / 2), + width: Math.round(targetWidth), + height: Math.round(targetHeight), + }; +} + +function buildSelectionHtml(display: Display, initialRegion: Rectangle) { + const initial = JSON.stringify(initialRegion).replace(/ + + + + + + + +
Drag to select an area · Enter to confirm · Esc to cancel
+
+
+ + + +
+
+ + × + + + +
+ + +`; +} + +async function runRegionSelection(): Promise { + const screen = getScreen(); + const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); + const initialRegion = buildInitialRegion(display); + + return await new Promise((resolve) => { + let settled = false; + const selectionWindow = new BrowserWindow({ + ...display.bounds, + frame: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + resizable: false, + movable: false, + fullscreenable: false, + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + const finish = (result: SelectedSource | null) => { + if (settled) return; + settled = true; + if (!selectionWindow.isDestroyed()) selectionWindow.close(); + resolve(result); + }; + + selectionWindow.setAlwaysOnTop(true, "screen-saver"); + selectionWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + selectionWindow.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + selectionWindow.webContents.on("will-navigate", (event, targetUrl) => { + if (!targetUrl.startsWith("recordly-region://")) return; + event.preventDefault(); + const url = new URL(targetUrl); + if (url.hostname === "cancel") { + finish(null); + return; + } + if (url.hostname !== "confirm") return; + + const rawRegion = { + x: Number(url.searchParams.get("x")), + y: Number(url.searchParams.get("y")), + width: Number(url.searchParams.get("width")), + height: Number(url.searchParams.get("height")), + }; + if (!Object.values(rawRegion).every(Number.isFinite)) return; + const region = normalizeCaptureRegion(rawRegion, display.bounds); + const pixels = toPixelCaptureRegion(region, display.bounds, display.scaleFactor); + if (pixels.width < MIN_USER_CAPTURE_SIZE || pixels.height < MIN_USER_CAPTURE_SIZE) + return; + + const captureRegion: CaptureRegion = { + ...region, + displayBounds: { ...display.bounds }, + scaleFactor: pixels.scaleFactor, + pixelX: pixels.x, + pixelY: pixels.y, + pixelWidth: pixels.width, + pixelHeight: pixels.height, + }; + finish({ + id: `screen:region:${display.id}`, + name: `Area ${pixels.width} × ${pixels.height}`, + display_id: String(display.id), + sourceType: "region", + captureRegion, + }); + }); + selectionWindow.once("closed", () => finish(null)); + selectionWindow + .loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent(buildSelectionHtml(display, initialRegion))}`, + ) + .then(() => { + if (!selectionWindow.isDestroyed()) { + selectionWindow.show(); + selectionWindow.focus(); + } + }) + .catch((error) => { + console.error("Failed to open capture region selector:", error); + finish(null); + }); + }); +} + +export function selectCaptureRegion() { + if (!activeRegionSelection) { + activeRegionSelection = runRegionSelection().finally(() => { + activeRegionSelection = null; + }); + } + return activeRegionSelection; +} diff --git a/electron/ipc/regionSelectionGeometry.test.ts b/electron/ipc/regionSelectionGeometry.test.ts new file mode 100644 index 000000000..58e8e0858 --- /dev/null +++ b/electron/ipc/regionSelectionGeometry.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeCaptureRegion, + normalizePointWithinRegion, + toPixelCaptureRegion, +} from "./regionSelectionGeometry"; + +describe("region selection geometry", () => { + it("normalizes reverse drags and clamps them to the display", () => { + expect( + normalizeCaptureRegion( + { x: 900, y: 700, width: -1000, height: -800 }, + { x: 0, y: 0, width: 800, height: 600 }, + ), + ).toEqual({ x: 0, y: 0, width: 800, height: 600 }); + }); + + it("converts points to even Retina pixels", () => { + expect( + toPixelCaptureRegion( + { x: 10.5, y: 20.5, width: 640.5, height: 360.5 }, + { x: 0, y: 0, width: 1512, height: 982 }, + 2, + ), + ).toEqual({ x: 21, y: 41, width: 1280, height: 720, scaleFactor: 2 }); + }); + + it("keeps the encoded rectangle inside the physical display", () => { + expect( + toPixelCaptureRegion( + { x: 795, y: 595, width: 100, height: 100 }, + { x: 0, y: 0, width: 800, height: 600 }, + 1.25, + ), + ).toEqual({ x: 994, y: 744, width: 6, height: 6, scaleFactor: 1.25 }); + }); + + it("normalizes cursor positions against the selected area", () => { + expect( + normalizePointWithinRegion( + { x: 500, y: 350 }, + { x: 100, y: 50, width: 800, height: 600 }, + ), + ).toEqual({ cx: 0.5, cy: 0.5 }); + expect( + normalizePointWithinRegion( + { x: 20, y: 900 }, + { x: 100, y: 50, width: 800, height: 600 }, + ), + ).toEqual({ cx: 0, cy: 1 }); + }); +}); diff --git a/electron/ipc/regionSelectionGeometry.ts b/electron/ipc/regionSelectionGeometry.ts new file mode 100644 index 000000000..6378ed626 --- /dev/null +++ b/electron/ipc/regionSelectionGeometry.ts @@ -0,0 +1,85 @@ +export type Rectangle = { + x: number; + y: number; + width: number; + height: number; +}; + +export type PixelCaptureRegion = Rectangle & { + scaleFactor: number; +}; + +export type Point = { x: number; y: number }; + +const MIN_CAPTURE_SIZE = 2; + +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(Math.max(value, minimum), maximum); +} + +/** + * Keep a display-local region inside its display and normalize negative drag + * directions. Electron display coordinates are density-independent points. + */ +export function normalizeCaptureRegion(region: Rectangle, displayBounds: Rectangle): Rectangle { + const rawLeft = region.width < 0 ? region.x + region.width : region.x; + const rawTop = region.height < 0 ? region.y + region.height : region.y; + const rawWidth = Math.abs(region.width); + const rawHeight = Math.abs(region.height); + const left = clamp(rawLeft, 0, Math.max(0, displayBounds.width - MIN_CAPTURE_SIZE)); + const top = clamp(rawTop, 0, Math.max(0, displayBounds.height - MIN_CAPTURE_SIZE)); + const width = clamp( + rawWidth, + MIN_CAPTURE_SIZE, + Math.max(MIN_CAPTURE_SIZE, displayBounds.width - left), + ); + const height = clamp( + rawHeight, + MIN_CAPTURE_SIZE, + Math.max(MIN_CAPTURE_SIZE, displayBounds.height - top), + ); + + return { x: left, y: top, width, height }; +} + +/** Convert Electron points into even physical pixels suitable for H.264 encoders. */ +export function toPixelCaptureRegion( + region: Rectangle, + displayBounds: Rectangle, + scaleFactor: number, +): PixelCaptureRegion { + const normalized = normalizeCaptureRegion(region, displayBounds); + const resolvedScaleFactor = Number.isFinite(scaleFactor) && scaleFactor > 0 ? scaleFactor : 1; + const maxWidth = Math.max( + MIN_CAPTURE_SIZE, + Math.floor(displayBounds.width * resolvedScaleFactor), + ); + const maxHeight = Math.max( + MIN_CAPTURE_SIZE, + Math.floor(displayBounds.height * resolvedScaleFactor), + ); + const x = clamp(Math.round(normalized.x * resolvedScaleFactor), 0, maxWidth - MIN_CAPTURE_SIZE); + const y = clamp( + Math.round(normalized.y * resolvedScaleFactor), + 0, + maxHeight - MIN_CAPTURE_SIZE, + ); + const makeEven = (value: number) => Math.max(MIN_CAPTURE_SIZE, Math.floor(value / 2) * 2); + const width = Math.min( + makeEven(normalized.width * resolvedScaleFactor), + makeEven(maxWidth - x), + ); + const height = Math.min( + makeEven(normalized.height * resolvedScaleFactor), + makeEven(maxHeight - y), + ); + + return { x, y, width, height, scaleFactor: resolvedScaleFactor }; +} + +export function normalizePointWithinRegion(point: Point, region: Rectangle) { + return { + cx: clamp((point.x - region.x) / Math.max(1, region.width), 0, 1), + cy: clamp((point.y - region.y) / Math.max(1, region.height), 0, 1), + }; +} diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 06a33f84f..2b60a23f4 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -435,12 +435,12 @@ export function registerRecordingHandlers( const timestamp = Date.now(); const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`); tempVideoPath = path.join(app.getPath("temp"), `recordly-native-${timestamp}.mp4`); - + let captureOutput = ""; let systemAudioPath: string | null = null; let microphonePath: string | null = null; let orphanedMicAudioPath: string | null = null; - + const browserMicFallbackRequested = shouldStartWindowsBrowserMicrophoneFallback(options); const captureTarget = resolveWindowsCaptureTarget( @@ -483,11 +483,19 @@ export function registerRecordingHandlers( // Fallback to coordinate-based matching if handle resolution fails config.displayId = captureTarget.displayId; } - + config.displayX = Math.round(captureTarget.bounds.x); config.displayY = Math.round(captureTarget.bounds.y); config.displayW = Math.round(captureTarget.bounds.width); config.displayH = Math.round(captureTarget.bounds.height); + if (source.captureRegion) { + config.cropX = source.captureRegion.pixelX; + config.cropY = source.captureRegion.pixelY; + config.cropWidth = source.captureRegion.pixelWidth; + config.cropHeight = source.captureRegion.pixelHeight; + config.width = source.captureRegion.pixelWidth; + config.height = source.captureRegion.pixelHeight; + } } if (options?.capturesSystemAudio) { @@ -745,6 +753,12 @@ export function registerRecordingHandlers( } else { config.displayId = Number(getScreen().getPrimaryDisplay().id); } + if (source.captureRegion) { + config.regionX = source.captureRegion.x; + config.regionY = source.captureRegion.y; + config.regionWidth = source.captureRegion.width; + config.regionHeight = source.captureRegion.height; + } setNativeCaptureOutputBuffer(""); setNativeCaptureTargetPath(outputPath); diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index 33c9ee74c..93c68c612 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -14,6 +14,7 @@ import { resolveLinuxWindowBounds, stopWindowBoundsCapture, } from "../cursor/bounds"; +import { selectCaptureRegion } from "../regionSelection"; import { reassertHudOverlayMousePassthrough } from "../../windows"; const execFileAsync = promisify(execFile); @@ -316,6 +317,15 @@ export function registerSourceHandlers({ return selectedSource; }); + ipcMain.handle("select-capture-region", async () => { + const source = await selectCaptureRegion(); + if (!source) return null; + setSelectedSource(source); + broadcastSelectedSourceChange(); + stopWindowBoundsCapture(); + return source; + }); + ipcMain.handle("show-source-highlight", async (_, source: SelectedSource) => { try { const isWindow = source.id?.startsWith("window:"); @@ -367,7 +377,14 @@ export function registerSourceHandlers({ // ── 2. Resolve bounds ── let bounds: { x: number; y: number; width: number; height: number } | null = null; - if (source.id?.startsWith("screen:")) { + if (source.captureRegion) { + bounds = { + x: source.captureRegion.displayBounds.x + source.captureRegion.x, + y: source.captureRegion.displayBounds.y + source.captureRegion.y, + width: source.captureRegion.width, + height: source.captureRegion.height, + }; + } else if (source.id?.startsWith("screen:")) { bounds = process.platform === "darwin" ? getDisplayWorkAreaForSource(source) @@ -400,7 +417,7 @@ export function registerSourceHandlers({ // On macOS, screen highlights use workArea and no outward padding — // macOS clamps window positions below the menu bar so outward // padding only works on the left/top while right/bottom run off-screen. - const isScreen = source.id?.startsWith("screen:"); + const isScreen = source.id?.startsWith("screen:") && !source.captureRegion; const isMacScreen = isScreen && process.platform === "darwin"; const pad = isMacScreen ? 0 : 6; const highlightWin = new BrowserWindow({ diff --git a/electron/ipc/types.ts b/electron/ipc/types.ts index 58f5425bd..ba69ba4d2 100644 --- a/electron/ipc/types.ts +++ b/electron/ipc/types.ts @@ -1,10 +1,24 @@ +export type CaptureRegion = { + x: number; + y: number; + width: number; + height: number; + displayBounds: WindowBounds; + scaleFactor: number; + pixelX: number; + pixelY: number; + pixelWidth: number; + pixelHeight: number; +}; + export type SelectedSource = { id?: string; name: string; display_id?: string; - sourceType?: "screen" | "window"; + sourceType?: "screen" | "window" | "region"; appName?: string; windowTitle?: string; + captureRegion?: CaptureRegion; [key: string]: unknown; }; diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index 1e2a397aa..956b8ad3a 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -14,6 +14,10 @@ struct CaptureConfig: Codable { let microphoneDeviceId: String? let microphoneLabel: String? let microphoneOutputPath: String? + let regionX: CGFloat? + let regionY: CGFloat? + let regionWidth: CGFloat? + let regionHeight: CGFloat? } let targetCaptureFPS = 60 @@ -127,8 +131,26 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: []) let displayBounds = CGDisplayBounds(display.displayID) let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: display.displayID) - outputWidth = max(2, Int(displayBounds.width) * scaleFactor) - outputHeight = max(2, Int(displayBounds.height) * scaleFactor) + if let requestedWidth = config.regionWidth, + let requestedHeight = config.regionHeight, + requestedWidth > 0, + requestedHeight > 0 { + let regionX = max(0, min(config.regionX ?? 0, displayBounds.width - 2)) + let regionY = max(0, min(config.regionY ?? 0, displayBounds.height - 2)) + let regionWidth = max(2, min(requestedWidth, displayBounds.width - regionX)) + let regionHeight = max(2, min(requestedHeight, displayBounds.height - regionY)) + streamConfig.sourceRect = CGRect( + x: regionX, + y: regionY, + width: regionWidth, + height: regionHeight + ) + outputWidth = max(2, (Int(regionWidth * CGFloat(scaleFactor)) / 2) * 2) + outputHeight = max(2, (Int(regionHeight * CGFloat(scaleFactor)) / 2) * 2) + } else { + outputWidth = max(2, Int(displayBounds.width) * scaleFactor) + outputHeight = max(2, Int(displayBounds.height) * scaleFactor) + } streamConfig.width = outputWidth streamConfig.height = outputHeight } @@ -715,4 +737,3 @@ DispatchQueue.global(qos: .utility).async { } service.waitUntilFinished() - diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 0f007826b..352aff669 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -38,6 +39,10 @@ struct CaptureConfig { int displayY = 0; int displayW = 0; int displayH = 0; + int cropX = 0; + int cropY = 0; + int cropWidth = 0; + int cropHeight = 0; bool hasDisplayBounds = false; bool captureSystemAudio = false; bool captureMic = false; @@ -117,6 +122,15 @@ static bool parseSimpleJson(const std::string& json, CaptureConfig& config) { int height = findInt("height"); if (height > 0) config.height = height; + int cropX = findInt("cropX"); + int cropY = findInt("cropY"); + int cropWidth = findInt("cropWidth"); + int cropHeight = findInt("cropHeight"); + if (cropX >= 0) config.cropX = cropX; + if (cropY >= 0) config.cropY = cropY; + if (cropWidth > 0) config.cropWidth = cropWidth; + if (cropHeight > 0) config.cropHeight = cropHeight; + config.audioOutputPath = findString("audioOutputPath"); config.micOutputPath = findString("micOutputPath"); config.micDeviceName = findString("micDeviceName"); @@ -325,6 +339,16 @@ int main(int argc, char* argv[]) { captureWidth = (captureWidth / 2) * 2; captureHeight = (captureHeight / 2) * 2; + const bool cropsDisplay = config.windowHandle <= 0 && config.cropWidth > 0 && config.cropHeight > 0; + if (cropsDisplay) { + config.cropX = std::max(0, std::min(config.cropX, session.captureWidth() - 2)); + config.cropY = std::max(0, std::min(config.cropY, session.captureHeight() - 2)); + captureWidth = std::max(2, std::min(captureWidth, session.captureWidth() - config.cropX)); + captureHeight = std::max(2, std::min(captureHeight, session.captureHeight() - config.cropY)); + captureWidth = (captureWidth / 2) * 2; + captureHeight = (captureHeight / 2) * 2; + } + // Initialize encoder MFEncoder encoder; std::wstring outputPathW = utf8ToWide(config.outputPath); @@ -334,6 +358,23 @@ int main(int argc, char* argv[]) { return 1; } + ComPtr croppedTexture; + if (cropsDisplay) { + D3D11_TEXTURE2D_DESC description{}; + description.Width = static_cast(captureWidth); + description.Height = static_cast(captureHeight); + description.MipLevels = 1; + description.ArraySize = 1; + description.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + description.SampleDesc.Count = 1; + description.Usage = D3D11_USAGE_DEFAULT; + description.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET; + if (FAILED(session.device()->CreateTexture2D(&description, nullptr, &croppedTexture))) { + std::cerr << "ERROR: Failed to create display crop texture" << std::endl; + return 1; + } + } + // Set up frame callback std::atomic frameCount{0}; std::atomic firstVideoTimestampHns{-1}; @@ -348,7 +389,22 @@ int main(int argc, char* argv[]) { const int64_t adjustedTimestampHns = adjustedVideoTimestampHns(timestampHns); - if (encoder.writeFrame(texture, adjustedTimestampHns)) { + ID3D11Texture2D* encoderTexture = texture; + if (croppedTexture) { + D3D11_BOX sourceBox{ + static_cast(config.cropX), + static_cast(config.cropY), + 0, + static_cast(config.cropX + captureWidth), + static_cast(config.cropY + captureHeight), + 1, + }; + session.context()->CopySubresourceRegion( + croppedTexture.Get(), 0, 0, 0, 0, texture, 0, &sourceBox); + encoderTexture = croppedTexture.Get(); + } + + if (encoder.writeFrame(encoderTexture, adjustedTimestampHns)) { const int64_t writtenFrames = frameCount.fetch_add(1) + 1; if (writtenFrames == 1 && !recordingStartedAnnounced.exchange(true)) { std::cout << "Recording started" << std::endl; diff --git a/electron/preload.ts b/electron/preload.ts index e55d42cbd..5f2f261fd 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -488,6 +488,9 @@ contextBridge.exposeInMainWorld("electronAPI", { selectSource: (source: ProcessedDesktopSource) => { return ipcRenderer.invoke("select-source", source); }, + selectCaptureRegion: () => { + return ipcRenderer.invoke("select-capture-region"); + }, showSourceHighlight: (source: ProcessedDesktopSource) => { return ipcRenderer.invoke("show-source-highlight", source); }, diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index baa4da59d..dd64b4f2e 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { MonitorIcon, AppWindowIcon, CaretUpIcon } from "@phosphor-icons/react"; +import { MonitorIcon, AppWindowIcon, CaretUpIcon, SelectionIcon } from "@phosphor-icons/react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useScopedT } from "@/contexts/I18nContext"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -26,6 +26,8 @@ interface SourceSelectorProps { loading?: boolean; /** Callback when a source is selected */ onSourceSelect?: (source: DesktopSource) => void; + /** Open the Screen Studio-style area selector */ + onRegionSelect?: () => Promise | void; /** Callback to fetch sources */ onFetchSources?: () => Promise; /** Whether the popover is open */ @@ -81,7 +83,16 @@ export const SourceSelectorContent = ({ selectedSource = "Screen", loading = false, onSourceSelect = () => undefined, -}: Pick) => { + onRegionSelect, +}: Pick< + SourceSelectorProps, + | "screenSources" + | "windowSources" + | "selectedSource" + | "loading" + | "onSourceSelect" + | "onRegionSelect" +>) => { const t = useScopedT("launch"); const renderSourceItem = (source: DesktopSource, index: number) => { const isSelected = selectedSource === source.name; @@ -156,6 +167,32 @@ export const SourceSelectorContent = ({
+ {onRegionSelect ? ( + + ) : null} {screenSources.map((source, index) => renderSourceItem(source, index))}
@@ -190,6 +227,7 @@ export const SourceSelector = React.memo(function SourceSelector({ selectedSource: propsSelectedSource, loading: propsLoading, onSourceSelect: propsOnSourceSelect, + onRegionSelect: propsOnRegionSelect, onFetchSources: propsOnFetchSources, open: propsOpen, onOpenChange: propsOnOpenChange, @@ -247,6 +285,14 @@ export const SourceSelector = React.memo(function SourceSelector({ }, [propsOnSourceSelect], ); + const onRegionSelect = useCallback(async () => { + if (propsOnRegionSelect) { + await propsOnRegionSelect(); + return; + } + const source = await window.electronAPI?.selectCaptureRegion?.(); + if (source) setInternalSelectedSource(source.name); + }, [propsOnRegionSelect]); // Split sources for internal use const internalScreenSources = useMemo( @@ -365,6 +411,7 @@ export const SourceSelector = React.memo(function SourceSelector({ selectedSource={selectedSource} loading={loading} onSourceSelect={onSourceSelect} + onRegionSelect={onRegionSelect} /> diff --git a/src/components/launch/popovers/SourcePopover.tsx b/src/components/launch/popovers/SourcePopover.tsx index 5459fae14..d91b0421f 100644 --- a/src/components/launch/popovers/SourcePopover.tsx +++ b/src/components/launch/popovers/SourcePopover.tsx @@ -52,6 +52,11 @@ export function SourcePopover({ windowSources={windowSources} selectedSource={selectedSource} loading={loading} + onRegionSelect={async () => { + requestClose(POPOVER_ID); + const source = await window.electronAPI.selectCaptureRegion(); + if (source) await onSourceSelect(source); + }} onSourceSelect={async (source) => { try { await onSourceSelect(source); diff --git a/src/components/launch/popovers/launchPopoverTypes.ts b/src/components/launch/popovers/launchPopoverTypes.ts index 1585aaed5..a8cbb6357 100644 --- a/src/components/launch/popovers/launchPopoverTypes.ts +++ b/src/components/launch/popovers/launchPopoverTypes.ts @@ -4,9 +4,10 @@ export interface DesktopSource { thumbnail: string | null; display_id: string; appIcon: string | null; - sourceType?: "screen" | "window"; + sourceType?: "screen" | "window" | "region"; appName?: string; windowTitle?: string; + captureRegion?: ProcessedDesktopSource["captureRegion"]; } /** @@ -44,6 +45,7 @@ export function mapRawSource(s: DesktopSource): DesktopSource { sourceType: type, appName, windowTitle: s.windowTitle ?? displayName, + captureRegion: s.captureRegion, }; }