From a11aaa01afbb93697e39c09686fff092c07d8437 Mon Sep 17 00:00:00 2001 From: Joshua Sattler <34030048+jsattler@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:22:21 +0100 Subject: [PATCH 01/11] feat: initial draft for user selected area --- BetterCapture/Service/CaptureEngine.swift | 17 +- BetterCapture/Service/PreviewService.swift | 24 +- BetterCapture/View/AreaSelectionOverlay.swift | 688 ++++++++++++++++++ BetterCapture/View/MenuBarView.swift | 57 +- .../ViewModel/RecorderViewModel.swift | 100 ++- 5 files changed, 874 insertions(+), 12 deletions(-) create mode 100644 BetterCapture/View/AreaSelectionOverlay.swift 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..a9a8ae7 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, @@ -198,6 +209,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..fa50529 --- /dev/null +++ b/BetterCapture/View/AreaSelectionOverlay.swift @@ -0,0 +1,688 @@ +// +// 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 { + + /// The screen this panel covers + let targetScreen: NSScreen + + init(screen: NSScreen) { + self.targetScreen = screen + 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() + } + + /// 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 +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 + + // 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 + 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 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) { + 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) { + interactionState = .resizing(handle: handle) + } + // Check if clicking inside the selection (to move it) + else if selectionRect.contains(point) { + 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 { + 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: + enforceMinimumSize() + if selectionRect.width >= minimumSize && selectionRect.height >= minimumSize { + interactionState = .adjusting + } else { + // Selection too small, reset + selectionRect = .zero + interactionState = .idle + showOverlay = false + } + + case .moving: + interactionState = .adjusting + + case .resizing: + enforceMinimumSize() + interactionState = .adjusting + + 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) + drawConfirmHint(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 = Int(selectionRect.width * scale) + let pixelHeight = Int(selectionRect.height * scale) + + // Snap to even for display + let evenWidth = pixelWidth % 2 == 0 ? pixelWidth : pixelWidth + 1 + let evenHeight = pixelHeight % 2 == 0 ? pixelHeight : pixelHeight + 1 + + 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) + } + + private func drawConfirmHint(in context: CGContext) { + let text = "Press Enter to confirm, Escape to cancel" + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 12, weight: .regular), + .foregroundColor: NSColor.white.withAlphaComponent(0.8) + ] + let attributedString = NSAttributedString(string: text, attributes: attributes) + let size = attributedString.size() + + let padding: CGFloat = 8 + let backgroundRect = CGRect( + x: (bounds.width - size.width - padding * 2) / 2, + y: 40, + width: size.width + padding * 2, + height: size.height + padding * 2 + ) + + context.setFillColor(NSColor.black.withAlphaComponent(0.6).cgColor) + let bgPath = CGPath(roundedRect: backgroundRect, cornerWidth: 4, cornerHeight: 4, transform: nil) + context.addPath(bgPath) + context.fillPath() + + let textPoint = CGPoint( + x: backgroundRect.origin.x + padding, + y: backgroundRect.origin.y + padding + ) + attributedString.draw(at: textPoint) + } + + // 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 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..6aa5136 100644 --- a/BetterCapture/View/MenuBarView.swift +++ b/BetterCapture/View/MenuBarView.swift @@ -56,6 +56,7 @@ struct MenuBarView: View { // Content Selection ContentSharingPickerButton(viewModel: viewModel) + AreaSelectionButton(viewModel: viewModel) // Preview thumbnail below the content selection button if viewModel.hasContentSelected { @@ -235,6 +236,10 @@ struct ContentSharingPickerButton: View { let viewModel: RecorderViewModel @State private var isHovered = false + private var isPickerSelection: Bool { + viewModel.hasContentSelected && !viewModel.isAreaSelection + } + var body: some View { Button { viewModel.presentPicker() @@ -242,15 +247,61 @@ struct ContentSharingPickerButton: View { HStack(spacing: 12) { ZStack { Circle() - .fill(viewModel.hasContentSelected ? .blue.opacity(0.8) : .gray.opacity(0.2)) + .fill(isPickerSelection ? .blue.opacity(0.8) : .gray.opacity(0.2)) + .frame(width: 24, height: 24) + + Image(systemName: "macwindow") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(isPickerSelection ? .white : .primary) + } + + Text(isPickerSelection ? "Change Selection..." : "Select Content...") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + + Spacer() + } + .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 + } + } +} + +// MARK: - Area Selection Button + +/// A button that presents the area selection overlay for drawing a capture rectangle +struct AreaSelectionButton: View { + let viewModel: RecorderViewModel + @State private var isHovered = false + + var body: some View { + Button { + Task { + await viewModel.presentAreaSelection() + } + } label: { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(viewModel.isAreaSelection ? .blue.opacity(0.8) : .gray.opacity(0.2)) .frame(width: 24, height: 24) Image(systemName: "rectangle.dashed") .font(.system(size: 12, weight: .medium)) - .foregroundStyle(viewModel.hasContentSelected ? .white : .primary) + .foregroundStyle(viewModel.isAreaSelection ? .white : .primary) } - Text(viewModel.hasContentSelected ? "Change Selection..." : "Select Content...") + Text(viewModel.isAreaSelection ? "Change Area..." : "Select Area...") .font(.system(size: 13, weight: .medium)) .foregroundStyle(.primary) diff --git a/BetterCapture/ViewModel/RecorderViewModel.swift b/BetterCapture/ViewModel/RecorderViewModel.swift index 7d5edd1..ff7f4b0 100644 --- a/BetterCapture/ViewModel/RecorderViewModel.swift +++ b/BetterCapture/ViewModel/RecorderViewModel.swift @@ -30,6 +30,14 @@ 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? + + /// 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 +79,7 @@ final class RecorderViewModel { private var recordingTimer: Timer? private var recordingStartTime: Date? private var videoSize: CGSize = .zero + private let areaSelectionOverlay = AreaSelectionOverlay() // MARK: - Initialization @@ -108,6 +117,77 @@ final class RecorderViewModel { captureEngine.presentPicker() } + /// Presents the area selection overlay on the display under the cursor + func presentAreaSelection() async { + guard let result = await areaSelectionOverlay.present() else { + logger.info("Area selection cancelled") + return + } + + // 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 + 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 { + logger.error("Failed to get shareable content for area selection: \(error.localizedDescription)") + } + } + + /// Clears the area selection + func clearAreaSelection() { + selectedSourceRect = nil + selectedContentFilter = nil + previewService.clearPreview() + logger.info("Area selection cleared") + } + /// Starts a new recording session func startRecording() async { guard canStartRecording else { @@ -127,7 +207,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 +220,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() @@ -227,6 +307,19 @@ final class RecorderViewModel { // MARK: - Helper Methods private func getContentSize(from filter: SCContentFilter) async -> CGSize { + // If area selection is active, use the source rect dimensions + if let sourceRect = selectedSourceRect { + let scale = CGFloat(filter.pointPixelScale) + let width = sourceRect.width * scale + let height = sourceRect.height * scale + + // Snap to even pixel counts for codec compatibility + let evenWidth = ceil(width / 2) * 2 + let evenHeight = ceil(height / 2) * 2 + + return CGSize(width: evenWidth, height: evenHeight) + } + // Get the content rect from the filter let rect = filter.contentRect let scale = CGFloat(filter.pointPixelScale) @@ -255,6 +348,9 @@ 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 + selectedContentFilter = filter logger.info("Content filter updated") From be5f3f63cdb6122c849edd33673e01982c8856da Mon Sep 17 00:00:00 2001 From: Joshua Sattler <34030048+jsattler@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:23:11 +0100 Subject: [PATCH 02/11] docs: rename to uppercase --- docs/{architecture.md => ARCHITECTURE.md} | 0 docs/{compatibility.md => COMPATIBILITY.md} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename docs/{architecture.md => ARCHITECTURE.md} (100%) rename docs/{compatibility.md => COMPATIBILITY.md} (100%) diff --git a/docs/architecture.md b/docs/ARCHITECTURE.md similarity index 100% rename from docs/architecture.md rename to docs/ARCHITECTURE.md diff --git a/docs/compatibility.md b/docs/COMPATIBILITY.md similarity index 100% rename from docs/compatibility.md rename to docs/COMPATIBILITY.md From b9f1cb27e6bd5b07eaa6ea98a392153f70295ead Mon Sep 17 00:00:00 2001 From: Joshua Sattler <34030048+jsattler@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:08:39 +0100 Subject: [PATCH 03/11] feat: improve ux for area selection --- BetterCapture/View/AreaSelectionOverlay.swift | 123 ++++++++++++++---- 1 file changed, 98 insertions(+), 25 deletions(-) diff --git a/BetterCapture/View/AreaSelectionOverlay.swift b/BetterCapture/View/AreaSelectionOverlay.swift index fa50529..b18bdb0 100644 --- a/BetterCapture/View/AreaSelectionOverlay.swift +++ b/BetterCapture/View/AreaSelectionOverlay.swift @@ -199,6 +199,11 @@ final class AreaSelectionView: NSView { /// 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) { @@ -219,6 +224,7 @@ final class AreaSelectionView: NSView { selectionRect = .zero interactionState = .idle showOverlay = false + hideActionButtons() needsDisplay = true } @@ -273,10 +279,12 @@ final class AreaSelectionView: NSView { 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 @@ -285,6 +293,7 @@ final class AreaSelectionView: NSView { } // Clicking outside the selection starts a new one else { + hideActionButtons() beginDrawing(at: point) } @@ -329,9 +338,9 @@ final class AreaSelectionView: NSView { override func mouseUp(with event: NSEvent) { switch interactionState { case .drawing: - enforceMinimumSize() if selectionRect.width >= minimumSize && selectionRect.height >= minimumSize { interactionState = .adjusting + showActionButtons() } else { // Selection too small, reset selectionRect = .zero @@ -341,10 +350,12 @@ final class AreaSelectionView: NSView { case .moving: interactionState = .adjusting + showActionButtons() case .resizing: enforceMinimumSize() interactionState = .adjusting + showActionButtons() default: break @@ -392,7 +403,6 @@ final class AreaSelectionView: NSView { if case .adjusting = interactionState { drawResizeHandles(in: context) drawDimensionLabel(in: context) - drawConfirmHint(in: context) } // Draw dimension label while drawing @@ -460,33 +470,94 @@ final class AreaSelectionView: NSView { attributedString.draw(at: textPoint) } - private func drawConfirmHint(in context: CGContext) { - let text = "Press Enter to confirm, Escape to cancel" - let attributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: 12, weight: .regular), - .foregroundColor: NSColor.white.withAlphaComponent(0.8) - ] - let attributedString = NSAttributedString(string: text, attributes: attributes) - let size = attributedString.size() + // MARK: - Action Buttons - let padding: CGFloat = 8 - let backgroundRect = CGRect( - x: (bounds.width - size.width - padding * 2) / 2, - y: 40, - width: size.width + padding * 2, - height: size.height + padding * 2 + private func showActionButtons() { + guard buttonContainer == nil else { + updateButtonPositions() + return + } + + let container = NSView() + + let confirm = makeActionButton( + title: "Confirm", + color: .systemBlue, + action: #selector(confirmButtonClicked) ) - context.setFillColor(NSColor.black.withAlphaComponent(0.6).cgColor) - let bgPath = CGPath(roundedRect: backgroundRect, cornerWidth: 4, cornerHeight: 4, transform: nil) - context.addPath(bgPath) - context.fillPath() + let cancel = makeActionButton( + title: "Cancel", + color: .systemRed, + action: #selector(cancelButtonClicked) + ) - let textPoint = CGPoint( - x: backgroundRect.origin.x + padding, - y: backgroundRect.origin.y + padding + 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.bottomAnchor.constraint(equalTo: container.bottomAnchor), + cancel.topAnchor.constraint(equalTo: container.topAnchor), + cancel.leadingAnchor.constraint(equalTo: confirm.trailingAnchor, constant: 12), + 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 ) - attributedString.draw(at: textPoint) + } + + private func makeActionButton(title: String, color: NSColor, action: Selector) -> NSButton { + let button = NSButton(title: title, target: self, action: action) + button.isBordered = false + button.wantsLayer = true + button.layer?.backgroundColor = color.withAlphaComponent(0.7).cgColor + button.layer?.cornerRadius = 18 + button.contentTintColor = .white + button.font = .systemFont(ofSize: 14, weight: .medium) + button.widthAnchor.constraint(greaterThanOrEqualToConstant: 100).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 @@ -610,7 +681,9 @@ final class AreaSelectionView: NSView { return } - if let handle = resizeHandle(at: point) { + 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() From 9e0d6e72828a35a917ce4f2242fe782de6cace96 Mon Sep 17 00:00:00 2001 From: Joshua Sattler <34030048+jsattler@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:57:15 +0100 Subject: [PATCH 04/11] feat: use button + dropdown to select mode --- BetterCapture/View/MenuBarView.swift | 215 ++++++++++++++++++--------- 1 file changed, 142 insertions(+), 73 deletions(-) diff --git a/BetterCapture/View/MenuBarView.swift b/BetterCapture/View/MenuBarView.swift index 6aa5136..b28065a 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,8 +57,7 @@ struct MenuBarView: View { MenuBarDivider() // Content Selection - ContentSharingPickerButton(viewModel: viewModel) - AreaSelectionButton(viewModel: viewModel) + ContentSelectionButton(viewModel: viewModel, onDismissPanel: { dismiss() }) // Preview thumbnail below the content selection button if viewModel.hasContentSelected { @@ -229,96 +230,164 @@ struct RecordingButton: View { } } -// MARK: - Content Sharing Picker Button +// MARK: - Content Selection Mode -/// A button that presents the system content sharing picker with hover effect -struct ContentSharingPickerButton: View { - let viewModel: RecorderViewModel - @State private var isHovered = false +/// The mode for content selection: picking content via the system picker, or drawing a screen area +enum ContentSelectionMode: String { + case pickContent + case selectArea - private var isPickerSelection: Bool { - viewModel.hasContentSelected && !viewModel.isAreaSelection + var label: String { + switch self { + case .pickContent: "Pick Content" + case .selectArea: "Select Area" + } } - var body: some View { - Button { - viewModel.presentPicker() - } label: { - HStack(spacing: 12) { - ZStack { - Circle() - .fill(isPickerSelection ? .blue.opacity(0.8) : .gray.opacity(0.2)) - .frame(width: 24, height: 24) - - Image(systemName: "macwindow") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(isPickerSelection ? .white : .primary) - } - - Text(isPickerSelection ? "Change Selection..." : "Select Content...") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.primary) - - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 4) - .contentShape(.rect) + var activeLabel: String { + switch self { + case .pickContent: "Change Selection..." + case .selectArea: "Change Area..." } - .buttonStyle(.plain) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(isHovered ? .gray.opacity(0.1) : .clear) - .padding(.horizontal, 4) - ) - .onHover { hovering in - isHovered = hovering + } + + var icon: String { + switch self { + case .pickContent: "macwindow" + case .selectArea: "rectangle.dashed" } } } -// MARK: - Area Selection Button +// MARK: - Content Selection Button -/// A button that presents the area selection overlay for drawing a capture rectangle -struct AreaSelectionButton: View { +/// 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 { + if hasActiveSelection { + return mode.activeLabel + } + return "\(mode.label)..." + } var body: some View { - Button { - Task { - await viewModel.presentAreaSelection() - } - } label: { - HStack(spacing: 12) { - ZStack { - Circle() - .fill(viewModel.isAreaSelection ? .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.isAreaSelection ? .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.isAreaSelection ? "Change Area..." : "Select Area...") - .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() + } } } } From 7a39276f3eb585f9e54851fa1667f8b2e3e0fe53 Mon Sep 17 00:00:00 2001 From: Joshua Sattler <34030048+jsattler@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:18:16 +0100 Subject: [PATCH 05/11] chore: simplify and clean up code --- BetterCapture/Service/PreviewService.swift | 22 ------------------- BetterCapture/View/AreaSelectionOverlay.swift | 16 ++++++-------- BetterCapture/View/MenuBarView.swift | 12 +--------- .../ViewModel/RecorderViewModel.swift | 20 +++-------------- 4 files changed, 11 insertions(+), 59 deletions(-) diff --git a/BetterCapture/Service/PreviewService.swift b/BetterCapture/Service/PreviewService.swift index a9a8ae7..9ce76a6 100644 --- a/BetterCapture/Service/PreviewService.swift +++ b/BetterCapture/Service/PreviewService.swift @@ -109,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 } diff --git a/BetterCapture/View/AreaSelectionOverlay.swift b/BetterCapture/View/AreaSelectionOverlay.swift index b18bdb0..66b7f97 100644 --- a/BetterCapture/View/AreaSelectionOverlay.swift +++ b/BetterCapture/View/AreaSelectionOverlay.swift @@ -21,11 +21,7 @@ struct AreaSelectionResult: Sendable { /// A borderless, transparent panel that covers a display for rectangle drawing final class AreaSelectionPanel: NSPanel { - /// The screen this panel covers - let targetScreen: NSScreen - init(screen: NSScreen) { - self.targetScreen = screen super.init( contentRect: screen.frame, styleMask: [.borderless, .nonactivatingPanel], @@ -110,6 +106,7 @@ final class AreaSelectionOverlay { } panels.removeAll() overlayViews.removeAll() + NSCursor.arrow.set() } /// Clears the selection on all overlay views except the given one @@ -173,6 +170,7 @@ private enum ResizeHandle { // MARK: - AreaSelectionView /// The NSView that handles drawing the overlay, selection rectangle, and user interaction +@MainActor final class AreaSelectionView: NSView { // MARK: - Properties @@ -426,12 +424,12 @@ final class AreaSelectionView: NSView { private func drawDimensionLabel(in context: CGContext) { let scale = screen.backingScaleFactor - let pixelWidth = Int(selectionRect.width * scale) - let pixelHeight = Int(selectionRect.height * scale) + let pixelWidth = selectionRect.width * scale + let pixelHeight = selectionRect.height * scale - // Snap to even for display - let evenWidth = pixelWidth % 2 == 0 ? pixelWidth : pixelWidth + 1 - let evenHeight = pixelHeight % 2 == 0 ? pixelHeight : pixelHeight + 1 + // 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] = [ diff --git a/BetterCapture/View/MenuBarView.swift b/BetterCapture/View/MenuBarView.swift index b28065a..f8038bf 100644 --- a/BetterCapture/View/MenuBarView.swift +++ b/BetterCapture/View/MenuBarView.swift @@ -244,13 +244,6 @@ enum ContentSelectionMode: String { } } - var activeLabel: String { - switch self { - case .pickContent: "Change Selection..." - case .selectArea: "Change Area..." - } - } - var icon: String { switch self { case .pickContent: "macwindow" @@ -283,10 +276,7 @@ struct ContentSelectionButton: View { } private var buttonLabel: String { - if hasActiveSelection { - return mode.activeLabel - } - return "\(mode.label)..." + hasActiveSelection ? "Change \(mode.label)..." : "\(mode.label)..." } var body: some View { diff --git a/BetterCapture/ViewModel/RecorderViewModel.swift b/BetterCapture/ViewModel/RecorderViewModel.swift index ff7f4b0..5f9bef8 100644 --- a/BetterCapture/ViewModel/RecorderViewModel.swift +++ b/BetterCapture/ViewModel/RecorderViewModel.swift @@ -180,14 +180,6 @@ final class RecorderViewModel { } } - /// Clears the area selection - func clearAreaSelection() { - selectedSourceRect = nil - selectedContentFilter = nil - previewService.clearPreview() - logger.info("Area selection cleared") - } - /// Starts a new recording session func startRecording() async { guard canStartRecording else { @@ -307,17 +299,11 @@ final class RecorderViewModel { // MARK: - Helper Methods private func getContentSize(from filter: SCContentFilter) async -> CGSize { - // If area selection is active, use the source rect dimensions + // 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) - let width = sourceRect.width * scale - let height = sourceRect.height * scale - - // Snap to even pixel counts for codec compatibility - let evenWidth = ceil(width / 2) * 2 - let evenHeight = ceil(height / 2) * 2 - - return CGSize(width: evenWidth, height: evenHeight) + return CGSize(width: sourceRect.width * scale, height: sourceRect.height * scale) } // Get the content rect from the filter From 18087c1759cb38fa475af8fb37595f3edda18785 Mon Sep 17 00:00:00 2001 From: Joshua Sattler <34030048+jsattler@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:59:06 +0100 Subject: [PATCH 06/11] docs: add initial spec for area selection feature --- docs/specs/0000-template.md | 19 +++++++ docs/specs/0001-area-selection.md | 95 +++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 docs/specs/0000-template.md create mode 100644 docs/specs/0001-area-selection.md 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. From 601419241f472cafe38a07c48e747ff7d4860da3 Mon Sep 17 00:00:00 2001 From: Joshua Sattler <34030048+jsattler@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:04:45 +0100 Subject: [PATCH 07/11] chore(landing): adapt new domain --- website/astro.config.mjs | 3 +- website/public/CNAME | 1 + website/public/robots.txt | 2 +- website/src/components/CTASection.svelte | 5 +- website/src/components/FeaturesSection.svelte | 12 ++--- website/src/components/Footer.svelte | 5 +- website/src/components/Hero.svelte | 6 +-- website/src/components/Navbar.svelte | 12 ++--- website/src/layouts/BaseLayout.astro | 6 +-- website/src/pages/coming-soon.astro | 47 ------------------- website/src/pages/index.astro | 10 ++-- 11 files changed, 26 insertions(+), 83 deletions(-) create mode 100644 website/public/CNAME delete mode 100644 website/src/pages/coming-soon.astro diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 1e691e1..4989fd8 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -7,8 +7,7 @@ import svelte from "@astrojs/svelte"; // https://astro.build/config export default defineConfig({ - site: "https://jsattler.github.io", - base: process.env.NODE_ENV === "production" ? "/BetterCapture/" : "/", + site: "https://bettercapture.app", integrations: [ svelte(), sitemap({ diff --git a/website/public/CNAME b/website/public/CNAME new file mode 100644 index 0000000..4225a96 --- /dev/null +++ b/website/public/CNAME @@ -0,0 +1 @@ +bettercapture.app diff --git a/website/public/robots.txt b/website/public/robots.txt index 119fc52..8d6a599 100644 --- a/website/public/robots.txt +++ b/website/public/robots.txt @@ -3,4 +3,4 @@ User-agent: * Allow: / # Sitemap -Sitemap: https://jsattler.github.io/BetterCapture/sitemap-index.xml +Sitemap: https://bettercapture.app/sitemap-index.xml diff --git a/website/src/components/CTASection.svelte b/website/src/components/CTASection.svelte index 90fff8c..7a28a45 100644 --- a/website/src/components/CTASection.svelte +++ b/website/src/components/CTASection.svelte @@ -1,6 +1,5 @@ @@ -13,7 +12,7 @@
- BetterCapture logo + BetterCapture logo BetterCapture

@@ -82,7 +81,7 @@

  • - + Privacy Policy
  • diff --git a/website/src/components/Hero.svelte b/website/src/components/Hero.svelte index 899feb5..4f3be8f 100644 --- a/website/src/components/Hero.svelte +++ b/website/src/components/Hero.svelte @@ -1,6 +1,4 @@ -