diff --git a/AGENTS.md b/AGENTS.md index 01c6e1c..d7b3a65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,73 @@ -# Agent guide for Swift and SwiftUI +# AGENT.md -This repository contains an Xcode project written with Swift and SwiftUI. Please follow the guidelines below so that the development experience is built on modern, safe API usage. +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. -## Role +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. -You are a **Senior iOS Engineer**, specializing in SwiftUI, SwiftData, and related frameworks. Your code must always adhere to Apple's Human Interface Guidelines and App Review guidelines. +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: + +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: + +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: + +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: + +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: + +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. ## Core instructions diff --git a/BetterCapture/Service/CaptureEngine.swift b/BetterCapture/Service/CaptureEngine.swift index f7cc9af..d5a1d90 100644 --- a/BetterCapture/Service/CaptureEngine.swift +++ b/BetterCapture/Service/CaptureEngine.swift @@ -95,7 +95,8 @@ final class CaptureEngine: NSObject { /// - Parameters: /// - settings: The settings store containing capture configuration /// - videoSize: The dimensions for the captured video - func startCapture(with settings: SettingsStore, videoSize: CGSize) async throws { + /// - sourceRect: Optional rectangle for area selection (display points, top-left origin) + func startCapture(with settings: SettingsStore, videoSize: CGSize, sourceRect: CGRect? = nil) async throws { guard let filter = contentFilter else { throw CaptureError.noContentFilterSelected } @@ -128,7 +129,7 @@ final class CaptureEngine: NSObject { let filteredContent = try await contentFilterService.applySettings(to: filter, settings: settings) logger.info("Content filter applied, creating stream...") - let streamConfig = createStreamConfiguration(from: settings, contentSize: videoSize) + let streamConfig = createStreamConfiguration(from: settings, contentSize: videoSize, sourceRect: sourceRect) stream = SCStream(filter: filteredContent, configuration: streamConfig, delegate: self) @@ -182,13 +183,23 @@ final class CaptureEngine: NSObject { // MARK: - Configuration /// Creates an SCStreamConfiguration from user settings - private func createStreamConfiguration(from settings: SettingsStore, contentSize: CGSize) -> SCStreamConfiguration { + /// - Parameters: + /// - settings: The settings store containing capture configuration + /// - contentSize: The output dimensions for the captured video + /// - sourceRect: Optional rectangle for area selection (display points, top-left origin) + private func createStreamConfiguration(from settings: SettingsStore, contentSize: CGSize, sourceRect: CGRect? = nil) -> SCStreamConfiguration { let config = SCStreamConfiguration() // Set output dimensions - required for proper capture config.width = Int(contentSize.width) config.height = Int(contentSize.height) + // Set source rect for area selection (only works with display captures) + if let sourceRect { + config.sourceRect = sourceRect + logger.info("Source rect set: \(sourceRect.origin.x),\(sourceRect.origin.y) \(sourceRect.width)x\(sourceRect.height)") + } + // Frame rate - native uses display sync (1/120 timescale) if settings.frameRate == .native { config.minimumFrameInterval = CMTime(value: 1, timescale: 120) diff --git a/BetterCapture/Service/PreviewService.swift b/BetterCapture/Service/PreviewService.swift index 26e94b0..9ce76a6 100644 --- a/BetterCapture/Service/PreviewService.swift +++ b/BetterCapture/Service/PreviewService.swift @@ -29,6 +29,7 @@ final class PreviewService: NSObject { private var stream: SCStream? private var currentFilter: SCContentFilter? + private var currentSourceRect: CGRect? private let previewQueue = DispatchQueue(label: "com.bettercapture.previewQueue", qos: .userInteractive) private let logger = Logger( @@ -43,20 +44,30 @@ final class PreviewService: NSObject { // MARK: - Public Methods /// Updates the content filter and captures a static thumbnail - /// - Parameter filter: The content filter to use - func setContentFilter(_ filter: SCContentFilter) async { + /// - Parameters: + /// - filter: The content filter to use + /// - sourceRect: Optional rectangle for area selection (display points, top-left origin) + func setContentFilter(_ filter: SCContentFilter, sourceRect: CGRect? = nil) async { currentFilter = filter - await captureStaticThumbnail(for: filter) + currentSourceRect = sourceRect + await captureStaticThumbnail(for: filter, sourceRect: sourceRect) } /// Captures a single static frame as a thumbnail (no continuous streaming) - private func captureStaticThumbnail(for filter: SCContentFilter) async { + /// - Parameters: + /// - filter: The content filter to capture + /// - sourceRect: Optional rectangle for area selection (display points, top-left origin) + private func captureStaticThumbnail(for filter: SCContentFilter, sourceRect: CGRect? = nil) async { let config = SCStreamConfiguration() config.width = previewWidth config.height = previewHeight config.pixelFormat = kCVPixelFormatType_32BGRA config.showsCursor = true + if let sourceRect { + config.sourceRect = sourceRect + } + do { let image = try await SCScreenshotManager.captureImage( contentFilter: filter, @@ -98,28 +109,6 @@ final class PreviewService: NSObject { await stopStream() } - /// Starts or updates the preview stream for the given content filter - /// - Parameter filter: The content filter to capture - func captureSnapshot(for filter: SCContentFilter) async { - currentFilter = filter - - // If already streaming, update the filter - if let stream, isCapturing { - do { - try await stream.updateContentFilter(filter) - logger.info("Updated preview stream filter") - } catch { - logger.error("Failed to update preview filter: \(error.localizedDescription)") - await stopStream() - await startStream(with: filter) - } - return - } - - // Otherwise start a new stream - await startStream(with: filter) - } - /// Starts the preview stream private func startStream(with filter: SCContentFilter) async { guard !isCapturing else { return } @@ -198,6 +187,11 @@ final class PreviewService: NSObject { // Show cursor in preview config.showsCursor = true + // Apply source rect for area selection + if let sourceRect = currentSourceRect { + config.sourceRect = sourceRect + } + return config } diff --git a/BetterCapture/View/AreaSelectionOverlay.swift b/BetterCapture/View/AreaSelectionOverlay.swift new file mode 100644 index 0000000..f1f304f --- /dev/null +++ b/BetterCapture/View/AreaSelectionOverlay.swift @@ -0,0 +1,764 @@ +// +// AreaSelectionOverlay.swift +// BetterCapture +// +// Created by Joshua Sattler on 11.02.26. +// + +import AppKit +import OSLog + +/// Result of an area selection operation +struct AreaSelectionResult: Sendable { + /// The selected rectangle in screen points (NSScreen coordinate space, origin bottom-left) + let screenRect: CGRect + /// The NSScreen on which the selection was made + let screen: NSScreen +} + +// MARK: - AreaSelectionPanel + +/// A borderless, transparent panel that covers a display for rectangle drawing +final class AreaSelectionPanel: NSPanel { + + init(screen: NSScreen) { + super.init( + contentRect: screen.frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + isOpaque = false + backgroundColor = .clear + hasShadow = false + level = .screenSaver + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + ignoresMouseEvents = false + acceptsMouseMovedEvents = true + isReleasedWhenClosed = false + } + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } +} + +// MARK: - AreaSelectionOverlay + +/// Manages the area selection overlay for drawing a capture rectangle on screen +@MainActor +final class AreaSelectionOverlay { + + // MARK: - Properties + + private var panels: [AreaSelectionPanel] = [] + private var overlayViews: [AreaSelectionView] = [] + private var continuation: CheckedContinuation? + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "BetterCapture", + category: "AreaSelectionOverlay" + ) + + // MARK: - Public Methods + + /// Presents the area selection overlay on all connected displays + /// - Returns: The selected area result, or nil if cancelled + func present() async -> AreaSelectionResult? { + let screens = NSScreen.screens + guard !screens.isEmpty else { + logger.error("No screens available") + return nil + } + + logger.info("Presenting area selection on \(screens.count) screen(s)") + + return await withCheckedContinuation { continuation in + self.continuation = continuation + + for screen in screens { + let panel = AreaSelectionPanel(screen: screen) + + let overlayView = AreaSelectionView( + frame: NSRect(origin: .zero, size: screen.frame.size), + screen: screen + ) + overlayView.delegate = self + + panel.contentView = overlayView + panel.makeKeyAndOrderFront(nil) + + panels.append(panel) + overlayViews.append(overlayView) + } + + // Ensure the panels capture all events + NSApp.activate(ignoringOtherApps: true) + } + } + + // MARK: - Private Methods + + private func dismiss() { + for panel in panels { + panel.orderOut(nil) + panel.close() + } + panels.removeAll() + overlayViews.removeAll() + NSCursor.arrow.set() + } + + /// Clears the selection on all overlay views except the given one + private func clearOtherViews(except activeView: AreaSelectionView) { + for view in overlayViews where view !== activeView { + view.resetSelection() + } + } +} + +// MARK: - AreaSelectionViewDelegate + +extension AreaSelectionOverlay: AreaSelectionViewDelegate { + + func areaSelectionView(_ view: AreaSelectionView, didConfirmSelection rect: CGRect, on screen: NSScreen) { + logger.info("Area selected: \(rect.origin.x),\(rect.origin.y) \(rect.width)x\(rect.height)") + + let result = AreaSelectionResult(screenRect: rect, screen: screen) + dismiss() + continuation?.resume(returning: result) + continuation = nil + } + + func areaSelectionViewDidCancel(_ view: AreaSelectionView) { + logger.info("Area selection cancelled") + dismiss() + continuation?.resume(returning: nil) + continuation = nil + } + + func areaSelectionViewDidBeginDrawing(_ view: AreaSelectionView) { + clearOtherViews(except: view) + } +} + +// MARK: - AreaSelectionViewDelegate Protocol + +@MainActor +protocol AreaSelectionViewDelegate: AnyObject { + func areaSelectionView(_ view: AreaSelectionView, didConfirmSelection rect: CGRect, on screen: NSScreen) + func areaSelectionViewDidCancel(_ view: AreaSelectionView) + func areaSelectionViewDidBeginDrawing(_ view: AreaSelectionView) +} + +// MARK: - Interaction State + +private enum InteractionState { + case idle + case drawing(origin: CGPoint) + case adjusting + case moving(offset: CGPoint) + case resizing(handle: ResizeHandle) +} + +private enum ResizeHandle { + case topLeft, top, topRight + case left, right + case bottomLeft, bottom, bottomRight +} + +// MARK: - AreaSelectionView + +/// The NSView that handles drawing the overlay, selection rectangle, and user interaction +@MainActor +final class AreaSelectionView: NSView { + + // MARK: - Properties + + weak var delegate: AreaSelectionViewDelegate? + + private let screen: NSScreen + private var selectionRect: CGRect = .zero + private var interactionState: InteractionState = .idle + private var trackingArea: NSTrackingArea? + + /// Whether the dimmed overlay should be shown (only after user starts drawing) + private var showOverlay = false + + /// Minimum selection size in points + private let minimumSize: CGFloat = 24 + + /// Size of resize handles in points + private let handleSize: CGFloat = 8 + + /// Margin around handles for hit testing + private let handleHitMargin: CGFloat = 8 + + /// Overlay dimming opacity + private let dimmingOpacity: CGFloat = 0.5 + + /// Confirm and cancel buttons shown during adjusting state + private var confirmButton: NSButton? + private var cancelButton: NSButton? + private var buttonContainer: NSView? + + // MARK: - Initialization + + init(frame: NSRect, screen: NSScreen) { + self.screen = screen + super.init(frame: frame) + setupTrackingArea() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public Methods + + /// Resets the selection, called by the overlay coordinator to clear other screens + func resetSelection() { + selectionRect = .zero + interactionState = .idle + showOverlay = false + hideActionButtons() + needsDisplay = true + } + + // MARK: - View Lifecycle + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { + removeTrackingArea(trackingArea) + } + setupTrackingArea() + } + + private func setupTrackingArea() { + let area = NSTrackingArea( + rect: bounds, + options: [.mouseMoved, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + trackingArea = area + } + + // MARK: - Key Events + + override var acceptsFirstResponder: Bool { true } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override func keyDown(with event: NSEvent) { + switch event.keyCode { + case 53: // Escape + delegate?.areaSelectionViewDidCancel(self) + case 36, 76: // Enter / Return + confirmSelectionIfValid() + default: + super.keyDown(with: event) + } + } + + // MARK: - Mouse Events + + override func mouseDown(with event: NSEvent) { + // Ensure this panel becomes key so keyboard events route here + window?.makeKey() + + let point = convert(event.locationInWindow, from: nil) + + // Double-click inside selection to confirm + if event.clickCount == 2, case .adjusting = interactionState, selectionRect.contains(point) { + confirmSelectionIfValid() + return + } + + switch interactionState { + case .adjusting: + // Check if clicking on a resize handle first (highest priority) + if let handle = resizeHandle(at: point) { + hideActionButtons() + interactionState = .resizing(handle: handle) + } + // Check if clicking inside the selection (to move it) + else if selectionRect.contains(point) { + hideActionButtons() + let offset = CGPoint( + x: point.x - selectionRect.origin.x, + y: point.y - selectionRect.origin.y + ) + interactionState = .moving(offset: offset) + } + // Clicking outside the selection starts a new one + else { + hideActionButtons() + beginDrawing(at: point) + } + + case .idle: + beginDrawing(at: point) + + default: + break + } + + needsDisplay = true + } + + override func mouseDragged(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + let clampedPoint = clampToView(point) + + switch interactionState { + case .drawing(let origin): + selectionRect = rectFrom(origin, to: clampedPoint) + + case .moving(let offset): + var newOrigin = CGPoint( + x: clampedPoint.x - offset.x, + y: clampedPoint.y - offset.y + ) + // Clamp to view bounds + newOrigin.x = max(0, min(newOrigin.x, bounds.width - selectionRect.width)) + newOrigin.y = max(0, min(newOrigin.y, bounds.height - selectionRect.height)) + selectionRect.origin = newOrigin + + case .resizing(let handle): + applyResize(handle: handle, to: clampedPoint) + + default: + break + } + + needsDisplay = true + } + + override func mouseUp(with event: NSEvent) { + switch interactionState { + case .drawing: + if selectionRect.width >= minimumSize && selectionRect.height >= minimumSize { + interactionState = .adjusting + showActionButtons() + } else { + // Selection too small, reset + selectionRect = .zero + interactionState = .idle + showOverlay = false + } + + case .moving: + interactionState = .adjusting + showActionButtons() + + case .resizing: + enforceMinimumSize() + interactionState = .adjusting + showActionButtons() + + default: + break + } + + needsDisplay = true + } + + override func mouseMoved(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + updateCursor(at: point) + } + + // MARK: - Drawing + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + guard let context = NSGraphicsContext.current?.cgContext else { return } + + // Only draw the dimmed overlay after the user starts drawing + guard showOverlay else { return } + + // Draw dimmed overlay + context.setFillColor(NSColor.black.withAlphaComponent(dimmingOpacity).cgColor) + context.fill(bounds) + + guard selectionRect.width > 0 && selectionRect.height > 0 else { return } + + // Clear the selection area (make it transparent to show the screen content) + context.setBlendMode(.clear) + context.fill(selectionRect) + context.setBlendMode(.normal) + + // Draw dashed selection border + context.setStrokeColor(NSColor.white.cgColor) + context.setLineWidth(1.5) + context.setLineDash(phase: 0, lengths: [6, 4]) + context.stroke(selectionRect) + + // Reset dash pattern for other drawing + context.setLineDash(phase: 0, lengths: []) + + // Draw resize handles if adjusting + if case .adjusting = interactionState { + drawResizeHandles(in: context) + drawDimensionLabel(in: context) + } + + // Draw dimension label while drawing + if case .drawing = interactionState { + drawDimensionLabel(in: context) + } + } + + private func drawResizeHandles(in context: CGContext) { + let handles = allHandleRects() + context.setFillColor(NSColor.white.cgColor) + context.setStrokeColor(NSColor.gray.withAlphaComponent(0.5).cgColor) + context.setLineWidth(0.5) + + for rect in handles.values { + let path = CGPath(ellipseIn: rect, transform: nil) + context.addPath(path) + context.drawPath(using: .fillStroke) + } + } + + private func drawDimensionLabel(in context: CGContext) { + let scale = screen.backingScaleFactor + let pixelWidth = selectionRect.width * scale + let pixelHeight = selectionRect.height * scale + + // Snap to even pixel counts (matches the formula used by RecorderViewModel) + let evenWidth = Int(ceil(pixelWidth / 2) * 2) + let evenHeight = Int(ceil(pixelHeight / 2) * 2) + + let text = "\(evenWidth) x \(evenHeight)" + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium), + .foregroundColor: NSColor.white + ] + let attributedString = NSAttributedString(string: text, attributes: attributes) + let size = attributedString.size() + + let padding: CGFloat = 6 + let backgroundRect = CGRect( + x: selectionRect.midX - (size.width + padding * 2) / 2, + y: selectionRect.minY - size.height - padding * 2 - 8, + width: size.width + padding * 2, + height: size.height + padding * 2 + ) + + // Ensure label stays within view bounds + var adjustedRect = backgroundRect + if adjustedRect.minY < 0 { + adjustedRect.origin.y = selectionRect.maxY + 8 + } + adjustedRect.origin.x = max(4, min(adjustedRect.origin.x, bounds.width - adjustedRect.width - 4)) + + // Draw background + context.setFillColor(NSColor.black.withAlphaComponent(0.7).cgColor) + let bgPath = CGPath(roundedRect: adjustedRect, cornerWidth: 4, cornerHeight: 4, transform: nil) + context.addPath(bgPath) + context.fillPath() + + // Draw text + let textPoint = CGPoint( + x: adjustedRect.origin.x + padding, + y: adjustedRect.origin.y + padding + ) + attributedString.draw(at: textPoint) + } + + // MARK: - Action Buttons + + private func showActionButtons() { + guard buttonContainer == nil else { + updateButtonPositions() + return + } + + let container = NSView() + + let confirm = makeActionButton( + title: "Confirm", + textColor: .systemGreen, + action: #selector(confirmButtonClicked) + ) + + let cancel = makeActionButton( + title: "Cancel", + textColor: .systemRed, + action: #selector(cancelButtonClicked) + ) + + container.addSubview(confirm) + container.addSubview(cancel) + addSubview(container) + + confirm.translatesAutoresizingMaskIntoConstraints = false + cancel.translatesAutoresizingMaskIntoConstraints = false + container.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + confirm.topAnchor.constraint(equalTo: container.topAnchor), + confirm.leadingAnchor.constraint(equalTo: container.leadingAnchor), + confirm.trailingAnchor.constraint(equalTo: container.trailingAnchor), + cancel.topAnchor.constraint(equalTo: confirm.bottomAnchor, constant: 8), + cancel.leadingAnchor.constraint(equalTo: container.leadingAnchor), + cancel.trailingAnchor.constraint(equalTo: container.trailingAnchor), + cancel.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + self.confirmButton = confirm + self.cancelButton = cancel + self.buttonContainer = container + + updateButtonPositions() + } + + private func hideActionButtons() { + buttonContainer?.removeFromSuperview() + buttonContainer = nil + confirmButton = nil + cancelButton = nil + } + + private func updateButtonPositions() { + guard let container = buttonContainer else { return } + + // Let auto layout calculate the intrinsic size, then position manually + container.layoutSubtreeIfNeeded() + let fittingSize = container.fittingSize + + container.frame = CGRect( + x: selectionRect.midX - fittingSize.width / 2, + y: selectionRect.midY - fittingSize.height / 2, + width: fittingSize.width, + height: fittingSize.height + ) + } + + private func makeActionButton(title: String, textColor: NSColor, action: Selector) -> NSButton { + let button = NSButton(title: title, target: self, action: action) + button.isBordered = false + button.wantsLayer = true + button.layer?.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.8).cgColor + button.layer?.cornerRadius = 6 + button.contentTintColor = textColor + button.font = .systemFont(ofSize: 14, weight: .regular) + button.widthAnchor.constraint(greaterThanOrEqualToConstant: 120).isActive = true + button.heightAnchor.constraint(equalToConstant: 36).isActive = true + return button + } + + @objc private func confirmButtonClicked() { + confirmSelectionIfValid() + } + + @objc private func cancelButtonClicked() { + delegate?.areaSelectionViewDidCancel(self) + } + + // MARK: - Handle Calculation + + private func allHandleRects() -> [ResizeHandle: CGRect] { + let r = selectionRect + let s = handleSize + let half = s / 2 + + return [ + .topLeft: CGRect(x: r.minX - half, y: r.maxY - half, width: s, height: s), + .top: CGRect(x: r.midX - half, y: r.maxY - half, width: s, height: s), + .topRight: CGRect(x: r.maxX - half, y: r.maxY - half, width: s, height: s), + .left: CGRect(x: r.minX - half, y: r.midY - half, width: s, height: s), + .right: CGRect(x: r.maxX - half, y: r.midY - half, width: s, height: s), + .bottomLeft: CGRect(x: r.minX - half, y: r.minY - half, width: s, height: s), + .bottom: CGRect(x: r.midX - half, y: r.minY - half, width: s, height: s), + .bottomRight: CGRect(x: r.maxX - half, y: r.minY - half, width: s, height: s), + ] + } + + /// Hit-tests resize handles with priority for corners over edges + private func resizeHandle(at point: CGPoint) -> ResizeHandle? { + let handles = allHandleRects() + + // Check corners first (they should have priority over edges) + let corners: [ResizeHandle] = [.topLeft, .topRight, .bottomLeft, .bottomRight] + for handle in corners { + if let rect = handles[handle] { + let hitRect = rect.insetBy(dx: -handleHitMargin, dy: -handleHitMargin) + if hitRect.contains(point) { + return handle + } + } + } + + // Then check edges + let edges: [ResizeHandle] = [.top, .bottom, .left, .right] + for handle in edges { + if let rect = handles[handle] { + let hitRect = rect.insetBy(dx: -handleHitMargin, dy: -handleHitMargin) + if hitRect.contains(point) { + return handle + } + } + } + + return nil + } + + // MARK: - Drawing Start + + /// Begins a new drawing operation, notifying the delegate to clear other screens + private func beginDrawing(at point: CGPoint) { + interactionState = .drawing(origin: point) + selectionRect = .zero + showOverlay = true + delegate?.areaSelectionViewDidBeginDrawing(self) + } + + // MARK: - Resize Logic + + /// Applies resize for a given handle, constraining axis movement for edge handles + private func applyResize(handle: ResizeHandle, to point: CGPoint) { + var newRect = selectionRect + + switch handle { + // Corner handles: free resize from the opposite corner + case .topLeft: + newRect = rectFrom(CGPoint(x: selectionRect.maxX, y: selectionRect.minY), to: point) + case .topRight: + newRect = rectFrom(CGPoint(x: selectionRect.minX, y: selectionRect.minY), to: point) + case .bottomLeft: + newRect = rectFrom(CGPoint(x: selectionRect.maxX, y: selectionRect.maxY), to: point) + case .bottomRight: + newRect = rectFrom(CGPoint(x: selectionRect.minX, y: selectionRect.maxY), to: point) + + // Edge handles: only move the affected edge, keep perpendicular axis fixed + case .top: + let newMaxY = max(selectionRect.minY + minimumSize, point.y) + newRect = CGRect( + x: selectionRect.minX, + y: selectionRect.minY, + width: selectionRect.width, + height: newMaxY - selectionRect.minY + ) + case .bottom: + let newMinY = min(selectionRect.maxY - minimumSize, point.y) + newRect = CGRect( + x: selectionRect.minX, + y: newMinY, + width: selectionRect.width, + height: selectionRect.maxY - newMinY + ) + case .left: + let newMinX = min(selectionRect.maxX - minimumSize, point.x) + newRect = CGRect( + x: newMinX, + y: selectionRect.minY, + width: selectionRect.maxX - newMinX, + height: selectionRect.height + ) + case .right: + let newMaxX = max(selectionRect.minX + minimumSize, point.x) + newRect = CGRect( + x: selectionRect.minX, + y: selectionRect.minY, + width: newMaxX - selectionRect.minX, + height: selectionRect.height + ) + } + + selectionRect = newRect + } + + // MARK: - Cursor Management + + private func updateCursor(at point: CGPoint) { + guard case .adjusting = interactionState else { + NSCursor.crosshair.set() + return + } + + if let container = buttonContainer, container.frame.contains(point) { + NSCursor.arrow.set() + } else if let handle = resizeHandle(at: point) { + cursorForHandle(handle).set() + } else if selectionRect.contains(point) { + NSCursor.openHand.set() + } else { + NSCursor.crosshair.set() + } + } + + /// Returns the native macOS frame resize cursor for a given handle position + private func cursorForHandle(_ handle: ResizeHandle) -> NSCursor { + let directions: NSCursor.FrameResizeDirection.Set = [.inward, .outward] + + switch handle { + case .topLeft: + return .frameResize(position: .topLeft, directions: directions) + case .top: + return .frameResize(position: .top, directions: directions) + case .topRight: + return .frameResize(position: .topRight, directions: directions) + case .left: + return .frameResize(position: .left, directions: directions) + case .right: + return .frameResize(position: .right, directions: directions) + case .bottomLeft: + return .frameResize(position: .bottomLeft, directions: directions) + case .bottom: + return .frameResize(position: .bottom, directions: directions) + case .bottomRight: + return .frameResize(position: .bottomRight, directions: directions) + } + } + + // MARK: - Helpers + + private func rectFrom(_ pointA: CGPoint, to pointB: CGPoint) -> CGRect { + CGRect( + x: min(pointA.x, pointB.x), + y: min(pointA.y, pointB.y), + width: abs(pointB.x - pointA.x), + height: abs(pointB.y - pointA.y) + ) + } + + private func clampToView(_ point: CGPoint) -> CGPoint { + CGPoint( + x: max(0, min(point.x, bounds.width)), + y: max(0, min(point.y, bounds.height)) + ) + } + + private func enforceMinimumSize() { + if selectionRect.width < minimumSize { + selectionRect.size.width = minimumSize + } + if selectionRect.height < minimumSize { + selectionRect.size.height = minimumSize + } + } + + private func confirmSelectionIfValid() { + guard selectionRect.width >= minimumSize && selectionRect.height >= minimumSize else { return } + + // Convert from view coordinates to screen coordinates + // The view fills the panel, which covers the screen frame + let screenOrigin = screen.frame.origin + let screenRect = CGRect( + x: screenOrigin.x + selectionRect.origin.x, + y: screenOrigin.y + selectionRect.origin.y, + width: selectionRect.width, + height: selectionRect.height + ) + + delegate?.areaSelectionView(self, didConfirmSelection: screenRect, on: screen) + } +} diff --git a/BetterCapture/View/MenuBarView.swift b/BetterCapture/View/MenuBarView.swift index fc83838..03d9586 100644 --- a/BetterCapture/View/MenuBarView.swift +++ b/BetterCapture/View/MenuBarView.swift @@ -12,6 +12,7 @@ import ScreenCaptureKit struct MenuBarView: View { @Bindable var viewModel: RecorderViewModel @Environment(\.openSettings) private var openSettings + @Environment(\.dismiss) private var dismiss @State private var currentPreview: NSImage? var body: some View { @@ -46,6 +47,7 @@ struct MenuBarView: View { accentColor: .green, isDisabled: !viewModel.canStartRecording ) { + dismiss() Task { await viewModel.startRecording() } @@ -55,7 +57,7 @@ struct MenuBarView: View { MenuBarDivider() // Content Selection - ContentSharingPickerButton(viewModel: viewModel) + ContentSelectionButton(viewModel: viewModel, onDismissPanel: { dismiss() }) // Preview thumbnail below the content selection button if viewModel.hasContentSelected { @@ -79,6 +81,21 @@ struct MenuBarView: View { .onAppear { currentPreview = viewModel.previewService.previewImage } + + Button { + Task { + await viewModel.resetAreaSelection() + } + } label: { + Text("Reset Selection") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.red) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(.gray.opacity(0.15), in: .rect(cornerRadius: 6)) + } + .buttonStyle(.plain) + .padding(.horizontal, 12) } MenuBarDivider() @@ -228,46 +245,154 @@ struct RecordingButton: View { } } -// MARK: - Content Sharing Picker Button +// MARK: - Content Selection Mode + +/// The mode for content selection: picking content via the system picker, or drawing a screen area +enum ContentSelectionMode: String { + case pickContent + case selectArea -/// A button that presents the system content sharing picker with hover effect -struct ContentSharingPickerButton: View { + var label: String { + switch self { + case .pickContent: "Pick Content" + case .selectArea: "Select Area" + } + } + + var icon: String { + switch self { + case .pickContent: "macwindow" + case .selectArea: "rectangle.dashed" + } + } +} + +// MARK: - Content Selection Button + +/// A split button that triggers the active content selection mode, with a dropdown chevron to switch modes. +/// The left portion triggers the action; the right chevron opens a dropdown to change the mode. +/// Styled consistently with other menu bar rows. +struct ContentSelectionButton: View { let viewModel: RecorderViewModel - @State private var isHovered = false + var onDismissPanel: (() -> Void)? + @AppStorage("contentSelectionMode") private var mode: ContentSelectionMode = .pickContent + @State private var isDropdownExpanded = false + @State private var isMainHovered = false + @State private var isChevronHovered = false + + /// Whether content has been selected via the currently active mode + private var hasActiveSelection: Bool { + switch mode { + case .pickContent: + viewModel.hasContentSelected && !viewModel.isAreaSelection + case .selectArea: + viewModel.isAreaSelection + } + } + + private var buttonLabel: String { + hasActiveSelection ? "Change \(mode.label.split(separator: " ").last, default: "Content")..." : "\(mode.label)..." + } var body: some View { - Button { - viewModel.presentPicker() - } label: { - HStack(spacing: 12) { - ZStack { - Circle() - .fill(viewModel.hasContentSelected ? .blue.opacity(0.8) : .gray.opacity(0.2)) - .frame(width: 24, height: 24) + VStack(spacing: 0) { + // Main button row + HStack(spacing: 0) { + // Left: action button + Button { + triggerAction() + } label: { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(hasActiveSelection ? .blue.opacity(0.8) : .gray.opacity(0.2)) + .frame(width: 24, height: 24) + + Image(systemName: mode.icon) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(hasActiveSelection ? .white : .primary) + } - Image(systemName: "rectangle.dashed") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(viewModel.hasContentSelected ? .white : .primary) + Text(buttonLabel) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + + Spacer() + } + .padding(.leading, 12) + .padding(.vertical, 4) + .contentShape(.rect) + } + .buttonStyle(.plain) + .onHover { hovering in + isMainHovered = hovering } - Text(viewModel.hasContentSelected ? "Change Selection..." : "Select Content...") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.primary) + // Right: chevron dropdown toggle + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isDropdownExpanded.toggle() + } + } label: { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(isDropdownExpanded ? 90 : 0)) + .frame(width: 28, height: 28) + .contentShape(.rect) + } + .buttonStyle(.plain) + .padding(.trailing, 12) + .onHover { hovering in + isChevronHovered = hovering + } + } + .background( + RoundedRectangle(cornerRadius: 4) + .fill((isMainHovered || isChevronHovered) ? .gray.opacity(0.1) : .clear) + .padding(.horizontal, 4) + ) - Spacer() + // Dropdown options + if isDropdownExpanded { + VStack(spacing: 0) { + DeviceRow( + name: ContentSelectionMode.pickContent.label, + icon: ContentSelectionMode.pickContent.icon, + isSelected: mode == .pickContent + ) { + mode = .pickContent + withAnimation(.easeInOut(duration: 0.2)) { + isDropdownExpanded = false + } + } + + DeviceRow( + name: ContentSelectionMode.selectArea.label, + icon: ContentSelectionMode.selectArea.icon, + isSelected: mode == .selectArea + ) { + mode = .selectArea + withAnimation(.easeInOut(duration: 0.2)) { + isDropdownExpanded = false + } + } + } + .padding(.leading, 12) + .background(.quaternary.opacity(0.3)) } - .padding(.horizontal, 12) - .padding(.vertical, 4) - .contentShape(.rect) } - .buttonStyle(.plain) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(isHovered ? .gray.opacity(0.1) : .clear) - .padding(.horizontal, 4) - ) - .onHover { hovering in - isHovered = hovering + } + + private func triggerAction() { + switch mode { + case .pickContent: + viewModel.presentPicker() + case .selectArea: + onDismissPanel?() + Task { + await viewModel.presentAreaSelection() + } } } } diff --git a/BetterCapture/View/SelectionBorderFrame.swift b/BetterCapture/View/SelectionBorderFrame.swift new file mode 100644 index 0000000..6cb0b90 --- /dev/null +++ b/BetterCapture/View/SelectionBorderFrame.swift @@ -0,0 +1,78 @@ +// +// SelectionBorderFrame.swift +// BetterCapture +// +// Created by Joshua Sattler on 13.02.26. +// + +import AppKit + +/// A lightweight, click-through panel that draws a dashed border around the +/// selected recording area while a recording is in progress. +@MainActor +final class SelectionBorderFrame { + + private var panel: NSPanel? + + /// Shows the dashed border frame at the given screen-coordinate rect. + /// - Parameter screenRect: Rectangle in NSScreen coordinates (bottom-left origin) + func show(screenRect: CGRect) { + dismiss() + + let expansion: CGFloat = 8 + let expandedRect = screenRect.insetBy(dx: -expansion, dy: -expansion) + + let panel = NSPanel( + contentRect: expandedRect, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.level = .screenSaver + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.ignoresMouseEvents = true + panel.isReleasedWhenClosed = false + + let borderView = SelectionBorderView(frame: NSRect(origin: .zero, size: expandedRect.size)) + panel.contentView = borderView + panel.orderFront(nil) + + self.panel = panel + } + + /// Removes the border frame from screen. + func dismiss() { + panel?.orderOut(nil) + panel?.close() + panel = nil + } +} + +// MARK: - SelectionBorderView + +/// Draws only a dashed rectangular border, nothing else. +private final class SelectionBorderView: NSView { + + private let expansion: CGFloat = 8 + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + + let borderRect = bounds.insetBy(dx: expansion - 2, dy: expansion - 2) + + // Black outline underneath for contrast on light backgrounds + context.setStrokeColor(NSColor.black.withAlphaComponent(0.6).cgColor) + context.setLineWidth(2) + context.stroke(borderRect) + + // White dashed stroke on top + context.setStrokeColor(NSColor.white.cgColor) + context.setLineWidth(1) + context.setLineDash(phase: 0, lengths: [6, 4]) + context.stroke(borderRect) + } +} diff --git a/BetterCapture/ViewModel/RecorderViewModel.swift b/BetterCapture/ViewModel/RecorderViewModel.swift index 7d5edd1..e07d04f 100644 --- a/BetterCapture/ViewModel/RecorderViewModel.swift +++ b/BetterCapture/ViewModel/RecorderViewModel.swift @@ -30,6 +30,17 @@ final class RecorderViewModel { private(set) var lastError: Error? private(set) var selectedContentFilter: SCContentFilter? + /// The source rectangle for area selection (in display points, top-left origin) + private(set) var selectedSourceRect: CGRect? + + /// The selected area in screen coordinates (bottom-left origin), used for the border frame overlay + private var selectedScreenRect: CGRect? + + /// Whether the current selection is an area selection (as opposed to a picker selection) + var isAreaSelection: Bool { + selectedSourceRect != nil + } + var isRecording: Bool { state == .recording } @@ -71,6 +82,8 @@ final class RecorderViewModel { private var recordingTimer: Timer? private var recordingStartTime: Date? private var videoSize: CGSize = .zero + private let areaSelectionOverlay = AreaSelectionOverlay() + private let selectionBorderFrame = SelectionBorderFrame() // MARK: - Initialization @@ -108,6 +121,77 @@ final class RecorderViewModel { captureEngine.presentPicker() } + /// Presents the area selection overlay on the display under the cursor + func presentAreaSelection() async { + // Dismiss any existing border frame so it doesn't overlap the selection overlay + selectionBorderFrame.dismiss() + + guard let result = await areaSelectionOverlay.present() else { + logger.info("Area selection cancelled") + return + } + + // Show the border frame immediately so the user sees the selection outline + selectionBorderFrame.show(screenRect: result.screenRect) + + // Find the corresponding SCDisplay for the selected screen + do { + let content = try await SCShareableContent.current + let screenNumber = result.screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID + + guard let display = content.displays.first(where: { $0.displayID == screenNumber }) else { + logger.error("Could not find SCDisplay for selected screen") + return + } + + // Create a content filter for the full display + let filter = SCContentFilter(display: display, excludingWindows: []) + + // Convert screen rect (NSScreen coordinates, bottom-left origin) to + // sourceRect (display coordinates, top-left origin) + let displayHeight = CGFloat(display.height) + let screenOrigin = result.screen.frame.origin + + let localX = result.screenRect.origin.x - screenOrigin.x + let localY = result.screenRect.origin.y - screenOrigin.y + + // Flip Y: NSScreen has origin at bottom-left, sourceRect uses top-left + let flippedY = displayHeight - localY - result.screenRect.height + + // Snap dimensions to even pixel counts for codec compatibility + let scale = result.screen.backingScaleFactor + let pixelWidth = result.screenRect.width * scale + let pixelHeight = result.screenRect.height * scale + let evenPixelWidth = ceil(pixelWidth / 2) * 2 + let evenPixelHeight = ceil(pixelHeight / 2) * 2 + + let sourceRect = CGRect( + x: localX, + y: flippedY, + width: evenPixelWidth / scale, + height: evenPixelHeight / scale + ) + + // Clear any existing picker selection (mutually exclusive) + captureEngine.clearSelection() + + // Store the area selection and set the filter on the capture engine + selectedSourceRect = sourceRect + selectedScreenRect = result.screenRect + selectedContentFilter = filter + try await captureEngine.updateFilter(filter) + + logger.info("Area selected: sourceRect=\(sourceRect.debugDescription), display=\(display.displayID)") + + // Update preview with the display filter and source rect + await previewService.setContentFilter(filter, sourceRect: sourceRect) + + } catch { + selectionBorderFrame.dismiss() + logger.error("Failed to get shareable content for area selection: \(error.localizedDescription)") + } + } + /// Starts a new recording session func startRecording() async { guard canStartRecording else { @@ -127,7 +211,7 @@ final class RecorderViewModel { logger.info("Live preview stopped") // Determine video size from filter - if let filter = captureEngine.contentFilter { + if let filter = selectedContentFilter { videoSize = await getContentSize(from: filter) } logger.info("Video size: \(self.videoSize.width)x\(self.videoSize.height)") @@ -140,7 +224,7 @@ final class RecorderViewModel { // Start capture with the calculated video size logger.info("Starting capture engine...") - try await captureEngine.startCapture(with: settings, videoSize: videoSize) + try await captureEngine.startCapture(with: settings, videoSize: videoSize, sourceRect: selectedSourceRect) // Start timer startTimer() @@ -150,6 +234,7 @@ final class RecorderViewModel { } catch { state = .idle lastError = error + selectionBorderFrame.dismiss() logger.error("Failed to start recording: \(error.localizedDescription)") } } @@ -160,6 +245,7 @@ final class RecorderViewModel { state = .stopping stopTimer() + selectionBorderFrame.dismiss() do { // Stop capture first @@ -193,6 +279,16 @@ final class RecorderViewModel { captureEngine.clearSelection() } + /// Resets the area selection, removing the border frame and clearing state + func resetAreaSelection() async { + selectedSourceRect = nil + selectedScreenRect = nil + selectedContentFilter = nil + selectionBorderFrame.dismiss() + await previewService.stopPreview() + previewService.clearPreview() + } + /// Starts the live preview stream (call when menu bar window opens) func startPreview() async { guard !isRecording else { return } @@ -227,6 +323,13 @@ final class RecorderViewModel { // MARK: - Helper Methods private func getContentSize(from filter: SCContentFilter) async -> CGSize { + // If area selection is active, use the source rect dimensions. + // The sourceRect is already snapped to even pixel counts in presentAreaSelection(). + if let sourceRect = selectedSourceRect { + let scale = CGFloat(filter.pointPixelScale) + return CGSize(width: sourceRect.width * scale, height: sourceRect.height * scale) + } + // Get the content rect from the filter let rect = filter.contentRect let scale = CGFloat(filter.pointPixelScale) @@ -255,6 +358,11 @@ final class RecorderViewModel { extension RecorderViewModel: CaptureEngineDelegate { func captureEngine(_ engine: CaptureEngine, didUpdateFilter filter: SCContentFilter) { + // Clear any area selection (picker and area selections are mutually exclusive) + selectedSourceRect = nil + selectedScreenRect = nil + selectionBorderFrame.dismiss() + selectedContentFilter = filter logger.info("Content filter updated") diff --git a/README.md b/README.md index 2df87a2..71a42d9 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

