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.