diff --git a/ScreenCapture/App/AppDelegate.swift b/ScreenCapture/App/AppDelegate.swift index 3871d4a..53a07e3 100644 --- a/ScreenCapture/App/AppDelegate.swift +++ b/ScreenCapture/App/AppDelegate.swift @@ -1,4 +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. @@ -18,6 +20,12 @@ 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? + + /// Registered hotkey for window capture with shadow + private var windowWithShadowHotkeyRegistration: HotkeyManager.Registration? + /// Shared app settings private let settings = AppSettings.shared @@ -30,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) @@ -43,6 +53,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) menuBarController?.setup() + NSLog("[ScreenCapture] Menu bar set up, registering hotkeys...") + // Register global hotkeys Task { await registerHotkeys() @@ -53,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. @@ -120,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( @@ -129,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 @@ -147,14 +156,40 @@ 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 { + NSLog("[ScreenCapture] ✗ Failed to register selection hotkey: %@", "\(error)") + } + + // Register window capture hotkey + do { + windowHotkeyRegistration = try await hotkeyManager.register( + shortcut: settings.windowShortcut + ) { [weak self] in + Task { @MainActor in + self?.captureWindow() + } + } + NSLog("[ScreenCapture] ✓ Registered window hotkey: %@", settings.windowShortcut.displayString) + } catch { + 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 { - #if DEBUG - print("Failed to register selection hotkey: \(error)") - #endif + NSLog("[ScreenCapture] ✗ Failed to register window with shadow hotkey: %@", "\(error)") } + + NSLog("[ScreenCapture] Hotkey registration complete") } /// Unregisters all global hotkeys @@ -170,6 +205,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { await hotkeyManager.unregister(registration) selectionHotkeyRegistration = nil } + + if let registration = windowHotkeyRegistration { + await hotkeyManager.unregister(registration) + windowHotkeyRegistration = nil + } + + if let registration = windowWithShadowHotkeyRegistration { + await hotkeyManager.unregister(registration) + windowWithShadowHotkeyRegistration = nil + } } /// Re-registers hotkeys after settings change @@ -186,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)) } } @@ -241,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 @@ -276,29 +321,142 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } catch { isCaptureInProgress = false - #if DEBUG - print("Failed to present selection overlay: \(error)") - #endif showCaptureError(.captureFailure(underlying: error)) } } } + /// Triggers a window capture + @objc func captureWindow() { + // Prevent overlapping captures + 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)) + } + } + } + + /// Triggers a window capture with shadow + @objc func captureWindowWithShadow() { + // Prevent overlapping captures + 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?.handleWindowSelectedWithShadow(windowID) + } + } + + selectorController.onCancel = { [weak self] in + Task { @MainActor in + self?.handleWindowSelectionCancel() + } + } + + try await selectorController.presentSelector() + + } catch { + isCaptureInProgress = false + showCaptureError(.captureFailure(underlying: error)) + } + } + } + + /// Handles successful window selection + private func handleWindowSelected(_ windowID: CGWindowID) async { + defer { isCaptureInProgress = false } + + do { + // Capture the selected window by ID + let screenshot = try await WindowCaptureService.shared.captureWindowByID(windowID) + + // 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 + // 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 + } + /// Handles successful selection completion private func handleSelectionComplete(rect: CGRect, display: DisplayInfo) async { 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 @@ -316,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) } @@ -334,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: "") @@ -390,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/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..7e9d4bf --- /dev/null +++ b/ScreenCapture/Features/Capture/WindowCaptureService.swift @@ -0,0 +1,349 @@ +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) + } + + // 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 { + return false + } + + // Exclude our own app + if app.bundleIdentifier == myBundleID { + 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 { + 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 { + return false + } + + 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. + /// - 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, includeShadow: Bool = false) async throws -> Screenshot { + // Invalidate cache to get fresh window list + invalidateCache() + let windows = try await getWindows() + + guard let window = windows.first(where: { $0.windowID == windowID }) else { + throw ScreenCaptureError.captureError(message: "Window not found (ID: \(windowID))") + } + + 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 + + // 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. + /// - 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) + + let matchingScreen = NSScreen.screens.first { screen in + screen.frame.contains(cocoaPoint) + } + + 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 + } + + // Capture the window + let cgImage: CGImage + do { + cgImage = try await SCScreenshotManager.captureImage( + contentFilter: filter, + configuration: config + ) + } catch { + throw ScreenCaptureError.captureFailure(underlying: error) + } + + // 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..02cc99c 100644 --- a/ScreenCapture/Features/MenuBar/MenuBarController.swift +++ b/ScreenCapture/Features/MenuBar/MenuBarController.swift @@ -1,9 +1,10 @@ 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. @MainActor -final class MenuBarController { +final class MenuBarController: NSObject, NSMenuDelegate { // MARK: - Properties /// The status item displayed in the menu bar @@ -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,26 +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: settings.windowShortcut.menuKeyEquivalent + ) + 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()) @@ -114,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) } @@ -160,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 289ca94..e9420c2 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() + } + + // 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) + // Info bar + infoBar + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.bar) + } } .alert( "Error", @@ -49,6 +84,19 @@ struct PreviewContentView: View { } } + // MARK: - Gallery Actions + + /// Loads a recent capture into the editor + private func openCapture(_ capture: RecentCapture) { + viewModel.loadCapture(capture) + } + + /// 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 @@ -78,6 +126,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, @@ -141,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 { @@ -559,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) + + 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) - styleCustomizationBar + 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 @@ -800,6 +1037,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() @@ -869,6 +1128,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() @@ -942,6 +1204,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/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/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..67fcce1 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") - } - - // 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") - } + .tag(SettingsTab.general) - // 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: 560, height: 640) .alert("Error", isPresented: $viewModel.showErrorAlert) { Button("OK") { viewModel.errorMessage = nil @@ -87,304 +56,340 @@ 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: 16) { + // Permissions Section + SettingsSection(title: "Permissions") { + VStack(spacing: 0) { + PermissionRow( + icon: "record.circle", + title: "Screen Recording", + isGranted: viewModel.hasScreenRecordingPermission, + isChecking: viewModel.isCheckingPermissions, + onGrant: { viewModel.requestScreenRecordingPermission() } + ) + + Divider() + .padding(.leading, 44) + + PermissionRow( + icon: "folder", + title: "File Access", + isGranted: viewModel.hasFolderAccessPermission, + isChecking: viewModel.isCheckingPermissions, + onGrant: { viewModel.requestFolderAccess() } + ) + } + } + .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 Section + SettingsSection(title: "Save Location") { + HStack(spacing: 12) { + Image(systemName: "folder.fill") + .font(.title2) + .foregroundStyle(.blue) - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - HStack(spacing: 8) { - Image(systemName: icon) - .foregroundStyle(.secondary) - .frame(width: 20) - Text(title) - } + Text(viewModel.saveLocationPath) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() - Spacer() - - if isChecking { - ProgressView() - .controlSize(.small) - } else { - HStack(spacing: 8) { - if isGranted { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text("Granted") + Button("Change...") { + viewModel.selectSaveLocation() + } + .buttonStyle(.bordered) + + Button { + viewModel.revealSaveLocation() + } label: { + Image(systemName: "arrow.right.circle.fill") + .font(.title2) .foregroundStyle(.secondary) - } else { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.red) - - Button { - onGrant() - } label: { - Text("Grant Access") - } - .buttonStyle(.borderedProminent) - .controlSize(.small) } + .buttonStyle(.plain) + .help("Reveal in Finder") } + .padding(.vertical, 4) } - } - if !isGranted && !isChecking { - Text(hint) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text("\(title): \(isGranted ? "Granted" : "Not Granted")")) - } -} + // 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: 10) { + HStack { + Text("Quality") + .foregroundStyle(.secondary) + Spacer() + Text("\(Int(viewModel.jpegQualityPercentage))%") + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.primary) + } -// MARK: - Save Location Picker + Slider( + value: $viewModel.jpegQuality, + in: SettingsViewModel.jpegQualityRange, + step: 0.05 + ) + .tint(.blue) + } + } + } + } -/// Picker for selecting the default save location. -private struct SaveLocationPicker: View { - @Bindable var viewModel: SettingsViewModel + // 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(.secondary) + } - 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) - } + Spacer() - Spacer() + Toggle("", isOn: $viewModel.launchAtLogin) + .toggleStyle(.switch) + .labelsHidden() + } - Button { - viewModel.selectSaveLocation() - } label: { - Text("Choose...") - } + Divider() + .padding(.vertical, 10) - Button { - viewModel.revealSaveLocation() - } label: { - Image(systemName: "folder") - } - .help("Show in Finder") - } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text("Save Location: \(viewModel.saveLocationPath)")) - } -} + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Auto-save on Close") + .font(.body) + Text("Save screenshots automatically when closing preview") + .font(.caption) + .foregroundStyle(.secondary) + } -// MARK: - Export Format Picker + Spacer() -/// Picker for selecting the default export format (PNG/JPEG). -private struct ExportFormatPicker: View { - @Bindable var viewModel: SettingsViewModel + Toggle("", isOn: $viewModel.autoSaveOnClose) + .toggleStyle(.switch) + .labelsHidden() + } + } + } - var body: some View { - Picker("Default Format", selection: $viewModel.defaultFormat) { - Text("PNG").tag(ExportFormat.png) - Text("JPEG").tag(ExportFormat.jpeg) + Spacer(minLength: 8) + + // Reset Button + HStack { + Spacer() + Button(role: .destructive) { + viewModel.resetAllToDefaults() + } label: { + Label("Reset All Settings", systemImage: "arrow.counterclockwise") + .font(.callout) + } + .buttonStyle(.plain) + .foregroundStyle(.red.opacity(0.8)) + } + } + .padding(24) } - .pickerStyle(.segmented) - .accessibilityLabel(Text("Export Format")) } } -// MARK: - JPEG Quality Slider +// MARK: - Shortcuts Settings Tab -/// Slider for adjusting JPEG compression quality. -private struct JPEGQualitySlider: View { +/// Keyboard shortcuts configuration tab. +private struct ShortcutsSettingsTab: View { @Bindable var viewModel: SettingsViewModel var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("JPEG Quality") - Spacer() - Text("\(Int(viewModel.jpegQualityPercentage))%") - .foregroundStyle(.secondary) - .monospacedDigit() - } + ScrollView { + VStack(spacing: 16) { + SettingsSection(title: "Capture Shortcuts") { + VStack(spacing: 0) { + ShortcutRow( + icon: "rectangle.dashed", + label: "Full Screen", + shortcut: viewModel.fullScreenShortcut, + isRecording: viewModel.isRecordingFullScreenShortcut, + onRecord: { viewModel.startRecordingFullScreenShortcut() }, + onReset: { viewModel.resetFullScreenShortcut() } + ) + + Divider().padding(.leading, 44) + + ShortcutRow( + icon: "crop", + label: "Selection", + shortcut: viewModel.selectionShortcut, + isRecording: viewModel.isRecordingSelectionShortcut, + onRecord: { viewModel.startRecordingSelectionShortcut() }, + onReset: { viewModel.resetSelectionShortcut() } + ) + + Divider().padding(.leading, 44) + + ShortcutRow( + icon: "macwindow", + label: "Window", + shortcut: viewModel.windowShortcut, + isRecording: viewModel.isRecordingWindowShortcut, + onRecord: { viewModel.startRecordingWindowShortcut() }, + onReset: { viewModel.resetWindowShortcut() } + ) + + Divider().padding(.leading, 44) + + ShortcutRow( + icon: "macwindow.on.rectangle", + label: "Window + Shadow", + shortcut: viewModel.windowWithShadowShortcut, + isRecording: viewModel.isRecordingWindowWithShadowShortcut, + onRecord: { viewModel.startRecordingWindowWithShadowShortcut() }, + onReset: { viewModel.resetWindowWithShadowShortcut() } + ) + } + } - Slider( - value: $viewModel.jpegQuality, - in: SettingsViewModel.jpegQualityRange, - step: 0.05 - ) { - Text("JPEG Quality") - } minimumValueLabel: { - Text("10%") - .font(.caption) - } maximumValueLabel: { - Text("100%") - .font(.caption) + // Instructions + 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) } - .accessibilityValue(Text("\(Int(viewModel.jpegQualityPercentage)) percent")) - - Text("Higher quality results in larger file sizes") - .font(.caption) - .foregroundStyle(.secondary) + .padding(24) } } } -// MARK: - Shortcut Recorder +// MARK: - Annotations Settings Tab -/// A control for recording keyboard shortcuts. -private struct ShortcutRecorder: View { - let label: String - let shortcut: KeyboardShortcut - let isRecording: Bool - let onRecord: () -> Void - let onReset: () -> Void +/// Annotation tools configuration tab. +private struct AnnotationsSettingsTab: View { + @Bindable var viewModel: SettingsViewModel var body: some View { - HStack { - Text(label) + ScrollView { + VStack(spacing: 16) { + // Color Selection + SettingsSection(title: "Stroke Color") { + VStack(spacing: 14) { + // Preset Colors Grid + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 9), spacing: 10) { + ForEach(SettingsViewModel.presetColors, id: \.self) { color in + ColorButton( + color: color, + isSelected: colorsAreEqual(viewModel.strokeColor, color), + action: { viewModel.strokeColor = color } + ) + } + } - Spacer() + Divider() - 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)) + HStack { + Text("Custom Color") + .foregroundStyle(.secondary) + Spacer() + ColorPicker("", selection: $viewModel.strokeColor, supportsOpacity: false) + .labelsHidden() + } + } } - .buttonStyle(.plain) - } - - Button { - onReset() - } label: { - Image(systemName: "arrow.counterclockwise") - } - .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 + // 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)) + .foregroundStyle(.secondary) + .frame(width: 55, alignment: .trailing) + } - var body: some View { - HStack { - Text("Stroke Color") + // Visual Preview + HStack { + Text("Preview") + .font(.callout) + .foregroundStyle(.tertiary) + Spacer() + RoundedRectangle(cornerRadius: viewModel.strokeWidth / 2) + .fill(viewModel.strokeColor) + .frame(width: 120, height: max(viewModel.strokeWidth, 2)) + } + } + } - Spacer() + // 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)) + .foregroundStyle(.secondary) + .frame(width: 55, alignment: .trailing) + } - // Preset color buttons - HStack(spacing: 4) { - ForEach(SettingsViewModel.presetColors, id: \.self) { color in - Button { - viewModel.strokeColor = color - } label: { - 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) - } - } + // Visual Preview + HStack { + Text("Preview") + .font(.callout) + .foregroundStyle(.tertiary) + Spacer() + Text("Sample Text") + .font(.system(size: min(viewModel.textSize, 28))) + .foregroundStyle(viewModel.strokeColor) + } } - .buttonStyle(.plain) - .accessibilityLabel(Text(colorName(for: color))) } } - - // Custom color picker - ColorPicker("", selection: $viewModel.strokeColor, supportsOpacity: false) - .labelsHidden() - .frame(width: 30) + .padding(24) } - .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 } @@ -394,92 +399,174 @@ private struct StrokeColorPicker: View { abs(colorA.greenComponent - colorB.greenComponent) < tolerance && abs(colorA.blueComponent - colorB.blueComponent) < tolerance } +} + +// MARK: - Reusable Components + +/// A section container for settings with a title. +private struct SettingsSection: View { + let title: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.headline) + .foregroundStyle(.primary) - /// 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" + 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) + } } } } -// MARK: - Stroke Width Slider - -/// Slider for adjusting annotation stroke width. -private struct StrokeWidthSlider: View { - @Bindable var viewModel: SettingsViewModel +/// A single permission row with status and action. +private struct PermissionRow: View { + let icon: String + let title: String + let isGranted: Bool + let isChecking: Bool + let onGrant: () -> Void 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: 14) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(isGranted ? .green : .orange) + .frame(width: 30) - HStack(spacing: 12) { - Slider( - value: $viewModel.strokeWidth, - in: SettingsViewModel.strokeWidthRange, - step: 0.5 - ) { - Text("Stroke Width") - } - .accessibilityValue(Text("\(viewModel.strokeWidth, specifier: "%.1f") points")) + Text(title) + .font(.body) - // Preview of stroke width - RoundedRectangle(cornerRadius: viewModel.strokeWidth / 2) - .fill(viewModel.strokeColor) - .frame(width: 40, height: viewModel.strokeWidth) + Spacer() + + if isChecking { + ProgressView() + .controlSize(.small) + } else if isGranted { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Granted") + .font(.callout) + .foregroundStyle(.secondary) + } + } else { + Button("Grant Access") { + onGrant() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) } } + .padding(.vertical, 10) } } -// MARK: - Text Size Slider - -/// Slider for adjusting text annotation font size. -private struct TextSizeSlider: View { - @Bindable var viewModel: SettingsViewModel +/// A single shortcut configuration row. +private struct ShortcutRow: View { + let icon: String + let label: String + let shortcut: KeyboardShortcut + let isRecording: Bool + let onRecord: () -> Void + let onReset: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Text Size") - Spacer() - Text("\(Int(viewModel.textSize)) pt") - .foregroundStyle(.secondary) - .monospacedDigit() - } + HStack(spacing: 14) { + Image(systemName: icon) + .font(.title3) + .foregroundStyle(.blue) + .frame(width: 26) - HStack(spacing: 12) { - Slider( - value: $viewModel.textSize, - in: SettingsViewModel.textSizeRange, - step: 1 - ) { - Text("Text Size") + Text(label) + .font(.body) + + Spacer() + + Button { + onRecord() + } label: { + HStack(spacing: 6) { + if isRecording { + Circle() + .fill(.red) + .frame(width: 8, height: 8) + Text("Press keys...") + .foregroundStyle(.secondary) + } else { + Text(shortcut.displayString) + .font(.system(.body, design: .monospaced, weight: .medium)) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(isRecording ? Color.red.opacity(0.1) : Color.primary.opacity(0.05)) } - .accessibilityValue(Text("\(Int(viewModel.textSize)) points")) + .overlay { + RoundedRectangle(cornerRadius: 8) + .strokeBorder(isRecording ? Color.red.opacity(0.3) : Color.primary.opacity(0.1), lineWidth: 1) + } + } + .buttonStyle(.plain) - // Preview of text size - Text("Aa") - .font(.system(size: min(viewModel.textSize, 24))) - .foregroundStyle(viewModel.strokeColor) - .frame(width: 40) + Button { + onReset() + } label: { + Image(systemName: "arrow.counterclockwise") + .font(.callout) + .foregroundStyle(.secondary) } + .buttonStyle(.plain) + .help("Reset to default") + .disabled(isRecording) + .opacity(isRecording ? 0.4 : 1) + } + .padding(.vertical, 10) + } +} + +/// A color selection button. +private struct ColorButton: View { + let color: Color + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Circle() + .fill(color) + .frame(width: 30, height: 30) + .overlay { + if isSelected { + Circle() + .stroke(Color.primary, lineWidth: 2.5) + .padding(3) + } + } + .overlay { + if color == .white || color == .yellow { + Circle() + .stroke(Color.gray.opacity(0.25), lineWidth: 1) + } + } + .shadow(color: .black.opacity(0.1), radius: 1, y: 1) } + .buttonStyle(.plain) } } @@ -488,6 +575,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..d687e90 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? @@ -70,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 } @@ -88,6 +102,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 +279,8 @@ final class SettingsViewModel { func startRecordingFullScreenShortcut() { isRecordingFullScreenShortcut = true isRecordingSelectionShortcut = false + isRecordingWindowShortcut = false + isRecordingWindowWithShadowShortcut = false recordedShortcut = nil } @@ -254,6 +288,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 +315,8 @@ final class SettingsViewModel { func cancelRecording() { isRecordingFullScreenShortcut = false isRecordingSelectionShortcut = false + isRecordingWindowShortcut = false + isRecordingWindowWithShadowShortcut = false recordedShortcut = nil } @@ -268,7 +324,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 +349,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 +389,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 0c98741..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. @@ -20,11 +21,14 @@ 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 windowWithShadowShortcut = prefix + "windowWithShadowShortcut" static let strokeColor = prefix + "strokeColor" static let strokeWidth = prefix + "strokeWidth" static let textSize = prefix + "textSize" static let rectangleFilled = prefix + "rectangleFilled" static let recentCaptures = prefix + "recentCaptures" + static let autoSaveOnClose = prefix + "autoSaveOnClose" } // MARK: - Properties @@ -54,6 +58,16 @@ final class AppSettings { didSet { saveShortcut(selectionShortcut, forKey: Keys.selectionShortcut) } } + /// Global hotkey for window capture + var windowShortcut: KeyboardShortcut { + 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) } @@ -79,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() { @@ -113,6 +150,10 @@ final class AppSettings { ?? KeyboardShortcut.fullScreenDefault selectionShortcut = Self.loadShortcut(forKey: Keys.selectionShortcut) ?? 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 @@ -123,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)") } @@ -163,11 +207,14 @@ final class AppSettings { jpegQuality = 0.9 fullScreenShortcut = .fullScreenDefault selectionShortcut = .selectionDefault + windowShortcut = .windowDefault + windowWithShadowShortcut = .windowWithShadowDefault strokeColor = .red strokeWidth = 2.0 textSize = 14.0 rectangleFilled = false recentCaptures = [] + autoSaveOnClose = true } // MARK: - Private Persistence Helpers diff --git a/ScreenCapture/Models/KeyboardShortcut.swift b/ScreenCapture/Models/KeyboardShortcut.swift index c6761b0..88037ef 100644 --- a/ScreenCapture/Models/KeyboardShortcut.swift +++ b/ScreenCapture/Models/KeyboardShortcut.swift @@ -25,15 +25,29 @@ 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 | controlKey) + ) + + /// Default window capture shortcut: Command + Shift + 6 + static let windowDefault = KeyboardShortcut( + keyCode: UInt32(kVK_ANSI_6), + 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) ) @@ -142,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 0623ef1..2e839bc 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,14 @@ "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"; +"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/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 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 - +