From 313c303230ee355526c9285275c4f8166b8b153e Mon Sep 17 00:00:00 2001 From: Nakul Srivastava Date: Sun, 27 Sep 2026 23:07:14 +0530 Subject: [PATCH 01/13] fix(dashboard): use the full main column for the Screenshots page Co-Authored-By: Claude Opus 5.5 --- .../dashboard/SavedCapturesView.test.tsx | 31 +++++++++++++++++++ .../dashboard/SavedCapturesView.tsx | 6 ++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/dashboard/SavedCapturesView.test.tsx b/apps/desktop/src/components/dashboard/SavedCapturesView.test.tsx index 1dcb488..8677b2b 100644 --- a/apps/desktop/src/components/dashboard/SavedCapturesView.test.tsx +++ b/apps/desktop/src/components/dashboard/SavedCapturesView.test.tsx @@ -52,4 +52,35 @@ describe("SavedCapturesView", () => { fireEvent.click(screen.getByRole("menuitem", { name: "Showcase" })); expect(onShowcase).toHaveBeenCalledOnce(); }); + + it("spans the full main column with matching grid and skeleton columns", async () => { + type Captures = Awaited>; + let resolveList!: (captures: Captures) => void; + const pending = new Promise((resolve) => { + resolveList = resolve; + }); + mocks.listCaptures.mockReturnValueOnce(pending); + render(); + + const skeleton = await screen.findByRole("status", { name: "Loading saved captures" }); + expect(skeleton.className).toContain("2xl:grid-cols-5"); + resolveList([ + { + path: "C:/Pictures/Snaphub/capture.png", + fileName: "capture.png", + thumbnailPath: "C:/Temp/Snaphub/library-thumbnails/capture.png", + thumbnailUrl: "asset://capture.png", + width: 1200, + height: 800, + sizeBytes: 2048, + modifiedAt: "2026-07-19T09:30:00+05:30", + }, + ]); + await screen.findByRole("button", { name: "View capture.png" }); + + const section = document.querySelector("section[aria-labelledby='saved-captures-title']"); + expect(section?.className).not.toContain("max-w-[1120px]"); + const grid = document.querySelector("section div.mt-5.grid"); + expect(grid?.className).toContain("2xl:grid-cols-5"); + }); }); diff --git a/apps/desktop/src/components/dashboard/SavedCapturesView.tsx b/apps/desktop/src/components/dashboard/SavedCapturesView.tsx index eb64e7f..52ab0a7 100644 --- a/apps/desktop/src/components/dashboard/SavedCapturesView.tsx +++ b/apps/desktop/src/components/dashboard/SavedCapturesView.tsx @@ -132,7 +132,7 @@ export function SavedCapturesView({ onShowcase }: SavedCapturesViewProps): React } return ( -
+

Local workspace

@@ -166,7 +166,7 @@ export function SavedCapturesView({ onShowcase }: SavedCapturesViewProps): React {library.status === "loading" && library.captures.length === 0 ? : null} {library.status !== "loading" && library.captures.length === 0 ? : null} {library.captures.length > 0 ? ( -
+
{library.captures.map((capture) => void viewCapture(selected)} />)}
) : null} @@ -212,7 +212,7 @@ function ContextAction({ icon: Icon, label, onClick, disabled = false, destructi } function LoadingGrid(): React.JSX.Element { - return
{Array.from({ length: 8 }, (_, index) =>
)}
; + return
{Array.from({ length: 8 }, (_, index) =>
)}
; } function EmptyLibrary(): React.JSX.Element { From 21090311bae5de1b258c4f132d80a0017a0d93be Mon Sep 17 00:00:00 2001 From: Nakul Srivastava Date: Sun, 27 Sep 2026 23:11:36 +0530 Subject: [PATCH 02/13] fix(recorder): open the recorder and camera on the first click Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src-tauri/src/lib.rs | 38 +++++++++++++------ .../src/components/CameraPreview.test.tsx | 35 +++++++++++++++++ apps/desktop/src/components/CameraPreview.tsx | 3 -- .../src/components/RecorderDock.test.tsx | 15 ++++++++ apps/desktop/src/components/RecorderDock.tsx | 7 ++-- .../components/dashboard/RecordView.test.tsx | 20 ++++++++++ .../src/components/dashboard/RecordView.tsx | 13 ++++++- 7 files changed, 110 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/src/components/CameraPreview.test.tsx diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9137d93..cfa6632 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1684,18 +1684,32 @@ fn show_recorder(app: &AppHandle) -> Result<(), SnaphubError> { let width = 720u32; let height = 132u32; - let window = WebviewWindowBuilder::new(app, "recorder", WebviewUrl::App("index.html".into())) - .title("CapKit Recorder") - .inner_size(f64::from(width), f64::from(height)) - .decorations(false) - .transparent(true) - .resizable(false) - .skip_taskbar(true) - .always_on_top(true) - .shadow(false) - .visible(false) - .build() - .map_err(|error| SnaphubError::Window(error.to_string()))?; + let window = + match WebviewWindowBuilder::new(app, "recorder", WebviewUrl::App("index.html".into())) + .title("CapKit Recorder") + .inner_size(f64::from(width), f64::from(height)) + .decorations(false) + .transparent(true) + .resizable(false) + .skip_taskbar(true) + .always_on_top(true) + .shadow(false) + .visible(false) + .build() + { + Ok(window) => window, + Err(error) => { + // A fast double click (or the shortcut racing the button) can + // reach here after the first call already built the window. + if let Some(window) = app.get_webview_window("recorder") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + return Ok(()); + } + return Err(SnaphubError::Window(error.to_string())); + } + }; let x = (bounds.width.saturating_sub(width)) / 2; let y = bounds.height.saturating_sub(height + 72); diff --git a/apps/desktop/src/components/CameraPreview.test.tsx b/apps/desktop/src/components/CameraPreview.test.tsx new file mode 100644 index 0000000..b2f0bb1 --- /dev/null +++ b/apps/desktop/src/components/CameraPreview.test.tsx @@ -0,0 +1,35 @@ +import { cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CameraPreview } from "./CameraPreview"; + +const mocks = { + ready: vi.fn<() => Promise>(() => Promise.resolve()), + close: vi.fn<() => Promise>(() => Promise.resolve()), +}; + +vi.mock("../lib/recordingTauri", () => ({ + cameraReady: (): Promise => mocks.ready(), + closeCamera: (): Promise => mocks.close(), +})); + +describe("CameraPreview", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("reveals itself under StrictMode remounting, where the first frame is cancelled", async () => { + const { StrictMode } = await import("react"); + render( + + + , + ); + await waitFor(() => { + expect(mocks.ready).toHaveBeenCalled(); + }); + // One frame is cancelled by the StrictMode cleanup; the remount schedules + // again. Zero calls is the old readyRef bug. + expect(mocks.ready.mock.calls.length).toBeLessThanOrEqual(2); + }); +}); diff --git a/apps/desktop/src/components/CameraPreview.tsx b/apps/desktop/src/components/CameraPreview.tsx index 6a77900..c78d3a9 100644 --- a/apps/desktop/src/components/CameraPreview.tsx +++ b/apps/desktop/src/components/CameraPreview.tsx @@ -20,7 +20,6 @@ export function CameraPreview(): React.JSX.Element { const [error, setError] = useState(null); const videoRef = useRef(null); const streamRef = useRef(null); - const readyRef = useRef(false); useEffect(() => { document.documentElement.classList.add("on-screen-surface"); @@ -54,8 +53,6 @@ export function CameraPreview(): React.JSX.Element { }, []); useEffect(() => { - if (readyRef.current) return; - readyRef.current = true; const frame = window.requestAnimationFrame(() => { void cameraReady().catch(() => setError("The camera window could not be shown.")); }); diff --git a/apps/desktop/src/components/RecorderDock.test.tsx b/apps/desktop/src/components/RecorderDock.test.tsx index 37cb13d..fd434b4 100644 --- a/apps/desktop/src/components/RecorderDock.test.tsx +++ b/apps/desktop/src/components/RecorderDock.test.tsx @@ -88,6 +88,21 @@ describe("RecorderDock", () => { }); }); + it("reveals itself under StrictMode remounting, where the first frame is cancelled", async () => { + const { StrictMode } = await import("react"); + render( + + + , + ); + await waitFor(() => { + expect(mocks.ready).toHaveBeenCalled(); + }); + // One frame is cancelled by the StrictMode cleanup; the remount schedules + // again. Zero calls is the old readyRef bug. + expect(mocks.ready.mock.calls.length).toBeLessThanOrEqual(2); + }); + it("offers the capture modes and lists sources for the chosen one, with a preview to pick from", async () => { render(); diff --git a/apps/desktop/src/components/RecorderDock.tsx b/apps/desktop/src/components/RecorderDock.tsx index 039b80f..5cf24d9 100644 --- a/apps/desktop/src/components/RecorderDock.tsx +++ b/apps/desktop/src/components/RecorderDock.tsx @@ -69,7 +69,6 @@ export function RecorderDock(): React.JSX.Element { const [paused, setPaused] = useState(false); const [camera, setCamera] = useState(false); const [sourcePickerOpen, setSourcePickerOpen] = useState(false); - const readyRef = useRef(false); const pickerRef = useRef(null); useEffect(() => { @@ -82,10 +81,10 @@ export function RecorderDock(): React.JSX.Element { }, []); // The dock is revealed only once its first styled frame exists, matching the - // capture overlay's prepare-then-reveal lifecycle. + // capture overlay's prepare-then-reveal lifecycle. No mounted guard: under + // StrictMode the cleanup runs before the second mount, and a guard would + // skip the reveal on the remount. useEffect(() => { - if (readyRef.current) return; - readyRef.current = true; const frame = window.requestAnimationFrame(() => { void recorderReady().catch((cause: unknown) => { setError(describeInvokeError(cause, "The recorder could not be shown")); diff --git a/apps/desktop/src/components/dashboard/RecordView.test.tsx b/apps/desktop/src/components/dashboard/RecordView.test.tsx index 44e3579..96eaabc 100644 --- a/apps/desktop/src/components/dashboard/RecordView.test.tsx +++ b/apps/desktop/src/components/dashboard/RecordView.test.tsx @@ -71,6 +71,26 @@ describe("RecordView", () => { await vi.waitFor(() => expect(openRecorder).toHaveBeenCalledTimes(1)); }); + it("disables Start recording while opening so a double click opens once", async () => { + let resolveOpen!: () => void; + openRecorder.mockImplementationOnce( + () => new Promise((resolve) => { + resolveOpen = resolve; + }), + ); + render(); + + const button = screen.getByRole("button", { name: "Start recording" }); + fireEvent.click(button); + fireEvent.click(button); + + expect(button).toBeDisabled(); + expect(openRecorder).toHaveBeenCalledTimes(1); + resolveOpen(); + await vi.waitFor(() => expect(button).not.toBeDisabled()); + expect(openRecorder).toHaveBeenCalledTimes(1); + }); + it("edits a recording in place and returns to the library", async () => { render(); diff --git a/apps/desktop/src/components/dashboard/RecordView.tsx b/apps/desktop/src/components/dashboard/RecordView.tsx index 1666c12..e5b8222 100644 --- a/apps/desktop/src/components/dashboard/RecordView.tsx +++ b/apps/desktop/src/components/dashboard/RecordView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { formatBytes, formatDuration, @@ -51,12 +51,21 @@ export function RecordView(): React.JSX.Element { return (): void => window.removeEventListener("storage", onStorage); }, []); + const [opening, setOpening] = useState(false); + const openingRef = useRef(false); + const start = useCallback(async (): Promise => { + if (openingRef.current) return; + openingRef.current = true; + setOpening(true); setError(null); try { await openRecorder(); } catch (cause: unknown) { setError(describeInvokeError(cause, "The recorder could not be opened")); + } finally { + openingRef.current = false; + setOpening(false); } }, []); @@ -104,7 +113,7 @@ export function RecordView(): React.JSX.Element { + )) + )} +
+ ) : null} {phase === "recording" || phase === "saving" ? (
-
+
- - {sourcePickerOpen ? ( -
- {visibleSources.length === 0 ? ( -

Nothing available to record

- ) : ( - visibleSources.map((source) => ( - - )) - )} -
- ) : null}
diff --git a/apps/desktop/src/lib/recordingTauri.ts b/apps/desktop/src/lib/recordingTauri.ts index 51f5601..72edbb5 100644 --- a/apps/desktop/src/lib/recordingTauri.ts +++ b/apps/desktop/src/lib/recordingTauri.ts @@ -74,6 +74,11 @@ export async function setRecordingPaused(paused: boolean): Promise { return Boolean(await invoke("set_recording_paused", { paused })); } +/** Fits the recorder window to its content; the backend anchors and clamps it. */ +export async function fitRecorder(width: number, height: number): Promise { + await invoke("fit_recorder", { width, height }); +} + /** * Shows a click-through outline around exactly what is about to be, or is * being, recorded. Bounds are in physical desktop pixels, matching a source's From c023bcc2b55ed341226022abd1be490b95876058 Mon Sep 17 00:00:00 2001 From: Nakul Srivastava Date: Sun, 27 Sep 2026 23:30:57 +0530 Subject: [PATCH 04/13] feat(recorder): draw the region area on screen Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src-tauri/src/lib.rs | 255 ++++++++++++++++++ .../src/components/RecordRegion.test.tsx | 70 +++++ apps/desktop/src/components/RecordRegion.tsx | 57 +++- .../src/components/RecorderDock.test.tsx | 84 +++++- apps/desktop/src/components/RecorderDock.tsx | 110 +++++++- apps/desktop/src/domain/recording.ts | 7 + apps/desktop/src/lib/recordingTauri.ts | 43 +++ 7 files changed, 610 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/components/RecordRegion.test.tsx diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index cc8dff7..296c097 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1636,12 +1636,171 @@ fn close_recorder( service: tauri::State<'_, RecordingService>, ) -> Result<(), SnaphubError> { service.cancel()?; + if let Some(window) = app.get_webview_window("record-region") { + let _ = window.destroy(); + } if let Some(window) = app.get_webview_window("recorder") { let _ = window.destroy(); } Ok(()) } +/// Converts a logical box drawn in the region overlay to physical desktop px. +/// +/// Rounds to whole physical px, enforces a 32x32 minimum, and clamps inside +/// `display`. `origin` is the overlay window's physical outer position. +fn logical_box_to_desktop(origin: (i32, i32), scale: f64, area: Rect, display: Rect) -> Rect { + let width = (area.width * scale).round().max(32.0).min(display.width); + let height = (area.height * scale).round().max(32.0).min(display.height); + let x = (f64::from(origin.0) + (area.x * scale).round()) + .max(display.x) + .min(display.x + display.width - width); + let y = (f64::from(origin.1) + (area.y * scale).round()) + .max(display.y) + .min(display.y + display.height - height); + Rect { + x, + y, + width, + height, + } +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct RecordRegionSelection { + display_id: String, + bounds: Rect, +} + +struct RecordRegionRegistry(Mutex>); + +/// Builds a WebView window: never call from a synchronous command or an event handler on Windows (deadlock). Use an async command or spawn_blocking. +#[tauri::command] +async fn open_record_region(app: AppHandle, display_id: String) -> Result<(), SnaphubError> { + if let Some(window) = app.get_webview_window("record-region") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + return Ok(()); + } + // Reuse the service's own enumeration rather than adding a second one. + let bounds = app + .state::() + .sources() + .map_err(|_| SnaphubError::Window("Recording sources are unavailable".into()))? + .into_iter() + .find(|source| source.kind == "display" && source.display_id == display_id) + .map(|source| source.bounds) + .ok_or_else(|| SnaphubError::Window("The chosen display is unavailable".into()))?; + if let Ok(mut registry) = app.state::().0.lock() { + *registry = Some(display_id); + } + WebviewWindowBuilder::new(&app, "record-region", WebviewUrl::App("index.html".into())) + .title("CapKit Record Region") + .decorations(false) + .transparent(true) + .resizable(false) + .skip_taskbar(true) + .always_on_top(true) + .shadow(false) + .visible(false) + .build() + .map_err(|error| SnaphubError::Window(error.to_string()))?; + let Some(window) = app.get_webview_window("record-region") else { + return Err(SnaphubError::Window("Region window is unavailable".into())); + }; + window + .set_position(PhysicalPosition::new( + bounds.x.round() as i32, + bounds.y.round() as i32, + )) + .map_err(|error| SnaphubError::Window(error.to_string()))?; + window + .set_size(PhysicalSize::new( + bounds.width.round() as u32, + bounds.height.round() as u32, + )) + .map_err(|error| SnaphubError::Window(error.to_string()))?; + Ok(()) +} + +#[tauri::command] +fn record_region_ready(app: AppHandle) -> Result<(), SnaphubError> { + let Some(window) = app.get_webview_window("record-region") else { + return Ok(()); + }; + let _ = window.unminimize(); + window + .show() + .map_err(|error| SnaphubError::Window(error.to_string()))?; + let _ = window.set_focus(); + Ok(()) +} + +#[tauri::command] +fn confirm_record_region( + app: AppHandle, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result<(), SnaphubError> { + let Some(window) = app.get_webview_window("record-region") else { + return Err(SnaphubError::Window("Region window is unavailable".into())); + }; + let display_id = app + .state::() + .0 + .lock() + .map_err(|_| SnaphubError::Window("Region state is unavailable".into()))? + .clone() + .ok_or_else(|| SnaphubError::Window("No display was chosen for the region".into()))?; + let bounds = app + .state::() + .sources() + .map_err(|_| SnaphubError::Window("Recording sources are unavailable".into()))? + .into_iter() + .find(|source| source.kind == "display" && source.display_id == display_id) + .map(|source| source.bounds) + .ok_or_else(|| SnaphubError::Window("The chosen display is unavailable".into()))?; + let position = window + .outer_position() + .map_err(|error| SnaphubError::Window(error.to_string()))?; + let scale = window + .scale_factor() + .map_err(|error| SnaphubError::Window(error.to_string()))?; + let desktop = logical_box_to_desktop( + (position.x, position.y), + scale, + Rect { + x, + y, + width, + height, + }, + bounds, + ); + let _ = app.emit( + "snaphub://record-region-selected", + RecordRegionSelection { + display_id, + bounds: desktop, + }, + ); + let _ = window.destroy(); + Ok(()) +} + +#[tauri::command] +fn cancel_record_region(app: AppHandle) -> Result<(), SnaphubError> { + let _ = app.emit("snaphub://record-region-cancelled", ()); + if let Some(window) = app.get_webview_window("record-region") { + let _ = window.destroy(); + } + Ok(()) +} + /// Opens the recorder, or brings it to front if it is already open. /// /// Bound to a global shortcut so recording can be reached without navigating @@ -1824,6 +1983,7 @@ pub fn run() { .manage(recording_service()) .manage(ShortcutConfiguration(Mutex::new(shortcuts))) .manage(PinnedCaptureRegistry(Mutex::new(HashMap::new()))) + .manage(RecordRegionRegistry(Mutex::new(None))) .manage(OnScreenModeRegistry(Mutex::new(OnScreenModeState::Idle))) .invoke_handler(tauri::generate_handler![ begin_capture, @@ -1873,6 +2033,10 @@ pub fn run() { fit_recorder, open_recorder, close_recorder, + open_record_region, + record_region_ready, + confirm_record_region, + cancel_record_region, set_recording_paused, show_recording_border, hide_recording_border, @@ -2288,6 +2452,97 @@ mod recorder_layout_tests { assert_eq!(frame.height, 400.0); assert_eq!(frame.y, 0.0); } + + #[test] + fn logical_box_scales_to_physical_px() { + let area = logical_box_to_desktop( + (0, 0), + 1.0, + Rect { + x: 10.0, + y: 20.0, + width: 100.0, + height: 80.0, + }, + work_area(0.0, 0.0, 1920.0, 1080.0), + ); + assert_eq!( + area, + Rect { + x: 10.0, + y: 20.0, + width: 100.0, + height: 80.0, + } + ); + // A 1280x720 logical box on a 150% display is a 1920x1080 recording. + let area = logical_box_to_desktop( + (0, 0), + 1.5, + Rect { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }, + work_area(0.0, 0.0, 1920.0, 1080.0), + ); + assert_eq!(area.width, 1920.0); + assert_eq!(area.height, 1080.0); + } + + #[test] + fn logical_box_supports_a_negative_origin() { + let area = logical_box_to_desktop( + (-1920, 0), + 1.0, + Rect { + x: 100.0, + y: 100.0, + width: 200.0, + height: 150.0, + }, + work_area(-1920.0, 0.0, 1920.0, 1080.0), + ); + assert_eq!(area.x, -1820.0); + assert_eq!(area.y, 100.0); + } + + #[test] + fn logical_box_clamps_at_the_display_edges() { + let area = logical_box_to_desktop( + (0, 0), + 1.0, + Rect { + x: 1850.0, + y: 1000.0, + width: 200.0, + height: 200.0, + }, + work_area(0.0, 0.0, 1920.0, 1080.0), + ); + assert_eq!(area.x, 1720.0); + assert_eq!(area.y, 880.0); + assert_eq!(area.width, 200.0); + assert_eq!(area.height, 200.0); + } + + #[test] + fn logical_box_enforces_the_minimum_size() { + let area = logical_box_to_desktop( + (0, 0), + 1.0, + Rect { + x: 50.0, + y: 50.0, + width: 5.0, + height: 4.0, + }, + work_area(0.0, 0.0, 1920.0, 1080.0), + ); + assert_eq!(area.width, 32.0); + assert_eq!(area.height, 32.0); + } } #[cfg(test)] diff --git a/apps/desktop/src/components/RecordRegion.test.tsx b/apps/desktop/src/components/RecordRegion.test.tsx new file mode 100644 index 0000000..40596e3 --- /dev/null +++ b/apps/desktop/src/components/RecordRegion.test.tsx @@ -0,0 +1,70 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RecordRegion } from "./RecordRegion"; + +const mocks = { + ready: vi.fn<() => Promise>(() => Promise.resolve()), + confirm: vi.fn<(box: unknown) => Promise>(() => Promise.resolve()), + cancel: vi.fn<() => Promise>(() => Promise.resolve()), +}; + +vi.mock("../lib/recordingTauri", () => ({ + recordRegionReady: (): Promise => mocks.ready(), + confirmRecordRegion: (box: unknown): Promise => mocks.confirm(box), + cancelRecordRegion: (): Promise => mocks.cancel(), +})); + +describe("RecordRegion", () => { + beforeEach(() => { + Object.defineProperties(HTMLElement.prototype, { + setPointerCapture: { configurable: true, value: vi.fn() }, + releasePointerCapture: { configurable: true, value: vi.fn() }, + hasPointerCapture: { configurable: true, value: vi.fn(() => true) }, + }); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + function drawBox(width = 200, height = 120): void { + const surface = screen.getByRole("application", { name: "Choose a region to record" }); + fireEvent.pointerDown(surface, { button: 0, clientX: 100, clientY: 100, pointerId: 1 }); + fireEvent.pointerMove(surface, { clientX: 100 + width, clientY: 100 + height, pointerId: 1 }); + fireEvent.pointerUp(surface, { button: 0, pointerId: 1 }); + } + + it("confirms the logical box with the button and with Enter", () => { + render(); + drawBox(); + + fireEvent.click(screen.getByRole("button", { name: "Use this area" })); + expect(mocks.confirm).toHaveBeenCalledWith({ x: 100, y: 100, width: 200, height: 120 }); + + fireEvent.keyDown(window, { key: "Enter" }); + expect(mocks.confirm).toHaveBeenCalledTimes(2); + }); + + it("cancels with Escape and offers Redraw", () => { + render(); + drawBox(); + + fireEvent.click(screen.getByRole("button", { name: "Redraw" })); + expect(screen.queryByRole("button", { name: "Use this area" })).not.toBeInTheDocument(); + + drawBox(); + expect(screen.getByRole("button", { name: "Use this area" })).toBeVisible(); + fireEvent.keyDown(window, { key: "Escape" }); + expect(mocks.cancel).toHaveBeenCalledTimes(1); + }); + + it("shows no confirm step for a drag under 32 px", () => { + render(); + drawBox(10, 10); + + expect(screen.queryByRole("button", { name: "Use this area" })).not.toBeInTheDocument(); + expect(mocks.confirm).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/components/RecordRegion.tsx b/apps/desktop/src/components/RecordRegion.tsx index afde3c9..f4f09f1 100644 --- a/apps/desktop/src/components/RecordRegion.tsx +++ b/apps/desktop/src/components/RecordRegion.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { cancelRecordRegion, confirmRecordRegion, recordRegionReady } from "../lib/recordingTauri"; type Drag = { readonly startX: number; readonly startY: number; readonly x: number; readonly y: number }; @@ -20,18 +21,34 @@ export function RecordRegion(): React.JSX.Element { return (): void => document.documentElement.classList.remove("on-screen-surface"); }, []); + useEffect(() => { + const frame = window.requestAnimationFrame(() => { + void recordRegionReady().catch(() => undefined); + }); + return (): void => window.cancelAnimationFrame(frame); + }, []); + + const confirm = useCallback((box: { x: number; y: number; width: number; height: number }): void => { + void confirmRecordRegion(box).catch(() => undefined); + }, []); + useEffect(() => { function onKeyDown(event: KeyboardEvent): void { + if (event.key === "Enter" && committed !== null) { + const box = normalize(committed); + if (box.width >= minimumSize && box.height >= minimumSize) { + event.preventDefault(); + confirm(box); + } + return; + } if (event.key !== "Escape") return; event.preventDefault(); - void (async (): Promise => { - const { getCurrentWindow } = await import("@tauri-apps/api/window"); - await getCurrentWindow().destroy(); - })(); + void cancelRecordRegion().catch(() => undefined); } window.addEventListener("keydown", onKeyDown, true); return (): void => window.removeEventListener("keydown", onKeyDown, true); - }, []); + }, [committed, confirm]); function begin(event: React.PointerEvent): void { event.currentTarget.setPointerCapture(event.pointerId); @@ -84,6 +101,34 @@ export function RecordRegion(): React.JSX.Element { > {`${String(Math.round(box.width))} × ${String(Math.round(box.height))}`}

+ {committed === null ? null : ( +
+ + +
+ )} )}
diff --git a/apps/desktop/src/components/RecorderDock.test.tsx b/apps/desktop/src/components/RecorderDock.test.tsx index 3efef54..fbf59ea 100644 --- a/apps/desktop/src/components/RecorderDock.test.tsx +++ b/apps/desktop/src/components/RecorderDock.test.tsx @@ -44,27 +44,50 @@ const artifacts: RecordingArtifacts = { }; const mocks = { - start: vi.fn<() => Promise>(() => Promise.resolve()), + start: vi.fn< + ( + settings: unknown, + source: unknown, + region: unknown, + ) => Promise + >(() => Promise.resolve()), stop: vi.fn<() => Promise>(() => Promise.resolve(artifacts)), cancel: vi.fn<() => Promise>(() => Promise.resolve()), close: vi.fn<() => Promise>(() => Promise.resolve()), ready: vi.fn<() => Promise>(() => Promise.resolve()), fit: vi.fn<(width: number, height: number) => Promise>(() => Promise.resolve()), + border: vi.fn<(bounds: unknown) => Promise>(() => Promise.resolve()), + openRegion: vi.fn<() => Promise>(() => Promise.resolve()), }; +const regionListeners = vi.hoisted(() => ({ + selected: null as null | ((selection: { displayId: string; bounds: { x: number; y: number; width: number; height: number } }) => void), + cancelled: null as null | (() => void), +})); + vi.mock("../lib/recordingTauri", () => ({ fitRecorder: (width: number, height: number): Promise => mocks.fit(width, height), + openRecordRegion: (): Promise => mocks.openRegion(), + listenForRecordRegion: ( + onSelected: (selection: { displayId: string; bounds: { x: number; y: number; width: number; height: number } }) => void, + onCancelled: () => void, + ): Promise<() => void> => { + regionListeners.selected = onSelected; + regionListeners.cancelled = onCancelled; + return Promise.resolve((): void => undefined); + }, listRecordingSources: (): Promise => Promise.resolve([display, secondDisplay, editorWindow]), listAudioDevices: (): Promise => Promise.resolve([{ id: "mic-1", name: "Headset", kind: "microphone", isDefault: true }]), - startRecording: (): Promise => mocks.start(), + startRecording: (settings: unknown, source: unknown, region: unknown): Promise => + mocks.start(settings, source, region), stopRecording: (): Promise => mocks.stop(), cancelRecording: (): Promise => mocks.cancel(), closeRecorder: (): Promise => mocks.close(), recorderReady: (): Promise => mocks.ready(), recordingStatus: (): Promise => Promise.resolve(null), - showRecordingBorder: (): Promise => Promise.resolve(), + showRecordingBorder: (bounds: unknown): Promise => mocks.border(bounds), hideRecordingBorder: (): Promise => Promise.resolve(), recordingSrc: (path: string): string => path, })); @@ -81,6 +104,8 @@ describe("RecorderDock", () => { afterEach(() => { cleanup(); vi.clearAllMocks(); + regionListeners.selected = null; + regionListeners.cancelled = null; }); it("reveals itself only after its first frame, so a hidden window never flashes", async () => { @@ -159,6 +184,59 @@ describe("RecorderDock", () => { expect(picker).toHaveFocus(); }); + it("disables Record in Region mode until an area is drawn", async () => { + render(); + await screen.findByRole("button", { name: "Choose what to record" }); + + fireEvent.click(screen.getByRole("button", { name: "Record a region" })); + + expect(screen.getByRole("button", { name: "Start recording" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Draw area to record" })).toBeVisible(); + }); + + it("records the drawn area and ignores areas drawn for another display", async () => { + render(); + await screen.findByRole("button", { name: "Choose what to record" }); + fireEvent.click(screen.getByRole("button", { name: "Record a region" })); + fireEvent.change(screen.getByRole("combobox", { name: "Countdown before recording" }), { + target: { value: "0" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Draw area to record" })); + expect(mocks.openRegion).toHaveBeenCalledTimes(1); + + regionListeners.selected?.({ displayId: "other", bounds: { x: 0, y: 0, width: 100, height: 100 } }); + expect(screen.getByRole("button", { name: "Start recording" })).toBeDisabled(); + + regionListeners.selected?.({ displayId: "1", bounds: { x: 10, y: 20, width: 640, height: 360 } }); + const redraw = await screen.findByRole("button", { name: "Redraw area to record" }); + expect(redraw).toHaveTextContent("640 × 360 · Redraw"); + expect(screen.getByRole("button", { name: "Start recording" })).not.toBeDisabled(); + + fireEvent.click(screen.getByRole("button", { name: "Start recording" })); + await waitFor(() => { + expect(mocks.border).toHaveBeenCalledWith({ x: 10, y: 20, width: 640, height: 360 }); + }); + expect(mocks.start).toHaveBeenCalledWith( + expect.objectContaining({ mode: "region" }), + expect.objectContaining({ displayId: "1" }), + { x: 10, y: 20, width: 640, height: 360 }, + ); + }); + + it("re-enables drawing with no error when the overlay is cancelled", async () => { + render(); + await screen.findByRole("button", { name: "Choose what to record" }); + fireEvent.click(screen.getByRole("button", { name: "Record a region" })); + + fireEvent.click(screen.getByRole("button", { name: "Draw area to record" })); + expect(screen.getByRole("button", { name: "Draw area to record" })).toBeDisabled(); + + regionListeners.cancelled?.(); + expect(await screen.findByRole("button", { name: "Draw area to record" })).not.toBeDisabled(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + it("offers the capture modes and lists sources for the chosen one, with a preview to pick from", async () => { render(); diff --git a/apps/desktop/src/components/RecorderDock.tsx b/apps/desktop/src/components/RecorderDock.tsx index a8194f6..9188c91 100644 --- a/apps/desktop/src/components/RecorderDock.tsx +++ b/apps/desktop/src/components/RecorderDock.tsx @@ -16,13 +16,16 @@ import { type RecorderSettings, type RecordingSource, type RecordingStats, + type RecordRegionSelection, } from "../domain/recording"; import { cancelRecording, closeCamera, closeRecorder, hideRecordingBorder, + listenForRecordRegion, openCamera, + openRecordRegion, fitRecorder, listAudioDevices, listRecordingSources, @@ -70,9 +73,12 @@ export function RecorderDock(): React.JSX.Element { const [paused, setPaused] = useState(false); const [camera, setCamera] = useState(false); const [sourcePickerOpen, setSourcePickerOpen] = useState(false); + const [region, setRegion] = useState(null); + const [drawingRegion, setDrawingRegion] = useState(false); const pickerRef = useRef(null); const dockSizeRef = useRef(null); const sourceTriggerRef = useRef(null); + const pendingDisplayRef = useRef(null); useEffect(() => { document.documentElement.classList.add("on-screen-surface"); @@ -188,8 +194,59 @@ export function RecorderDock(): React.JSX.Element { sourceTriggerRef.current?.focus(); }, []); + // Region overlay results. A selection for a display that is no longer + // current is ignored, but drawing always ends. No isTauri guard: the dock + // only renders inside the Tauri recorder window, and the subscription must + // stay testable through the mocked recordingTauri module. + useEffect(() => { + let stop: (() => void) | undefined; + void listenForRecordRegion( + (selection) => { + if (selection.displayId !== pendingDisplayRef.current) { + setDrawingRegion(false); + return; + } + setRegion(selection); + setDrawingRegion(false); + }, + () => setDrawingRegion(false), + ) + .then((stopListening) => { + stop = stopListening; + }) + .catch(() => undefined); + return (): void => { + stop?.(); + }; + }, []); + + // Choosing a different display clears the drawn area; switching mode away + // and back keeps it. + const regionDisplayId = region?.displayId ?? null; + const activeDisplayId = active?.displayId ?? null; + useEffect(() => { + if (settings.mode !== "region" || regionDisplayId === null) return; + if (activeDisplayId !== regionDisplayId) setRegion(null); + }, [settings.mode, regionDisplayId, activeDisplayId]); + + const drawArea = useCallback((): void => { + if (active === null || drawingRegion) return; + pendingDisplayRef.current = active.displayId; + setDrawingRegion(true); + void openRecordRegion(active.displayId).catch((cause: unknown) => { + setDrawingRegion(false); + setError(describeInvokeError(cause, "The area picker could not be opened")); + }); + }, [active, drawingRegion]); + const begin = useCallback(async (): Promise => { - if (active === null) { + const target = + settings.mode === "region" && region !== null + ? (sources.find( + (source) => source.kind === "display" && source.displayId === region.displayId, + ) ?? active) + : active; + if (target === null) { setError("Choose something to record first"); return; } @@ -197,25 +254,38 @@ export function RecorderDock(): React.JSX.Element { setError(null); try { // A window records as the crop of its display it currently occupies, so - // the region is derived rather than asked for again. - await startRecording(settings, active, settings.mode === "region" ? active.bounds : null); + // the region is derived rather than asked for again. A drawn area is + // used directly; `toRecordingRequest` keeps the window behaviour. + await startRecording( + settings, + target, + settings.mode === "region" && region !== null ? region.bounds : null, + ); setPhase("recording"); } catch (cause: unknown) { setPhase("setup"); setError(describeInvokeError(cause, "That recording could not be started")); void hideRecordingBorder(); } - }, [active, settings]); + }, [active, region, settings, sources]); function requestStart(): void { - if (active === null) { + const target = + settings.mode === "region" && region !== null + ? (sources.find( + (source) => source.kind === "display" && source.displayId === region.displayId, + ) ?? active) + : active; + if (target === null) { setError("Choose something to record first"); return; } setSourcePickerOpen(false); // Shown for the whole countdown, not just once recording starts, so the // target is visible before a single frame is captured. - void showRecordingBorder(active.bounds).catch(() => undefined); + const bounds = + settings.mode === "region" && region !== null ? region.bounds : target.bounds; + void showRecordingBorder(bounds).catch(() => undefined); if (settings.countdown <= 0) { void begin(); return; @@ -428,6 +498,32 @@ export function RecorderDock(): React.JSX.Element {
+ {settings.mode === "region" ? ( + region === null ? ( + + ) : ( + + ) + ) : null} +
+ patch({ systemAudioDeviceId })} + /> + patch({ microphoneDeviceId })} + /> - {settings.microphone && microphones.length > 0 ? ( - - ) : null} +
+