- Website · + Website · Installation · Features · Contributing diff --git a/docs/specs/0000-template.md b/docs/specs/0000-template.md new file mode 100644 index 0000000..a269fb1 --- /dev/null +++ b/docs/specs/0000-template.md @@ -0,0 +1,19 @@ +# Title + +> One-line summary of the change. + +## Why + +What problem does this solve or what opportunity does it unlock? + +## Expected outcome + +What should be true when this is done? Describe the observable result from the user's or developer's perspective. + +## Approach + +Brief description of how to achieve this. Bullet points are fine. + +## Open questions + +- List anything unresolved here. Remove this section if there are none. diff --git a/docs/specs/0001-area-selection.md b/docs/specs/0001-area-selection.md new file mode 100644 index 0000000..b4893b0 --- /dev/null +++ b/docs/specs/0001-area-selection.md @@ -0,0 +1,95 @@ +# Area Selection Recording + +> Allow users to draw a rectangle on a display and record only that region. + +## Why + +Currently BetterCapture relies on `SCContentSharingPicker` which limits content selection to full displays, windows, or applications. Users often need to record a specific region of the screen (e.g., a portion of a webpage, a panel in an IDE, or a UI demo area) without capturing the entire display or window. This feature fills that gap. + +## Expected outcome + +- A new "Select Area" button appears alongside the existing "Select Content" button in the menu bar popover. +- Clicking "Select Area" shows a translucent overlay on the display under the mouse cursor. +- The user draws a rectangle by clicking and dragging on the overlay. +- After drawing, the user can reposition (drag) or resize (drag corner/edge handles) the rectangle before confirming. +- Pressing Enter or clicking a confirm button accepts the selection; pressing Escape cancels it. +- Once confirmed, the menu bar popover shows a preview thumbnail of the selected area and the "Start Recording" button becomes enabled. +- Recording captures only the selected rectangle of the display. +- The overlay disappears once the selection is confirmed (no visible border during recording). + +## Approach + +### Key API: `SCStreamConfiguration.sourceRect` + +The `sourceRect` property on `SCStreamConfiguration` specifies a sub-region of the display to capture. Important constraints from Apple's documentation: + +- `sourceRect` only works with **display captures** (not window or application captures). +- Coordinates are in the display's native coordinate space (points). +- If `sourceRect` is not set, the full display is captured. + +### UI changes + +1. **MenuBarView** - Add a "Select Area" button next to the existing "Select Content" button. When an area selection is active, show "Change Area" instead. + - Update the existing "Select Content" button icon from `rectangle.dashed` to `macwindow` (an application-style icon, since it selects windows/apps/displays via the system picker). + - The new "Select Area" button uses `rectangle.dashed` (the current icon, which visually represents drawing a rectangle region). +2. **AreaSelectionOverlay** - A new `NSWindow`/`NSPanel` subclass (borderless, transparent, full-screen on the target display) that: + - Dims the screen with a translucent overlay. + - Lets the user draw a rectangle via click-and-drag. + - Shows resize handles (corners + edges) and allows repositioning after the initial draw. + - Displays the pixel dimensions of the selection as a label near the rectangle. + - Confirms on Enter / double-click, cancels on Escape. + - Uses `NSWindow.Level.screenSaver` (or similar) to float above all content. +3. **PreviewThumbnailView** - No changes needed; the preview service will receive the display filter and `sourceRect` will crop the preview automatically. + +### Model changes + +4. **RecorderViewModel** - Add: + - `selectedSourceRect: CGRect?` property to store the user's area selection. + - `presentAreaSelection()` method that determines the display under the cursor (via `NSEvent.mouseLocation` and `NSScreen.screens`), creates the overlay window, and awaits the result. + - `clearAreaSelection()` method that resets `selectedSourceRect` and clears the associated display filter. + - When an area is confirmed, create an `SCContentFilter` for the detected display and store `selectedSourceRect`. + +### Service changes + +5. **CaptureEngine** - Modify `createStreamConfiguration(from:contentSize:)` to accept an optional `sourceRect: CGRect?` parameter. When provided, set `config.sourceRect` and adjust `config.width`/`config.height` to match the source rect dimensions (scaled by the display's `backingScaleFactor`). +6. **CaptureEngine** - Modify `startCapture(with:videoSize:)` to accept the optional `sourceRect` and pass it to `createStreamConfiguration`. +7. **PreviewService** - Modify `createPreviewConfiguration()` and `captureStaticThumbnail(for:)` to accept an optional `sourceRect: CGRect?` and apply it to the `SCStreamConfiguration` so the preview shows only the selected area. +8. **RecorderViewModel** - Update `getContentSize(from:)` to use `selectedSourceRect` dimensions (scaled) when an area selection is active, instead of the full display `contentRect`. +9. **RecorderViewModel** - Update `startRecording()` to pass `selectedSourceRect` through to the capture engine. + +### Display detection + +10. When the user clicks "Select Area": + - Read `NSEvent.mouseLocation` (in global screen coordinates). + - Find the matching `NSScreen` from `NSScreen.screens` using `frame.contains()`. + - Query `SCShareableContent.current` for the corresponding `SCDisplay` (match by `displayID` from `screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")]`). + - Create the overlay on that screen and, upon confirmation, build an `SCContentFilter(display:excludingWindows: [])` for that display. + +### Coordinate mapping + +11. The overlay captures the rectangle in the `NSScreen` coordinate space (points, origin at bottom-left). `sourceRect` uses the display's coordinate space (points, origin at top-left). The conversion is: + - `sourceRect.origin.x` = overlay rect origin x relative to the display. + - `sourceRect.origin.y` = display height - overlay rect origin y - overlay rect height (flip Y axis). + - Width and height remain the same. + +### Content filter interaction + +12. Area selection and content picker selection are mutually exclusive: + - Selecting an area clears any existing `SCContentSharingPicker` selection. + - Using `SCContentSharingPicker` clears any existing area selection. + - The UI reflects which mode is active. + +## File changes + +| File | Change | +|------|--------| +| `View/MenuBarView.swift` | Add "Select Area" / "Change Area" button | +| `View/AreaSelectionOverlay.swift` | **New file** - NSPanel subclass for the rectangle drawing overlay | +| `ViewModel/RecorderViewModel.swift` | Add `selectedSourceRect`, `presentAreaSelection()`, `clearAreaSelection()`, update `startRecording()` and `getContentSize()` | +| `Service/CaptureEngine.swift` | Accept `sourceRect` in `startCapture` and `createStreamConfiguration` | +| `Service/PreviewService.swift` | Accept `sourceRect` in preview configuration methods | + +## Constraints + +- Minimum selection size is 24x24 points to prevent accidental tiny selections. +- Selection dimensions are snapped to even pixel counts to avoid codec issues with odd dimensions.