From edb10ad6fca3b234dd7cab0e0b5905ad83351b44 Mon Sep 17 00:00:00 2001 From: diegoavarela <89712036+diegoavarela@users.noreply.github.com> Date: Thu, 8 Jan 2026 21:31:13 -0800 Subject: [PATCH 1/5] Add window capture feature (Cmd+Shift+6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WindowCaptureService actor for capturing specific windows - Add WindowSelector overlay UI for selecting windows visually - Fix multi-monitor coordinate conversion in SelectionOverlayWindow - Add high quality interpolation for image display - Integrate window capture into MenuBarController and AppDelegate 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ScreenCapture/App/AppDelegate.swift | 110 ++++++ .../Capture/SelectionOverlayWindow.swift | 15 +- .../Capture/WindowCaptureService.swift | 338 +++++++++++++++++ .../Features/Capture/WindowSelector.swift | 348 ++++++++++++++++++ .../Features/MenuBar/MenuBarController.swift | 10 + .../Features/Preview/PreviewContentView.swift | 1 + ScreenCapture/Models/AppSettings.swift | 9 + ScreenCapture/Models/KeyboardShortcut.swift | 6 + ScreenCapture/Resources/Localizable.strings | 8 + 9 files changed, 838 insertions(+), 7 deletions(-) create mode 100644 ScreenCapture/Features/Capture/WindowCaptureService.swift create mode 100644 ScreenCapture/Features/Capture/WindowSelector.swift diff --git a/ScreenCapture/App/AppDelegate.swift b/ScreenCapture/App/AppDelegate.swift index 3871d4a..d52bcbb 100644 --- a/ScreenCapture/App/AppDelegate.swift +++ b/ScreenCapture/App/AppDelegate.swift @@ -1,4 +1,5 @@ import AppKit +import ScreenCaptureKit /// Application delegate responsible for menu bar setup, hotkey registration, and app lifecycle. /// Runs on the main actor to ensure thread-safe UI operations. @@ -18,6 +19,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Registered hotkey for selection capture private var selectionHotkeyRegistration: HotkeyManager.Registration? + /// Registered hotkey for window capture + private var windowHotkeyRegistration: HotkeyManager.Registration? + /// Shared app settings private let settings = AppSettings.shared @@ -155,6 +159,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate { print("Failed to register selection hotkey: \(error)") #endif } + + // Register window capture hotkey + do { + windowHotkeyRegistration = try await hotkeyManager.register( + shortcut: settings.windowShortcut + ) { [weak self] in + Task { @MainActor in + self?.captureWindow() + } + } + #if DEBUG + print("Registered window hotkey: \(settings.windowShortcut.displayString)") + #endif + } catch { + #if DEBUG + print("Failed to register window hotkey: \(error)") + #endif + } } /// Unregisters all global hotkeys @@ -170,6 +192,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { await hotkeyManager.unregister(registration) selectionHotkeyRegistration = nil } + + if let registration = windowHotkeyRegistration { + await hotkeyManager.unregister(registration) + windowHotkeyRegistration = nil + } } /// Re-registers hotkeys after settings change @@ -284,6 +311,89 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + /// Triggers a window capture + @objc func captureWindow() { + // Prevent overlapping captures + guard !isCaptureInProgress else { + #if DEBUG + print("Capture already in progress, ignoring request") + #endif + return + } + + #if DEBUG + print("Window capture triggered via hotkey or menu") + #endif + + isCaptureInProgress = true + + Task { + do { + // Present the window selector + let selectorController = WindowSelectorController.shared + + // Set up callbacks before presenting + selectorController.onWindowSelected = { [weak self] windowID in + Task { @MainActor in + await self?.handleWindowSelected(windowID) + } + } + + selectorController.onCancel = { [weak self] in + Task { @MainActor in + self?.handleWindowSelectionCancel() + } + } + + try await selectorController.presentSelector() + + } catch { + isCaptureInProgress = false + #if DEBUG + print("Failed to present window selector: \(error)") + #endif + showCaptureError(.captureFailure(underlying: error)) + } + } + } + + /// Handles successful window selection + private func handleWindowSelected(_ windowID: CGWindowID) async { + defer { isCaptureInProgress = false } + + do { + #if DEBUG + print("Window selected: ID \(windowID)") + #endif + + // Capture the selected window by ID + let screenshot = try await WindowCaptureService.shared.captureWindowByID(windowID) + + #if DEBUG + print("Window capture successful: \(screenshot.formattedDimensions)") + #endif + + // Show preview window + PreviewWindowController.shared.showPreview(for: screenshot) { [weak self] savedURL in + // Add to recent captures when saved + self?.addRecentCapture(filePath: savedURL, image: screenshot.image) + } + + } catch let error as ScreenCaptureError { + showCaptureError(error) + } catch { + showCaptureError(.captureFailure(underlying: error)) + } + } + + /// Handles window selection cancellation + private func handleWindowSelectionCancel() { + isCaptureInProgress = false + #if DEBUG + print("Window selection cancelled by user") + #endif + } + /// Handles successful selection completion private func handleSelectionComplete(rect: CGRect, display: DisplayInfo) async { defer { isCaptureInProgress = false } diff --git a/ScreenCapture/Features/Capture/SelectionOverlayWindow.swift b/ScreenCapture/Features/Capture/SelectionOverlayWindow.swift index 18a75d9..5a5c9c0 100644 --- a/ScreenCapture/Features/Capture/SelectionOverlayWindow.swift +++ b/ScreenCapture/Features/Capture/SelectionOverlayWindow.swift @@ -415,17 +415,18 @@ final class SelectionOverlayView: NSView { print("[5] NSScreen.screens.first?.frame: \(String(describing: NSScreen.screens.first?.frame))") #endif - // Get the screen height for coordinate conversion - // Use the window's screen, not necessarily the primary screen - // Cocoa uses Y=0 at bottom, ScreenCaptureKit/Quartz uses Y=0 at top - let screenHeight = window.screen?.frame.height ?? NSScreen.screens.first?.frame.height ?? 0 + // Get the PRIMARY screen height for coordinate conversion + // Cocoa screen coords: Y=0 at bottom of PRIMARY screen + // Quartz/SCK coords: Y=0 at TOP of PRIMARY screen + // IMPORTANT: Must use primary screen height, not current screen height! + let primaryScreenHeight = NSScreen.screens.first?.frame.height ?? 0 #if DEBUG - print("[6] screenHeight for conversion: \(screenHeight)") + print("[6] primaryScreenHeight for conversion: \(primaryScreenHeight)") #endif - // Convert from Cocoa coordinates (Y=0 at bottom) to Quartz coordinates (Y=0 at top) - let quartzY = screenHeight - screenRect.origin.y - screenRect.height + // Convert from Cocoa coordinates (Y=0 at bottom of primary) to Quartz coordinates (Y=0 at top of primary) + let quartzY = primaryScreenHeight - screenRect.origin.y - screenRect.height #if DEBUG print("[7] quartzY (converted): \(quartzY)") diff --git a/ScreenCapture/Features/Capture/WindowCaptureService.swift b/ScreenCapture/Features/Capture/WindowCaptureService.swift new file mode 100644 index 0000000..22c7841 --- /dev/null +++ b/ScreenCapture/Features/Capture/WindowCaptureService.swift @@ -0,0 +1,338 @@ +import Foundation +import ScreenCaptureKit +import AppKit + +/// Sendable window info for cross-actor transfer +struct WindowInfo: Sendable { + let id: CGWindowID + let title: String + let appName: String + let frame: CGRect +} + +/// Actor responsible for capturing specific windows using ScreenCaptureKit. +/// Provides window enumeration and capture functionality with proper coordinate handling. +actor WindowCaptureService { + // MARK: - Singleton + + /// Shared instance for app-wide window capture + static let shared = WindowCaptureService() + + // MARK: - Properties + + /// Cached windows from last enumeration + private var cachedWindows: [SCWindow] = [] + + /// Last time windows were enumerated + private var lastEnumerationTime: Date? + + /// Cache validity duration (100ms for responsive UI) + private let cacheValidityDuration: TimeInterval = 0.1 + + // MARK: - Initialization + + private init() {} + + // MARK: - Public API + + /// Returns all visible, capturable windows. + /// Filters out system windows, our own app, and windows that are too small. + /// - Returns: Array of SCWindow for all capturable windows + /// - Throws: ScreenCaptureError if enumeration fails + func getWindows() async throws -> [SCWindow] { + // Check cache validity + if let lastTime = lastEnumerationTime, + Date().timeIntervalSince(lastTime) < cacheValidityDuration, + !cachedWindows.isEmpty { + return cachedWindows + } + + // Enumerate windows using ScreenCaptureKit + // excludingDesktopWindows: true = exclude desktop/wallpaper windows + let content: SCShareableContent + do { + content = try await SCShareableContent.excludingDesktopWindows( + true, + onScreenWindowsOnly: true + ) + } catch { + throw ScreenCaptureError.captureFailure(underlying: error) + } + + #if DEBUG + print("=== Window enumeration ===") + print("Total windows from SCK: \(content.windows.count)") + #endif + + // Filter windows + let myBundleID = Bundle.main.bundleIdentifier + let filteredWindows = content.windows.filter { window in + // Must have an owning application + guard let app = window.owningApplication else { + #if DEBUG + print("Excluding window (no app): \(window.title ?? "untitled")") + #endif + return false + } + + // Exclude our own app + if app.bundleIdentifier == myBundleID { + #if DEBUG + print("Excluding own app window: \(window.title ?? "untitled")") + #endif + return false + } + + // Exclude windows that are too small (likely UI elements) + if window.frame.width < 50 || window.frame.height < 50 { + return false + } + + // Exclude Finder desktop windows (they cover the whole screen) + if app.bundleIdentifier == "com.apple.finder" && window.title == nil { + #if DEBUG + print("Excluding Finder desktop window") + #endif + return false + } + + // Exclude windows below layer 0 (desktop level) + // Note: Normal windows are at layer 0, so we only exclude negative layers + if window.windowLayer < 0 { + #if DEBUG + print("Excluding window at layer \(window.windowLayer): \(app.applicationName) - \(window.title ?? "untitled")") + #endif + return false + } + + #if DEBUG + print("Including window: \(app.applicationName) - \(window.title ?? "untitled") layer=\(window.windowLayer) frame=\(window.frame)") + #endif + + return true + } + + // Sort by window layer (frontmost first) - lower windowLayer = more front + let sortedWindows = filteredWindows.sorted { $0.windowLayer < $1.windowLayer } + + // Update cache + cachedWindows = sortedWindows + lastEnumerationTime = Date() + + return sortedWindows + } + + /// Returns window info as Sendable data for UI display. + /// - Returns: Array of WindowInfo for all capturable windows + /// - Throws: ScreenCaptureError if enumeration fails + func getWindowInfoList() async throws -> [WindowInfo] { + let windows = try await getWindows() + return windows.map { window in + WindowInfo( + id: window.windowID, + title: window.title ?? "", + appName: window.owningApplication?.applicationName ?? "Unknown", + frame: window.frame + ) + } + } + + /// Finds the topmost window at the given screen point. + /// - Parameter point: Point in screen coordinates (Quartz: Y=0 at top) + /// - Returns: Window info tuple (windowID, frame, displayName) if found + func windowAtPoint(_ point: CGPoint) async throws -> (windowID: CGWindowID, frame: CGRect, displayName: String)? { + let windows = try await getWindows() + + // SCWindow.frame is in screen coordinates with Y=0 at top + // Find the first (topmost) window containing this point + if let window = windows.first(where: { $0.frame.contains(point) }) { + return (window.windowID, window.frame, window.displayName) + } + return nil + } + + /// Captures the window with the given ID. + /// - Parameter windowID: The CGWindowID to capture + /// - Returns: Screenshot containing the captured window image + /// - Throws: ScreenCaptureError if capture fails + func captureWindowByID(_ windowID: CGWindowID) async throws -> Screenshot { + // Invalidate cache to get fresh window list + invalidateCache() + let windows = try await getWindows() + + #if DEBUG + print("Looking for window ID: \(windowID)") + print("Available SCK windows: \(windows.map { "\($0.windowID): \($0.displayName)" })") + #endif + + guard let window = windows.first(where: { $0.windowID == windowID }) else { + #if DEBUG + print("Window ID \(windowID) not found in SCK list!") + #endif + throw ScreenCaptureError.captureError(message: "Window not found (ID: \(windowID))") + } + + #if DEBUG + print("Found window: \(window.displayName)") + #endif + + return try await captureWindow(window) + } + + /// Captures the specified window. + /// - Parameter window: The SCWindow to capture + /// - Returns: Screenshot containing the captured window image + /// - Throws: ScreenCaptureError if capture fails + func captureWindow(_ window: SCWindow) async throws -> Screenshot { + // Create content filter for just this window + let filter = SCContentFilter(desktopIndependentWindow: window) + + // Configure capture for retina resolution + let config = SCStreamConfiguration() + + // Get the actual scale factor from the window's screen + // Extract window frame values before MainActor closure to avoid data race + let windowFrame = window.frame + let scaleFactor = await MainActor.run { + // Find the screen containing this window + let windowCenter = CGPoint( + x: windowFrame.midX, + y: windowFrame.midY + ) + + // Convert Quartz Y to Cocoa Y for NSScreen lookup + let primaryScreenHeight = NSScreen.screens.first?.frame.height ?? 0 + let cocoaY = primaryScreenHeight - windowCenter.y + + let cocoaPoint = CGPoint(x: windowCenter.x, y: cocoaY) + + #if DEBUG + print("=== SCALE FACTOR DEBUG ===") + print("Window frame (Quartz): \(windowFrame)") + print("Window center (Quartz): \(windowCenter)") + print("Primary screen height: \(primaryScreenHeight)") + print("Cocoa point: \(cocoaPoint)") + for (i, screen) in NSScreen.screens.enumerated() { + print("Screen \(i): frame=\(screen.frame), scale=\(screen.backingScaleFactor)") + } + #endif + + let matchingScreen = NSScreen.screens.first { screen in + screen.frame.contains(cocoaPoint) + } + + #if DEBUG + print("Matching screen: \(matchingScreen?.localizedName ?? "NONE")") + print("Using scale factor: \(matchingScreen?.backingScaleFactor ?? 2.0)") + print("=== END SCALE FACTOR DEBUG ===") + #endif + + return matchingScreen?.backingScaleFactor ?? 2.0 + } + + config.width = Int(window.frame.width * scaleFactor) + config.height = Int(window.frame.height * scaleFactor) + config.scalesToFit = false // Don't scale - capture at native resolution + config.pixelFormat = kCVPixelFormatType_32BGRA + config.showsCursor = false + config.colorSpaceName = CGColorSpace.sRGB + + // Try to capture at highest resolution + if #available(macOS 14.0, *) { + config.captureResolution = .best + } + + #if DEBUG + print("=== CAPTURE CONFIG ===") + print("Window size (points): \(window.frame.width) x \(window.frame.height)") + print("Scale factor: \(scaleFactor)") + print("Capture size (pixels): \(config.width) x \(config.height)") + print("=== END CAPTURE CONFIG ===") + #endif + + // Capture the window + let cgImage: CGImage + do { + cgImage = try await SCScreenshotManager.captureImage( + contentFilter: filter, + configuration: config + ) + } catch { + throw ScreenCaptureError.captureFailure(underlying: error) + } + + #if DEBUG + print("=== CAPTURED IMAGE ===") + print("Actual image size: \(cgImage.width) x \(cgImage.height)") + print("=== END CAPTURED IMAGE ===") + #endif + + // Create display info for the window's location + // Get the display containing this window + let display = try await findDisplayForWindow(window) + + // Create screenshot + let screenshot = Screenshot( + image: cgImage, + captureDate: Date(), + sourceDisplay: display + ) + + return screenshot + } + + /// Invalidates the window cache, forcing a fresh enumeration on next call. + func invalidateCache() { + cachedWindows = [] + lastEnumerationTime = nil + } + + // MARK: - Private Helpers + + /// Finds the display that contains the given window. + private func findDisplayForWindow(_ window: SCWindow) async throws -> DisplayInfo { + let displays = try await ScreenDetector.shared.availableDisplays() + + // Find display containing window center + let windowCenter = CGPoint( + x: window.frame.midX, + y: window.frame.midY + ) + + if let display = displays.first(where: { $0.frame.contains(windowCenter) }) { + return display + } + + // Fallback to primary display + if let primary = displays.first { + return primary + } + + throw ScreenCaptureError.captureError(message: "No display found for window") + } +} + +// MARK: - Window Info Extension + +extension SCWindow { + /// Human-readable display name for this window + var displayName: String { + if let title = title, !title.isEmpty { + if let app = owningApplication, !app.applicationName.isEmpty { + return "\(app.applicationName) - \(title)" + } + return title + } + + if let app = owningApplication, !app.applicationName.isEmpty { + return app.applicationName + } + + return "Unknown Window" + } + + /// Just the application name + var appName: String { + owningApplication?.applicationName ?? "Unknown" + } +} diff --git a/ScreenCapture/Features/Capture/WindowSelector.swift b/ScreenCapture/Features/Capture/WindowSelector.swift new file mode 100644 index 0000000..afb0d02 --- /dev/null +++ b/ScreenCapture/Features/Capture/WindowSelector.swift @@ -0,0 +1,348 @@ +import AppKit +import ScreenCaptureKit + +// MARK: - Window Info + +/// Window information for selection +struct SelectableWindow { + let windowID: CGWindowID + let frame: CGRect + let name: String +} + +// MARK: - WindowSelectorController + +/// Manages window selection using a visual overlay. +@MainActor +final class WindowSelectorController: NSObject { + // MARK: - Singleton + + static let shared = WindowSelectorController() + + // MARK: - Properties + + /// Callback for window selection + var onWindowSelected: ((CGWindowID) -> Void)? + + /// Callback for cancellation + var onCancel: (() -> Void)? + + /// Overlay panel + private var overlayPanel: NSPanel? + + /// Cached windows + private var windows: [SelectableWindow] = [] + + /// Currently highlighted window + private var highlightedWindow: SelectableWindow? + + /// Tracking area for mouse movement + private var trackingArea: NSTrackingArea? + + /// Main screen height for coordinate conversion + private var mainScreenHeight: CGFloat = 0 + + // MARK: - Initialization + + private override init() { + super.init() + } + + // MARK: - Public API + + /// Presents the window selector. + func presentSelector() async throws { + mainScreenHeight = NSScreen.screens.first?.frame.height ?? 0 + + // Get windows before showing overlay + windows = getWindowList() + + guard !windows.isEmpty else { + throw ScreenCaptureError.captureError(message: "No windows available") + } + + #if DEBUG + print("=== Window Selector ===") + print("Found \(windows.count) windows") + #endif + + // Create and show overlay + showOverlay() + } + + /// Dismisses the selector. + func dismissSelector() { + // Remove key monitor + if let monitor = keyMonitor { + NSEvent.removeMonitor(monitor) + keyMonitor = nil + } + + // Close all overlay panels + for panel in overlayPanels { + panel.orderOut(nil) + panel.close() + } + overlayPanels = [] + overlayPanel = nil + windows = [] + highlightedWindow = nil + } + + // MARK: - Window List + + private func getWindowList() -> [SelectableWindow] { + let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + guard let infoList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { + return [] + } + + let myPID = ProcessInfo.processInfo.processIdentifier + var result: [SelectableWindow] = [] + + for info in infoList { + guard let windowID = info[kCGWindowNumber as String] as? CGWindowID, + let bounds = info[kCGWindowBounds as String] as? [String: Any], + let x = bounds["X"] as? CGFloat, + let y = bounds["Y"] as? CGFloat, + let width = bounds["Width"] as? CGFloat, + let height = bounds["Height"] as? CGFloat else { continue } + + // Skip small windows + guard width >= 100 && height >= 100 else { continue } + + // Skip our own app + if let pid = info[kCGWindowOwnerPID as String] as? Int32, pid == myPID { continue } + + // Only layer 0 (normal windows) + let layer = info[kCGWindowLayer as String] as? Int ?? -1 + guard layer == 0 else { continue } + + let ownerName = info[kCGWindowOwnerName as String] as? String ?? "Unknown" + let windowName = info[kCGWindowName as String] as? String ?? "" + let name = windowName.isEmpty ? ownerName : "\(ownerName) - \(windowName)" + + result.append(SelectableWindow( + windowID: windowID, + frame: CGRect(x: x, y: y, width: width, height: height), + name: name + )) + } + + return result + } + + /// Overlay panels for all screens + private var overlayPanels: [NSPanel] = [] + + /// Key monitor + private var keyMonitor: Any? + + // MARK: - Overlay + + private func showOverlay() { + // Create overlay for ALL screens + for screen in NSScreen.screens { + let panel = NSPanel( + contentRect: screen.frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + panel.level = .screenSaver + panel.isOpaque = false + panel.backgroundColor = NSColor.black.withAlphaComponent(0.3) + panel.hasShadow = false + panel.ignoresMouseEvents = false + panel.acceptsMouseMovedEvents = true + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + // Create content view with local coordinates + let contentView = OverlayContentView(frame: NSRect(origin: .zero, size: screen.frame.size)) + contentView.controller = self + contentView.screenFrame = screen.frame // Store screen frame for coordinate conversion + contentView.wantsLayer = true + panel.contentView = contentView + + // Add tracking area for mouse movement + let trackingArea = NSTrackingArea( + rect: contentView.bounds, + options: [.mouseMoved, .activeAlways, .inVisibleRect], + owner: contentView, + userInfo: nil + ) + contentView.addTrackingArea(trackingArea) + + overlayPanels.append(panel) + panel.makeKeyAndOrderFront(nil) + } + + // Use first panel as main overlay + overlayPanel = overlayPanels.first + + // Monitor for ESC key + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + if event.keyCode == 53 { // ESC + self?.handleCancel() + return nil + } + return event + } + } + + // MARK: - Coordinate Conversion + + func windowAtPoint(_ cocoaPoint: CGPoint) -> SelectableWindow? { + // Convert Cocoa to Quartz coordinates + let quartzPoint = CGPoint(x: cocoaPoint.x, y: mainScreenHeight - cocoaPoint.y) + + return windows.first { $0.frame.contains(quartzPoint) } + } + + func quartzToCocoaFrame(_ quartzFrame: CGRect) -> CGRect { + return CGRect( + x: quartzFrame.origin.x, + y: mainScreenHeight - quartzFrame.origin.y - quartzFrame.height, + width: quartzFrame.width, + height: quartzFrame.height + ) + } + + // MARK: - Event Handling + + func handleMouseMoved(at point: CGPoint) { + highlightedWindow = windowAtPoint(point) + + // Update ALL overlay views + for panel in overlayPanels { + guard let view = panel.contentView as? OverlayContentView else { continue } + + if let window = highlightedWindow { + let cocoaFrame = quartzToCocoaFrame(window.frame) + // Convert to view-local coordinates + let localFrame = CGRect( + x: cocoaFrame.origin.x - view.screenFrame.origin.x, + y: cocoaFrame.origin.y - view.screenFrame.origin.y, + width: cocoaFrame.width, + height: cocoaFrame.height + ) + // Only show if it intersects this screen + if localFrame.intersects(view.bounds) { + view.highlightFrame = localFrame + view.highlightName = window.name + } else { + view.highlightFrame = nil + view.highlightName = nil + } + } else { + view.highlightFrame = nil + view.highlightName = nil + } + view.needsDisplay = true + } + } + + func handleClick() { + guard let window = highlightedWindow else { return } + + #if DEBUG + print("Selected window: \(window.name) ID: \(window.windowID)") + #endif + + let windowID = window.windowID + dismissSelector() + onWindowSelected?(windowID) + } + + func handleCancel() { + #if DEBUG + print("Selection cancelled") + #endif + + dismissSelector() + onCancel?() + } +} + +// MARK: - Overlay Content View + +private class OverlayContentView: NSView { + weak var controller: WindowSelectorController? + var highlightFrame: CGRect? + var highlightName: String? + var screenFrame: CGRect = .zero // The screen this view is on + + override func draw(_ dirtyRect: NSRect) { + // Background is already set by panel backgroundColor + + guard let highlightFrame = highlightFrame else { + // Draw instructions only + drawInstructions() + return + } + + // Draw "hole" for highlighted window + NSColor.clear.setFill() + let path = NSBezierPath(rect: highlightFrame) + path.fill() + + // Draw blue border + NSColor.systemBlue.setStroke() + let borderPath = NSBezierPath(rect: highlightFrame.insetBy(dx: -3, dy: -3)) + borderPath.lineWidth = 6 + borderPath.stroke() + + // Draw label + if let name = highlightName { + drawLabel(name, below: highlightFrame) + } + + drawInstructions() + } + + private func drawLabel(_ text: String, below rect: CGRect) { + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 14, weight: .semibold), + .foregroundColor: NSColor.white + ] + let size = (text as NSString).size(withAttributes: attrs) + let x = rect.midX - size.width / 2 + let y = rect.minY - size.height - 20 + + // Background + let bgRect = CGRect(x: x - 12, y: y - 4, width: size.width + 24, height: size.height + 8) + NSColor.black.withAlphaComponent(0.8).setFill() + NSBezierPath(roundedRect: bgRect, xRadius: 6, yRadius: 6).fill() + + (text as NSString).draw(at: CGPoint(x: x, y: y), withAttributes: attrs) + } + + private func drawInstructions() { + let text = "Click on a window to capture. Press ESC to cancel." + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 18, weight: .medium), + .foregroundColor: NSColor.white + ] + let size = (text as NSString).size(withAttributes: attrs) + let x = bounds.midX - size.width / 2 + let y = bounds.maxY - 80 + + let bgRect = CGRect(x: x - 20, y: y - 6, width: size.width + 40, height: size.height + 16) + NSColor.black.withAlphaComponent(0.8).setFill() + NSBezierPath(roundedRect: bgRect, xRadius: 10, yRadius: 10).fill() + + (text as NSString).draw(at: CGPoint(x: x, y: y), withAttributes: attrs) + } + + override func mouseMoved(with event: NSEvent) { + let point = NSEvent.mouseLocation + controller?.handleMouseMoved(at: point) + } + + override func mouseDown(with event: NSEvent) { + controller?.handleClick() + } + + override var acceptsFirstResponder: Bool { true } +} diff --git a/ScreenCapture/Features/MenuBar/MenuBarController.swift b/ScreenCapture/Features/MenuBar/MenuBarController.swift index 1365c4f..d307bcc 100644 --- a/ScreenCapture/Features/MenuBar/MenuBarController.swift +++ b/ScreenCapture/Features/MenuBar/MenuBarController.swift @@ -73,6 +73,16 @@ final class MenuBarController { selectionItem.target = appDelegate menu.addItem(selectionItem) + // Capture Window + let windowItem = NSMenuItem( + title: NSLocalizedString("menu.capture.window", comment: "Capture Window"), + action: #selector(AppDelegate.captureWindow), + keyEquivalent: "6" + ) + windowItem.keyEquivalentModifierMask = [.command, .shift] + windowItem.target = appDelegate + menu.addItem(windowItem) + menu.addItem(NSMenuItem.separator()) // Recent Captures submenu diff --git a/ScreenCapture/Features/Preview/PreviewContentView.swift b/ScreenCapture/Features/Preview/PreviewContentView.swift index 289ca94..048896a 100644 --- a/ScreenCapture/Features/Preview/PreviewContentView.swift +++ b/ScreenCapture/Features/Preview/PreviewContentView.swift @@ -78,6 +78,7 @@ struct PreviewContentView: View { // Base image Image(viewModel.image, scale: 1.0, label: Text("Screenshot")) .resizable() + .interpolation(.high) // High quality downscaling .aspectRatio(contentMode: .fit) .frame( width: displayInfo.displaySize.width, diff --git a/ScreenCapture/Models/AppSettings.swift b/ScreenCapture/Models/AppSettings.swift index 0c98741..6dac0cd 100644 --- a/ScreenCapture/Models/AppSettings.swift +++ b/ScreenCapture/Models/AppSettings.swift @@ -20,6 +20,7 @@ final class AppSettings { static let jpegQuality = prefix + "jpegQuality" static let fullScreenShortcut = prefix + "fullScreenShortcut" static let selectionShortcut = prefix + "selectionShortcut" + static let windowShortcut = prefix + "windowShortcut" static let strokeColor = prefix + "strokeColor" static let strokeWidth = prefix + "strokeWidth" static let textSize = prefix + "textSize" @@ -54,6 +55,11 @@ final class AppSettings { didSet { saveShortcut(selectionShortcut, forKey: Keys.selectionShortcut) } } + /// Global hotkey for window capture + var windowShortcut: KeyboardShortcut { + didSet { saveShortcut(windowShortcut, forKey: Keys.windowShortcut) } + } + /// Default annotation stroke color var strokeColor: CodableColor { didSet { saveColor(strokeColor, forKey: Keys.strokeColor) } @@ -113,6 +119,8 @@ final class AppSettings { ?? KeyboardShortcut.fullScreenDefault selectionShortcut = Self.loadShortcut(forKey: Keys.selectionShortcut) ?? KeyboardShortcut.selectionDefault + windowShortcut = Self.loadShortcut(forKey: Keys.windowShortcut) + ?? KeyboardShortcut.windowDefault // Load annotation defaults strokeColor = Self.loadColor(forKey: Keys.strokeColor) ?? .red @@ -163,6 +171,7 @@ final class AppSettings { jpegQuality = 0.9 fullScreenShortcut = .fullScreenDefault selectionShortcut = .selectionDefault + windowShortcut = .windowDefault strokeColor = .red strokeWidth = 2.0 textSize = 14.0 diff --git a/ScreenCapture/Models/KeyboardShortcut.swift b/ScreenCapture/Models/KeyboardShortcut.swift index c6761b0..eb8cd1f 100644 --- a/ScreenCapture/Models/KeyboardShortcut.swift +++ b/ScreenCapture/Models/KeyboardShortcut.swift @@ -37,6 +37,12 @@ struct KeyboardShortcut: Equatable, Codable, Sendable { modifiers: UInt32(cmdKey | shiftKey) ) + /// Default window capture shortcut: Command + Shift + 6 + static let windowDefault = KeyboardShortcut( + keyCode: UInt32(kVK_ANSI_6), + modifiers: UInt32(cmdKey | shiftKey) + ) + // MARK: - Validation /// Checks if the shortcut includes at least one modifier key diff --git a/ScreenCapture/Resources/Localizable.strings b/ScreenCapture/Resources/Localizable.strings index 0623ef1..c804142 100644 --- a/ScreenCapture/Resources/Localizable.strings +++ b/ScreenCapture/Resources/Localizable.strings @@ -37,6 +37,7 @@ "menu.capture.full.screen" = "Capture Full Screen"; "menu.capture.fullscreen" = "Capture Full Screen"; "menu.capture.selection" = "Capture Selection"; +"menu.capture.window" = "Capture Window"; "menu.recent.captures" = "Recent Captures"; "menu.recent.captures.empty" = "No Recent Captures"; "menu.recent.captures.clear" = "Clear Recent"; @@ -123,3 +124,10 @@ "permission.prompt.message" = "ScreenCapture needs permission to capture your screen. This is required to take screenshots.\n\nAfter clicking Continue, macOS will ask you to grant Screen Recording permission. You can grant it in System Settings > Privacy & Security > Screen Recording."; "permission.prompt.continue" = "Continue"; "permission.prompt.later" = "Later"; + +/* Window Selector */ +"window.selector.header" = "Choose window to capture:"; +"window.selector.instructions" = "Click on a window to capture. Press ESC to cancel."; + +/* Settings - Window Shortcut */ +"settings.shortcut.window" = "Window Capture"; From 1af69e850b784b3f8dbbc03b320c7324d11f3bc5 Mon Sep 17 00:00:00 2001 From: diegoavarela <89712036+diegoavarela@users.noreply.github.com> Date: Fri, 9 Jan 2026 08:13:34 -0800 Subject: [PATCH 2/5] feat: Add window capture, screenshot sound, and multiple improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Window capture (Cmd+Shift+6) and window with shadow (Cmd+Shift+7) - Screenshot sound on capture - Drag & drop with file URL support (works with terminals) - Fix DisplaySelector race condition (menuDidClose vs displaySelected) - Fix WindowCaptureService filter (windowLayer < 0) - Change default shortcuts to Cmd+Ctrl+3/4 (avoid macOS conflict) - Disable sandbox for global hotkeys - Modernized Settings UI with tabs - Menu width 250px minimum - All 4 shortcuts configurable in Settings - NSLog debugging (can be removed later) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ScreenCapture/App/AppDelegate.swift | 252 +++--- .../Features/Capture/DisplaySelector.swift | 47 +- .../Capture/WindowCaptureService.swift | 143 ++-- .../Features/MenuBar/MenuBarController.swift | 94 ++- .../Features/Preview/PreviewContentView.swift | 181 +++- .../Features/Preview/PreviewWindow.swift | 25 +- .../Preview/RecentCapturesGallery.swift | 235 ++++++ .../Features/Settings/SettingsView.swift | 774 +++++++++--------- .../Features/Settings/SettingsViewModel.swift | 83 +- ScreenCapture/Models/AppSettings.swift | 9 + ScreenCapture/Models/KeyboardShortcut.swift | 74 +- ScreenCapture/Resources/Localizable.strings | 4 + ScreenCapture/Services/HotkeyManager.swift | 14 +- .../ScreenCapture.entitlements | 4 +- 14 files changed, 1358 insertions(+), 581 deletions(-) create mode 100644 ScreenCapture/Features/Preview/RecentCapturesGallery.swift diff --git a/ScreenCapture/App/AppDelegate.swift b/ScreenCapture/App/AppDelegate.swift index d52bcbb..53a07e3 100644 --- a/ScreenCapture/App/AppDelegate.swift +++ b/ScreenCapture/App/AppDelegate.swift @@ -1,5 +1,6 @@ import AppKit import ScreenCaptureKit +import AudioToolbox /// Application delegate responsible for menu bar setup, hotkey registration, and app lifecycle. /// Runs on the main actor to ensure thread-safe UI operations. @@ -22,6 +23,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Registered hotkey for window capture private var windowHotkeyRegistration: HotkeyManager.Registration? + /// Registered hotkey for window capture with shadow + private var windowWithShadowHotkeyRegistration: HotkeyManager.Registration? + /// Shared app settings private let settings = AppSettings.shared @@ -34,6 +38,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - NSApplicationDelegate func applicationDidFinishLaunching(_ notification: Notification) { + NSLog("[ScreenCapture] Application did finish launching") + // Ensure we're a menu bar only app (no dock icon) NSApp.setActivationPolicy(.accessory) @@ -47,6 +53,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) menuBarController?.setup() + NSLog("[ScreenCapture] Menu bar set up, registering hotkeys...") + // Register global hotkeys Task { await registerHotkeys() @@ -57,9 +65,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { await checkAndRequestScreenRecordingPermission() } - #if DEBUG - print("ScreenCapture launched - settings loaded from: \(settings.saveLocation.path)") - #endif + NSLog("[ScreenCapture] Settings save location: %@", settings.saveLocation.path) } /// Checks for screen recording permission and shows an explanatory prompt if needed. @@ -124,6 +130,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func registerHotkeys() async { let hotkeyManager = HotkeyManager.shared + NSLog("[ScreenCapture] Registering hotkeys...") + NSLog("[ScreenCapture] Full screen shortcut: %@ (keyCode: %u, modifiers: %u)", settings.fullScreenShortcut.displayString, settings.fullScreenShortcut.keyCode, settings.fullScreenShortcut.modifiers) + // Register full screen capture hotkey do { fullScreenHotkeyRegistration = try await hotkeyManager.register( @@ -133,13 +142,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.captureFullScreen() } } - #if DEBUG - print("Registered full screen hotkey: \(settings.fullScreenShortcut.displayString)") - #endif + NSLog("[ScreenCapture] ✓ Registered full screen hotkey: %@", settings.fullScreenShortcut.displayString) } catch { - #if DEBUG - print("Failed to register full screen hotkey: \(error)") - #endif + NSLog("[ScreenCapture] ✗ Failed to register full screen hotkey: %@", "\(error)") } // Register selection capture hotkey @@ -151,13 +156,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.captureSelection() } } - #if DEBUG - print("Registered selection hotkey: \(settings.selectionShortcut.displayString)") - #endif + NSLog("[ScreenCapture] ✓ Registered selection hotkey: %@", settings.selectionShortcut.displayString) } catch { - #if DEBUG - print("Failed to register selection hotkey: \(error)") - #endif + NSLog("[ScreenCapture] ✗ Failed to register selection hotkey: %@", "\(error)") } // Register window capture hotkey @@ -169,14 +170,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.captureWindow() } } - #if DEBUG - print("Registered window hotkey: \(settings.windowShortcut.displayString)") - #endif + NSLog("[ScreenCapture] ✓ Registered window hotkey: %@", settings.windowShortcut.displayString) } catch { - #if DEBUG - print("Failed to register window hotkey: \(error)") - #endif + NSLog("[ScreenCapture] ✗ Failed to register window hotkey: %@", "\(error)") } + + // Register window with shadow capture hotkey + do { + windowWithShadowHotkeyRegistration = try await hotkeyManager.register( + shortcut: settings.windowWithShadowShortcut + ) { [weak self] in + Task { @MainActor in + self?.captureWindowWithShadow() + } + } + NSLog("[ScreenCapture] ✓ Registered window with shadow hotkey: %@", settings.windowWithShadowShortcut.displayString) + } catch { + NSLog("[ScreenCapture] ✗ Failed to register window with shadow hotkey: %@", "\(error)") + } + + NSLog("[ScreenCapture] Hotkey registration complete") } /// Unregisters all global hotkeys @@ -197,6 +210,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { await hotkeyManager.unregister(registration) windowHotkeyRegistration = nil } + + if let registration = windowWithShadowHotkeyRegistration { + await hotkeyManager.unregister(registration) + windowWithShadowHotkeyRegistration = nil + } } /// Re-registers hotkeys after settings change @@ -213,53 +231,62 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc func captureFullScreen() { // Prevent overlapping captures guard !isCaptureInProgress else { - #if DEBUG - print("Capture already in progress, ignoring request") - #endif + NSLog("[ScreenCapture] Capture already in progress") return } - #if DEBUG - print("Full screen capture triggered via hotkey or menu") - #endif - + NSLog("[ScreenCapture] Starting full screen capture") isCaptureInProgress = true Task { - defer { isCaptureInProgress = false } + defer { + isCaptureInProgress = false + NSLog("[ScreenCapture] Capture task finished") + } do { // Get available displays + NSLog("[ScreenCapture] Getting available displays...") let displays = try await CaptureManager.shared.availableDisplays() + NSLog("[ScreenCapture] Found %d displays", displays.count) // Select display (shows menu if multiple) + NSLog("[ScreenCapture] Showing display selector...") guard let selectedDisplay = await displaySelector.selectDisplay(from: displays) else { - #if DEBUG - print("Display selection cancelled") - #endif + NSLog("[ScreenCapture] Display selection cancelled") return } - - #if DEBUG - print("Capturing display: \(selectedDisplay.name)") - #endif + NSLog("[ScreenCapture] Selected display: %@", selectedDisplay.name) // Perform capture - let screenshot = try await CaptureManager.shared.captureFullScreen(display: selectedDisplay) + NSLog("[ScreenCapture] Capturing display...") + let screenshot: Screenshot + do { + screenshot = try await CaptureManager.shared.captureFullScreen(display: selectedDisplay) + } catch { + NSLog("[ScreenCapture] CAPTURE FAILED: %@", "\(error)") + throw error + } + NSLog("[ScreenCapture] Capture successful: %@", screenshot.formattedDimensions) - #if DEBUG - print("Capture successful: \(screenshot.formattedDimensions)") - #endif + // Play screenshot sound + playScreenshotSound() // Show preview window - PreviewWindowController.shared.showPreview(for: screenshot) { [weak self] savedURL in - // Add to recent captures when saved - self?.addRecentCapture(filePath: savedURL, image: screenshot.image) + NSLog("[ScreenCapture] Showing preview window...") + await MainActor.run { + PreviewWindowController.shared.showPreview(for: screenshot) { [weak self] savedURL in + // Add to recent captures when saved + self?.addRecentCapture(filePath: savedURL, image: screenshot.image) + } } + NSLog("[ScreenCapture] Preview window shown") } catch let error as ScreenCaptureError { + NSLog("[ScreenCapture] ScreenCaptureError: %@", "\(error)") showCaptureError(error) } catch { + NSLog("[ScreenCapture] Error: %@", "\(error)") showCaptureError(.captureFailure(underlying: error)) } } @@ -268,16 +295,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Triggers a selection capture @objc func captureSelection() { // Prevent overlapping captures - guard !isCaptureInProgress else { - #if DEBUG - print("Capture already in progress, ignoring request") - #endif - return - } - - #if DEBUG - print("Selection capture triggered via hotkey or menu") - #endif + guard !isCaptureInProgress else { return } isCaptureInProgress = true @@ -303,9 +321,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } catch { isCaptureInProgress = false - #if DEBUG - print("Failed to present selection overlay: \(error)") - #endif showCaptureError(.captureFailure(underlying: error)) } } @@ -314,16 +329,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Triggers a window capture @objc func captureWindow() { // Prevent overlapping captures - guard !isCaptureInProgress else { - #if DEBUG - print("Capture already in progress, ignoring request") - #endif - return + guard !isCaptureInProgress else { return } + + isCaptureInProgress = true + + Task { + do { + // Present the window selector + let selectorController = WindowSelectorController.shared + + // Set up callbacks before presenting + selectorController.onWindowSelected = { [weak self] windowID in + Task { @MainActor in + await self?.handleWindowSelected(windowID) + } + } + + selectorController.onCancel = { [weak self] in + Task { @MainActor in + self?.handleWindowSelectionCancel() + } + } + + try await selectorController.presentSelector() + + } catch { + isCaptureInProgress = false + showCaptureError(.captureFailure(underlying: error)) + } } + } - #if DEBUG - print("Window capture triggered via hotkey or menu") - #endif + /// Triggers a window capture with shadow + @objc func captureWindowWithShadow() { + // Prevent overlapping captures + guard !isCaptureInProgress else { return } isCaptureInProgress = true @@ -335,7 +375,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Set up callbacks before presenting selectorController.onWindowSelected = { [weak self] windowID in Task { @MainActor in - await self?.handleWindowSelected(windowID) + await self?.handleWindowSelectedWithShadow(windowID) } } @@ -349,9 +389,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } catch { isCaptureInProgress = false - #if DEBUG - print("Failed to present window selector: \(error)") - #endif showCaptureError(.captureFailure(underlying: error)) } } @@ -362,16 +399,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate { defer { isCaptureInProgress = false } do { - #if DEBUG - print("Window selected: ID \(windowID)") - #endif - // Capture the selected window by ID let screenshot = try await WindowCaptureService.shared.captureWindowByID(windowID) - #if DEBUG - print("Window capture successful: \(screenshot.formattedDimensions)") - #endif + // Play screenshot sound + playScreenshotSound() + + // Show preview window + PreviewWindowController.shared.showPreview(for: screenshot) { [weak self] savedURL in + // Add to recent captures when saved + self?.addRecentCapture(filePath: savedURL, image: screenshot.image) + } + + } catch let error as ScreenCaptureError { + showCaptureError(error) + } catch { + showCaptureError(.captureFailure(underlying: error)) + } + } + + /// Handles successful window selection with shadow + private func handleWindowSelectedWithShadow(_ windowID: CGWindowID) async { + defer { isCaptureInProgress = false } + + do { + // Capture the selected window by ID with shadow + let screenshot = try await WindowCaptureService.shared.captureWindowByID(windowID, includeShadow: true) + + // Play screenshot sound + playScreenshotSound() // Show preview window PreviewWindowController.shared.showPreview(for: screenshot) { [weak self] savedURL in @@ -389,9 +445,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Handles window selection cancellation private func handleWindowSelectionCancel() { isCaptureInProgress = false - #if DEBUG - print("Window selection cancelled by user") - #endif } /// Handles successful selection completion @@ -399,16 +452,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { defer { isCaptureInProgress = false } do { - #if DEBUG - print("Selection complete: \(Int(rect.width))×\(Int(rect.height)) on \(display.name)") - #endif - // Capture the selected region let screenshot = try await CaptureManager.shared.captureRegion(rect, from: display) - #if DEBUG - print("Region capture successful: \(screenshot.formattedDimensions)") - #endif + // Play screenshot sound + playScreenshotSound() // Show preview window PreviewWindowController.shared.showPreview(for: screenshot) { [weak self] savedURL in @@ -426,17 +474,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Handles selection cancellation private func handleSelectionCancel() { isCaptureInProgress = false - #if DEBUG - print("Selection cancelled by user") - #endif } /// Opens the settings window @objc func openSettings() { - #if DEBUG - print("Opening settings window") - #endif - SettingsWindowController.shared.showSettings(appDelegate: self) } @@ -444,10 +485,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Shows an error alert for capture failures private func showCaptureError(_ error: ScreenCaptureError) { - #if DEBUG - print("Capture error: \(error)") - #endif - let alert = NSAlert() alert.alertStyle = .warning alert.messageText = error.errorDescription ?? NSLocalizedString("error.capture.failed", comment: "") @@ -500,4 +537,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate { recentCapturesStore?.add(filePath: filePath, image: image) menuBarController?.updateRecentCapturesMenu() } + + // MARK: - Sound + + /// Plays the macOS screenshot sound + private func playScreenshotSound() { + // Use the system screenshot sound (same as Cmd+Shift+3) + if let soundURL = Bundle.main.url(forResource: "Grab", withExtension: "aiff") { + // Try bundled sound first + var soundID: SystemSoundID = 0 + AudioServicesCreateSystemSoundID(soundURL as CFURL, &soundID) + AudioServicesPlaySystemSound(soundID) + } else if let systemSoundPath = "/System/Library/Components/CoreAudio.component/Contents/SharedSupport/SystemSounds/system/Grab.aif" as String?, + FileManager.default.fileExists(atPath: systemSoundPath) { + // Try system sound + var soundID: SystemSoundID = 0 + let soundURL = URL(fileURLWithPath: systemSoundPath) + AudioServicesCreateSystemSoundID(soundURL as CFURL, &soundID) + AudioServicesPlaySystemSound(soundID) + } else { + // Fallback: play a simple system beep + NSSound.beep() + } + } } diff --git a/ScreenCapture/Features/Capture/DisplaySelector.swift b/ScreenCapture/Features/Capture/DisplaySelector.swift index 3f33af5..e890983 100644 --- a/ScreenCapture/Features/Capture/DisplaySelector.swift +++ b/ScreenCapture/Features/Capture/DisplaySelector.swift @@ -4,7 +4,7 @@ import Foundation /// Manages display selection UI when multiple displays are connected. /// Provides a popup menu for the user to select which display to capture. @MainActor -final class DisplaySelector { +final class DisplaySelector: NSObject { // MARK: - Types /// Result of display selection @@ -24,6 +24,9 @@ final class DisplaySelector { /// Menu delegate (retained to prevent deallocation) private var menuDelegate: DisplaySelectorMenuDelegate? + /// Flag to track if a selection was made (to avoid treating menu close as cancellation) + private var selectionWasMade = false + // MARK: - Public API /// Shows a display selection menu if multiple displays are available. @@ -54,6 +57,9 @@ final class DisplaySelector { /// Creates and shows the display selection menu. /// - Parameter displays: Available displays to choose from private func showSelectionMenu(for displays: [DisplayInfo]) { + // Reset selection flag + selectionWasMade = false + let menu = NSMenu(title: NSLocalizedString("display.selector.title", comment: "Select Display")) // Add header item (disabled, for context) @@ -100,30 +106,57 @@ final class DisplaySelector { /// Called when a display is selected from the menu. /// - Parameter sender: The selected menu item @objc private func displaySelected(_ sender: NSMenuItem) { - guard let displayItem = sender as? DisplayMenuItem else { return } + NSLog("[DisplaySelector] displaySelected called") + guard let displayItem = sender as? DisplayMenuItem else { + NSLog("[DisplaySelector] ERROR: sender is not DisplayMenuItem") + return + } + NSLog("[DisplaySelector] Selected: %@", displayItem.display.name) + // Mark that a selection was made (prevents menuDidClose from cancelling) + selectionWasMade = true completeSelection(with: .selected(displayItem.display)) } /// Called when selection is cancelled. @objc private func selectionCancelled() { + NSLog("[DisplaySelector] selectionCancelled called") + selectionWasMade = true // Explicit cancel is also a "selection" completeSelection(with: .cancelled) } /// Called when the menu is dismissed without selection. fileprivate func menuDidClose() { - // If continuation is still pending, treat as cancellation - if selectionContinuation != nil { - completeSelection(with: .cancelled) + NSLog("[DisplaySelector] menuDidClose called, selectionWasMade: %@, continuation pending: %@", + selectionWasMade ? "yes" : "no", + selectionContinuation != nil ? "yes" : "no") + // Only treat as cancellation if no selection was made + // (menuDidClose fires before displaySelected, so we use a small delay) + if !selectionWasMade { + // Use async dispatch to allow displaySelected to fire first + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + // Check again after the dispatch - displaySelected may have run + if !self.selectionWasMade && self.selectionContinuation != nil { + NSLog("[DisplaySelector] No selection made, treating as cancellation") + self.completeSelection(with: .cancelled) + } + } } } /// Completes the selection with the given result. /// - Parameter result: The selection result private func completeSelection(with result: SelectionResult) { + NSLog("[DisplaySelector] completeSelection called with result") selectionMenu = nil menuDelegate = nil - selectionContinuation?.resume(returning: result) - selectionContinuation = nil + if selectionContinuation != nil { + NSLog("[DisplaySelector] Resuming continuation") + selectionContinuation?.resume(returning: result) + selectionContinuation = nil + } else { + NSLog("[DisplaySelector] WARNING: continuation was nil!") + } } } diff --git a/ScreenCapture/Features/Capture/WindowCaptureService.swift b/ScreenCapture/Features/Capture/WindowCaptureService.swift index 22c7841..7e9d4bf 100644 --- a/ScreenCapture/Features/Capture/WindowCaptureService.swift +++ b/ScreenCapture/Features/Capture/WindowCaptureService.swift @@ -59,27 +59,16 @@ actor WindowCaptureService { throw ScreenCaptureError.captureFailure(underlying: error) } - #if DEBUG - print("=== Window enumeration ===") - print("Total windows from SCK: \(content.windows.count)") - #endif - // Filter windows let myBundleID = Bundle.main.bundleIdentifier let filteredWindows = content.windows.filter { window in // Must have an owning application guard let app = window.owningApplication else { - #if DEBUG - print("Excluding window (no app): \(window.title ?? "untitled")") - #endif return false } // Exclude our own app if app.bundleIdentifier == myBundleID { - #if DEBUG - print("Excluding own app window: \(window.title ?? "untitled")") - #endif return false } @@ -90,25 +79,15 @@ actor WindowCaptureService { // Exclude Finder desktop windows (they cover the whole screen) if app.bundleIdentifier == "com.apple.finder" && window.title == nil { - #if DEBUG - print("Excluding Finder desktop window") - #endif return false } // Exclude windows below layer 0 (desktop level) // Note: Normal windows are at layer 0, so we only exclude negative layers if window.windowLayer < 0 { - #if DEBUG - print("Excluding window at layer \(window.windowLayer): \(app.applicationName) - \(window.title ?? "untitled")") - #endif return false } - #if DEBUG - print("Including window: \(app.applicationName) - \(window.title ?? "untitled") layer=\(window.windowLayer) frame=\(window.frame)") - #endif - return true } @@ -152,31 +131,94 @@ actor WindowCaptureService { } /// Captures the window with the given ID. - /// - Parameter windowID: The CGWindowID to capture + /// - Parameters: + /// - windowID: The CGWindowID to capture + /// - includeShadow: Whether to include the window shadow in the capture /// - Returns: Screenshot containing the captured window image /// - Throws: ScreenCaptureError if capture fails - func captureWindowByID(_ windowID: CGWindowID) async throws -> Screenshot { + func captureWindowByID(_ windowID: CGWindowID, includeShadow: Bool = false) async throws -> Screenshot { // Invalidate cache to get fresh window list invalidateCache() let windows = try await getWindows() - #if DEBUG - print("Looking for window ID: \(windowID)") - print("Available SCK windows: \(windows.map { "\($0.windowID): \($0.displayName)" })") - #endif - guard let window = windows.first(where: { $0.windowID == windowID }) else { - #if DEBUG - print("Window ID \(windowID) not found in SCK list!") - #endif throw ScreenCaptureError.captureError(message: "Window not found (ID: \(windowID))") } - #if DEBUG - print("Found window: \(window.displayName)") - #endif + if includeShadow { + return try await captureWindowWithShadow(windowID: windowID, window: window) + } else { + return try await captureWindow(window) + } + } + + /// Captures a window with its shadow by capturing a larger region + /// - Parameters: + /// - windowID: The CGWindowID to capture + /// - window: The SCWindow for display info + /// - Returns: Screenshot containing the captured window with shadow + private func captureWindowWithShadow(windowID: CGWindowID, window: SCWindow) async throws -> Screenshot { + // Shadow is typically about 20-30 pixels around the window + // We'll capture a region larger than the window to include the shadow + let shadowPadding: CGFloat = 30 - return try await captureWindow(window) + // Get the display containing this window + let display = try await findDisplayForWindow(window) + + // Calculate the expanded rect with shadow padding + let expandedRect = CGRect( + x: max(0, window.frame.origin.x - shadowPadding - display.frame.origin.x), + y: max(0, window.frame.origin.y - shadowPadding - display.frame.origin.y), + width: window.frame.width + shadowPadding * 2, + height: window.frame.height + shadowPadding * 2 + ) + + // Get the SCDisplay for this display + let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + guard let scDisplay = content.displays.first(where: { $0.displayID == display.id }) else { + throw ScreenCaptureError.captureError(message: "Display not found for window capture") + } + + // Create filter to capture just this window on the display + // We include the window in the filter to get its shadow + let filter = SCContentFilter(display: scDisplay, including: [window]) + + // Configure capture + let config = SCStreamConfiguration() + let scaleFactor = await MainActor.run { + NSScreen.screens.first(where: { + ($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID) == display.id + })?.backingScaleFactor ?? 2.0 + } + + config.width = Int(expandedRect.width * scaleFactor) + config.height = Int(expandedRect.height * scaleFactor) + config.sourceRect = expandedRect + config.scalesToFit = false + config.pixelFormat = kCVPixelFormatType_32BGRA + config.showsCursor = false + config.colorSpaceName = CGColorSpace.sRGB + + if #available(macOS 14.0, *) { + config.captureResolution = .best + } + + // Capture + let cgImage: CGImage + do { + cgImage = try await SCScreenshotManager.captureImage( + contentFilter: filter, + configuration: config + ) + } catch { + throw ScreenCaptureError.captureFailure(underlying: error) + } + + return Screenshot( + image: cgImage, + captureDate: Date(), + sourceDisplay: display + ) } /// Captures the specified window. @@ -206,27 +248,10 @@ actor WindowCaptureService { let cocoaPoint = CGPoint(x: windowCenter.x, y: cocoaY) - #if DEBUG - print("=== SCALE FACTOR DEBUG ===") - print("Window frame (Quartz): \(windowFrame)") - print("Window center (Quartz): \(windowCenter)") - print("Primary screen height: \(primaryScreenHeight)") - print("Cocoa point: \(cocoaPoint)") - for (i, screen) in NSScreen.screens.enumerated() { - print("Screen \(i): frame=\(screen.frame), scale=\(screen.backingScaleFactor)") - } - #endif - let matchingScreen = NSScreen.screens.first { screen in screen.frame.contains(cocoaPoint) } - #if DEBUG - print("Matching screen: \(matchingScreen?.localizedName ?? "NONE")") - print("Using scale factor: \(matchingScreen?.backingScaleFactor ?? 2.0)") - print("=== END SCALE FACTOR DEBUG ===") - #endif - return matchingScreen?.backingScaleFactor ?? 2.0 } @@ -242,14 +267,6 @@ actor WindowCaptureService { config.captureResolution = .best } - #if DEBUG - print("=== CAPTURE CONFIG ===") - print("Window size (points): \(window.frame.width) x \(window.frame.height)") - print("Scale factor: \(scaleFactor)") - print("Capture size (pixels): \(config.width) x \(config.height)") - print("=== END CAPTURE CONFIG ===") - #endif - // Capture the window let cgImage: CGImage do { @@ -261,12 +278,6 @@ actor WindowCaptureService { throw ScreenCaptureError.captureFailure(underlying: error) } - #if DEBUG - print("=== CAPTURED IMAGE ===") - print("Actual image size: \(cgImage.width) x \(cgImage.height)") - print("=== END CAPTURED IMAGE ===") - #endif - // Create display info for the window's location // Get the display containing this window let display = try await findDisplayForWindow(window) diff --git a/ScreenCapture/Features/MenuBar/MenuBarController.swift b/ScreenCapture/Features/MenuBar/MenuBarController.swift index d307bcc..5964254 100644 --- a/ScreenCapture/Features/MenuBar/MenuBarController.swift +++ b/ScreenCapture/Features/MenuBar/MenuBarController.swift @@ -1,4 +1,5 @@ import AppKit +import Combine /// Manages the menu bar status item and its menu. /// Responsible for setting up the menu bar icon and building the app menu. @@ -15,9 +16,21 @@ final class MenuBarController { /// Store for recent captures private let recentCapturesStore: RecentCapturesStore + /// The main menu + private var menu: NSMenu? + /// The submenu for recent captures private var recentCapturesMenu: NSMenu? + /// Menu items that need shortcut updates + private var fullScreenMenuItem: NSMenuItem? + private var selectionMenuItem: NSMenuItem? + private var windowMenuItem: NSMenuItem? + private var windowWithShadowMenuItem: NSMenuItem? + + /// Settings observation + private var settingsObservation: Any? + // MARK: - Initialization init(appDelegate: AppDelegate, recentCapturesStore: RecentCapturesStore) { @@ -36,7 +49,11 @@ final class MenuBarController { button.image?.isTemplate = true } - statusItem?.menu = buildMenu() + menu = buildMenu() + statusItem?.menu = menu + + // Observe settings changes to update shortcuts + observeSettingsChanges() } /// Removes the status item from the menu bar @@ -45,6 +62,48 @@ final class MenuBarController { NSStatusBar.system.removeStatusItem(item) statusItem = nil } + settingsObservation = nil + } + + // MARK: - Settings Observation + + private func observeSettingsChanges() { + // Use a timer-based approach since @Observable doesn't work well with NSMenu + // Check every 0.5 seconds for changes + Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.updateShortcutsInMenu() + } + } + } + + /// Updates shortcut display in menu items + private func updateShortcutsInMenu() { + let settings = AppSettings.shared + + // Update Full Screen + if let item = fullScreenMenuItem { + item.keyEquivalent = settings.fullScreenShortcut.menuKeyEquivalent + item.keyEquivalentModifierMask = settings.fullScreenShortcut.menuModifierMask + } + + // Update Selection + if let item = selectionMenuItem { + item.keyEquivalent = settings.selectionShortcut.menuKeyEquivalent + item.keyEquivalentModifierMask = settings.selectionShortcut.menuModifierMask + } + + // Update Window + if let item = windowMenuItem { + item.keyEquivalent = settings.windowShortcut.menuKeyEquivalent + item.keyEquivalentModifierMask = settings.windowShortcut.menuModifierMask + } + + // Update Window with Shadow + if let item = windowWithShadowMenuItem { + item.keyEquivalent = settings.windowWithShadowShortcut.menuKeyEquivalent + item.keyEquivalentModifierMask = settings.windowWithShadowShortcut.menuModifierMask + } } // MARK: - Menu Construction @@ -52,36 +111,57 @@ final class MenuBarController { /// Builds the complete menu for the status item private func buildMenu() -> NSMenu { let menu = NSMenu() + menu.minimumWidth = 250 // Ensure all options are visible + + let settings = AppSettings.shared // Capture Full Screen let fullScreenItem = NSMenuItem( title: NSLocalizedString("menu.capture.full.screen", comment: "Capture Full Screen"), action: #selector(AppDelegate.captureFullScreen), - keyEquivalent: "3" + keyEquivalent: settings.fullScreenShortcut.menuKeyEquivalent ) - fullScreenItem.keyEquivalentModifierMask = [.command, .shift] + fullScreenItem.keyEquivalentModifierMask = settings.fullScreenShortcut.menuModifierMask fullScreenItem.target = appDelegate menu.addItem(fullScreenItem) + self.fullScreenMenuItem = fullScreenItem + NSLog("[MenuBar] Full screen menu item: keyEquiv=%@, modifiers=%lu, target=%@", + fullScreenItem.keyEquivalent, + UInt(fullScreenItem.keyEquivalentModifierMask.rawValue), + String(describing: fullScreenItem.target)) // Capture Selection let selectionItem = NSMenuItem( title: NSLocalizedString("menu.capture.selection", comment: "Capture Selection"), action: #selector(AppDelegate.captureSelection), - keyEquivalent: "4" + keyEquivalent: settings.selectionShortcut.menuKeyEquivalent ) - selectionItem.keyEquivalentModifierMask = [.command, .shift] + selectionItem.keyEquivalentModifierMask = settings.selectionShortcut.menuModifierMask selectionItem.target = appDelegate menu.addItem(selectionItem) + self.selectionMenuItem = selectionItem // Capture Window let windowItem = NSMenuItem( title: NSLocalizedString("menu.capture.window", comment: "Capture Window"), action: #selector(AppDelegate.captureWindow), - keyEquivalent: "6" + keyEquivalent: settings.windowShortcut.menuKeyEquivalent ) - windowItem.keyEquivalentModifierMask = [.command, .shift] + windowItem.keyEquivalentModifierMask = settings.windowShortcut.menuModifierMask windowItem.target = appDelegate menu.addItem(windowItem) + self.windowMenuItem = windowItem + + // Capture Window with Shadow + let windowShadowItem = NSMenuItem( + title: NSLocalizedString("menu.capture.window.shadow", comment: "Capture Window with Shadow"), + action: #selector(AppDelegate.captureWindowWithShadow), + keyEquivalent: settings.windowWithShadowShortcut.menuKeyEquivalent + ) + windowShadowItem.keyEquivalentModifierMask = settings.windowWithShadowShortcut.menuModifierMask + windowShadowItem.target = appDelegate + menu.addItem(windowShadowItem) + self.windowWithShadowMenuItem = windowShadowItem menu.addItem(NSMenuItem.separator()) diff --git a/ScreenCapture/Features/Preview/PreviewContentView.swift b/ScreenCapture/Features/Preview/PreviewContentView.swift index 048896a..61e0a4c 100644 --- a/ScreenCapture/Features/Preview/PreviewContentView.swift +++ b/ScreenCapture/Features/Preview/PreviewContentView.swift @@ -9,32 +9,67 @@ struct PreviewContentView: View { /// The view model driving this view @Bindable var viewModel: PreviewViewModel + /// Recent captures store for the gallery sidebar + @ObservedObject var recentCapturesStore: RecentCapturesStore + /// State for tracking the image display size and scale @State private var imageDisplaySize: CGSize = .zero @State private var imageScale: CGFloat = 1.0 @State private var imageOffset: CGPoint = .zero + /// Whether to show the recent captures gallery sidebar + @State private var isShowingGallery: Bool = false + /// Focus state for the text input field @FocusState private var isTextFieldFocused: Bool /// Environment variable for Reduce Motion preference @Environment(\.accessibilityReduceMotion) private var reduceMotion + // MARK: - Initialization + + init(viewModel: PreviewViewModel, recentCapturesStore: RecentCapturesStore = RecentCapturesStore()) { + self.viewModel = viewModel + self.recentCapturesStore = recentCapturesStore + } + // MARK: - Body var body: some View { - VStack(spacing: 0) { - // Main image view with annotation canvas - annotatedImageView - .frame(maxWidth: .infinity, maxHeight: .infinity) + HStack(spacing: 0) { + // Recent Captures Gallery sidebar (toggleable) + if isShowingGallery { + RecentCapturesGallery( + store: recentCapturesStore, + onSelect: { capture in + openCapture(capture) + }, + onReveal: { capture in + revealCapture(capture) + }, + onDelete: { capture in + recentCapturesStore.remove(capture: capture) + } + ) + .transition(.move(edge: .leading).combined(with: .opacity)) - Divider() + Divider() + } - // Info bar - infoBar - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(.bar) + // Main content + VStack(spacing: 0) { + // Main image view with annotation canvas + annotatedImageView + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + + // Info bar + infoBar + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.bar) + } } .alert( "Error", @@ -49,6 +84,20 @@ struct PreviewContentView: View { } } + // MARK: - Gallery Actions + + /// Opens a recent capture in Finder (double-click behavior) + private func openCapture(_ capture: RecentCapture) { + guard capture.fileExists else { return } + NSWorkspace.shared.open(capture.filePath) + } + + /// Reveals a capture in Finder + private func revealCapture(_ capture: RecentCapture) { + guard capture.fileExists else { return } + NSWorkspace.shared.selectFile(capture.filePath.path, inFileViewerRootedAtPath: "") + } + // MARK: - Subviews /// The main image display area with annotation overlay @@ -801,6 +850,28 @@ struct PreviewContentView: View { /// Action buttons for save, copy, etc. private var actionButtons: some View { HStack(spacing: 8) { + // Gallery toggle button + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isShowingGallery.toggle() + } + } label: { + Image(systemName: "sidebar.left") + } + .buttonStyle(.accessoryBar) + .background( + isShowingGallery + ? Color.accentColor.opacity(0.2) + : Color.clear + ) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .help(isShowingGallery ? "Hide Recent Captures" : "Show Recent Captures (G)") + .accessibilityLabel(Text(isShowingGallery ? "Hide gallery" : "Show gallery")) + + Divider() + .frame(height: 16) + .accessibilityHidden(true) + // Crop button Button { viewModel.toggleCropMode() @@ -870,6 +941,9 @@ struct PreviewContentView: View { .accessibilityLabel(Text(viewModel.isCopying ? "Copying to clipboard" : "Copy to clipboard")) .accessibilityHint(Text("Command C")) + // Drag to other apps + DraggableImageButton(image: viewModel.image, annotations: viewModel.annotations) + // Save Button { viewModel.saveScreenshot() @@ -943,6 +1017,93 @@ struct CropDimOverlay: Shape { } } +// MARK: - Draggable Image Button + +/// A button that can be dragged to other apps to drop the screenshot image. +struct DraggableImageButton: View { + let image: CGImage + let annotations: [Annotation] + + @State private var isDragging = false + + var body: some View { + Button { } label: { + Image(systemName: "square.and.arrow.up.on.square") + } + .buttonStyle(.accessoryBar) + .background(isDragging ? Color.accentColor.opacity(0.2) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .onDrag { + isDragging = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + isDragging = false + } + return createItemProvider() + } + .help("Drag to another app") + .accessibilityLabel(Text("Drag image")) + .accessibilityHint(Text("Drag to another application to share the screenshot")) + } + + private func createItemProvider() -> NSItemProvider { + // Save to a temp file so we can provide a file URL (needed for terminal apps) + let tempDir = FileManager.default.temporaryDirectory + let filename = "screenshot_\(Date().timeIntervalSince1970).png" + let tempURL = tempDir.appendingPathComponent(filename) + + // Render and save the image with annotations + do { + try ImageExporter.shared.save(image, annotations: annotations, to: tempURL, format: .png) + } catch { + // Fallback: just use the original image without annotations + if let dest = CGImageDestinationCreateWithURL(tempURL as CFURL, "public.png" as CFString, 1, nil) { + CGImageDestinationAddImage(dest, image, nil) + CGImageDestinationFinalize(dest) + } + } + + // Create provider with the file URL - this works with terminals + let provider = NSItemProvider(contentsOf: tempURL) ?? NSItemProvider() + + // Also register as NSImage for apps that prefer that + let nsImage = NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height)) + provider.registerObject(nsImage, visibility: .all) + + return provider + } + + private func renderImageWithAnnotations() -> CGImage { + // If no annotations, return original image + guard !annotations.isEmpty else { + return image + } + + // Use a temporary file approach to leverage ImageExporter + let tempDir = FileManager.default.temporaryDirectory + let tempURL = tempDir.appendingPathComponent("drag_temp_\(UUID().uuidString).png") + + do { + try ImageExporter.shared.save(image, annotations: annotations, to: tempURL, format: .png) + if let data = try? Data(contentsOf: tempURL), + let provider = CGDataProvider(data: data as CFData), + let renderedImage = CGImage( + pngDataProviderSource: provider, + decode: nil, + shouldInterpolate: true, + intent: .defaultIntent + ) { + try? FileManager.default.removeItem(at: tempURL) + return renderedImage + } + try? FileManager.default.removeItem(at: tempURL) + } catch { + // If export fails, return original image + } + + return image + } +} + // MARK: - Preview #if DEBUG diff --git a/ScreenCapture/Features/Preview/PreviewWindow.swift b/ScreenCapture/Features/Preview/PreviewWindow.swift index f71ca43..35c14d8 100644 --- a/ScreenCapture/Features/Preview/PreviewWindow.swift +++ b/ScreenCapture/Features/Preview/PreviewWindow.swift @@ -9,6 +9,9 @@ final class PreviewWindow: NSPanel { /// The view model for this preview private let viewModel: PreviewViewModel + /// The recent captures store + private let recentCapturesStore: RecentCapturesStore + /// The hosting view for SwiftUI content private var hostingView: NSHostingView? @@ -17,16 +20,19 @@ final class PreviewWindow: NSPanel { /// Creates a new preview window for the given screenshot. /// - Parameters: /// - screenshot: The screenshot to preview + /// - recentCapturesStore: The store for recent captures /// - onDismiss: Callback when the window should close /// - onSave: Callback when the screenshot is saved @MainActor init( screenshot: Screenshot, + recentCapturesStore: RecentCapturesStore, onDismiss: @escaping () -> Void, onSave: @escaping (URL) -> Void ) { // Create view model - self.viewModel = PreviewViewModel(screenshot: screenshot) + self.viewModel = PreviewViewModel(screenshot: screenshot, recentCapturesStore: recentCapturesStore) + self.recentCapturesStore = recentCapturesStore viewModel.onDismiss = onDismiss viewModel.onSave = onSave @@ -74,7 +80,7 @@ final class PreviewWindow: NSPanel { /// Sets up the SwiftUI hosting view @MainActor private func setupHostingView() { - let contentView = PreviewContentView(viewModel: viewModel) + let contentView = PreviewContentView(viewModel: viewModel, recentCapturesStore: recentCapturesStore) let hosting = NSHostingView(rootView: contentView) hosting.autoresizingMask = [.width, .height] @@ -357,6 +363,9 @@ final class PreviewWindowController: NSWindowController { /// The current preview window private var previewWindow: PreviewWindow? + /// Recent captures store (shared across previews) + private var recentCapturesStore: RecentCapturesStore? + /// Shared instance static let shared = PreviewWindowController() @@ -373,6 +382,11 @@ final class PreviewWindowController: NSWindowController { // MARK: - Public API + /// Sets the recent captures store to use for previews. + func setRecentCapturesStore(_ store: RecentCapturesStore) { + self.recentCapturesStore = store + } + /// Shows a preview window for the given screenshot. /// - Parameters: /// - screenshot: The screenshot to preview @@ -384,9 +398,16 @@ final class PreviewWindowController: NSWindowController { // Close any existing preview closePreview() + // Ensure we have a recent captures store + let store = recentCapturesStore ?? RecentCapturesStore() + if recentCapturesStore == nil { + recentCapturesStore = store + } + // Create new preview window previewWindow = PreviewWindow( screenshot: screenshot, + recentCapturesStore: store, onDismiss: { [weak self] in self?.closePreview() }, diff --git a/ScreenCapture/Features/Preview/RecentCapturesGallery.swift b/ScreenCapture/Features/Preview/RecentCapturesGallery.swift new file mode 100644 index 0000000..3b36d5a --- /dev/null +++ b/ScreenCapture/Features/Preview/RecentCapturesGallery.swift @@ -0,0 +1,235 @@ +import SwiftUI +import AppKit + +/// A sidebar gallery showing recent captures with thumbnails. +/// Allows users to quickly switch between recent screenshots or open them. +struct RecentCapturesGallery: View { + @ObservedObject var store: RecentCapturesStore + let onSelect: (RecentCapture) -> Void + let onReveal: (RecentCapture) -> Void + let onDelete: (RecentCapture) -> Void + + @State private var hoveredID: UUID? + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Label("Recent", systemImage: "clock") + .font(.headline) + .foregroundStyle(.secondary) + Spacer() + if !store.captures.isEmpty { + Button { + store.clear() + } label: { + Text("Clear") + .font(.caption) + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + + Divider() + + if store.captures.isEmpty { + // Empty state + VStack(spacing: 8) { + Spacer() + Image(systemName: "photo.on.rectangle.angled") + .font(.system(size: 32)) + .foregroundStyle(.tertiary) + Text("No recent captures") + .font(.caption) + .foregroundStyle(.tertiary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + // Capture list + ScrollView { + LazyVStack(spacing: 8) { + ForEach(store.captures) { capture in + CaptureCard( + capture: capture, + isHovered: hoveredID == capture.id, + onSelect: { onSelect(capture) }, + onReveal: { onReveal(capture) }, + onDelete: { onDelete(capture) } + ) + .onHover { isHovered in + hoveredID = isHovered ? capture.id : nil + } + } + } + .padding(8) + } + } + } + .frame(width: 180) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.5)) + } +} + +// MARK: - Capture Card + +/// A single capture card in the gallery showing thumbnail and metadata. +private struct CaptureCard: View { + let capture: RecentCapture + let isHovered: Bool + let onSelect: () -> Void + let onReveal: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack(spacing: 0) { + // Thumbnail + ZStack { + if let thumbnailData = capture.thumbnailData, + let nsImage = NSImage(data: thumbnailData) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 90) + .clipped() + } else { + // Placeholder + Rectangle() + .fill(Color.gray.opacity(0.2)) + .frame(height: 90) + .overlay { + Image(systemName: "photo") + .font(.title2) + .foregroundStyle(.tertiary) + } + } + + // Hover overlay with actions + if isHovered { + Color.black.opacity(0.4) + .overlay { + HStack(spacing: 12) { + Button { + onReveal() + } label: { + Image(systemName: "folder") + .font(.title3) + } + .buttonStyle(.plain) + .foregroundStyle(.white) + .help("Show in Finder") + + Button { + onDelete() + } label: { + Image(systemName: "trash") + .font(.title3) + } + .buttonStyle(.plain) + .foregroundStyle(.white) + .help("Remove from recent") + } + } + } + + // File exists indicator + if !capture.fileExists { + VStack { + HStack { + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + .font(.caption) + .padding(4) + } + Spacer() + } + } + } + .frame(height: 90) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + // Metadata + VStack(alignment: .leading, spacing: 2) { + Text(capture.filename) + .font(.caption) + .lineLimit(1) + .truncationMode(.middle) + + Text(capture.captureDate.formatted(date: .abbreviated, time: .shortened)) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + .padding(.vertical, 4) + } + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isHovered ? Color.accentColor.opacity(0.1) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isHovered ? Color.accentColor.opacity(0.3) : Color.clear, lineWidth: 1) + ) + .contentShape(Rectangle()) + .onTapGesture { + onSelect() + } + } +} + +// MARK: - Gallery Toggle Button + +/// Button to toggle the gallery sidebar visibility. +struct GalleryToggleButton: View { + @Binding var isShowingGallery: Bool + + var body: some View { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isShowingGallery.toggle() + } + } label: { + Image(systemName: isShowingGallery ? "sidebar.left" : "sidebar.left") + .symbolVariant(isShowingGallery ? .none : .none) + } + .buttonStyle(.accessoryBar) + .background( + isShowingGallery + ? Color.accentColor.opacity(0.2) + : Color.clear + ) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .help(isShowingGallery ? "Hide Recent Captures" : "Show Recent Captures") + } +} + +// MARK: - Preview + +#if DEBUG +#Preview("Gallery with captures") { + let store = RecentCapturesStore() + return RecentCapturesGallery( + store: store, + onSelect: { _ in }, + onReveal: { _ in }, + onDelete: { _ in } + ) + .frame(height: 400) +} + +#Preview("Empty gallery") { + let store = RecentCapturesStore() + return RecentCapturesGallery( + store: store, + onSelect: { _ in }, + onReveal: { _ in }, + onDelete: { _ in } + ) + .frame(height: 400) +} +#endif diff --git a/ScreenCapture/Features/Settings/SettingsView.swift b/ScreenCapture/Features/Settings/SettingsView.swift index 4ea05d5..cf192b2 100644 --- a/ScreenCapture/Features/Settings/SettingsView.swift +++ b/ScreenCapture/Features/Settings/SettingsView.swift @@ -1,80 +1,49 @@ import SwiftUI import AppKit -/// Main settings view with all preference controls. -/// Organized into sections: General, Export, Keyboard Shortcuts, and Annotations. +/// Settings tab enumeration +enum SettingsTab: String, CaseIterable, Identifiable { + case general = "General" + case shortcuts = "Shortcuts" + case annotations = "Annotations" + + var id: String { rawValue } + + var icon: String { + switch self { + case .general: return "gearshape" + case .shortcuts: return "keyboard" + case .annotations: return "pencil.tip.crop.circle" + } + } +} + +/// Main settings view with modern tabbed interface. struct SettingsView: View { @Bindable var viewModel: SettingsViewModel + @State private var selectedTab: SettingsTab = .general var body: some View { - Form { - // Permissions Section - Section { - PermissionRow(viewModel: viewModel) - } header: { - Label("Permissions", systemImage: "lock.shield") - } - - // General Settings Section - Section { - SaveLocationPicker(viewModel: viewModel) - } header: { - Label("General", systemImage: "gearshape") - } - - // Export Settings Section - Section { - ExportFormatPicker(viewModel: viewModel) - if viewModel.defaultFormat == .jpeg { - JPEGQualitySlider(viewModel: viewModel) + TabView(selection: $selectedTab) { + GeneralSettingsTab(viewModel: viewModel) + .tabItem { + Label("General", systemImage: "gearshape") } - } header: { - Label("Export", systemImage: "square.and.arrow.up") - } + .tag(SettingsTab.general) - // Keyboard Shortcuts Section - Section { - ShortcutRecorder( - label: "Full Screen Capture", - shortcut: viewModel.fullScreenShortcut, - isRecording: viewModel.isRecordingFullScreenShortcut, - onRecord: { viewModel.startRecordingFullScreenShortcut() }, - onReset: { viewModel.resetFullScreenShortcut() } - ) - - ShortcutRecorder( - label: "Selection Capture", - shortcut: viewModel.selectionShortcut, - isRecording: viewModel.isRecordingSelectionShortcut, - onRecord: { viewModel.startRecordingSelectionShortcut() }, - onReset: { viewModel.resetSelectionShortcut() } - ) - } header: { - Label("Keyboard Shortcuts", systemImage: "keyboard") - } - - // Annotation Settings Section - Section { - StrokeColorPicker(viewModel: viewModel) - StrokeWidthSlider(viewModel: viewModel) - TextSizeSlider(viewModel: viewModel) - } header: { - Label("Annotations", systemImage: "pencil.tip.crop.circle") - } + ShortcutsSettingsTab(viewModel: viewModel) + .tabItem { + Label("Shortcuts", systemImage: "keyboard") + } + .tag(SettingsTab.shortcuts) - // Reset Section - Section { - Button(role: .destructive) { - viewModel.resetAllToDefaults() - } label: { - Label("Reset All to Defaults", systemImage: "arrow.counterclockwise") + AnnotationsSettingsTab(viewModel: viewModel) + .tabItem { + Label("Annotations", systemImage: "pencil.tip.crop.circle") } - .buttonStyle(.plain) - .foregroundStyle(.red) - } + .tag(SettingsTab.annotations) } - .formStyle(.grouped) - .frame(minWidth: 450, minHeight: 500) + .frame(width: 500, height: 420) .alert("Error", isPresented: $viewModel.showErrorAlert) { Button("OK") { viewModel.errorMessage = nil @@ -87,206 +56,378 @@ struct SettingsView: View { } } -// MARK: - Permission Row +// MARK: - General Settings Tab -/// Row showing permission status with action button. -private struct PermissionRow: View { +/// General settings including permissions, save location, and export format. +private struct GeneralSettingsTab: View { @Bindable var viewModel: SettingsViewModel var body: some View { - VStack(alignment: .leading, spacing: 16) { - // Screen Recording permission - PermissionItem( - icon: "record.circle", - title: "Screen Recording", - hint: "Required to capture screenshots", - isGranted: viewModel.hasScreenRecordingPermission, - isChecking: viewModel.isCheckingPermissions, - onGrant: { viewModel.requestScreenRecordingPermission() } - ) - - Divider() - - // Folder Access permission - PermissionItem( - icon: "folder", - title: "Save Location Access", - hint: "Required to save screenshots to the selected folder", - isGranted: viewModel.hasFolderAccessPermission, - isChecking: viewModel.isCheckingPermissions, - onGrant: { viewModel.requestFolderAccess() } - ) - - HStack { - Spacer() - Button { + ScrollView { + VStack(spacing: 20) { + // Permissions Card + SettingsCard(title: "Permissions", icon: "lock.shield") { + VStack(spacing: 12) { + PermissionRow( + icon: "record.circle", + title: "Screen Recording", + description: "Required to capture screenshots", + isGranted: viewModel.hasScreenRecordingPermission, + isChecking: viewModel.isCheckingPermissions, + onGrant: { viewModel.requestScreenRecordingPermission() } + ) + + Divider() + + PermissionRow( + icon: "folder", + title: "Save Location", + description: "Required to save screenshots", + isGranted: viewModel.hasFolderAccessPermission, + isChecking: viewModel.isCheckingPermissions, + onGrant: { viewModel.requestFolderAccess() } + ) + + HStack { + Spacer() + Button { + viewModel.checkPermissions() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + .font(.caption) + } + .buttonStyle(.borderless) + } + } + } + .onAppear { viewModel.checkPermissions() - } label: { - Label("Refresh", systemImage: "arrow.clockwise") } - .buttonStyle(.borderless) - } - } - .onAppear { - viewModel.checkPermissions() - } - } -} -/// Individual permission item row -private struct PermissionItem: View { - let icon: String - let title: String - let hint: String - let isGranted: Bool - let isChecking: Bool - let onGrant: () -> Void + // Save Location Card + SettingsCard(title: "Save Location", icon: "folder") { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.saveLocationPath) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + } - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - HStack(spacing: 8) { - Image(systemName: icon) - .foregroundStyle(.secondary) - .frame(width: 20) - Text(title) + Spacer() + + Button("Choose...") { + viewModel.selectSaveLocation() + } + + Button { + viewModel.revealSaveLocation() + } label: { + Image(systemName: "arrow.right.circle") + } + .buttonStyle(.borderless) + .help("Reveal in Finder") + } } - Spacer() - - if isChecking { - ProgressView() - .controlSize(.small) - } else { - HStack(spacing: 8) { - if isGranted { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text("Granted") - .foregroundStyle(.secondary) - } else { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.red) + // Export Format Card + SettingsCard(title: "Export Format", icon: "photo") { + VStack(spacing: 12) { + Picker("Format", selection: $viewModel.defaultFormat) { + Text("PNG").tag(ExportFormat.png) + Text("JPEG").tag(ExportFormat.jpeg) + } + .pickerStyle(.segmented) + + if viewModel.defaultFormat == .jpeg { + VStack(spacing: 8) { + HStack { + Text("Quality") + .foregroundStyle(.secondary) + Spacer() + Text("\(Int(viewModel.jpegQualityPercentage))%") + .monospacedDigit() + .foregroundStyle(.secondary) + } - Button { - onGrant() - } label: { - Text("Grant Access") + Slider( + value: $viewModel.jpegQuality, + in: SettingsViewModel.jpegQualityRange, + step: 0.05 + ) + + Text("Higher quality = larger file size") + .font(.caption) + .foregroundStyle(.tertiary) } - .buttonStyle(.borderedProminent) - .controlSize(.small) } } } - } - if !isGranted && !isChecking { - Text(hint) - .font(.caption) - .foregroundStyle(.secondary) + // Reset Button + HStack { + Spacer() + Button(role: .destructive) { + viewModel.resetAllToDefaults() + } label: { + Label("Reset All Settings", systemImage: "arrow.counterclockwise") + } + .buttonStyle(.borderless) + .foregroundStyle(.red) + } + .padding(.top, 8) } + .padding(20) } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text("\(title): \(isGranted ? "Granted" : "Not Granted")")) } } -// MARK: - Save Location Picker +// MARK: - Shortcuts Settings Tab -/// Picker for selecting the default save location. -private struct SaveLocationPicker: View { +/// Keyboard shortcuts configuration tab. +private struct ShortcutsSettingsTab: View { @Bindable var viewModel: SettingsViewModel var body: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Save Location") - .font(.headline) - Text(viewModel.saveLocationPath) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) + ScrollView { + VStack(spacing: 20) { + SettingsCard(title: "Capture Shortcuts", icon: "camera") { + VStack(spacing: 16) { + ShortcutRow( + icon: "rectangle.dashed", + label: "Full Screen", + shortcut: viewModel.fullScreenShortcut, + isRecording: viewModel.isRecordingFullScreenShortcut, + onRecord: { viewModel.startRecordingFullScreenShortcut() }, + onReset: { viewModel.resetFullScreenShortcut() } + ) + + Divider() + + ShortcutRow( + icon: "crop", + label: "Selection", + shortcut: viewModel.selectionShortcut, + isRecording: viewModel.isRecordingSelectionShortcut, + onRecord: { viewModel.startRecordingSelectionShortcut() }, + onReset: { viewModel.resetSelectionShortcut() } + ) + + Divider() + + ShortcutRow( + icon: "macwindow", + label: "Window", + shortcut: viewModel.windowShortcut, + isRecording: viewModel.isRecordingWindowShortcut, + onRecord: { viewModel.startRecordingWindowShortcut() }, + onReset: { viewModel.resetWindowShortcut() } + ) + + Divider() + + ShortcutRow( + icon: "macwindow.on.rectangle", + label: "Window + Shadow", + shortcut: viewModel.windowWithShadowShortcut, + isRecording: viewModel.isRecordingWindowWithShadowShortcut, + onRecord: { viewModel.startRecordingWindowWithShadowShortcut() }, + onReset: { viewModel.resetWindowWithShadowShortcut() } + ) + } + } + + // Instructions + HStack(spacing: 8) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + Text("Click on a shortcut to change it. Press Escape to cancel.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 4) } + .padding(20) + } + } +} - Spacer() +// MARK: - Annotations Settings Tab - Button { - viewModel.selectSaveLocation() - } label: { - Text("Choose...") - } +/// Annotation tools configuration tab. +private struct AnnotationsSettingsTab: View { + @Bindable var viewModel: SettingsViewModel - Button { - viewModel.revealSaveLocation() - } label: { - Image(systemName: "folder") + var body: some View { + ScrollView { + VStack(spacing: 20) { + // Color Selection Card + SettingsCard(title: "Stroke Color", icon: "paintpalette") { + VStack(spacing: 12) { + // Preset Colors Grid + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 9), spacing: 8) { + ForEach(SettingsViewModel.presetColors, id: \.self) { color in + ColorButton( + color: color, + isSelected: colorsAreEqual(viewModel.strokeColor, color), + action: { viewModel.strokeColor = color } + ) + } + } + + HStack { + Text("Custom:") + .foregroundStyle(.secondary) + ColorPicker("", selection: $viewModel.strokeColor, supportsOpacity: false) + .labelsHidden() + Spacer() + } + } + } + + // Stroke Width Card + SettingsCard(title: "Stroke Width", icon: "lineweight") { + VStack(spacing: 8) { + HStack { + Slider( + value: $viewModel.strokeWidth, + in: SettingsViewModel.strokeWidthRange, + step: 0.5 + ) + + Text("\(viewModel.strokeWidth, specifier: "%.1f") pt") + .font(.system(.body, design: .monospaced)) + .frame(width: 60, alignment: .trailing) + } + + // Visual Preview + HStack { + Text("Preview:") + .foregroundStyle(.secondary) + Spacer() + RoundedRectangle(cornerRadius: viewModel.strokeWidth / 2) + .fill(viewModel.strokeColor) + .frame(width: 100, height: max(viewModel.strokeWidth, 2)) + } + } + } + + // Text Size Card + SettingsCard(title: "Text Size", icon: "textformat.size") { + VStack(spacing: 8) { + HStack { + Slider( + value: $viewModel.textSize, + in: SettingsViewModel.textSizeRange, + step: 1 + ) + + Text("\(Int(viewModel.textSize)) pt") + .font(.system(.body, design: .monospaced)) + .frame(width: 60, alignment: .trailing) + } + + // Visual Preview + HStack { + Text("Preview:") + .foregroundStyle(.secondary) + Spacer() + Text("Aa") + .font(.system(size: min(viewModel.textSize, 32))) + .foregroundStyle(viewModel.strokeColor) + } + } + } } - .help("Show in Finder") + .padding(20) } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text("Save Location: \(viewModel.saveLocationPath)")) + } + + /// Compare colors approximately + private func colorsAreEqual(_ a: Color, _ b: Color) -> Bool { + let nsA = NSColor(a).usingColorSpace(.deviceRGB) + let nsB = NSColor(b).usingColorSpace(.deviceRGB) + guard let colorA = nsA, let colorB = nsB else { return false } + + let tolerance: CGFloat = 0.01 + return abs(colorA.redComponent - colorB.redComponent) < tolerance && + abs(colorA.greenComponent - colorB.greenComponent) < tolerance && + abs(colorA.blueComponent - colorB.blueComponent) < tolerance } } -// MARK: - Export Format Picker +// MARK: - Reusable Components -/// Picker for selecting the default export format (PNG/JPEG). -private struct ExportFormatPicker: View { - @Bindable var viewModel: SettingsViewModel +/// A card container for settings sections. +private struct SettingsCard: View { + let title: String + let icon: String + @ViewBuilder let content: Content var body: some View { - Picker("Default Format", selection: $viewModel.defaultFormat) { - Text("PNG").tag(ExportFormat.png) - Text("JPEG").tag(ExportFormat.jpeg) + VStack(alignment: .leading, spacing: 12) { + Label(title, systemImage: icon) + .font(.headline) + .foregroundStyle(.primary) + + content } - .pickerStyle(.segmented) - .accessibilityLabel(Text("Export Format")) + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 10)) } } -// MARK: - JPEG Quality Slider - -/// Slider for adjusting JPEG compression quality. -private struct JPEGQualitySlider: View { - @Bindable var viewModel: SettingsViewModel +/// A single permission row with status and action. +private struct PermissionRow: View { + let icon: String + let title: String + let description: String + let isGranted: Bool + let isChecking: Bool + let onGrant: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("JPEG Quality") - Spacer() - Text("\(Int(viewModel.jpegQualityPercentage))%") - .foregroundStyle(.secondary) - .monospacedDigit() + HStack(spacing: 12) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(isGranted ? .green : .secondary) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .fontWeight(.medium) + if !isGranted && !isChecking { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } } - Slider( - value: $viewModel.jpegQuality, - in: SettingsViewModel.jpegQualityRange, - step: 0.05 - ) { - Text("JPEG Quality") - } minimumValueLabel: { - Text("10%") - .font(.caption) - } maximumValueLabel: { - Text("100%") - .font(.caption) - } - .accessibilityValue(Text("\(Int(viewModel.jpegQualityPercentage)) percent")) + Spacer() - Text("Higher quality results in larger file sizes") - .font(.caption) - .foregroundStyle(.secondary) + if isChecking { + ProgressView() + .controlSize(.small) + } else if isGranted { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.title3) + } else { + Button("Grant") { + onGrant() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } } } } -// MARK: - Shortcut Recorder - -/// A control for recording keyboard shortcuts. -private struct ShortcutRecorder: View { +/// A single shortcut configuration row. +private struct ShortcutRow: View { + let icon: String let label: String let shortcut: KeyboardShortcut let isRecording: Bool @@ -294,192 +435,74 @@ private struct ShortcutRecorder: View { let onReset: () -> Void var body: some View { - HStack { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.title3) + .foregroundStyle(.secondary) + .frame(width: 24) + Text(label) Spacer() - if isRecording { - Text("Press keys...") - .foregroundStyle(.secondary) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.accentColor.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } else { - Button { - onRecord() - } label: { - Text(shortcut.displayString) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.secondary.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 6)) + Button { + onRecord() + } label: { + HStack(spacing: 4) { + if isRecording { + Text("Recording...") + .foregroundStyle(.secondary) + } else { + Text(shortcut.displayString) + .fontWeight(.medium) + } } - .buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isRecording ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) } + .buttonStyle(.plain) Button { onReset() } label: { Image(systemName: "arrow.counterclockwise") + .font(.caption) } .buttonStyle(.borderless) .help("Reset to default") .disabled(isRecording) } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text("\(label): \(shortcut.displayString)")) } } -// MARK: - Stroke Color Picker - -/// Color picker for annotation stroke color. -private struct StrokeColorPicker: View { - @Bindable var viewModel: SettingsViewModel +/// A color selection button. +private struct ColorButton: View { + let color: Color + let isSelected: Bool + let action: () -> Void var body: some View { - HStack { - Text("Stroke Color") - - Spacer() - - // Preset color buttons - HStack(spacing: 4) { - ForEach(SettingsViewModel.presetColors, id: \.self) { color in - Button { - viewModel.strokeColor = color - } label: { + Button(action: action) { + Circle() + .fill(color) + .frame(width: 28, height: 28) + .overlay { + if isSelected { Circle() - .fill(color) - .frame(width: 20, height: 20) - .overlay { - if colorsAreEqual(viewModel.strokeColor, color) { - Circle() - .stroke(Color.primary, lineWidth: 2) - } - } - .overlay { - // Add border for light colors - if color == .white || color == .yellow { - Circle() - .stroke(Color.gray.opacity(0.3), lineWidth: 1) - } - } + .stroke(Color.primary, lineWidth: 2.5) + .padding(2) } - .buttonStyle(.plain) - .accessibilityLabel(Text(colorName(for: color))) } - } - - // Custom color picker - ColorPicker("", selection: $viewModel.strokeColor, supportsOpacity: false) - .labelsHidden() - .frame(width: 30) - } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text("Stroke Color")) - } - - /// Compare colors approximately - private func colorsAreEqual(_ a: Color, _ b: Color) -> Bool { - // Convert to NSColor for comparison - let nsA = NSColor(a).usingColorSpace(.deviceRGB) - let nsB = NSColor(b).usingColorSpace(.deviceRGB) - guard let colorA = nsA, let colorB = nsB else { return false } - - let tolerance: CGFloat = 0.01 - return abs(colorA.redComponent - colorB.redComponent) < tolerance && - abs(colorA.greenComponent - colorB.greenComponent) < tolerance && - abs(colorA.blueComponent - colorB.blueComponent) < tolerance - } - - /// Get accessible color name - private func colorName(for color: Color) -> String { - switch color { - case .red: return "Red" - case .orange: return "Orange" - case .yellow: return "Yellow" - case .green: return "Green" - case .blue: return "Blue" - case .purple: return "Purple" - case .pink: return "Pink" - case .white: return "White" - case .black: return "Black" - default: return "Custom" - } - } -} - -// MARK: - Stroke Width Slider - -/// Slider for adjusting annotation stroke width. -private struct StrokeWidthSlider: View { - @Bindable var viewModel: SettingsViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Stroke Width") - Spacer() - Text("\(viewModel.strokeWidth, specifier: "%.1f") pt") - .foregroundStyle(.secondary) - .monospacedDigit() - } - - HStack(spacing: 12) { - Slider( - value: $viewModel.strokeWidth, - in: SettingsViewModel.strokeWidthRange, - step: 0.5 - ) { - Text("Stroke Width") - } - .accessibilityValue(Text("\(viewModel.strokeWidth, specifier: "%.1f") points")) - - // Preview of stroke width - RoundedRectangle(cornerRadius: viewModel.strokeWidth / 2) - .fill(viewModel.strokeColor) - .frame(width: 40, height: viewModel.strokeWidth) - } - } - } -} - -// MARK: - Text Size Slider - -/// Slider for adjusting text annotation font size. -private struct TextSizeSlider: View { - @Bindable var viewModel: SettingsViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Text Size") - Spacer() - Text("\(Int(viewModel.textSize)) pt") - .foregroundStyle(.secondary) - .monospacedDigit() - } - - HStack(spacing: 12) { - Slider( - value: $viewModel.textSize, - in: SettingsViewModel.textSizeRange, - step: 1 - ) { - Text("Text Size") + .overlay { + if color == .white || color == .yellow { + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + } } - .accessibilityValue(Text("\(Int(viewModel.textSize)) points")) - - // Preview of text size - Text("Aa") - .font(.system(size: min(viewModel.textSize, 24))) - .foregroundStyle(viewModel.strokeColor) - .frame(width: 40) - } } + .buttonStyle(.plain) } } @@ -488,6 +511,5 @@ private struct TextSizeSlider: View { #if DEBUG #Preview { SettingsView(viewModel: SettingsViewModel()) - .frame(width: 500, height: 600) } #endif diff --git a/ScreenCapture/Features/Settings/SettingsViewModel.swift b/ScreenCapture/Features/Settings/SettingsViewModel.swift index d7efb8e..fef5c9e 100644 --- a/ScreenCapture/Features/Settings/SettingsViewModel.swift +++ b/ScreenCapture/Features/Settings/SettingsViewModel.swift @@ -20,6 +20,8 @@ final class SettingsViewModel { /// Whether a shortcut is currently being recorded var isRecordingFullScreenShortcut = false var isRecordingSelectionShortcut = false + var isRecordingWindowShortcut = false + var isRecordingWindowWithShadowShortcut = false /// Temporary storage for shortcut recording var recordedShortcut: KeyboardShortcut? @@ -88,6 +90,24 @@ final class SettingsViewModel { } } + /// Window capture shortcut + var windowShortcut: KeyboardShortcut { + get { settings.windowShortcut } + set { + settings.windowShortcut = newValue + appDelegate?.updateHotkeys() + } + } + + /// Window with shadow capture shortcut + var windowWithShadowShortcut: KeyboardShortcut { + get { settings.windowWithShadowShortcut } + set { + settings.windowWithShadowShortcut = newValue + appDelegate?.updateHotkeys() + } + } + /// Annotation stroke color var strokeColor: Color { get { settings.strokeColor.color } @@ -247,6 +267,8 @@ final class SettingsViewModel { func startRecordingFullScreenShortcut() { isRecordingFullScreenShortcut = true isRecordingSelectionShortcut = false + isRecordingWindowShortcut = false + isRecordingWindowWithShadowShortcut = false recordedShortcut = nil } @@ -254,6 +276,26 @@ final class SettingsViewModel { func startRecordingSelectionShortcut() { isRecordingFullScreenShortcut = false isRecordingSelectionShortcut = true + isRecordingWindowShortcut = false + isRecordingWindowWithShadowShortcut = false + recordedShortcut = nil + } + + /// Starts recording a keyboard shortcut for window capture + func startRecordingWindowShortcut() { + isRecordingFullScreenShortcut = false + isRecordingSelectionShortcut = false + isRecordingWindowShortcut = true + isRecordingWindowWithShadowShortcut = false + recordedShortcut = nil + } + + /// Starts recording a keyboard shortcut for window with shadow capture + func startRecordingWindowWithShadowShortcut() { + isRecordingFullScreenShortcut = false + isRecordingSelectionShortcut = false + isRecordingWindowShortcut = false + isRecordingWindowWithShadowShortcut = true recordedShortcut = nil } @@ -261,6 +303,8 @@ final class SettingsViewModel { func cancelRecording() { isRecordingFullScreenShortcut = false isRecordingSelectionShortcut = false + isRecordingWindowShortcut = false + isRecordingWindowWithShadowShortcut = false recordedShortcut = nil } @@ -268,7 +312,9 @@ final class SettingsViewModel { /// - Parameter event: The key event /// - Returns: Whether the event was handled func handleKeyEvent(_ event: NSEvent) -> Bool { - guard isRecordingFullScreenShortcut || isRecordingSelectionShortcut else { + let isRecording = isRecordingFullScreenShortcut || isRecordingSelectionShortcut || + isRecordingWindowShortcut || isRecordingWindowWithShadowShortcut + guard isRecording else { return false } @@ -291,20 +337,29 @@ final class SettingsViewModel { } // Check for conflicts with other shortcuts - if isRecordingFullScreenShortcut && shortcut == selectionShortcut { - showError("This shortcut is already used for Selection Capture") - return true - } - if isRecordingSelectionShortcut && shortcut == fullScreenShortcut { - showError("This shortcut is already used for Full Screen Capture") - return true + let allShortcuts = [ + ("Full Screen Capture", fullScreenShortcut, isRecordingFullScreenShortcut), + ("Selection Capture", selectionShortcut, isRecordingSelectionShortcut), + ("Window Capture", windowShortcut, isRecordingWindowShortcut), + ("Window with Shadow", windowWithShadowShortcut, isRecordingWindowWithShadowShortcut) + ] + + for (name, existingShortcut, isCurrentlyRecording) in allShortcuts { + if !isCurrentlyRecording && shortcut == existingShortcut { + showError("This shortcut is already used for \(name)") + return true + } } // Apply the shortcut if isRecordingFullScreenShortcut { fullScreenShortcut = shortcut - } else { + } else if isRecordingSelectionShortcut { selectionShortcut = shortcut + } else if isRecordingWindowShortcut { + windowShortcut = shortcut + } else if isRecordingWindowWithShadowShortcut { + windowWithShadowShortcut = shortcut } // End recording @@ -322,6 +377,16 @@ final class SettingsViewModel { selectionShortcut = .selectionDefault } + /// Resets window shortcut to default + func resetWindowShortcut() { + windowShortcut = .windowDefault + } + + /// Resets window with shadow shortcut to default + func resetWindowWithShadowShortcut() { + windowWithShadowShortcut = .windowWithShadowDefault + } + /// Resets all settings to defaults func resetAllToDefaults() { settings.resetToDefaults() diff --git a/ScreenCapture/Models/AppSettings.swift b/ScreenCapture/Models/AppSettings.swift index 6dac0cd..ce01e5a 100644 --- a/ScreenCapture/Models/AppSettings.swift +++ b/ScreenCapture/Models/AppSettings.swift @@ -21,6 +21,7 @@ final class AppSettings { static let fullScreenShortcut = prefix + "fullScreenShortcut" static let selectionShortcut = prefix + "selectionShortcut" static let windowShortcut = prefix + "windowShortcut" + static let windowWithShadowShortcut = prefix + "windowWithShadowShortcut" static let strokeColor = prefix + "strokeColor" static let strokeWidth = prefix + "strokeWidth" static let textSize = prefix + "textSize" @@ -60,6 +61,11 @@ final class AppSettings { didSet { saveShortcut(windowShortcut, forKey: Keys.windowShortcut) } } + /// Global hotkey for window capture with shadow + var windowWithShadowShortcut: KeyboardShortcut { + didSet { saveShortcut(windowWithShadowShortcut, forKey: Keys.windowWithShadowShortcut) } + } + /// Default annotation stroke color var strokeColor: CodableColor { didSet { saveColor(strokeColor, forKey: Keys.strokeColor) } @@ -121,6 +127,8 @@ final class AppSettings { ?? KeyboardShortcut.selectionDefault windowShortcut = Self.loadShortcut(forKey: Keys.windowShortcut) ?? KeyboardShortcut.windowDefault + windowWithShadowShortcut = Self.loadShortcut(forKey: Keys.windowWithShadowShortcut) + ?? KeyboardShortcut.windowWithShadowDefault // Load annotation defaults strokeColor = Self.loadColor(forKey: Keys.strokeColor) ?? .red @@ -172,6 +180,7 @@ final class AppSettings { fullScreenShortcut = .fullScreenDefault selectionShortcut = .selectionDefault windowShortcut = .windowDefault + windowWithShadowShortcut = .windowWithShadowDefault strokeColor = .red strokeWidth = 2.0 textSize = 14.0 diff --git a/ScreenCapture/Models/KeyboardShortcut.swift b/ScreenCapture/Models/KeyboardShortcut.swift index eb8cd1f..88037ef 100644 --- a/ScreenCapture/Models/KeyboardShortcut.swift +++ b/ScreenCapture/Models/KeyboardShortcut.swift @@ -25,16 +25,18 @@ struct KeyboardShortcut: Equatable, Codable, Sendable { // MARK: - Default Shortcuts - /// Default full screen capture shortcut: Command + Shift + 3 + /// Default full screen capture shortcut: Command + Control + 3 + /// Note: Cmd+Shift+3 conflicts with macOS system screenshot static let fullScreenDefault = KeyboardShortcut( keyCode: UInt32(kVK_ANSI_3), - modifiers: UInt32(cmdKey | shiftKey) + modifiers: UInt32(cmdKey | controlKey) ) - /// Default selection capture shortcut: Command + Shift + 4 + /// Default selection capture shortcut: Command + Control + 4 + /// Note: Cmd+Shift+4 conflicts with macOS system screenshot static let selectionDefault = KeyboardShortcut( keyCode: UInt32(kVK_ANSI_4), - modifiers: UInt32(cmdKey | shiftKey) + modifiers: UInt32(cmdKey | controlKey) ) /// Default window capture shortcut: Command + Shift + 6 @@ -43,6 +45,12 @@ struct KeyboardShortcut: Equatable, Codable, Sendable { modifiers: UInt32(cmdKey | shiftKey) ) + /// Default window with shadow capture shortcut: Command + Shift + 7 + static let windowWithShadowDefault = KeyboardShortcut( + keyCode: UInt32(kVK_ANSI_7), + modifiers: UInt32(cmdKey | shiftKey) + ) + // MARK: - Validation /// Checks if the shortcut includes at least one modifier key @@ -148,6 +156,64 @@ struct KeyboardShortcut: Equatable, Codable, Sendable { return flags } + // MARK: - NSMenuItem Support + + /// Returns the key equivalent string for NSMenuItem (lowercase character) + var menuKeyEquivalent: String { + Self.keyCodeToMenuEquivalent(keyCode) ?? "" + } + + /// Returns the modifier mask for NSMenuItem + var menuModifierMask: NSEvent.ModifierFlags { + nsModifierFlags + } + + /// Converts a virtual key code to NSMenuItem key equivalent (lowercase) + private static func keyCodeToMenuEquivalent(_ keyCode: UInt32) -> String? { + switch Int(keyCode) { + case kVK_ANSI_0: return "0" + case kVK_ANSI_1: return "1" + case kVK_ANSI_2: return "2" + case kVK_ANSI_3: return "3" + case kVK_ANSI_4: return "4" + case kVK_ANSI_5: return "5" + case kVK_ANSI_6: return "6" + case kVK_ANSI_7: return "7" + case kVK_ANSI_8: return "8" + case kVK_ANSI_9: return "9" + case kVK_ANSI_A: return "a" + case kVK_ANSI_B: return "b" + case kVK_ANSI_C: return "c" + case kVK_ANSI_D: return "d" + case kVK_ANSI_E: return "e" + case kVK_ANSI_F: return "f" + case kVK_ANSI_G: return "g" + case kVK_ANSI_H: return "h" + case kVK_ANSI_I: return "i" + case kVK_ANSI_J: return "j" + case kVK_ANSI_K: return "k" + case kVK_ANSI_L: return "l" + case kVK_ANSI_M: return "m" + case kVK_ANSI_N: return "n" + case kVK_ANSI_O: return "o" + case kVK_ANSI_P: return "p" + case kVK_ANSI_Q: return "q" + case kVK_ANSI_R: return "r" + case kVK_ANSI_S: return "s" + case kVK_ANSI_T: return "t" + case kVK_ANSI_U: return "u" + case kVK_ANSI_V: return "v" + case kVK_ANSI_W: return "w" + case kVK_ANSI_X: return "x" + case kVK_ANSI_Y: return "y" + case kVK_ANSI_Z: return "z" + case kVK_Space: return " " + case kVK_Return: return "\r" + case kVK_Tab: return "\t" + default: return nil + } + } + // MARK: - Key Code to String /// Converts a virtual key code to its string representation diff --git a/ScreenCapture/Resources/Localizable.strings b/ScreenCapture/Resources/Localizable.strings index c804142..2e839bc 100644 --- a/ScreenCapture/Resources/Localizable.strings +++ b/ScreenCapture/Resources/Localizable.strings @@ -131,3 +131,7 @@ /* Settings - Window Shortcut */ "settings.shortcut.window" = "Window Capture"; +"settings.shortcut.window.shadow" = "Window with Shadow"; + +/* Menu - Window with Shadow */ +"menu.capture.window.shadow" = "Capture Window with Shadow"; diff --git a/ScreenCapture/Services/HotkeyManager.swift b/ScreenCapture/Services/HotkeyManager.swift index 81d783c..b761556 100644 --- a/ScreenCapture/Services/HotkeyManager.swift +++ b/ScreenCapture/Services/HotkeyManager.swift @@ -132,6 +132,7 @@ actor HotkeyManager { /// Called when a hotkey event is received nonisolated func handleHotkeyEvent(id: UInt32) { + NSLog("[HotkeyManager] handleHotkeyEvent called with id: %u", id) Task { await invokeHandler(for: id) } @@ -139,7 +140,12 @@ actor HotkeyManager { /// Invokes the handler for the given hotkey ID private func invokeHandler(for id: UInt32) { - guard let handler = handlers[id] else { return } + NSLog("[HotkeyManager] invokeHandler for id: %u", id) + guard let handler = handlers[id] else { + NSLog("[HotkeyManager] No handler found for id: %u", id) + return + } + NSLog("[HotkeyManager] Calling handler") handler() } @@ -191,8 +197,11 @@ private func hotkeyEventHandler( _ event: EventRef?, _ userData: UnsafeMutableRawPointer? ) -> OSStatus { + NSLog("[HotkeyManager] hotkeyEventHandler called!") + guard let event = event, let userData = userData else { + NSLog("[HotkeyManager] Missing event or userData") return OSStatus(eventNotHandledErr) } @@ -209,9 +218,12 @@ private func hotkeyEventHandler( ) guard status == noErr else { + NSLog("[HotkeyManager] GetEventParameter failed: %d", status) return OSStatus(eventNotHandledErr) } + NSLog("[HotkeyManager] Hotkey ID: %u", hotKeyID.id) + // Get the HotkeyManager instance and handle the event let manager = Unmanaged.fromOpaque(userData).takeUnretainedValue() manager.handleHotkeyEvent(id: hotKeyID.id) diff --git a/ScreenCapture/Supporting Files/ScreenCapture.entitlements b/ScreenCapture/Supporting Files/ScreenCapture.entitlements index 19afff1..e89b7f3 100644 --- a/ScreenCapture/Supporting Files/ScreenCapture.entitlements +++ b/ScreenCapture/Supporting Files/ScreenCapture.entitlements @@ -3,8 +3,6 @@ com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-write - + From 4399e086e7328315cce629246bf32b5672f53851 Mon Sep 17 00:00:00 2001 From: diegoavarela <89712036+diegoavarela@users.noreply.github.com> Date: Fri, 9 Jan 2026 09:19:20 -0800 Subject: [PATCH 3/5] Add auto-save setting, floating style panel, and recent captures improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add auto-save on close setting (default: true) with toggle in Settings - Replace inline toolbar style options with floating panel over image - Recent captures in menu now open in editor instead of Finder - Recent captures in sidebar gallery now load into current editor - Crop no longer resizes the window, only the image - Recent captures menu reloads from UserDefaults when opened - Increase Settings window height to 640px 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Features/MenuBar/MenuBarController.swift | 51 ++- .../Features/Preview/PreviewContentView.swift | 207 +++++++++++- .../Features/Preview/PreviewViewModel.swift | 50 ++- .../Features/Settings/SettingsView.swift | 308 +++++++++++------- .../Features/Settings/SettingsViewModel.swift | 12 + ScreenCapture/Models/AppSettings.swift | 29 ++ .../Services/RecentCapturesStore.swift | 5 + 7 files changed, 518 insertions(+), 144 deletions(-) diff --git a/ScreenCapture/Features/MenuBar/MenuBarController.swift b/ScreenCapture/Features/MenuBar/MenuBarController.swift index 5964254..02cc99c 100644 --- a/ScreenCapture/Features/MenuBar/MenuBarController.swift +++ b/ScreenCapture/Features/MenuBar/MenuBarController.swift @@ -4,7 +4,7 @@ import Combine /// Manages the menu bar status item and its menu. /// Responsible for setting up the menu bar icon and building the app menu. @MainActor -final class MenuBarController { +final class MenuBarController: NSObject, NSMenuDelegate { // MARK: - Properties /// The status item displayed in the menu bar @@ -204,13 +204,27 @@ final class MenuBarController { /// Builds the recent captures submenu private func buildRecentCapturesMenu() -> NSMenu { let menu = NSMenu() + menu.delegate = self // Reload captures when menu opens updateRecentCapturesMenu(menu) return menu } + // MARK: - NSMenuDelegate + + /// Called when the menu is about to open - reload recent captures + nonisolated func menuNeedsUpdate(_ menu: NSMenu) { + MainActor.assumeIsolated { + self.recentCapturesStore.reload() + if let recentMenu = self.recentCapturesMenu { + self.updateRecentCapturesMenu(recentMenu) + } + } + } + /// Updates the recent captures submenu with current captures func updateRecentCapturesMenu() { guard let menu = recentCapturesMenu else { return } + recentCapturesStore.reload() // Reload from UserDefaults before updating updateRecentCapturesMenu(menu) } @@ -250,17 +264,40 @@ final class MenuBarController { // MARK: - Actions - /// Opens a recent capture file in Finder + /// Opens a recent capture in the editor for viewing/editing @objc private func openRecentCapture(_ sender: NSMenuItem) { guard let item = sender as? RecentCaptureMenuItem else { return } - let url = item.capture.filePath + let capture = item.capture - if item.capture.fileExists { - NSWorkspace.shared.activateFileViewerSelecting([url]) - } else { + guard capture.fileExists else { // File no longer exists, remove from recent captures - recentCapturesStore.remove(capture: item.capture) + recentCapturesStore.remove(capture: capture) updateRecentCapturesMenu() + return + } + + // Load image from file + guard let nsImage = NSImage(contentsOf: capture.filePath), + let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return + } + + // Create screenshot and open in editor + let screenshot = Screenshot( + image: cgImage, + captureDate: capture.captureDate, + sourceDisplay: DisplayInfo( + id: 0, + name: "Recent Capture", + frame: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height), + scaleFactor: 1.0, + isPrimary: true + ), + filePath: capture.filePath + ) + + PreviewWindowController.shared.showPreview(for: screenshot) { [weak self] savedURL in + self?.appDelegate?.addRecentCapture(filePath: savedURL, image: cgImage) } } diff --git a/ScreenCapture/Features/Preview/PreviewContentView.swift b/ScreenCapture/Features/Preview/PreviewContentView.swift index 61e0a4c..e9420c2 100644 --- a/ScreenCapture/Features/Preview/PreviewContentView.swift +++ b/ScreenCapture/Features/Preview/PreviewContentView.swift @@ -86,10 +86,9 @@ struct PreviewContentView: View { // MARK: - Gallery Actions - /// Opens a recent capture in Finder (double-click behavior) + /// Loads a recent capture into the editor private func openCapture(_ capture: RecentCapture) { - guard capture.fileExists else { return } - NSWorkspace.shared.open(capture.filePath) + viewModel.loadCapture(capture) } /// Reveals a capture in Finder @@ -191,6 +190,13 @@ struct PreviewContentView: View { .padding(8) } } + .overlay(alignment: .top) { + // Floating style panel when tool is selected or annotation is being edited + if viewModel.selectedTool != nil || viewModel.selectedAnnotationIndex != nil { + floatingStylePanel + .padding(.top, 8) + } + } .overlay(alignment: .bottom) { // Crop action buttons if viewModel.cropRect != nil && !viewModel.isCropSelecting { @@ -609,19 +615,200 @@ struct PreviewContentView: View { .accessibilityAddTraits(isSelected ? [.isSelected] : []) } - // Show customization options when a tool is selected OR an annotation is selected - if viewModel.selectedTool != nil || viewModel.selectedAnnotationIndex != nil { + } + .accessibilityElement(children: .contain) + .accessibilityLabel(Text("Annotation tools")) + } + + /// Floating style panel that appears over the image + private var floatingStylePanel: some View { + let isEditingAnnotation = viewModel.selectedAnnotationIndex != nil + let effectiveToolType = isEditingAnnotation ? viewModel.selectedAnnotationType : viewModel.selectedTool + + return HStack(spacing: 8) { + // Color picker with preset colors + HStack(spacing: 4) { + ForEach(presetColors, id: \.self) { color in + Button { + if isEditingAnnotation { + viewModel.updateSelectedAnnotationColor(CodableColor(color)) + } else { + AppSettings.shared.strokeColor = CodableColor(color) + } + } label: { + Circle() + .fill(color) + .frame(width: 20, height: 20) + .overlay { + let currentColor = isEditingAnnotation + ? (viewModel.selectedAnnotationColor?.color ?? .clear) + : AppSettings.shared.strokeColor.color + if colorsAreEqual(currentColor, color) { + Circle() + .stroke(Color.white, lineWidth: 2) + } + } + .overlay { + if color == .white || color == .yellow { + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + } + } + } + .buttonStyle(.plain) + } + + ColorPicker("", selection: Binding( + get: { + if isEditingAnnotation { + return viewModel.selectedAnnotationColor?.color ?? .red + } + return AppSettings.shared.strokeColor.color + }, + set: { newColor in + if isEditingAnnotation { + viewModel.updateSelectedAnnotationColor(CodableColor(newColor)) + } else { + AppSettings.shared.strokeColor = CodableColor(newColor) + } + } + ), supportsOpacity: false) + .labelsHidden() + .frame(width: 24) + } + + // Rectangle fill toggle (for rectangle only) + if effectiveToolType == .rectangle { Divider() - .frame(height: 16) + .frame(height: 20) - styleCustomizationBar + let isFilled = isEditingAnnotation + ? (viewModel.selectedAnnotationIsFilled ?? false) + : AppSettings.shared.rectangleFilled + + Button { + if isEditingAnnotation { + viewModel.updateSelectedAnnotationFilled(!isFilled) + } else { + AppSettings.shared.rectangleFilled.toggle() + } + } label: { + Image(systemName: isFilled ? "rectangle.fill" : "rectangle") + .font(.system(size: 14)) + } + .buttonStyle(.plain) + .foregroundStyle(.white) + .help(isFilled ? "Filled (click for hollow)" : "Hollow (click for filled)") + } + + // Stroke width control + if effectiveToolType == .freehand || effectiveToolType == .arrow || + (effectiveToolType == .rectangle && !(isEditingAnnotation ? (viewModel.selectedAnnotationIsFilled ?? false) : AppSettings.shared.rectangleFilled)) { + Divider() + .frame(height: 20) + + HStack(spacing: 4) { + Image(systemName: "lineweight") + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + + Slider( + value: Binding( + get: { + if isEditingAnnotation { + return viewModel.selectedAnnotationStrokeWidth ?? 3.0 + } + return AppSettings.shared.strokeWidth + }, + set: { newWidth in + if isEditingAnnotation { + viewModel.updateSelectedAnnotationStrokeWidth(newWidth) + } else { + AppSettings.shared.strokeWidth = newWidth + } + } + ), + in: 1.0...20.0, + step: 0.5 + ) + .frame(width: 60) + .tint(.white) + + let width = isEditingAnnotation + ? Int(viewModel.selectedAnnotationStrokeWidth ?? 3) + : Int(AppSettings.shared.strokeWidth) + Text("\(width)") + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + .frame(width: 16) + } + } + + // Text size control + if effectiveToolType == .text { + Divider() + .frame(height: 20) + + HStack(spacing: 4) { + Image(systemName: "textformat.size") + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + + Slider( + value: Binding( + get: { + if isEditingAnnotation { + return viewModel.selectedAnnotationFontSize ?? 16.0 + } + return AppSettings.shared.textSize + }, + set: { newSize in + if isEditingAnnotation { + viewModel.updateSelectedAnnotationFontSize(newSize) + } else { + AppSettings.shared.textSize = newSize + } + } + ), + in: 8.0...72.0, + step: 1 + ) + .frame(width: 60) + .tint(.white) + + let size = isEditingAnnotation + ? Int(viewModel.selectedAnnotationFontSize ?? 16) + : Int(AppSettings.shared.textSize) + Text("\(size)") + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + .frame(width: 20) + } + } + + // Delete button for selected annotation + if isEditingAnnotation { + Divider() + .frame(height: 20) + + Button { + viewModel.deleteSelectedAnnotation() + } label: { + Image(systemName: "trash") + .font(.system(size: 12)) + .foregroundStyle(.red) + } + .buttonStyle(.plain) + .help("Delete annotation") } } - .accessibilityElement(children: .contain) - .accessibilityLabel(Text("Annotation tools")) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.black.opacity(0.75)) + .clipShape(RoundedRectangle(cornerRadius: 8)) } - /// Style customization bar for color and stroke width + /// Style customization bar for color and stroke width (unused, kept for reference) @ViewBuilder private var styleCustomizationBar: some View { let isEditingAnnotation = viewModel.selectedAnnotationIndex != nil diff --git a/ScreenCapture/Features/Preview/PreviewViewModel.swift b/ScreenCapture/Features/Preview/PreviewViewModel.swift index 3b64343..85951f3 100644 --- a/ScreenCapture/Features/Preview/PreviewViewModel.swift +++ b/ScreenCapture/Features/Preview/PreviewViewModel.swift @@ -263,6 +263,42 @@ final class PreviewViewModel { onDismiss?() } + /// Loads a recent capture into the editor for editing + func loadCapture(_ capture: RecentCapture) { + guard capture.fileExists else { return } + + // Load image from file + guard let nsImage = NSImage(contentsOf: capture.filePath), + let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return + } + + // Clear undo/redo stacks + undoStack.removeAll() + redoStack.removeAll() + + // Create new screenshot with the loaded image + screenshot = Screenshot( + image: cgImage, + captureDate: capture.captureDate, + sourceDisplay: DisplayInfo( + id: 0, + name: "Recent Capture", + frame: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height), + scaleFactor: 1.0, + isPrimary: true + ), + filePath: capture.filePath + ) + + // Reset tool state + selectedTool = nil + _currentAnnotation = nil + selectedAnnotationIndex = nil + isCropMode = false + cropRect = nil + } + /// Adds an annotation to the screenshot func addAnnotation(_ annotation: Annotation) { pushUndoState() @@ -509,9 +545,8 @@ final class PreviewViewModel { // Exit crop mode isCropMode = false cropRect = nil - - // Notify that image size changed (for window resize) - imageSizeChangeCounter += 1 + // Note: We don't increment imageSizeChangeCounter here because + // crop should not resize the window, only the image within it } /// Cancels the current crop selection @@ -824,9 +859,14 @@ final class PreviewViewModel { _textInputPosition = nil } - /// Dismisses the preview (Escape key action) + /// Dismisses the preview - auto-saves before closing if enabled func dismiss() { - hide() + // Auto-save if enabled and not already saved + if settings.autoSaveOnClose && screenshot.filePath == nil && !isSaving { + saveScreenshot() + } else { + hide() + } } /// Copies the screenshot to clipboard (Cmd+C action) diff --git a/ScreenCapture/Features/Settings/SettingsView.swift b/ScreenCapture/Features/Settings/SettingsView.swift index cf192b2..67fcce1 100644 --- a/ScreenCapture/Features/Settings/SettingsView.swift +++ b/ScreenCapture/Features/Settings/SettingsView.swift @@ -43,7 +43,7 @@ struct SettingsView: View { } .tag(SettingsTab.annotations) } - .frame(width: 500, height: 420) + .frame(width: 560, height: 640) .alert("Error", isPresented: $viewModel.showErrorAlert) { Button("OK") { viewModel.errorMessage = nil @@ -64,90 +64,86 @@ private struct GeneralSettingsTab: View { var body: some View { ScrollView { - VStack(spacing: 20) { - // Permissions Card - SettingsCard(title: "Permissions", icon: "lock.shield") { - VStack(spacing: 12) { + VStack(spacing: 16) { + // Permissions Section + SettingsSection(title: "Permissions") { + VStack(spacing: 0) { PermissionRow( icon: "record.circle", title: "Screen Recording", - description: "Required to capture screenshots", isGranted: viewModel.hasScreenRecordingPermission, isChecking: viewModel.isCheckingPermissions, onGrant: { viewModel.requestScreenRecordingPermission() } ) Divider() + .padding(.leading, 44) PermissionRow( icon: "folder", - title: "Save Location", - description: "Required to save screenshots", + title: "File Access", isGranted: viewModel.hasFolderAccessPermission, isChecking: viewModel.isCheckingPermissions, onGrant: { viewModel.requestFolderAccess() } ) - - HStack { - Spacer() - Button { - viewModel.checkPermissions() - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - .font(.caption) - } - .buttonStyle(.borderless) - } } } .onAppear { viewModel.checkPermissions() } - // Save Location Card - SettingsCard(title: "Save Location", icon: "folder") { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(viewModel.saveLocationPath) - .font(.system(.body, design: .monospaced)) - .lineLimit(1) - .truncationMode(.middle) - } + // Save Location Section + SettingsSection(title: "Save Location") { + HStack(spacing: 12) { + Image(systemName: "folder.fill") + .font(.title2) + .foregroundStyle(.blue) + + Text(viewModel.saveLocationPath) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.middle) Spacer() - Button("Choose...") { + Button("Change...") { viewModel.selectSaveLocation() } + .buttonStyle(.bordered) Button { viewModel.revealSaveLocation() } label: { - Image(systemName: "arrow.right.circle") + Image(systemName: "arrow.right.circle.fill") + .font(.title2) + .foregroundStyle(.secondary) } - .buttonStyle(.borderless) + .buttonStyle(.plain) .help("Reveal in Finder") } + .padding(.vertical, 4) } - // Export Format Card - SettingsCard(title: "Export Format", icon: "photo") { - VStack(spacing: 12) { + // Export Format Section + SettingsSection(title: "Export Format") { + VStack(spacing: 14) { Picker("Format", selection: $viewModel.defaultFormat) { Text("PNG").tag(ExportFormat.png) Text("JPEG").tag(ExportFormat.jpeg) } .pickerStyle(.segmented) + .labelsHidden() if viewModel.defaultFormat == .jpeg { - VStack(spacing: 8) { + VStack(spacing: 10) { HStack { Text("Quality") .foregroundStyle(.secondary) Spacer() Text("\(Int(viewModel.jpegQualityPercentage))%") - .monospacedDigit() - .foregroundStyle(.secondary) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.primary) } Slider( @@ -155,15 +151,54 @@ private struct GeneralSettingsTab: View { in: SettingsViewModel.jpegQualityRange, step: 0.05 ) + .tint(.blue) + } + } + } + } - Text("Higher quality = larger file size") + // Startup Section + SettingsSection(title: "Startup") { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Launch at Login") + .font(.body) + Text("Start ScreenCapture when you log in") .font(.caption) - .foregroundStyle(.tertiary) + .foregroundStyle(.secondary) } + + Spacer() + + Toggle("", isOn: $viewModel.launchAtLogin) + .toggleStyle(.switch) + .labelsHidden() + } + + Divider() + .padding(.vertical, 10) + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Auto-save on Close") + .font(.body) + Text("Save screenshots automatically when closing preview") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Toggle("", isOn: $viewModel.autoSaveOnClose) + .toggleStyle(.switch) + .labelsHidden() } } } + Spacer(minLength: 8) + // Reset Button HStack { Spacer() @@ -171,13 +206,13 @@ private struct GeneralSettingsTab: View { viewModel.resetAllToDefaults() } label: { Label("Reset All Settings", systemImage: "arrow.counterclockwise") + .font(.callout) } - .buttonStyle(.borderless) - .foregroundStyle(.red) + .buttonStyle(.plain) + .foregroundStyle(.red.opacity(0.8)) } - .padding(.top, 8) } - .padding(20) + .padding(24) } } } @@ -190,9 +225,9 @@ private struct ShortcutsSettingsTab: View { var body: some View { ScrollView { - VStack(spacing: 20) { - SettingsCard(title: "Capture Shortcuts", icon: "camera") { - VStack(spacing: 16) { + VStack(spacing: 16) { + SettingsSection(title: "Capture Shortcuts") { + VStack(spacing: 0) { ShortcutRow( icon: "rectangle.dashed", label: "Full Screen", @@ -202,7 +237,7 @@ private struct ShortcutsSettingsTab: View { onReset: { viewModel.resetFullScreenShortcut() } ) - Divider() + Divider().padding(.leading, 44) ShortcutRow( icon: "crop", @@ -213,7 +248,7 @@ private struct ShortcutsSettingsTab: View { onReset: { viewModel.resetSelectionShortcut() } ) - Divider() + Divider().padding(.leading, 44) ShortcutRow( icon: "macwindow", @@ -224,7 +259,7 @@ private struct ShortcutsSettingsTab: View { onReset: { viewModel.resetWindowShortcut() } ) - Divider() + Divider().padding(.leading, 44) ShortcutRow( icon: "macwindow.on.rectangle", @@ -238,16 +273,18 @@ private struct ShortcutsSettingsTab: View { } // Instructions - HStack(spacing: 8) { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) - Text("Click on a shortcut to change it. Press Escape to cancel.") - .font(.caption) + HStack(spacing: 10) { + Image(systemName: "info.circle.fill") + .foregroundStyle(.blue.opacity(0.7)) + Text("Click a shortcut to change it. Press Escape to cancel.") + .font(.callout) .foregroundStyle(.secondary) + Spacer() } .padding(.horizontal, 4) + .padding(.top, 4) } - .padding(20) + .padding(24) } } } @@ -260,12 +297,12 @@ private struct AnnotationsSettingsTab: View { var body: some View { ScrollView { - VStack(spacing: 20) { - // Color Selection Card - SettingsCard(title: "Stroke Color", icon: "paintpalette") { - VStack(spacing: 12) { + VStack(spacing: 16) { + // Color Selection + SettingsSection(title: "Stroke Color") { + VStack(spacing: 14) { // Preset Colors Grid - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 9), spacing: 8) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 9), spacing: 10) { ForEach(SettingsViewModel.presetColors, id: \.self) { color in ColorButton( color: color, @@ -275,71 +312,79 @@ private struct AnnotationsSettingsTab: View { } } + Divider() + HStack { - Text("Custom:") + Text("Custom Color") .foregroundStyle(.secondary) + Spacer() ColorPicker("", selection: $viewModel.strokeColor, supportsOpacity: false) .labelsHidden() - Spacer() } } } - // Stroke Width Card - SettingsCard(title: "Stroke Width", icon: "lineweight") { - VStack(spacing: 8) { - HStack { + // Stroke Width + SettingsSection(title: "Stroke Width") { + VStack(spacing: 12) { + HStack(spacing: 16) { Slider( value: $viewModel.strokeWidth, in: SettingsViewModel.strokeWidthRange, step: 0.5 ) + .tint(.blue) Text("\(viewModel.strokeWidth, specifier: "%.1f") pt") .font(.system(.body, design: .monospaced)) - .frame(width: 60, alignment: .trailing) + .foregroundStyle(.secondary) + .frame(width: 55, alignment: .trailing) } // Visual Preview HStack { - Text("Preview:") - .foregroundStyle(.secondary) + Text("Preview") + .font(.callout) + .foregroundStyle(.tertiary) Spacer() RoundedRectangle(cornerRadius: viewModel.strokeWidth / 2) .fill(viewModel.strokeColor) - .frame(width: 100, height: max(viewModel.strokeWidth, 2)) + .frame(width: 120, height: max(viewModel.strokeWidth, 2)) } } } - // Text Size Card - SettingsCard(title: "Text Size", icon: "textformat.size") { - VStack(spacing: 8) { - HStack { + // Text Size + SettingsSection(title: "Text Size") { + VStack(spacing: 12) { + HStack(spacing: 16) { Slider( value: $viewModel.textSize, in: SettingsViewModel.textSizeRange, step: 1 ) + .tint(.blue) Text("\(Int(viewModel.textSize)) pt") .font(.system(.body, design: .monospaced)) - .frame(width: 60, alignment: .trailing) + .foregroundStyle(.secondary) + .frame(width: 55, alignment: .trailing) } // Visual Preview HStack { - Text("Preview:") - .foregroundStyle(.secondary) + Text("Preview") + .font(.callout) + .foregroundStyle(.tertiary) Spacer() - Text("Aa") - .font(.system(size: min(viewModel.textSize, 32))) + Text("Sample Text") + .font(.system(size: min(viewModel.textSize, 28))) .foregroundStyle(viewModel.strokeColor) } } } } - .padding(20) + .padding(24) } } @@ -358,24 +403,32 @@ private struct AnnotationsSettingsTab: View { // MARK: - Reusable Components -/// A card container for settings sections. -private struct SettingsCard: View { +/// A section container for settings with a title. +private struct SettingsSection: View { let title: String - let icon: String @ViewBuilder let content: Content var body: some View { VStack(alignment: .leading, spacing: 12) { - Label(title, systemImage: icon) + Text(title) .font(.headline) .foregroundStyle(.primary) - content + VStack(spacing: 0) { + content + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 10) + .fill(Color(nsColor: .windowBackgroundColor)) + .shadow(color: .black.opacity(0.06), radius: 2, y: 1) + } + .overlay { + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 1) + } } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 10)) } } @@ -383,27 +436,19 @@ private struct SettingsCard: View { private struct PermissionRow: View { let icon: String let title: String - let description: String let isGranted: Bool let isChecking: Bool let onGrant: () -> Void var body: some View { - HStack(spacing: 12) { + HStack(spacing: 14) { Image(systemName: icon) .font(.title2) - .foregroundStyle(isGranted ? .green : .secondary) - .frame(width: 28) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .fontWeight(.medium) - if !isGranted && !isChecking { - Text(description) - .font(.caption) - .foregroundStyle(.secondary) - } - } + .foregroundStyle(isGranted ? .green : .orange) + .frame(width: 30) + + Text(title) + .font(.body) Spacer() @@ -411,17 +456,22 @@ private struct PermissionRow: View { ProgressView() .controlSize(.small) } else if isGranted { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.title3) + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Granted") + .font(.callout) + .foregroundStyle(.secondary) + } } else { - Button("Grant") { + Button("Grant Access") { onGrant() } .buttonStyle(.borderedProminent) .controlSize(.small) } } + .padding(.vertical, 10) } } @@ -435,32 +485,42 @@ private struct ShortcutRow: View { let onReset: () -> Void var body: some View { - HStack(spacing: 12) { + HStack(spacing: 14) { Image(systemName: icon) .font(.title3) - .foregroundStyle(.secondary) - .frame(width: 24) + .foregroundStyle(.blue) + .frame(width: 26) Text(label) + .font(.body) Spacer() Button { onRecord() } label: { - HStack(spacing: 4) { + HStack(spacing: 6) { if isRecording { - Text("Recording...") + Circle() + .fill(.red) + .frame(width: 8, height: 8) + Text("Press keys...") .foregroundStyle(.secondary) } else { Text(shortcut.displayString) - .fontWeight(.medium) + .font(.system(.body, design: .monospaced, weight: .medium)) } } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(isRecording ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 6)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(isRecording ? Color.red.opacity(0.1) : Color.primary.opacity(0.05)) + } + .overlay { + RoundedRectangle(cornerRadius: 8) + .strokeBorder(isRecording ? Color.red.opacity(0.3) : Color.primary.opacity(0.1), lineWidth: 1) + } } .buttonStyle(.plain) @@ -468,12 +528,15 @@ private struct ShortcutRow: View { onReset() } label: { Image(systemName: "arrow.counterclockwise") - .font(.caption) + .font(.callout) + .foregroundStyle(.secondary) } - .buttonStyle(.borderless) + .buttonStyle(.plain) .help("Reset to default") .disabled(isRecording) + .opacity(isRecording ? 0.4 : 1) } + .padding(.vertical, 10) } } @@ -487,20 +550,21 @@ private struct ColorButton: View { Button(action: action) { Circle() .fill(color) - .frame(width: 28, height: 28) + .frame(width: 30, height: 30) .overlay { if isSelected { Circle() .stroke(Color.primary, lineWidth: 2.5) - .padding(2) + .padding(3) } } .overlay { if color == .white || color == .yellow { Circle() - .stroke(Color.gray.opacity(0.3), lineWidth: 1) + .stroke(Color.gray.opacity(0.25), lineWidth: 1) } } + .shadow(color: .black.opacity(0.1), radius: 1, y: 1) } .buttonStyle(.plain) } diff --git a/ScreenCapture/Features/Settings/SettingsViewModel.swift b/ScreenCapture/Features/Settings/SettingsViewModel.swift index fef5c9e..d687e90 100644 --- a/ScreenCapture/Features/Settings/SettingsViewModel.swift +++ b/ScreenCapture/Features/Settings/SettingsViewModel.swift @@ -72,6 +72,18 @@ final class SettingsViewModel { set { jpegQuality = newValue / 100 } } + /// Launch at login + var launchAtLogin: Bool { + get { settings.launchAtLogin } + set { settings.launchAtLogin = newValue } + } + + /// Auto-save on close + var autoSaveOnClose: Bool { + get { settings.autoSaveOnClose } + set { settings.autoSaveOnClose = newValue } + } + /// Full screen capture shortcut var fullScreenShortcut: KeyboardShortcut { get { settings.fullScreenShortcut } diff --git a/ScreenCapture/Models/AppSettings.swift b/ScreenCapture/Models/AppSettings.swift index ce01e5a..0d750a9 100644 --- a/ScreenCapture/Models/AppSettings.swift +++ b/ScreenCapture/Models/AppSettings.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import ServiceManagement /// User preferences persisted across sessions via UserDefaults. /// All properties automatically sync to UserDefaults with the `ScreenCapture.` prefix. @@ -27,6 +28,7 @@ final class AppSettings { static let textSize = prefix + "textSize" static let rectangleFilled = prefix + "rectangleFilled" static let recentCaptures = prefix + "recentCaptures" + static let autoSaveOnClose = prefix + "autoSaveOnClose" } // MARK: - Properties @@ -91,6 +93,29 @@ final class AppSettings { didSet { saveRecentCaptures() } } + /// Whether to auto-save screenshots when closing the preview + var autoSaveOnClose: Bool { + didSet { save(autoSaveOnClose, forKey: Keys.autoSaveOnClose) } + } + + /// Whether to launch at login + var launchAtLogin: Bool { + get { + SMAppService.mainApp.status == .enabled + } + set { + do { + if newValue { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } catch { + print("Failed to \(newValue ? "enable" : "disable") launch at login: \(error)") + } + } + } + // MARK: - Initialization private init() { @@ -139,6 +164,9 @@ final class AppSettings { // Load recent captures recentCaptures = Self.loadRecentCaptures() + // Load auto-save setting (default: true) + autoSaveOnClose = defaults.object(forKey: Keys.autoSaveOnClose) as? Bool ?? true + print("ScreenCapture launched - settings loaded from: \(loadedLocation.path)") } @@ -186,6 +214,7 @@ final class AppSettings { textSize = 14.0 rectangleFilled = false recentCaptures = [] + autoSaveOnClose = true } // MARK: - Private Persistence Helpers diff --git a/ScreenCapture/Services/RecentCapturesStore.swift b/ScreenCapture/Services/RecentCapturesStore.swift index 0e9d8c3..3dff600 100644 --- a/ScreenCapture/Services/RecentCapturesStore.swift +++ b/ScreenCapture/Services/RecentCapturesStore.swift @@ -91,6 +91,11 @@ final class RecentCapturesStore: ObservableObject { // MARK: - Persistence + /// Reloads captures from UserDefaults (call before displaying menu) + func reload() { + loadCaptures() + } + /// Loads captures from UserDefaults via AppSettings private func loadCaptures() { captures = settings.recentCaptures From bda5983fb1ff424fa970e05eff4e4f1320fa7f52 Mon Sep 17 00:00:00 2001 From: diegoavarela <89712036+diegoavarela@users.noreply.github.com> Date: Fri, 9 Jan 2026 09:24:28 -0800 Subject: [PATCH 4/5] docs: update documentation with new features and correct shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update keyboard shortcuts from Cmd+Shift to Cmd+Ctrl throughout - Add window capture documentation (Cmd+Ctrl+6/7) - Document auto-save on close feature and setting - Add gallery sidebar documentation (G key) - Document floating style panel behavior - Update Recent Captures section to explain editor re-editing - Add Quick Window Capture and Re-edit tips 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 25 ++++++++---- docs/user-guide.md | 95 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 96 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index d20dd8f..929cc93 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,11 @@ ## Features -- **Instant Capture** - Full screen or region selection with global hotkeys -- **Annotation Tools** - Rectangles, arrows, freehand drawing, and text +- **Multiple Capture Modes** - Full screen, region selection, window, and window with shadow +- **Annotation Tools** - Rectangles, arrows, freehand drawing, and text with floating style panel - **Multi-Monitor Support** - Works seamlessly across all connected displays +- **Auto-Save** - Screenshots automatically saved when closing preview (configurable) +- **Recent Captures** - Quick access to recent screenshots from menu bar and editor sidebar - **Quick Export** - Save to disk or copy to clipboard instantly - **Lightweight** - Runs quietly in your menu bar with minimal resources @@ -37,8 +39,8 @@ Download the latest release from the [Releases](../../releases) page. ```bash # Clone the repository -git clone https://github.com/sadopc/ScreenCapture.git -cd ScreenCapture +git clone https://github.com/diegoavarela/screencapture.git +cd screencapture # Open in Xcode open ScreenCapture.xcodeproj @@ -48,12 +50,16 @@ open ScreenCapture.xcodeproj ## Usage -### Keyboard Shortcuts +### Global Keyboard Shortcuts | Shortcut | Action | |----------|--------| -| `Cmd+Shift+3` | Capture full screen | -| `Cmd+Shift+4` | Capture selection | +| `Cmd+Ctrl+3` | Capture full screen | +| `Cmd+Ctrl+4` | Capture selection | +| `Cmd+Ctrl+6` | Capture window | +| `Cmd+Ctrl+7` | Capture window with shadow | + +> **Note**: Shortcuts can be customized in Settings. ### In Preview Window @@ -61,13 +67,16 @@ open ScreenCapture.xcodeproj |----------|--------| | `Enter` / `Cmd+S` | Save screenshot | | `Cmd+C` | Copy to clipboard | -| `Escape` | Dismiss | +| `Escape` | Dismiss (auto-saves if enabled) | | `R` / `1` | Rectangle tool | | `D` / `2` | Freehand tool | | `A` / `3` | Arrow tool | | `T` / `4` | Text tool | | `C` | Crop mode | +| `G` | Toggle recent captures gallery | | `Cmd+Z` | Undo | +| `Cmd+Shift+Z` | Redo | +| `Delete` | Delete selected annotation | ## Documentation diff --git a/docs/user-guide.md b/docs/user-guide.md index 46abd80..864d034 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -33,7 +33,7 @@ The app runs in your menu bar with a camera icon. ### Full Screen Capture **Method 1: Keyboard Shortcut** -- Press `Cmd + Shift + 3` +- Press `Cmd + Ctrl + 3` **Method 2: Menu Bar** - Click the camera icon in menu bar @@ -44,7 +44,7 @@ If you have multiple monitors, a menu will appear to select which display to cap ### Selection Capture **Method 1: Keyboard Shortcut** -- Press `Cmd + Shift + 4` +- Press `Cmd + Ctrl + 4` **Method 2: Menu Bar** - Click the camera icon in menu bar @@ -56,6 +56,22 @@ If you have multiple monitors, a menu will appear to select which display to cap 3. Release to capture 4. Press `Escape` to cancel +### Window Capture + +**Method 1: Keyboard Shortcut** +- Press `Cmd + Ctrl + 6` for window only +- Press `Cmd + Ctrl + 7` for window with shadow + +**Method 2: Menu Bar** +- Click the camera icon in menu bar +- Select "Capture Window" or "Capture Window + Shadow" + +**Selecting the Window:** +1. Move mouse over the window you want to capture +2. The window is highlighted with a blue border +3. Click to capture +4. Press `Escape` to cancel + --- ## Preview Window @@ -68,7 +84,7 @@ After capturing, the Preview window appears showing your screenshot. |--------|--------| | Drag edges | Resize window | | Drag title bar | Move window | -| Close button | Dismiss without saving | +| Close button | Auto-save and dismiss (if enabled) | ### Quick Actions @@ -76,7 +92,18 @@ After capturing, the Preview window appears showing your screenshot. |----------|--------| | `Enter` or `Cmd+S` | Save screenshot | | `Cmd+C` | Copy to clipboard and close | -| `Escape` | Dismiss window | +| `Escape` | Auto-save and dismiss (if enabled) | +| `G` | Toggle recent captures gallery | + +### Floating Style Panel + +When you select an annotation tool (Rectangle, Arrow, etc.) or click on an existing annotation, a floating style panel appears over the image. This panel lets you: +- Change color +- Adjust stroke width +- Toggle filled mode (for rectangles) +- Change text size (for text tool) + +The panel floats above the image and doesn't affect the window size. --- @@ -244,6 +271,12 @@ When using JPEG format, adjust quality: Range: 0% to 100% (default: 90%) +### Auto-save on Close + +When enabled (default), screenshots are automatically saved when you close the preview window or press Escape. This ensures you never lose a capture. + +Toggle this in Settings → Auto-save on Close. + ### Keyboard Shortcuts Customize global hotkeys: @@ -253,8 +286,10 @@ Customize global hotkeys: 3. Must include Cmd, Ctrl, or Option **Defaults:** -- Full Screen: `Cmd+Shift+3` -- Selection: `Cmd+Shift+4` +- Full Screen: `Cmd+Ctrl+3` +- Selection: `Cmd+Ctrl+4` +- Window: `Cmd+Ctrl+6` +- Window with Shadow: `Cmd+Ctrl+7` ### Annotation Defaults @@ -273,15 +308,24 @@ Click "Reset to Defaults" to restore all settings. ## Recent Captures -Access recently saved screenshots from the menu bar. +Access and re-edit recently saved screenshots. + +### From Menu Bar 1. Click menu bar icon 2. Hover over "Recent Captures" -3. Click any capture to open in Finder +3. Click any capture to open in editor for further editing + +### From Editor Gallery + +1. In the preview window, press `G` to toggle the gallery sidebar +2. Browse your recent captures with thumbnails +3. Click any capture to load it for editing **Features:** - Shows last 5 captures - Displays thumbnails +- Click to re-open in editor (add more annotations, crop, etc.) - "Clear Recent" removes the list --- @@ -292,8 +336,10 @@ Access recently saved screenshots from the menu bar. | Shortcut | Action | |----------|--------| -| `Cmd+Shift+3` | Capture full screen | -| `Cmd+Shift+4` | Capture selection | +| `Cmd+Ctrl+3` | Capture full screen | +| `Cmd+Ctrl+4` | Capture selection | +| `Cmd+Ctrl+6` | Capture window | +| `Cmd+Ctrl+7` | Capture window with shadow | ### In Preview Window @@ -301,7 +347,7 @@ Access recently saved screenshots from the menu bar. |----------|--------| | `Enter` / `Cmd+S` | Save screenshot | | `Cmd+C` | Copy and close | -| `Escape` | Deselect / Dismiss | +| `Escape` | Deselect / Dismiss (auto-saves if enabled) | | `Delete` | Delete selected annotation | | `Cmd+Z` | Undo | | `Shift+Cmd+Z` | Redo | @@ -310,13 +356,15 @@ Access recently saved screenshots from the menu bar. | `A` / `3` | Arrow tool | | `T` / `4` | Text tool | | `C` | Toggle crop mode | +| `G` | Toggle recent captures gallery | -### In Selection Mode +### In Selection/Window Mode | Shortcut | Action | |----------|--------| | `Escape` | Cancel selection | -| Click + Drag | Draw selection | +| Click + Drag | Draw selection (selection mode) | +| Click | Capture window (window mode) | | Release | Complete capture | --- @@ -325,7 +373,7 @@ Access recently saved screenshots from the menu bar. ### Quick Annotation Workflow -1. Capture with `Cmd+Shift+4` +1. Capture with `Cmd+Ctrl+4` 2. Draw selection 3. Press `R` for rectangle 4. Draw highlight @@ -333,6 +381,13 @@ Access recently saved screenshots from the menu bar. Total time: ~3 seconds! +### Quick Window Capture + +1. Press `Cmd+Ctrl+6` +2. Hover over target window (blue highlight appears) +3. Click to capture +4. Press `Escape` to dismiss (auto-saves) + ### Multi-Monitor Capture When capturing full screen with multiple monitors: @@ -348,13 +403,21 @@ Screenshots automatically capture at native resolution: File sizes will be larger for Retina captures. +### Re-edit Previous Captures + +Access any recent screenshot for further editing: +1. Press `G` in the editor to open gallery +2. Click a previous capture +3. Add more annotations or crop +4. Save again + ### Keyboard-Only Workflow Never touch the mouse: -1. `Cmd+Shift+3` - Capture +1. `Cmd+Ctrl+3` - Capture full screen 2. `R` - Rectangle tool 3. Use mouse to draw (unavoidable) -4. `Cmd+S` - Save +4. `Escape` - Auto-save and close --- From 7c100723d812fffce8491927f2fdca8684a3e76b Mon Sep 17 00:00:00 2001 From: diegoavarela <89712036+diegoavarela@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:56:51 -0800 Subject: [PATCH 5/5] feat: Add blur tool and fix region capture quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add brush-based blur tool (B key or 5) with adjustable intensity - Fix region capture quality - now pixel-perfect using CGImage cropping instead of ScreenCaptureKit's sourceRect which caused scaling artifacts - Fix first click not registering in region selection overlay - Hide color picker when blur tool is selected (blur doesn't use color) - Add blur rendering to clipboard copy and file export - Add blur radius slider (5-30) in floating style panel 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Features/Annotations/BlurTool.swift | 108 ++++++++++++++ .../Features/Capture/CaptureManager.swift | 38 +++-- .../Capture/SelectionOverlayWindow.swift | 5 +- .../Features/Preview/AnnotationCanvas.swift | 137 ++++++++++++++++++ .../Features/Preview/PreviewContentView.swift | 70 +++++++-- .../Features/Preview/PreviewViewModel.swift | 72 +++++++++ .../Features/Preview/PreviewWindow.swift | 13 +- ScreenCapture/Models/Annotation.swift | 62 ++++++++ ScreenCapture/Models/AppSettings.swift | 8 + ScreenCapture/Services/ClipboardService.swift | 99 ++++++++++++- ScreenCapture/Services/ImageExporter.swift | 100 ++++++++++++- 11 files changed, 678 insertions(+), 34 deletions(-) create mode 100644 ScreenCapture/Features/Annotations/BlurTool.swift diff --git a/ScreenCapture/Features/Annotations/BlurTool.swift b/ScreenCapture/Features/Annotations/BlurTool.swift new file mode 100644 index 0000000..a76c185 --- /dev/null +++ b/ScreenCapture/Features/Annotations/BlurTool.swift @@ -0,0 +1,108 @@ +import Foundation +import CoreGraphics + +/// Tool for drawing blur annotations using a brush. +/// User paints over areas to blur them, similar to freehand drawing. +@MainActor +struct BlurTool: AnnotationTool { + // MARK: - Properties + + let toolType: AnnotationToolType = .blur + + var strokeStyle: StrokeStyle = .default + + var textStyle: TextStyle = .default + + /// Blur intensity (sigma value for Gaussian blur) - captured at drawing start + private var capturedBlurRadius: CGFloat = 15.0 + + /// Brush size (diameter of the blur brush) - captured at drawing start + private var capturedBrushSize: CGFloat = 40.0 + + private var drawingState = DrawingState() + + /// Minimum distance between recorded points (for performance) + private let minimumPointDistance: CGFloat = 3.0 + + // MARK: - Public setters for configuration + + var blurRadius: CGFloat { + get { capturedBlurRadius } + set { capturedBlurRadius = newValue } + } + + var brushSize: CGFloat { + get { capturedBrushSize } + set { capturedBrushSize = newValue } + } + + // MARK: - AnnotationTool Conformance + + var isActive: Bool { + drawingState.isDrawing + } + + var currentAnnotation: Annotation? { + guard isActive else { return nil } + let allPoints = [drawingState.startPoint] + drawingState.points + guard allPoints.count >= 2 else { return nil } + return .blur(BlurAnnotation( + points: allPoints, + blurRadius: capturedBlurRadius, + brushSize: capturedBrushSize + )) + } + + mutating func beginDrawing(at point: CGPoint) { + drawingState = DrawingState(startPoint: point) + drawingState.isDrawing = true + } + + mutating func continueDrawing(to point: CGPoint) { + guard isActive else { return } + + // Only add point if it's far enough from the last point + if let lastPoint = drawingState.points.last { + let dx = point.x - lastPoint.x + let dy = point.y - lastPoint.y + let distance = sqrt(dx * dx + dy * dy) + + if distance >= minimumPointDistance { + drawingState.points.append(point) + } + } else { + // First continuation point + let dx = point.x - drawingState.startPoint.x + let dy = point.y - drawingState.startPoint.y + let distance = sqrt(dx * dx + dy * dy) + + if distance >= minimumPointDistance { + drawingState.points.append(point) + } + } + } + + mutating func endDrawing(at point: CGPoint) -> Annotation? { + guard isActive else { return nil } + + // Add final point + continueDrawing(to: point) + + // Build the final points array + let allPoints = [drawingState.startPoint] + drawingState.points + drawingState.reset() + + // Need at least 2 points for a valid blur stroke + guard allPoints.count >= 2 else { return nil } + + return .blur(BlurAnnotation( + points: allPoints, + blurRadius: blurRadius, + brushSize: brushSize + )) + } + + mutating func cancelDrawing() { + drawingState.reset() + } +} diff --git a/ScreenCapture/Features/Capture/CaptureManager.swift b/ScreenCapture/Features/Capture/CaptureManager.swift index 4a8ee69..d423272 100644 --- a/ScreenCapture/Features/Capture/CaptureManager.swift +++ b/ScreenCapture/Features/Capture/CaptureManager.swift @@ -198,14 +198,12 @@ actor CaptureManager { throw ScreenCaptureError.displayDisconnected(displayName: display.name) } - // Configure capture for the full display first + // Configure capture for the full display at native resolution let filter = SCContentFilter(display: scDisplay, excludingWindows: []) let config = createCaptureConfiguration(for: display) - // Set source rect for region capture - // sourceRect must be in PIXEL coordinates (not normalized!) - // The rect is in points from SelectionOverlayWindow, convert to pixels - let sourceRect = CGRect( + // Calculate the crop region in pixels + let cropRect = CGRect( x: rect.origin.x * display.scaleFactor, y: rect.origin.y * display.scaleFactor, width: rect.width * display.scaleFactor, @@ -217,23 +215,17 @@ actor CaptureManager { print("[CAP-1] Input rect (points): \(rect)") print("[CAP-2] display.frame (points): \(display.frame)") print("[CAP-3] display.scaleFactor: \(display.scaleFactor)") - print("[CAP-4] sourceRect (pixels): \(sourceRect)") + print("[CAP-4] cropRect (pixels): \(cropRect)") print("=== END CAPTURE MANAGER DEBUG ===") #endif - config.sourceRect = sourceRect - - // Adjust output size to match the region - config.width = Int(rect.width * display.scaleFactor) - config.height = Int(rect.height * display.scaleFactor) - - // Perform capture with signpost for profiling + // Capture full display at native resolution (no sourceRect to avoid scaling) os_signpost(.begin, log: Self.performanceLog, name: "RegionCapture", signpostID: Self.signpostID) let captureStartTime = CFAbsoluteTimeGetCurrent() - let cgImage: CGImage + let fullImage: CGImage do { - cgImage = try await SCScreenshotManager.captureImage( + fullImage = try await SCScreenshotManager.captureImage( contentFilter: filter, configuration: config ) @@ -242,11 +234,19 @@ actor CaptureManager { throw ScreenCaptureError.captureFailure(underlying: error) } + // Crop to the selected region - this preserves pixel-perfect quality + guard let cgImage = fullImage.cropping(to: cropRect) else { + os_signpost(.end, log: Self.performanceLog, name: "RegionCapture", signpostID: Self.signpostID) + throw ScreenCaptureError.captureError(message: "Failed to crop region") + } + let captureLatency = (CFAbsoluteTimeGetCurrent() - captureStartTime) * 1000 os_signpost(.end, log: Self.performanceLog, name: "RegionCapture", signpostID: Self.signpostID) #if DEBUG print("Region capture latency: \(String(format: "%.1f", captureLatency))ms") + print("[CAP-5] Full image size: \(fullImage.width)x\(fullImage.height)") + print("[CAP-6] Cropped image size: \(cgImage.width)x\(cgImage.height)") #endif // Create screenshot with metadata @@ -287,6 +287,9 @@ actor CaptureManager { config.width = Int(display.frame.width * display.scaleFactor) config.height = Int(display.frame.height * display.scaleFactor) + // CRITICAL: Don't scale - capture at native pixel resolution + config.scalesToFit = false + // High quality settings for screenshots config.minimumFrameInterval = CMTime(value: 1, timescale: 1) // Single frame config.pixelFormat = kCVPixelFormatType_32BGRA @@ -295,6 +298,11 @@ actor CaptureManager { // Color settings for accurate reproduction config.colorSpaceName = CGColorSpace.sRGB + // Use best capture resolution on macOS 14+ + if #available(macOS 14.0, *) { + config.captureResolution = .best + } + return config } } diff --git a/ScreenCapture/Features/Capture/SelectionOverlayWindow.swift b/ScreenCapture/Features/Capture/SelectionOverlayWindow.swift index 5a5c9c0..79df640 100644 --- a/ScreenCapture/Features/Capture/SelectionOverlayWindow.swift +++ b/ScreenCapture/Features/Capture/SelectionOverlayWindow.swift @@ -563,6 +563,10 @@ final class SelectionOverlayController { overlayWindows.append(overlayWindow) } + // IMPORTANT: Activate the app BEFORE showing windows + // This prevents the first click from being absorbed as a "click to focus" event + NSApp.activate(ignoringOtherApps: true) + // Show all overlay windows for window in overlayWindows { window.showOverlay() @@ -571,7 +575,6 @@ final class SelectionOverlayController { // Make the first window (primary display) key if let primaryWindow = overlayWindows.first { primaryWindow.makeKey() - NSApp.activate(ignoringOtherApps: true) } } diff --git a/ScreenCapture/Features/Preview/AnnotationCanvas.swift b/ScreenCapture/Features/Preview/AnnotationCanvas.swift index af02e3f..67a1264 100644 --- a/ScreenCapture/Features/Preview/AnnotationCanvas.swift +++ b/ScreenCapture/Features/Preview/AnnotationCanvas.swift @@ -1,5 +1,6 @@ import SwiftUI import AppKit +import CoreImage /// SwiftUI Canvas view for drawing and displaying annotations. /// Renders existing annotations and in-progress drawing. @@ -21,6 +22,12 @@ struct AnnotationCanvas: View { /// Index of the selected annotation (nil = none selected) var selectedIndex: Int? + /// The original image (needed for blur rendering) + var originalImage: CGImage? + + /// Cached CIContext for blur rendering + private static let ciContext = CIContext() + // MARK: - Body var body: some View { @@ -67,6 +74,11 @@ struct AnnotationCanvas: View { return false }.count + let blurCount = annotations.filter { + if case .blur = $0 { return true } + return false + }.count + var parts: [String] = [] if rectangleCount > 0 { parts.append("\(rectangleCount) rectangle\(rectangleCount == 1 ? "" : "s")") @@ -77,6 +89,9 @@ struct AnnotationCanvas: View { if textCount > 0 { parts.append("\(textCount) text\(textCount == 1 ? "" : "s")") } + if blurCount > 0 { + parts.append("\(blurCount) blur\(blurCount == 1 ? "" : "s")") + } return "Annotations: \(parts.joined(separator: ", "))" } @@ -98,6 +113,8 @@ struct AnnotationCanvas: View { drawArrow(arrow, in: &context, size: size) case .text(let text): drawText(text, in: &context, size: size) + case .blur(let blur): + drawBlur(blur, in: &context, size: size) } } @@ -232,6 +249,126 @@ struct AnnotationCanvas: View { ) } + /// Draws a blur annotation with brush-based blur effect + private func drawBlur( + _ annotation: BlurAnnotation, + in context: inout GraphicsContext, + size: CGSize + ) { + guard annotation.points.count >= 2 else { return } + + guard let originalImage = originalImage else { + drawBlurPlaceholder(annotation, in: &context) + return + } + + let imageWidth = CGFloat(originalImage.width) + let imageHeight = CGFloat(originalImage.height) + + // Get bounds of the blur stroke + let bounds = annotation.bounds + guard bounds.width > 0, bounds.height > 0 else { return } + + // Clamp to image bounds + let clampedBounds = CGRect( + x: max(0, bounds.origin.x), + y: max(0, bounds.origin.y), + width: min(bounds.width, imageWidth - max(0, bounds.origin.x)), + height: min(bounds.height, imageHeight - max(0, bounds.origin.y)) + ) + + guard clampedBounds.width > 0, clampedBounds.height > 0 else { return } + + // Convert to CG coordinates (bottom-left origin) + let cgBounds = CGRect( + x: clampedBounds.origin.x, + y: imageHeight - clampedBounds.origin.y - clampedBounds.height, + width: clampedBounds.width, + height: clampedBounds.height + ) + + // Crop the region + guard let croppedImage = originalImage.cropping(to: cgBounds) else { return } + + // Apply Gaussian blur (invert the range so higher values = more blur) + let ciImage = CIImage(cgImage: croppedImage) + let effectiveSigma = 35.0 - annotation.blurRadius // 5→30 (intense), 30→5 (light) + let blurredCIImage = ciImage.applyingGaussianBlur(sigma: effectiveSigma) + + // Clamp the blurred image back to original bounds (blur expands the extent) + let clampedBlurred = blurredCIImage.clamped(to: ciImage.extent) + guard let blurredCGImage = Self.ciContext.createCGImage(clampedBlurred, from: ciImage.extent) else { + drawBlurPlaceholder(annotation, in: &context) + return + } + + // Create the brush stroke path in view coordinates + var brushPath = Path() + let scaledPoints = annotation.points.map { scalePoint($0) } + let scaledBrushSize = annotation.brushSize * scale + + for point in scaledPoints { + brushPath.addEllipse(in: CGRect( + x: point.x - scaledBrushSize / 2, + y: point.y - scaledBrushSize / 2, + width: scaledBrushSize, + height: scaledBrushSize + )) + } + + // Also connect points with thick line for continuous coverage + if scaledPoints.count >= 2 { + var linePath = Path() + linePath.move(to: scaledPoints[0]) + for point in scaledPoints.dropFirst() { + linePath.addLine(to: point) + } + let strokedLine = linePath.strokedPath(SwiftUI.StrokeStyle( + lineWidth: scaledBrushSize, + lineCap: .round, + lineJoin: .round + )) + brushPath.addPath(strokedLine) + } + + // Draw the blurred image clipped to the brush path + let scaledBounds = scaleRect(clampedBounds) + let blurImage = Image(decorative: blurredCGImage, scale: 1.0) + + // Use drawLayer to isolate the clipping operation + context.drawLayer { layerContext in + var ctx = layerContext + ctx.clip(to: brushPath) + ctx.draw(blurImage, in: scaledBounds) + } + } + + /// Fallback placeholder when blur can't be rendered + private func drawBlurPlaceholder( + _ annotation: BlurAnnotation, + in context: inout GraphicsContext + ) { + guard annotation.points.count >= 2 else { return } + + // Draw the brush stroke path as a visual indicator + var brushPath = Path() + let scaledPoints = annotation.points.map { scalePoint($0) } + let scaledBrushSize = annotation.brushSize * scale + + if scaledPoints.count >= 2 { + brushPath.move(to: scaledPoints[0]) + for point in scaledPoints.dropFirst() { + brushPath.addLine(to: point) + } + } + + context.stroke( + brushPath, + with: .color(.white.opacity(0.6)), + style: SwiftUI.StrokeStyle(lineWidth: scaledBrushSize, lineCap: .round, lineJoin: .round) + ) + } + /// Draws a selection indicator around an annotation private func drawSelectionIndicator( for annotation: Annotation, diff --git a/ScreenCapture/Features/Preview/PreviewContentView.swift b/ScreenCapture/Features/Preview/PreviewContentView.swift index e9420c2..b5c7837 100644 --- a/ScreenCapture/Features/Preview/PreviewContentView.swift +++ b/ScreenCapture/Features/Preview/PreviewContentView.swift @@ -123,10 +123,11 @@ struct PreviewContentView: View { Spacer() ZStack(alignment: .topLeading) { - // Base image - Image(viewModel.image, scale: 1.0, label: Text("Screenshot")) + // Base image - use source scale factor for proper Retina display + Image(viewModel.image, scale: viewModel.sourceScaleFactor, label: Text("Screenshot")) .resizable() - .interpolation(.high) // High quality downscaling + .interpolation(.none) // No interpolation - pixel perfect + .antialiased(false) // Disable antialiasing .aspectRatio(contentMode: .fit) .frame( width: displayInfo.displaySize.width, @@ -140,7 +141,8 @@ struct PreviewContentView: View { currentAnnotation: viewModel.currentAnnotation, canvasSize: imageSize, scale: displayInfo.scale, - selectedIndex: viewModel.selectedAnnotationIndex + selectedIndex: viewModel.selectedAnnotationIndex, + originalImage: viewModel.image ) .frame( width: displayInfo.displaySize.width, @@ -261,7 +263,7 @@ struct PreviewContentView: View { } switch tool { - case .rectangle, .freehand, .arrow: + case .rectangle, .freehand, .arrow, .blur: return .crosshair case .text: return .iBeam @@ -626,9 +628,10 @@ struct PreviewContentView: View { let effectiveToolType = isEditingAnnotation ? viewModel.selectedAnnotationType : viewModel.selectedTool return HStack(spacing: 8) { - // Color picker with preset colors - HStack(spacing: 4) { - ForEach(presetColors, id: \.self) { color in + // Color picker with preset colors (not shown for blur tool) + if effectiveToolType != .blur { + HStack(spacing: 4) { + ForEach(presetColors, id: \.self) { color in Button { if isEditingAnnotation { viewModel.updateSelectedAnnotationColor(CodableColor(color)) @@ -675,6 +678,7 @@ struct PreviewContentView: View { ), supportsOpacity: false) .labelsHidden() .frame(width: 24) + } } // Rectangle fill toggle (for rectangle only) @@ -701,8 +705,8 @@ struct PreviewContentView: View { .help(isFilled ? "Filled (click for hollow)" : "Hollow (click for filled)") } - // Stroke width control - if effectiveToolType == .freehand || effectiveToolType == .arrow || + // Stroke width control (also controls brush size for blur) + if effectiveToolType == .freehand || effectiveToolType == .arrow || effectiveToolType == .blur || (effectiveToolType == .rectangle && !(isEditingAnnotation ? (viewModel.selectedAnnotationIsFilled ?? false) : AppSettings.shared.rectangleFilled)) { Divider() .frame(height: 20) @@ -786,6 +790,48 @@ struct PreviewContentView: View { } } + // Blur radius control + if effectiveToolType == .blur { + Divider() + .frame(height: 20) + + HStack(spacing: 4) { + Image(systemName: "eye.slash") + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + + Slider( + value: Binding( + get: { + if isEditingAnnotation { + return viewModel.selectedAnnotationBlurRadius ?? 15.0 + } + return AppSettings.shared.blurRadius + }, + set: { newRadius in + if isEditingAnnotation { + viewModel.updateSelectedAnnotationBlurRadius(newRadius) + } else { + AppSettings.shared.blurRadius = newRadius + } + } + ), + in: 5.0...30.0, + step: 1 + ) + .frame(width: 60) + .tint(.white) + + let radius = isEditingAnnotation + ? Int(viewModel.selectedAnnotationBlurRadius ?? 15) + : Int(AppSettings.shared.blurRadius) + Text("\(radius)") + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + .frame(width: 20) + } + } + // Delete button for selected annotation if isEditingAnnotation { Divider() @@ -906,8 +952,8 @@ struct PreviewContentView: View { .frame(height: 16) } - // Stroke width control (for rectangle/freehand/arrow - only show for hollow rectangles) - if effectiveToolType == .freehand || effectiveToolType == .arrow || + // Stroke width control (for rectangle/freehand/arrow/blur - only show for hollow rectangles) + if effectiveToolType == .freehand || effectiveToolType == .arrow || effectiveToolType == .blur || (effectiveToolType == .rectangle && !(isEditingAnnotation ? (viewModel.selectedAnnotationIsFilled ?? false) : AppSettings.shared.rectangleFilled)) { HStack(spacing: 4) { Image(systemName: "lineweight") diff --git a/ScreenCapture/Features/Preview/PreviewViewModel.swift b/ScreenCapture/Features/Preview/PreviewViewModel.swift index 85951f3..736df7d 100644 --- a/ScreenCapture/Features/Preview/PreviewViewModel.swift +++ b/ScreenCapture/Features/Preview/PreviewViewModel.swift @@ -106,6 +106,10 @@ final class PreviewViewModel { @ObservationIgnored private(set) var textTool = TextTool() + /// Blur tool + @ObservationIgnored + private(set) var blurTool = BlurTool() + /// Counter to trigger view updates during drawing /// Incremented each time drawing state changes to force re-render private(set) var drawingUpdateCounter: Int = 0 @@ -153,6 +157,7 @@ final class PreviewViewModel { case .freehand: return freehandTool case .arrow: return arrowTool case .text: return textTool + case .blur: return blurTool } } @@ -210,6 +215,11 @@ final class PreviewViewModel { screenshot.sourceDisplay.name } + /// Source display scale factor (for Retina displays) + var sourceScaleFactor: CGFloat { + screenshot.sourceDisplay.scaleFactor + } + /// Current export format var format: ExportFormat { get { screenshot.format } @@ -390,6 +400,11 @@ final class PreviewViewModel { // Update observable properties for text input UI _isWaitingForTextInput = true _textInputPosition = point + case .blur: + // Read directly from shared settings to ensure we get the latest values + blurTool.blurRadius = AppSettings.shared.blurRadius + blurTool.brushSize = AppSettings.shared.strokeWidth * 10 // Use stroke width scaled up for brush + blurTool.beginDrawing(at: point) } updateCurrentAnnotation() @@ -409,6 +424,8 @@ final class PreviewViewModel { arrowTool.continueDrawing(to: point) case .text: textTool.continueDrawing(to: point) + case .blur: + blurTool.continueDrawing(to: point) } updateCurrentAnnotation() @@ -433,6 +450,8 @@ final class PreviewViewModel { _ = textTool.endDrawing(at: point) updateCurrentAnnotation() return + case .blur: + annotation = blurTool.endDrawing(at: point) } _currentAnnotation = nil @@ -449,6 +468,7 @@ final class PreviewViewModel { freehandTool.cancelDrawing() arrowTool.cancelDrawing() textTool.cancelDrawing() + blurTool.cancelDrawing() _currentAnnotation = nil _isWaitingForTextInput = false _textInputPosition = nil @@ -620,6 +640,8 @@ final class PreviewViewModel { dragOriginalPosition = arrow.bounds.origin case .text(let text): dragOriginalPosition = text.position + case .blur(let blur): + dragOriginalPosition = blur.rect.origin } } @@ -678,6 +700,16 @@ final class PreviewViewModel { y: originalPosition.y + delta.y ) updatedAnnotation = .text(text) + + case .blur(var blur): + // Translate all points + blur.points = blur.points.map { point in + CGPoint( + x: point.x + delta.x, + y: point.y + delta.y + ) + } + updatedAnnotation = .blur(blur) } if let updated = updatedAnnotation { @@ -719,6 +751,10 @@ final class PreviewViewModel { case .text(var text): text.style.color = color updatedAnnotation = .text(text) + + case .blur: + // Blur doesn't have a color + return } if let updated = updatedAnnotation { @@ -752,6 +788,10 @@ final class PreviewViewModel { case .text: // Text doesn't have stroke width return + + case .blur: + // Blur doesn't have stroke width + return } if let updated = updatedAnnotation { @@ -784,6 +824,7 @@ final class PreviewViewModel { case .freehand: return .freehand case .arrow: return .arrow case .text: return .text + case .blur: return .blur } } @@ -797,6 +838,7 @@ final class PreviewViewModel { case .freehand(let freehand): return freehand.style.color case .arrow(let arrow): return arrow.style.color case .text(let text): return text.style.color + case .blur: return nil // Blur doesn't have a color } } @@ -810,6 +852,7 @@ final class PreviewViewModel { case .freehand(let freehand): return freehand.style.lineWidth case .arrow(let arrow): return arrow.style.lineWidth case .text: return nil + case .blur: return nil // Blur doesn't have stroke width } } @@ -849,6 +892,31 @@ final class PreviewViewModel { redoStack.removeAll() } + /// Returns the blur radius of the selected blur annotation + var selectedAnnotationBlurRadius: CGFloat? { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return nil } + + if case .blur(let blur) = annotations[index] { + return blur.blurRadius + } + return nil + } + + /// Updates the blur radius of the selected blur annotation + func updateSelectedAnnotationBlurRadius(_ radius: CGFloat) { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return } + + let annotation = annotations[index] + guard case .blur(var blur) = annotation else { return } + + pushUndoState() + blur.blurRadius = radius + screenshot = screenshot.replacingAnnotation(at: index, with: .blur(blur)) + redoStack.removeAll() + } + /// Commits the current text input and adds the annotation func commitTextInput() { if let annotation = textTool.commitText() { @@ -983,6 +1051,7 @@ enum AnnotationToolType: String, CaseIterable, Identifiable, Sendable { case freehand case arrow case text + case blur var id: String { rawValue } @@ -992,6 +1061,7 @@ enum AnnotationToolType: String, CaseIterable, Identifiable, Sendable { case .freehand: return "Draw" case .arrow: return "Arrow" case .text: return "Text" + case .blur: return "Blur" } } @@ -1001,6 +1071,7 @@ enum AnnotationToolType: String, CaseIterable, Identifiable, Sendable { case .freehand: return "d" case .arrow: return "a" case .text: return "t" + case .blur: return "b" } } @@ -1010,6 +1081,7 @@ enum AnnotationToolType: String, CaseIterable, Identifiable, Sendable { case .freehand: return "pencil.line" case .arrow: return "arrow.up.right" case .text: return "textformat" + case .blur: return "eye.slash" } } } diff --git a/ScreenCapture/Features/Preview/PreviewWindow.swift b/ScreenCapture/Features/Preview/PreviewWindow.swift index 35c14d8..86c04ab 100644 --- a/ScreenCapture/Features/Preview/PreviewWindow.swift +++ b/ScreenCapture/Features/Preview/PreviewWindow.swift @@ -291,8 +291,17 @@ final class PreviewWindow: NSPanel { } } return - case "1", "2", "3", "4": - // Number keys to quickly select tools (1=Rectangle, 2=Freehand, 3=Arrow, 4=Text) + case "b": + Task { @MainActor in + if viewModel.selectedTool == .blur { + viewModel.selectTool(nil) + } else { + viewModel.selectTool(.blur) + } + } + return + case "1", "2", "3", "4", "5": + // Number keys to quickly select tools (1=Rectangle, 2=Freehand, 3=Arrow, 4=Text, 5=Blur) let toolIndex = Int(String(char))! - 1 let tools = AnnotationToolType.allCases if toolIndex < tools.count { diff --git a/ScreenCapture/Models/Annotation.swift b/ScreenCapture/Models/Annotation.swift index de1c810..6680593 100644 --- a/ScreenCapture/Models/Annotation.swift +++ b/ScreenCapture/Models/Annotation.swift @@ -8,6 +8,7 @@ enum Annotation: Identifiable, Equatable, Sendable { case freehand(FreehandAnnotation) case arrow(ArrowAnnotation) case text(TextAnnotation) + case blur(BlurAnnotation) /// Unique identifier for this annotation var id: UUID { @@ -20,6 +21,8 @@ enum Annotation: Identifiable, Equatable, Sendable { return annotation.id case .text(let annotation): return annotation.id + case .blur(let annotation): + return annotation.id } } @@ -34,6 +37,8 @@ enum Annotation: Identifiable, Equatable, Sendable { return annotation.bounds case .text(let annotation): return annotation.bounds + case .blur(let annotation): + return annotation.rect } } } @@ -200,6 +205,61 @@ struct TextAnnotation: Identifiable, Equatable, Sendable { } } +// MARK: - Blur Annotation + +/// A blur annotation that obscures content along a painted brush stroke. +struct BlurAnnotation: Identifiable, Equatable, Sendable { + /// Unique identifier + let id: UUID + + /// Points along the blur brush stroke (in image coordinates) + var points: [CGPoint] + + /// Blur intensity (sigma value for Gaussian blur) + var blurRadius: CGFloat + + /// Brush size (diameter of the blur brush) + var brushSize: CGFloat + + init(id: UUID = UUID(), points: [CGPoint] = [], blurRadius: CGFloat = 15.0, brushSize: CGFloat = 40.0) { + self.id = id + self.points = points + self.blurRadius = blurRadius + self.brushSize = brushSize + } + + /// The bounding rect of all points plus brush size + var rect: CGRect { + bounds + } + + /// Computed bounding box of all points + var bounds: CGRect { + guard !points.isEmpty else { return .zero } + + let xs = points.map { $0.x } + let ys = points.map { $0.y } + + guard let minX = xs.min(), let maxX = xs.max(), + let minY = ys.min(), let maxY = ys.max() else { + return .zero + } + + let padding = brushSize / 2 + return CGRect( + x: minX - padding, + y: minY - padding, + width: maxX - minX + brushSize, + height: maxY - minY + brushSize + ) + } + + /// Whether this annotation has meaningful content + var isValid: Bool { + points.count >= 2 + } +} + // MARK: - CGPoint Sendable Conformance extension CGPoint: @retroactive @unchecked Sendable {} @@ -218,6 +278,8 @@ extension Annotation { return NSLocalizedString("tool.arrow", comment: "") case .text: return NSLocalizedString("tool.text", comment: "") + case .blur: + return NSLocalizedString("tool.blur", comment: "") } } } diff --git a/ScreenCapture/Models/AppSettings.swift b/ScreenCapture/Models/AppSettings.swift index 0d750a9..6f48473 100644 --- a/ScreenCapture/Models/AppSettings.swift +++ b/ScreenCapture/Models/AppSettings.swift @@ -27,6 +27,7 @@ final class AppSettings { static let strokeWidth = prefix + "strokeWidth" static let textSize = prefix + "textSize" static let rectangleFilled = prefix + "rectangleFilled" + static let blurRadius = prefix + "blurRadius" static let recentCaptures = prefix + "recentCaptures" static let autoSaveOnClose = prefix + "autoSaveOnClose" } @@ -88,6 +89,11 @@ final class AppSettings { didSet { save(rectangleFilled, forKey: Keys.rectangleFilled) } } + /// Default blur radius for blur tool + var blurRadius: CGFloat { + didSet { save(Double(blurRadius), forKey: Keys.blurRadius) } + } + /// Last 5 saved captures var recentCaptures: [RecentCapture] { didSet { saveRecentCaptures() } @@ -160,6 +166,7 @@ final class AppSettings { strokeWidth = CGFloat(defaults.object(forKey: Keys.strokeWidth) as? Double ?? 2.0) textSize = CGFloat(defaults.object(forKey: Keys.textSize) as? Double ?? 14.0) rectangleFilled = defaults.object(forKey: Keys.rectangleFilled) as? Bool ?? false + blurRadius = CGFloat(defaults.object(forKey: Keys.blurRadius) as? Double ?? 15.0) // Load recent captures recentCaptures = Self.loadRecentCaptures() @@ -213,6 +220,7 @@ final class AppSettings { strokeWidth = 2.0 textSize = 14.0 rectangleFilled = false + blurRadius = 15.0 recentCaptures = [] autoSaveOnClose = true } diff --git a/ScreenCapture/Services/ClipboardService.swift b/ScreenCapture/Services/ClipboardService.swift index 35ac13c..783b5fc 100644 --- a/ScreenCapture/Services/ClipboardService.swift +++ b/ScreenCapture/Services/ClipboardService.swift @@ -1,6 +1,7 @@ import Foundation import AppKit import CoreGraphics +import CoreImage /// Service for copying screenshots to the system clipboard. /// Uses NSPasteboard for compatibility with all macOS applications. @@ -92,7 +93,7 @@ struct ClipboardService: Sendable { // Draw each annotation for annotation in annotations { - renderAnnotation(annotation, in: context, imageHeight: CGFloat(height)) + renderAnnotation(annotation, in: context, imageHeight: CGFloat(height), baseImage: image) } // Create final image @@ -107,7 +108,8 @@ struct ClipboardService: Sendable { private func renderAnnotation( _ annotation: Annotation, in context: CGContext, - imageHeight: CGFloat + imageHeight: CGFloat, + baseImage: CGImage? = nil ) { switch annotation { case .rectangle(let rect): @@ -118,6 +120,10 @@ struct ClipboardService: Sendable { renderArrow(arrow, in: context, imageHeight: imageHeight) case .text(let text): renderText(text, in: context, imageHeight: imageHeight) + case .blur(let blur): + if let baseImage = baseImage { + renderBlur(blur, in: context, imageHeight: imageHeight, baseImage: baseImage) + } } } @@ -236,6 +242,95 @@ struct ClipboardService: Sendable { CTLineDraw(line, context) context.restoreGState() } + + /// Renders a blur annotation using Gaussian blur with brush-based clipping. + private func renderBlur( + _ annotation: BlurAnnotation, + in context: CGContext, + imageHeight: CGFloat, + baseImage: CGImage + ) { + guard annotation.points.count >= 2 else { return } + + let imageWidth = CGFloat(baseImage.width) + + // Get bounds of the blur stroke (in SwiftUI coordinates) + let bounds = annotation.bounds + guard bounds.width > 0, bounds.height > 0 else { return } + + // Clamp to image bounds + let clampedBounds = CGRect( + x: max(0, bounds.origin.x), + y: max(0, bounds.origin.y), + width: min(bounds.width, imageWidth - max(0, bounds.origin.x)), + height: min(bounds.height, imageHeight - max(0, bounds.origin.y)) + ) + + guard clampedBounds.width > 0, clampedBounds.height > 0 else { return } + + // Convert to CG coordinates (bottom-left origin) + let cgBounds = CGRect( + x: clampedBounds.origin.x, + y: imageHeight - clampedBounds.origin.y - clampedBounds.height, + width: clampedBounds.width, + height: clampedBounds.height + ) + + // Crop the region from base image + guard let croppedImage = baseImage.cropping(to: cgBounds) else { return } + + // Apply Gaussian blur (invert the range so higher values = more blur) + let ciImage = CIImage(cgImage: croppedImage) + let effectiveSigma = 35.0 - annotation.blurRadius // 5→30 (intense), 30→5 (light) + let blurredCIImage = ciImage.applyingGaussianBlur(sigma: effectiveSigma) + + // Clamp the blurred image back to original bounds (blur expands the extent) + let clampedBlurred = blurredCIImage.clamped(to: ciImage.extent) + let ciContext = CIContext() + guard let blurredCGImage = ciContext.createCGImage(clampedBlurred, from: ciImage.extent) else { return } + + // Create brush stroke path (in CG coordinates) + let brushPath = CGMutablePath() + let brushSize = annotation.brushSize + + // Transform points from SwiftUI to CG coordinates + let cgPoints = annotation.points.map { point in + CGPoint(x: point.x, y: imageHeight - point.y) + } + + // Add circles at each point + for point in cgPoints { + brushPath.addEllipse(in: CGRect( + x: point.x - brushSize / 2, + y: point.y - brushSize / 2, + width: brushSize, + height: brushSize + )) + } + + // Add stroked line connecting points for continuous coverage + if cgPoints.count >= 2 { + let linePath = CGMutablePath() + linePath.move(to: cgPoints[0]) + for point in cgPoints.dropFirst() { + linePath.addLine(to: point) + } + let strokedLine = linePath.copy( + strokingWithWidth: brushSize, + lineCap: .round, + lineJoin: .round, + miterLimit: 10 + ) + brushPath.addPath(strokedLine) + } + + // Draw blurred image clipped to brush path + context.saveGState() + context.addPath(brushPath) + context.clip() + context.draw(blurredCGImage, in: cgBounds) + context.restoreGState() + } } // MARK: - Shared Instance diff --git a/ScreenCapture/Services/ImageExporter.swift b/ScreenCapture/Services/ImageExporter.swift index 7f6273a..f7c437b 100644 --- a/ScreenCapture/Services/ImageExporter.swift +++ b/ScreenCapture/Services/ImageExporter.swift @@ -2,6 +2,7 @@ import Foundation import CoreGraphics import AppKit import UniformTypeIdentifiers +import CoreImage /// Service for exporting screenshots to PNG or JPEG files. /// Uses CGImageDestination for efficient image encoding. @@ -175,7 +176,7 @@ struct ImageExporter: Sendable { // Draw each annotation for annotation in annotations { - renderAnnotation(annotation, in: context, imageHeight: CGFloat(height)) + renderAnnotation(annotation, in: context, imageHeight: CGFloat(height), baseImage: image) } // Create final image @@ -191,10 +192,12 @@ struct ImageExporter: Sendable { /// - annotation: The annotation to render /// - context: The graphics context /// - imageHeight: The image height (for coordinate transformation) + /// - baseImage: The original image (needed for blur) private func renderAnnotation( _ annotation: Annotation, in context: CGContext, - imageHeight: CGFloat + imageHeight: CGFloat, + baseImage: CGImage? = nil ) { switch annotation { case .rectangle(let rect): @@ -205,6 +208,10 @@ struct ImageExporter: Sendable { renderArrow(arrow, in: context, imageHeight: imageHeight) case .text(let text): renderText(text, in: context, imageHeight: imageHeight) + case .blur(let blur): + if let baseImage = baseImage { + renderBlur(blur, in: context, imageHeight: imageHeight, baseImage: baseImage) + } } } @@ -334,6 +341,95 @@ struct ImageExporter: Sendable { // Restore context state context.restoreGState() } + + /// Renders a blur annotation using Gaussian blur with brush-based clipping. + private func renderBlur( + _ annotation: BlurAnnotation, + in context: CGContext, + imageHeight: CGFloat, + baseImage: CGImage + ) { + guard annotation.points.count >= 2 else { return } + + let imageWidth = CGFloat(baseImage.width) + + // Get bounds of the blur stroke (in SwiftUI coordinates) + let bounds = annotation.bounds + guard bounds.width > 0, bounds.height > 0 else { return } + + // Clamp to image bounds + let clampedBounds = CGRect( + x: max(0, bounds.origin.x), + y: max(0, bounds.origin.y), + width: min(bounds.width, imageWidth - max(0, bounds.origin.x)), + height: min(bounds.height, imageHeight - max(0, bounds.origin.y)) + ) + + guard clampedBounds.width > 0, clampedBounds.height > 0 else { return } + + // Convert to CG coordinates (bottom-left origin) + let cgBounds = CGRect( + x: clampedBounds.origin.x, + y: imageHeight - clampedBounds.origin.y - clampedBounds.height, + width: clampedBounds.width, + height: clampedBounds.height + ) + + // Crop the region from base image + guard let croppedImage = baseImage.cropping(to: cgBounds) else { return } + + // Apply Gaussian blur (invert the range so higher values = more blur) + let ciImage = CIImage(cgImage: croppedImage) + let effectiveSigma = 35.0 - annotation.blurRadius // 5→30 (intense), 30→5 (light) + let blurredCIImage = ciImage.applyingGaussianBlur(sigma: effectiveSigma) + + // Clamp the blurred image back to original bounds (blur expands the extent) + let clampedBlurred = blurredCIImage.clamped(to: ciImage.extent) + let ciContext = CIContext() + guard let blurredCGImage = ciContext.createCGImage(clampedBlurred, from: ciImage.extent) else { return } + + // Create brush stroke path (in CG coordinates) + let brushPath = CGMutablePath() + let brushSize = annotation.brushSize + + // Transform points from SwiftUI to CG coordinates + let cgPoints = annotation.points.map { point in + CGPoint(x: point.x, y: imageHeight - point.y) + } + + // Add circles at each point + for point in cgPoints { + brushPath.addEllipse(in: CGRect( + x: point.x - brushSize / 2, + y: point.y - brushSize / 2, + width: brushSize, + height: brushSize + )) + } + + // Add stroked line connecting points for continuous coverage + if cgPoints.count >= 2 { + let linePath = CGMutablePath() + linePath.move(to: cgPoints[0]) + for point in cgPoints.dropFirst() { + linePath.addLine(to: point) + } + let strokedLine = linePath.copy( + strokingWithWidth: brushSize, + lineCap: .round, + lineJoin: .round, + miterLimit: 10 + ) + brushPath.addPath(strokedLine) + } + + // Draw blurred image clipped to brush path + context.saveGState() + context.addPath(brushPath) + context.clip() + context.draw(blurredCGImage, in: cgBounds) + context.restoreGState() + } } // MARK: - Shared Instance