diff --git a/README.md b/README.md index d20dd8f..929cc93 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,11 @@ ## Features -- **Instant Capture** - Full screen or region selection with global hotkeys -- **Annotation Tools** - Rectangles, arrows, freehand drawing, and text +- **Multiple Capture Modes** - Full screen, region selection, window, and window with shadow +- **Annotation Tools** - Rectangles, arrows, freehand drawing, and text with floating style panel - **Multi-Monitor Support** - Works seamlessly across all connected displays +- **Auto-Save** - Screenshots automatically saved when closing preview (configurable) +- **Recent Captures** - Quick access to recent screenshots from menu bar and editor sidebar - **Quick Export** - Save to disk or copy to clipboard instantly - **Lightweight** - Runs quietly in your menu bar with minimal resources @@ -37,8 +39,8 @@ Download the latest release from the [Releases](../../releases) page. ```bash # Clone the repository -git clone https://github.com/sadopc/ScreenCapture.git -cd ScreenCapture +git clone https://github.com/diegoavarela/screencapture.git +cd screencapture # Open in Xcode open ScreenCapture.xcodeproj @@ -48,12 +50,16 @@ open ScreenCapture.xcodeproj ## Usage -### Keyboard Shortcuts +### Global Keyboard Shortcuts | Shortcut | Action | |----------|--------| -| `Cmd+Shift+3` | Capture full screen | -| `Cmd+Shift+4` | Capture selection | +| `Cmd+Ctrl+3` | Capture full screen | +| `Cmd+Ctrl+4` | Capture selection | +| `Cmd+Ctrl+6` | Capture window | +| `Cmd+Ctrl+7` | Capture window with shadow | + +> **Note**: Shortcuts can be customized in Settings. ### In Preview Window @@ -61,13 +67,16 @@ open ScreenCapture.xcodeproj |----------|--------| | `Enter` / `Cmd+S` | Save screenshot | | `Cmd+C` | Copy to clipboard | -| `Escape` | Dismiss | +| `Escape` | Dismiss (auto-saves if enabled) | | `R` / `1` | Rectangle tool | | `D` / `2` | Freehand tool | | `A` / `3` | Arrow tool | | `T` / `4` | Text tool | | `C` | Crop mode | +| `G` | Toggle recent captures gallery | | `Cmd+Z` | Undo | +| `Cmd+Shift+Z` | Redo | +| `Delete` | Delete selected annotation | ## Documentation diff --git a/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/Annotations/BlurTool.swift b/ScreenCapture/Features/Annotations/BlurTool.swift new file mode 100644 index 0000000..a76c185 --- /dev/null +++ b/ScreenCapture/Features/Annotations/BlurTool.swift @@ -0,0 +1,108 @@ +import Foundation +import CoreGraphics + +/// Tool for drawing blur annotations using a brush. +/// User paints over areas to blur them, similar to freehand drawing. +@MainActor +struct BlurTool: AnnotationTool { + // MARK: - Properties + + let toolType: AnnotationToolType = .blur + + var strokeStyle: StrokeStyle = .default + + var textStyle: TextStyle = .default + + /// Blur intensity (sigma value for Gaussian blur) - captured at drawing start + private var capturedBlurRadius: CGFloat = 15.0 + + /// Brush size (diameter of the blur brush) - captured at drawing start + private var capturedBrushSize: CGFloat = 40.0 + + private var drawingState = DrawingState() + + /// Minimum distance between recorded points (for performance) + private let minimumPointDistance: CGFloat = 3.0 + + // MARK: - Public setters for configuration + + var blurRadius: CGFloat { + get { capturedBlurRadius } + set { capturedBlurRadius = newValue } + } + + var brushSize: CGFloat { + get { capturedBrushSize } + set { capturedBrushSize = newValue } + } + + // MARK: - AnnotationTool Conformance + + var isActive: Bool { + drawingState.isDrawing + } + + var currentAnnotation: Annotation? { + guard isActive else { return nil } + let allPoints = [drawingState.startPoint] + drawingState.points + guard allPoints.count >= 2 else { return nil } + return .blur(BlurAnnotation( + points: allPoints, + blurRadius: capturedBlurRadius, + brushSize: capturedBrushSize + )) + } + + mutating func beginDrawing(at point: CGPoint) { + drawingState = DrawingState(startPoint: point) + drawingState.isDrawing = true + } + + mutating func continueDrawing(to point: CGPoint) { + guard isActive else { return } + + // Only add point if it's far enough from the last point + if let lastPoint = drawingState.points.last { + let dx = point.x - lastPoint.x + let dy = point.y - lastPoint.y + let distance = sqrt(dx * dx + dy * dy) + + if distance >= minimumPointDistance { + drawingState.points.append(point) + } + } else { + // First continuation point + let dx = point.x - drawingState.startPoint.x + let dy = point.y - drawingState.startPoint.y + let distance = sqrt(dx * dx + dy * dy) + + if distance >= minimumPointDistance { + drawingState.points.append(point) + } + } + } + + mutating func endDrawing(at point: CGPoint) -> Annotation? { + guard isActive else { return nil } + + // Add final point + continueDrawing(to: point) + + // Build the final points array + let allPoints = [drawingState.startPoint] + drawingState.points + drawingState.reset() + + // Need at least 2 points for a valid blur stroke + guard allPoints.count >= 2 else { return nil } + + return .blur(BlurAnnotation( + points: allPoints, + blurRadius: blurRadius, + brushSize: brushSize + )) + } + + mutating func cancelDrawing() { + drawingState.reset() + } +} diff --git a/ScreenCapture/Features/Capture/CaptureManager.swift b/ScreenCapture/Features/Capture/CaptureManager.swift index 4a8ee69..d423272 100644 --- a/ScreenCapture/Features/Capture/CaptureManager.swift +++ b/ScreenCapture/Features/Capture/CaptureManager.swift @@ -198,14 +198,12 @@ actor CaptureManager { throw ScreenCaptureError.displayDisconnected(displayName: display.name) } - // Configure capture for the full display first + // Configure capture for the full display at native resolution let filter = SCContentFilter(display: scDisplay, excludingWindows: []) let config = createCaptureConfiguration(for: display) - // Set source rect for region capture - // sourceRect must be in PIXEL coordinates (not normalized!) - // The rect is in points from SelectionOverlayWindow, convert to pixels - let sourceRect = CGRect( + // Calculate the crop region in pixels + let cropRect = CGRect( x: rect.origin.x * display.scaleFactor, y: rect.origin.y * display.scaleFactor, width: rect.width * display.scaleFactor, @@ -217,23 +215,17 @@ actor CaptureManager { print("[CAP-1] Input rect (points): \(rect)") print("[CAP-2] display.frame (points): \(display.frame)") print("[CAP-3] display.scaleFactor: \(display.scaleFactor)") - print("[CAP-4] sourceRect (pixels): \(sourceRect)") + print("[CAP-4] cropRect (pixels): \(cropRect)") print("=== END CAPTURE MANAGER DEBUG ===") #endif - config.sourceRect = sourceRect - - // Adjust output size to match the region - config.width = Int(rect.width * display.scaleFactor) - config.height = Int(rect.height * display.scaleFactor) - - // Perform capture with signpost for profiling + // Capture full display at native resolution (no sourceRect to avoid scaling) os_signpost(.begin, log: Self.performanceLog, name: "RegionCapture", signpostID: Self.signpostID) let captureStartTime = CFAbsoluteTimeGetCurrent() - let cgImage: CGImage + let fullImage: CGImage do { - cgImage = try await SCScreenshotManager.captureImage( + fullImage = try await SCScreenshotManager.captureImage( contentFilter: filter, configuration: config ) @@ -242,11 +234,19 @@ actor CaptureManager { throw ScreenCaptureError.captureFailure(underlying: error) } + // Crop to the selected region - this preserves pixel-perfect quality + guard let cgImage = fullImage.cropping(to: cropRect) else { + os_signpost(.end, log: Self.performanceLog, name: "RegionCapture", signpostID: Self.signpostID) + throw ScreenCaptureError.captureError(message: "Failed to crop region") + } + let captureLatency = (CFAbsoluteTimeGetCurrent() - captureStartTime) * 1000 os_signpost(.end, log: Self.performanceLog, name: "RegionCapture", signpostID: Self.signpostID) #if DEBUG print("Region capture latency: \(String(format: "%.1f", captureLatency))ms") + print("[CAP-5] Full image size: \(fullImage.width)x\(fullImage.height)") + print("[CAP-6] Cropped image size: \(cgImage.width)x\(cgImage.height)") #endif // Create screenshot with metadata @@ -287,6 +287,9 @@ actor CaptureManager { config.width = Int(display.frame.width * display.scaleFactor) config.height = Int(display.frame.height * display.scaleFactor) + // CRITICAL: Don't scale - capture at native pixel resolution + config.scalesToFit = false + // High quality settings for screenshots config.minimumFrameInterval = CMTime(value: 1, timescale: 1) // Single frame config.pixelFormat = kCVPixelFormatType_32BGRA @@ -295,6 +298,11 @@ actor CaptureManager { // Color settings for accurate reproduction config.colorSpaceName = CGColorSpace.sRGB + // Use best capture resolution on macOS 14+ + if #available(macOS 14.0, *) { + config.captureResolution = .best + } + return config } } diff --git a/ScreenCapture/Features/Capture/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..79df640 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)") @@ -562,6 +563,10 @@ final class SelectionOverlayController { overlayWindows.append(overlayWindow) } + // IMPORTANT: Activate the app BEFORE showing windows + // This prevents the first click from being absorbed as a "click to focus" event + NSApp.activate(ignoringOtherApps: true) + // Show all overlay windows for window in overlayWindows { window.showOverlay() @@ -570,7 +575,6 @@ final class SelectionOverlayController { // Make the first window (primary display) key if let primaryWindow = overlayWindows.first { primaryWindow.makeKey() - NSApp.activate(ignoringOtherApps: true) } } diff --git a/ScreenCapture/Features/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/AnnotationCanvas.swift b/ScreenCapture/Features/Preview/AnnotationCanvas.swift index af02e3f..67a1264 100644 --- a/ScreenCapture/Features/Preview/AnnotationCanvas.swift +++ b/ScreenCapture/Features/Preview/AnnotationCanvas.swift @@ -1,5 +1,6 @@ import SwiftUI import AppKit +import CoreImage /// SwiftUI Canvas view for drawing and displaying annotations. /// Renders existing annotations and in-progress drawing. @@ -21,6 +22,12 @@ struct AnnotationCanvas: View { /// Index of the selected annotation (nil = none selected) var selectedIndex: Int? + /// The original image (needed for blur rendering) + var originalImage: CGImage? + + /// Cached CIContext for blur rendering + private static let ciContext = CIContext() + // MARK: - Body var body: some View { @@ -67,6 +74,11 @@ struct AnnotationCanvas: View { return false }.count + let blurCount = annotations.filter { + if case .blur = $0 { return true } + return false + }.count + var parts: [String] = [] if rectangleCount > 0 { parts.append("\(rectangleCount) rectangle\(rectangleCount == 1 ? "" : "s")") @@ -77,6 +89,9 @@ struct AnnotationCanvas: View { if textCount > 0 { parts.append("\(textCount) text\(textCount == 1 ? "" : "s")") } + if blurCount > 0 { + parts.append("\(blurCount) blur\(blurCount == 1 ? "" : "s")") + } return "Annotations: \(parts.joined(separator: ", "))" } @@ -98,6 +113,8 @@ struct AnnotationCanvas: View { drawArrow(arrow, in: &context, size: size) case .text(let text): drawText(text, in: &context, size: size) + case .blur(let blur): + drawBlur(blur, in: &context, size: size) } } @@ -232,6 +249,126 @@ struct AnnotationCanvas: View { ) } + /// Draws a blur annotation with brush-based blur effect + private func drawBlur( + _ annotation: BlurAnnotation, + in context: inout GraphicsContext, + size: CGSize + ) { + guard annotation.points.count >= 2 else { return } + + guard let originalImage = originalImage else { + drawBlurPlaceholder(annotation, in: &context) + return + } + + let imageWidth = CGFloat(originalImage.width) + let imageHeight = CGFloat(originalImage.height) + + // Get bounds of the blur stroke + let bounds = annotation.bounds + guard bounds.width > 0, bounds.height > 0 else { return } + + // Clamp to image bounds + let clampedBounds = CGRect( + x: max(0, bounds.origin.x), + y: max(0, bounds.origin.y), + width: min(bounds.width, imageWidth - max(0, bounds.origin.x)), + height: min(bounds.height, imageHeight - max(0, bounds.origin.y)) + ) + + guard clampedBounds.width > 0, clampedBounds.height > 0 else { return } + + // Convert to CG coordinates (bottom-left origin) + let cgBounds = CGRect( + x: clampedBounds.origin.x, + y: imageHeight - clampedBounds.origin.y - clampedBounds.height, + width: clampedBounds.width, + height: clampedBounds.height + ) + + // Crop the region + guard let croppedImage = originalImage.cropping(to: cgBounds) else { return } + + // Apply Gaussian blur (invert the range so higher values = more blur) + let ciImage = CIImage(cgImage: croppedImage) + let effectiveSigma = 35.0 - annotation.blurRadius // 5→30 (intense), 30→5 (light) + let blurredCIImage = ciImage.applyingGaussianBlur(sigma: effectiveSigma) + + // Clamp the blurred image back to original bounds (blur expands the extent) + let clampedBlurred = blurredCIImage.clamped(to: ciImage.extent) + guard let blurredCGImage = Self.ciContext.createCGImage(clampedBlurred, from: ciImage.extent) else { + drawBlurPlaceholder(annotation, in: &context) + return + } + + // Create the brush stroke path in view coordinates + var brushPath = Path() + let scaledPoints = annotation.points.map { scalePoint($0) } + let scaledBrushSize = annotation.brushSize * scale + + for point in scaledPoints { + brushPath.addEllipse(in: CGRect( + x: point.x - scaledBrushSize / 2, + y: point.y - scaledBrushSize / 2, + width: scaledBrushSize, + height: scaledBrushSize + )) + } + + // Also connect points with thick line for continuous coverage + if scaledPoints.count >= 2 { + var linePath = Path() + linePath.move(to: scaledPoints[0]) + for point in scaledPoints.dropFirst() { + linePath.addLine(to: point) + } + let strokedLine = linePath.strokedPath(SwiftUI.StrokeStyle( + lineWidth: scaledBrushSize, + lineCap: .round, + lineJoin: .round + )) + brushPath.addPath(strokedLine) + } + + // Draw the blurred image clipped to the brush path + let scaledBounds = scaleRect(clampedBounds) + let blurImage = Image(decorative: blurredCGImage, scale: 1.0) + + // Use drawLayer to isolate the clipping operation + context.drawLayer { layerContext in + var ctx = layerContext + ctx.clip(to: brushPath) + ctx.draw(blurImage, in: scaledBounds) + } + } + + /// Fallback placeholder when blur can't be rendered + private func drawBlurPlaceholder( + _ annotation: BlurAnnotation, + in context: inout GraphicsContext + ) { + guard annotation.points.count >= 2 else { return } + + // Draw the brush stroke path as a visual indicator + var brushPath = Path() + let scaledPoints = annotation.points.map { scalePoint($0) } + let scaledBrushSize = annotation.brushSize * scale + + if scaledPoints.count >= 2 { + brushPath.move(to: scaledPoints[0]) + for point in scaledPoints.dropFirst() { + brushPath.addLine(to: point) + } + } + + context.stroke( + brushPath, + with: .color(.white.opacity(0.6)), + style: SwiftUI.StrokeStyle(lineWidth: scaledBrushSize, lineCap: .round, lineJoin: .round) + ) + } + /// Draws a selection indicator around an annotation private func drawSelectionIndicator( for annotation: Annotation, diff --git a/ScreenCapture/Features/Preview/PreviewContentView.swift b/ScreenCapture/Features/Preview/PreviewContentView.swift index 289ca94..b5c7837 100644 --- a/ScreenCapture/Features/Preview/PreviewContentView.swift +++ b/ScreenCapture/Features/Preview/PreviewContentView.swift @@ -9,32 +9,67 @@ struct PreviewContentView: View { /// The view model driving this view @Bindable var viewModel: PreviewViewModel + /// Recent captures store for the gallery sidebar + @ObservedObject var recentCapturesStore: RecentCapturesStore + /// State for tracking the image display size and scale @State private var imageDisplaySize: CGSize = .zero @State private var imageScale: CGFloat = 1.0 @State private var imageOffset: CGPoint = .zero + /// Whether to show the recent captures gallery sidebar + @State private var isShowingGallery: Bool = false + /// Focus state for the text input field @FocusState private var isTextFieldFocused: Bool /// Environment variable for Reduce Motion preference @Environment(\.accessibilityReduceMotion) private var reduceMotion + // MARK: - Initialization + + init(viewModel: PreviewViewModel, recentCapturesStore: RecentCapturesStore = RecentCapturesStore()) { + self.viewModel = viewModel + self.recentCapturesStore = recentCapturesStore + } + // MARK: - Body var body: some View { - VStack(spacing: 0) { - // Main image view with annotation canvas - annotatedImageView - .frame(maxWidth: .infinity, maxHeight: .infinity) + HStack(spacing: 0) { + // Recent Captures Gallery sidebar (toggleable) + if isShowingGallery { + RecentCapturesGallery( + store: recentCapturesStore, + onSelect: { capture in + openCapture(capture) + }, + onReveal: { capture in + revealCapture(capture) + }, + onDelete: { capture in + recentCapturesStore.remove(capture: capture) + } + ) + .transition(.move(edge: .leading).combined(with: .opacity)) - Divider() + Divider() + } - // Info bar - infoBar - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(.bar) + // Main content + VStack(spacing: 0) { + // Main image view with annotation canvas + annotatedImageView + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + + // Info bar + infoBar + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.bar) + } } .alert( "Error", @@ -49,6 +84,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 @@ -75,9 +123,11 @@ struct PreviewContentView: View { Spacer() ZStack(alignment: .topLeading) { - // Base image - Image(viewModel.image, scale: 1.0, label: Text("Screenshot")) + // Base image - use source scale factor for proper Retina display + Image(viewModel.image, scale: viewModel.sourceScaleFactor, label: Text("Screenshot")) .resizable() + .interpolation(.none) // No interpolation - pixel perfect + .antialiased(false) // Disable antialiasing .aspectRatio(contentMode: .fit) .frame( width: displayInfo.displaySize.width, @@ -91,7 +141,8 @@ struct PreviewContentView: View { currentAnnotation: viewModel.currentAnnotation, canvasSize: imageSize, scale: displayInfo.scale, - selectedIndex: viewModel.selectedAnnotationIndex + selectedIndex: viewModel.selectedAnnotationIndex, + originalImage: viewModel.image ) .frame( width: displayInfo.displaySize.width, @@ -141,6 +192,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 { @@ -205,7 +263,7 @@ struct PreviewContentView: View { } switch tool { - case .rectangle, .freehand, .arrow: + case .rectangle, .freehand, .arrow, .blur: return .crosshair case .text: return .iBeam @@ -559,19 +617,244 @@ 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 (not shown for blur tool) + if effectiveToolType != .blur { + 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 - styleCustomizationBar + 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 (also controls brush size for blur) + if effectiveToolType == .freehand || effectiveToolType == .arrow || effectiveToolType == .blur || + (effectiveToolType == .rectangle && !(isEditingAnnotation ? (viewModel.selectedAnnotationIsFilled ?? false) : AppSettings.shared.rectangleFilled)) { + Divider() + .frame(height: 20) + + HStack(spacing: 4) { + Image(systemName: "lineweight") + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + + Slider( + value: Binding( + get: { + if isEditingAnnotation { + return viewModel.selectedAnnotationStrokeWidth ?? 3.0 + } + return AppSettings.shared.strokeWidth + }, + set: { newWidth in + if isEditingAnnotation { + viewModel.updateSelectedAnnotationStrokeWidth(newWidth) + } else { + AppSettings.shared.strokeWidth = newWidth + } + } + ), + in: 1.0...20.0, + step: 0.5 + ) + .frame(width: 60) + .tint(.white) + + let width = isEditingAnnotation + ? Int(viewModel.selectedAnnotationStrokeWidth ?? 3) + : Int(AppSettings.shared.strokeWidth) + Text("\(width)") + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + .frame(width: 16) + } + } + + // Text size control + if effectiveToolType == .text { + Divider() + .frame(height: 20) + + HStack(spacing: 4) { + Image(systemName: "textformat.size") + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + + Slider( + value: Binding( + get: { + if isEditingAnnotation { + return viewModel.selectedAnnotationFontSize ?? 16.0 + } + return AppSettings.shared.textSize + }, + set: { newSize in + if isEditingAnnotation { + viewModel.updateSelectedAnnotationFontSize(newSize) + } else { + AppSettings.shared.textSize = newSize + } + } + ), + in: 8.0...72.0, + step: 1 + ) + .frame(width: 60) + .tint(.white) + + let size = isEditingAnnotation + ? Int(viewModel.selectedAnnotationFontSize ?? 16) + : Int(AppSettings.shared.textSize) + Text("\(size)") + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + .frame(width: 20) + } + } + + // Blur radius control + if effectiveToolType == .blur { + Divider() + .frame(height: 20) + + HStack(spacing: 4) { + Image(systemName: "eye.slash") + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + + Slider( + value: Binding( + get: { + if isEditingAnnotation { + return viewModel.selectedAnnotationBlurRadius ?? 15.0 + } + return AppSettings.shared.blurRadius + }, + set: { newRadius in + if isEditingAnnotation { + viewModel.updateSelectedAnnotationBlurRadius(newRadius) + } else { + AppSettings.shared.blurRadius = newRadius + } + } + ), + in: 5.0...30.0, + step: 1 + ) + .frame(width: 60) + .tint(.white) + + let radius = isEditingAnnotation + ? Int(viewModel.selectedAnnotationBlurRadius ?? 15) + : Int(AppSettings.shared.blurRadius) + Text("\(radius)") + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + .frame(width: 20) + } + } + + // Delete button for selected annotation + if isEditingAnnotation { + Divider() + .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 @@ -669,8 +952,8 @@ struct PreviewContentView: View { .frame(height: 16) } - // Stroke width control (for rectangle/freehand/arrow - only show for hollow rectangles) - if effectiveToolType == .freehand || effectiveToolType == .arrow || + // Stroke width control (for rectangle/freehand/arrow/blur - only show for hollow rectangles) + if effectiveToolType == .freehand || effectiveToolType == .arrow || effectiveToolType == .blur || (effectiveToolType == .rectangle && !(isEditingAnnotation ? (viewModel.selectedAnnotationIsFilled ?? false) : AppSettings.shared.rectangleFilled)) { HStack(spacing: 4) { Image(systemName: "lineweight") @@ -800,6 +1083,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 +1174,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 +1250,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..736df7d 100644 --- a/ScreenCapture/Features/Preview/PreviewViewModel.swift +++ b/ScreenCapture/Features/Preview/PreviewViewModel.swift @@ -106,6 +106,10 @@ final class PreviewViewModel { @ObservationIgnored private(set) var textTool = TextTool() + /// Blur tool + @ObservationIgnored + private(set) var blurTool = BlurTool() + /// Counter to trigger view updates during drawing /// Incremented each time drawing state changes to force re-render private(set) var drawingUpdateCounter: Int = 0 @@ -153,6 +157,7 @@ final class PreviewViewModel { case .freehand: return freehandTool case .arrow: return arrowTool case .text: return textTool + case .blur: return blurTool } } @@ -210,6 +215,11 @@ final class PreviewViewModel { screenshot.sourceDisplay.name } + /// Source display scale factor (for Retina displays) + var sourceScaleFactor: CGFloat { + screenshot.sourceDisplay.scaleFactor + } + /// Current export format var format: ExportFormat { get { screenshot.format } @@ -263,6 +273,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() @@ -354,6 +400,11 @@ final class PreviewViewModel { // Update observable properties for text input UI _isWaitingForTextInput = true _textInputPosition = point + case .blur: + // Read directly from shared settings to ensure we get the latest values + blurTool.blurRadius = AppSettings.shared.blurRadius + blurTool.brushSize = AppSettings.shared.strokeWidth * 10 // Use stroke width scaled up for brush + blurTool.beginDrawing(at: point) } updateCurrentAnnotation() @@ -373,6 +424,8 @@ final class PreviewViewModel { arrowTool.continueDrawing(to: point) case .text: textTool.continueDrawing(to: point) + case .blur: + blurTool.continueDrawing(to: point) } updateCurrentAnnotation() @@ -397,6 +450,8 @@ final class PreviewViewModel { _ = textTool.endDrawing(at: point) updateCurrentAnnotation() return + case .blur: + annotation = blurTool.endDrawing(at: point) } _currentAnnotation = nil @@ -413,6 +468,7 @@ final class PreviewViewModel { freehandTool.cancelDrawing() arrowTool.cancelDrawing() textTool.cancelDrawing() + blurTool.cancelDrawing() _currentAnnotation = nil _isWaitingForTextInput = false _textInputPosition = nil @@ -509,9 +565,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 @@ -585,6 +640,8 @@ final class PreviewViewModel { dragOriginalPosition = arrow.bounds.origin case .text(let text): dragOriginalPosition = text.position + case .blur(let blur): + dragOriginalPosition = blur.rect.origin } } @@ -643,6 +700,16 @@ final class PreviewViewModel { y: originalPosition.y + delta.y ) updatedAnnotation = .text(text) + + case .blur(var blur): + // Translate all points + blur.points = blur.points.map { point in + CGPoint( + x: point.x + delta.x, + y: point.y + delta.y + ) + } + updatedAnnotation = .blur(blur) } if let updated = updatedAnnotation { @@ -684,6 +751,10 @@ final class PreviewViewModel { case .text(var text): text.style.color = color updatedAnnotation = .text(text) + + case .blur: + // Blur doesn't have a color + return } if let updated = updatedAnnotation { @@ -717,6 +788,10 @@ final class PreviewViewModel { case .text: // Text doesn't have stroke width return + + case .blur: + // Blur doesn't have stroke width + return } if let updated = updatedAnnotation { @@ -749,6 +824,7 @@ final class PreviewViewModel { case .freehand: return .freehand case .arrow: return .arrow case .text: return .text + case .blur: return .blur } } @@ -762,6 +838,7 @@ final class PreviewViewModel { case .freehand(let freehand): return freehand.style.color case .arrow(let arrow): return arrow.style.color case .text(let text): return text.style.color + case .blur: return nil // Blur doesn't have a color } } @@ -775,6 +852,7 @@ final class PreviewViewModel { case .freehand(let freehand): return freehand.style.lineWidth case .arrow(let arrow): return arrow.style.lineWidth case .text: return nil + case .blur: return nil // Blur doesn't have stroke width } } @@ -814,6 +892,31 @@ final class PreviewViewModel { redoStack.removeAll() } + /// Returns the blur radius of the selected blur annotation + var selectedAnnotationBlurRadius: CGFloat? { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return nil } + + if case .blur(let blur) = annotations[index] { + return blur.blurRadius + } + return nil + } + + /// Updates the blur radius of the selected blur annotation + func updateSelectedAnnotationBlurRadius(_ radius: CGFloat) { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return } + + let annotation = annotations[index] + guard case .blur(var blur) = annotation else { return } + + pushUndoState() + blur.blurRadius = radius + screenshot = screenshot.replacingAnnotation(at: index, with: .blur(blur)) + redoStack.removeAll() + } + /// Commits the current text input and adds the annotation func commitTextInput() { if let annotation = textTool.commitText() { @@ -824,9 +927,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) @@ -943,6 +1051,7 @@ enum AnnotationToolType: String, CaseIterable, Identifiable, Sendable { case freehand case arrow case text + case blur var id: String { rawValue } @@ -952,6 +1061,7 @@ enum AnnotationToolType: String, CaseIterable, Identifiable, Sendable { case .freehand: return "Draw" case .arrow: return "Arrow" case .text: return "Text" + case .blur: return "Blur" } } @@ -961,6 +1071,7 @@ enum AnnotationToolType: String, CaseIterable, Identifiable, Sendable { case .freehand: return "d" case .arrow: return "a" case .text: return "t" + case .blur: return "b" } } @@ -970,6 +1081,7 @@ enum AnnotationToolType: String, CaseIterable, Identifiable, Sendable { case .freehand: return "pencil.line" case .arrow: return "arrow.up.right" case .text: return "textformat" + case .blur: return "eye.slash" } } } diff --git a/ScreenCapture/Features/Preview/PreviewWindow.swift b/ScreenCapture/Features/Preview/PreviewWindow.swift index f71ca43..86c04ab 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] @@ -285,8 +291,17 @@ final class PreviewWindow: NSPanel { } } return - case "1", "2", "3", "4": - // Number keys to quickly select tools (1=Rectangle, 2=Freehand, 3=Arrow, 4=Text) + case "b": + Task { @MainActor in + if viewModel.selectedTool == .blur { + viewModel.selectTool(nil) + } else { + viewModel.selectTool(.blur) + } + } + return + case "1", "2", "3", "4", "5": + // Number keys to quickly select tools (1=Rectangle, 2=Freehand, 3=Arrow, 4=Text, 5=Blur) let toolIndex = Int(String(char))! - 1 let tools = AnnotationToolType.allCases if toolIndex < tools.count { @@ -357,6 +372,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 +391,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 +407,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/Annotation.swift b/ScreenCapture/Models/Annotation.swift index de1c810..6680593 100644 --- a/ScreenCapture/Models/Annotation.swift +++ b/ScreenCapture/Models/Annotation.swift @@ -8,6 +8,7 @@ enum Annotation: Identifiable, Equatable, Sendable { case freehand(FreehandAnnotation) case arrow(ArrowAnnotation) case text(TextAnnotation) + case blur(BlurAnnotation) /// Unique identifier for this annotation var id: UUID { @@ -20,6 +21,8 @@ enum Annotation: Identifiable, Equatable, Sendable { return annotation.id case .text(let annotation): return annotation.id + case .blur(let annotation): + return annotation.id } } @@ -34,6 +37,8 @@ enum Annotation: Identifiable, Equatable, Sendable { return annotation.bounds case .text(let annotation): return annotation.bounds + case .blur(let annotation): + return annotation.rect } } } @@ -200,6 +205,61 @@ struct TextAnnotation: Identifiable, Equatable, Sendable { } } +// MARK: - Blur Annotation + +/// A blur annotation that obscures content along a painted brush stroke. +struct BlurAnnotation: Identifiable, Equatable, Sendable { + /// Unique identifier + let id: UUID + + /// Points along the blur brush stroke (in image coordinates) + var points: [CGPoint] + + /// Blur intensity (sigma value for Gaussian blur) + var blurRadius: CGFloat + + /// Brush size (diameter of the blur brush) + var brushSize: CGFloat + + init(id: UUID = UUID(), points: [CGPoint] = [], blurRadius: CGFloat = 15.0, brushSize: CGFloat = 40.0) { + self.id = id + self.points = points + self.blurRadius = blurRadius + self.brushSize = brushSize + } + + /// The bounding rect of all points plus brush size + var rect: CGRect { + bounds + } + + /// Computed bounding box of all points + var bounds: CGRect { + guard !points.isEmpty else { return .zero } + + let xs = points.map { $0.x } + let ys = points.map { $0.y } + + guard let minX = xs.min(), let maxX = xs.max(), + let minY = ys.min(), let maxY = ys.max() else { + return .zero + } + + let padding = brushSize / 2 + return CGRect( + x: minX - padding, + y: minY - padding, + width: maxX - minX + brushSize, + height: maxY - minY + brushSize + ) + } + + /// Whether this annotation has meaningful content + var isValid: Bool { + points.count >= 2 + } +} + // MARK: - CGPoint Sendable Conformance extension CGPoint: @retroactive @unchecked Sendable {} @@ -218,6 +278,8 @@ extension Annotation { return NSLocalizedString("tool.arrow", comment: "") case .text: return NSLocalizedString("tool.text", comment: "") + case .blur: + return NSLocalizedString("tool.blur", comment: "") } } } diff --git a/ScreenCapture/Models/AppSettings.swift b/ScreenCapture/Models/AppSettings.swift index 0c98741..6f48473 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,15 @@ 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 blurRadius = prefix + "blurRadius" static let recentCaptures = prefix + "recentCaptures" + static let autoSaveOnClose = prefix + "autoSaveOnClose" } // MARK: - Properties @@ -54,6 +59,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) } @@ -74,11 +89,39 @@ final class AppSettings { didSet { save(rectangleFilled, forKey: Keys.rectangleFilled) } } + /// Default blur radius for blur tool + var blurRadius: CGFloat { + didSet { save(Double(blurRadius), forKey: Keys.blurRadius) } + } + /// Last 5 saved captures var recentCaptures: [RecentCapture] { didSet { saveRecentCaptures() } } + /// 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,16 +156,24 @@ 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 strokeWidth = CGFloat(defaults.object(forKey: Keys.strokeWidth) as? Double ?? 2.0) textSize = CGFloat(defaults.object(forKey: Keys.textSize) as? Double ?? 14.0) rectangleFilled = defaults.object(forKey: Keys.rectangleFilled) as? Bool ?? false + blurRadius = CGFloat(defaults.object(forKey: Keys.blurRadius) as? Double ?? 15.0) // Load recent captures recentCaptures = Self.loadRecentCaptures() + // 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 +214,15 @@ final class AppSettings { jpegQuality = 0.9 fullScreenShortcut = .fullScreenDefault selectionShortcut = .selectionDefault + windowShortcut = .windowDefault + windowWithShadowShortcut = .windowWithShadowDefault strokeColor = .red strokeWidth = 2.0 textSize = 14.0 rectangleFilled = false + blurRadius = 15.0 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/ClipboardService.swift b/ScreenCapture/Services/ClipboardService.swift index 35ac13c..783b5fc 100644 --- a/ScreenCapture/Services/ClipboardService.swift +++ b/ScreenCapture/Services/ClipboardService.swift @@ -1,6 +1,7 @@ import Foundation import AppKit import CoreGraphics +import CoreImage /// Service for copying screenshots to the system clipboard. /// Uses NSPasteboard for compatibility with all macOS applications. @@ -92,7 +93,7 @@ struct ClipboardService: Sendable { // Draw each annotation for annotation in annotations { - renderAnnotation(annotation, in: context, imageHeight: CGFloat(height)) + renderAnnotation(annotation, in: context, imageHeight: CGFloat(height), baseImage: image) } // Create final image @@ -107,7 +108,8 @@ struct ClipboardService: Sendable { private func renderAnnotation( _ annotation: Annotation, in context: CGContext, - imageHeight: CGFloat + imageHeight: CGFloat, + baseImage: CGImage? = nil ) { switch annotation { case .rectangle(let rect): @@ -118,6 +120,10 @@ struct ClipboardService: Sendable { renderArrow(arrow, in: context, imageHeight: imageHeight) case .text(let text): renderText(text, in: context, imageHeight: imageHeight) + case .blur(let blur): + if let baseImage = baseImage { + renderBlur(blur, in: context, imageHeight: imageHeight, baseImage: baseImage) + } } } @@ -236,6 +242,95 @@ struct ClipboardService: Sendable { CTLineDraw(line, context) context.restoreGState() } + + /// Renders a blur annotation using Gaussian blur with brush-based clipping. + private func renderBlur( + _ annotation: BlurAnnotation, + in context: CGContext, + imageHeight: CGFloat, + baseImage: CGImage + ) { + guard annotation.points.count >= 2 else { return } + + let imageWidth = CGFloat(baseImage.width) + + // Get bounds of the blur stroke (in SwiftUI coordinates) + let bounds = annotation.bounds + guard bounds.width > 0, bounds.height > 0 else { return } + + // Clamp to image bounds + let clampedBounds = CGRect( + x: max(0, bounds.origin.x), + y: max(0, bounds.origin.y), + width: min(bounds.width, imageWidth - max(0, bounds.origin.x)), + height: min(bounds.height, imageHeight - max(0, bounds.origin.y)) + ) + + guard clampedBounds.width > 0, clampedBounds.height > 0 else { return } + + // Convert to CG coordinates (bottom-left origin) + let cgBounds = CGRect( + x: clampedBounds.origin.x, + y: imageHeight - clampedBounds.origin.y - clampedBounds.height, + width: clampedBounds.width, + height: clampedBounds.height + ) + + // Crop the region from base image + guard let croppedImage = baseImage.cropping(to: cgBounds) else { return } + + // Apply Gaussian blur (invert the range so higher values = more blur) + let ciImage = CIImage(cgImage: croppedImage) + let effectiveSigma = 35.0 - annotation.blurRadius // 5→30 (intense), 30→5 (light) + let blurredCIImage = ciImage.applyingGaussianBlur(sigma: effectiveSigma) + + // Clamp the blurred image back to original bounds (blur expands the extent) + let clampedBlurred = blurredCIImage.clamped(to: ciImage.extent) + let ciContext = CIContext() + guard let blurredCGImage = ciContext.createCGImage(clampedBlurred, from: ciImage.extent) else { return } + + // Create brush stroke path (in CG coordinates) + let brushPath = CGMutablePath() + let brushSize = annotation.brushSize + + // Transform points from SwiftUI to CG coordinates + let cgPoints = annotation.points.map { point in + CGPoint(x: point.x, y: imageHeight - point.y) + } + + // Add circles at each point + for point in cgPoints { + brushPath.addEllipse(in: CGRect( + x: point.x - brushSize / 2, + y: point.y - brushSize / 2, + width: brushSize, + height: brushSize + )) + } + + // Add stroked line connecting points for continuous coverage + if cgPoints.count >= 2 { + let linePath = CGMutablePath() + linePath.move(to: cgPoints[0]) + for point in cgPoints.dropFirst() { + linePath.addLine(to: point) + } + let strokedLine = linePath.copy( + strokingWithWidth: brushSize, + lineCap: .round, + lineJoin: .round, + miterLimit: 10 + ) + brushPath.addPath(strokedLine) + } + + // Draw blurred image clipped to brush path + context.saveGState() + context.addPath(brushPath) + context.clip() + context.draw(blurredCGImage, in: cgBounds) + context.restoreGState() + } } // MARK: - Shared Instance diff --git a/ScreenCapture/Services/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/ImageExporter.swift b/ScreenCapture/Services/ImageExporter.swift index 7f6273a..f7c437b 100644 --- a/ScreenCapture/Services/ImageExporter.swift +++ b/ScreenCapture/Services/ImageExporter.swift @@ -2,6 +2,7 @@ import Foundation import CoreGraphics import AppKit import UniformTypeIdentifiers +import CoreImage /// Service for exporting screenshots to PNG or JPEG files. /// Uses CGImageDestination for efficient image encoding. @@ -175,7 +176,7 @@ struct ImageExporter: Sendable { // Draw each annotation for annotation in annotations { - renderAnnotation(annotation, in: context, imageHeight: CGFloat(height)) + renderAnnotation(annotation, in: context, imageHeight: CGFloat(height), baseImage: image) } // Create final image @@ -191,10 +192,12 @@ struct ImageExporter: Sendable { /// - annotation: The annotation to render /// - context: The graphics context /// - imageHeight: The image height (for coordinate transformation) + /// - baseImage: The original image (needed for blur) private func renderAnnotation( _ annotation: Annotation, in context: CGContext, - imageHeight: CGFloat + imageHeight: CGFloat, + baseImage: CGImage? = nil ) { switch annotation { case .rectangle(let rect): @@ -205,6 +208,10 @@ struct ImageExporter: Sendable { renderArrow(arrow, in: context, imageHeight: imageHeight) case .text(let text): renderText(text, in: context, imageHeight: imageHeight) + case .blur(let blur): + if let baseImage = baseImage { + renderBlur(blur, in: context, imageHeight: imageHeight, baseImage: baseImage) + } } } @@ -334,6 +341,95 @@ struct ImageExporter: Sendable { // Restore context state context.restoreGState() } + + /// Renders a blur annotation using Gaussian blur with brush-based clipping. + private func renderBlur( + _ annotation: BlurAnnotation, + in context: CGContext, + imageHeight: CGFloat, + baseImage: CGImage + ) { + guard annotation.points.count >= 2 else { return } + + let imageWidth = CGFloat(baseImage.width) + + // Get bounds of the blur stroke (in SwiftUI coordinates) + let bounds = annotation.bounds + guard bounds.width > 0, bounds.height > 0 else { return } + + // Clamp to image bounds + let clampedBounds = CGRect( + x: max(0, bounds.origin.x), + y: max(0, bounds.origin.y), + width: min(bounds.width, imageWidth - max(0, bounds.origin.x)), + height: min(bounds.height, imageHeight - max(0, bounds.origin.y)) + ) + + guard clampedBounds.width > 0, clampedBounds.height > 0 else { return } + + // Convert to CG coordinates (bottom-left origin) + let cgBounds = CGRect( + x: clampedBounds.origin.x, + y: imageHeight - clampedBounds.origin.y - clampedBounds.height, + width: clampedBounds.width, + height: clampedBounds.height + ) + + // Crop the region from base image + guard let croppedImage = baseImage.cropping(to: cgBounds) else { return } + + // Apply Gaussian blur (invert the range so higher values = more blur) + let ciImage = CIImage(cgImage: croppedImage) + let effectiveSigma = 35.0 - annotation.blurRadius // 5→30 (intense), 30→5 (light) + let blurredCIImage = ciImage.applyingGaussianBlur(sigma: effectiveSigma) + + // Clamp the blurred image back to original bounds (blur expands the extent) + let clampedBlurred = blurredCIImage.clamped(to: ciImage.extent) + let ciContext = CIContext() + guard let blurredCGImage = ciContext.createCGImage(clampedBlurred, from: ciImage.extent) else { return } + + // Create brush stroke path (in CG coordinates) + let brushPath = CGMutablePath() + let brushSize = annotation.brushSize + + // Transform points from SwiftUI to CG coordinates + let cgPoints = annotation.points.map { point in + CGPoint(x: point.x, y: imageHeight - point.y) + } + + // Add circles at each point + for point in cgPoints { + brushPath.addEllipse(in: CGRect( + x: point.x - brushSize / 2, + y: point.y - brushSize / 2, + width: brushSize, + height: brushSize + )) + } + + // Add stroked line connecting points for continuous coverage + if cgPoints.count >= 2 { + let linePath = CGMutablePath() + linePath.move(to: cgPoints[0]) + for point in cgPoints.dropFirst() { + linePath.addLine(to: point) + } + let strokedLine = linePath.copy( + strokingWithWidth: brushSize, + lineCap: .round, + lineJoin: .round, + miterLimit: 10 + ) + brushPath.addPath(strokedLine) + } + + // Draw blurred image clipped to brush path + context.saveGState() + context.addPath(brushPath) + context.clip() + context.draw(blurredCGImage, in: cgBounds) + context.restoreGState() + } } // MARK: - Shared Instance 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 - + diff --git a/docs/user-guide.md b/docs/user-guide.md index 46abd80..864d034 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -33,7 +33,7 @@ The app runs in your menu bar with a camera icon. ### Full Screen Capture **Method 1: Keyboard Shortcut** -- Press `Cmd + Shift + 3` +- Press `Cmd + Ctrl + 3` **Method 2: Menu Bar** - Click the camera icon in menu bar @@ -44,7 +44,7 @@ If you have multiple monitors, a menu will appear to select which display to cap ### Selection Capture **Method 1: Keyboard Shortcut** -- Press `Cmd + Shift + 4` +- Press `Cmd + Ctrl + 4` **Method 2: Menu Bar** - Click the camera icon in menu bar @@ -56,6 +56,22 @@ If you have multiple monitors, a menu will appear to select which display to cap 3. Release to capture 4. Press `Escape` to cancel +### Window Capture + +**Method 1: Keyboard Shortcut** +- Press `Cmd + Ctrl + 6` for window only +- Press `Cmd + Ctrl + 7` for window with shadow + +**Method 2: Menu Bar** +- Click the camera icon in menu bar +- Select "Capture Window" or "Capture Window + Shadow" + +**Selecting the Window:** +1. Move mouse over the window you want to capture +2. The window is highlighted with a blue border +3. Click to capture +4. Press `Escape` to cancel + --- ## Preview Window @@ -68,7 +84,7 @@ After capturing, the Preview window appears showing your screenshot. |--------|--------| | Drag edges | Resize window | | Drag title bar | Move window | -| Close button | Dismiss without saving | +| Close button | Auto-save and dismiss (if enabled) | ### Quick Actions @@ -76,7 +92,18 @@ After capturing, the Preview window appears showing your screenshot. |----------|--------| | `Enter` or `Cmd+S` | Save screenshot | | `Cmd+C` | Copy to clipboard and close | -| `Escape` | Dismiss window | +| `Escape` | Auto-save and dismiss (if enabled) | +| `G` | Toggle recent captures gallery | + +### Floating Style Panel + +When you select an annotation tool (Rectangle, Arrow, etc.) or click on an existing annotation, a floating style panel appears over the image. This panel lets you: +- Change color +- Adjust stroke width +- Toggle filled mode (for rectangles) +- Change text size (for text tool) + +The panel floats above the image and doesn't affect the window size. --- @@ -244,6 +271,12 @@ When using JPEG format, adjust quality: Range: 0% to 100% (default: 90%) +### Auto-save on Close + +When enabled (default), screenshots are automatically saved when you close the preview window or press Escape. This ensures you never lose a capture. + +Toggle this in Settings → Auto-save on Close. + ### Keyboard Shortcuts Customize global hotkeys: @@ -253,8 +286,10 @@ Customize global hotkeys: 3. Must include Cmd, Ctrl, or Option **Defaults:** -- Full Screen: `Cmd+Shift+3` -- Selection: `Cmd+Shift+4` +- Full Screen: `Cmd+Ctrl+3` +- Selection: `Cmd+Ctrl+4` +- Window: `Cmd+Ctrl+6` +- Window with Shadow: `Cmd+Ctrl+7` ### Annotation Defaults @@ -273,15 +308,24 @@ Click "Reset to Defaults" to restore all settings. ## Recent Captures -Access recently saved screenshots from the menu bar. +Access and re-edit recently saved screenshots. + +### From Menu Bar 1. Click menu bar icon 2. Hover over "Recent Captures" -3. Click any capture to open in Finder +3. Click any capture to open in editor for further editing + +### From Editor Gallery + +1. In the preview window, press `G` to toggle the gallery sidebar +2. Browse your recent captures with thumbnails +3. Click any capture to load it for editing **Features:** - Shows last 5 captures - Displays thumbnails +- Click to re-open in editor (add more annotations, crop, etc.) - "Clear Recent" removes the list --- @@ -292,8 +336,10 @@ Access recently saved screenshots from the menu bar. | Shortcut | Action | |----------|--------| -| `Cmd+Shift+3` | Capture full screen | -| `Cmd+Shift+4` | Capture selection | +| `Cmd+Ctrl+3` | Capture full screen | +| `Cmd+Ctrl+4` | Capture selection | +| `Cmd+Ctrl+6` | Capture window | +| `Cmd+Ctrl+7` | Capture window with shadow | ### In Preview Window @@ -301,7 +347,7 @@ Access recently saved screenshots from the menu bar. |----------|--------| | `Enter` / `Cmd+S` | Save screenshot | | `Cmd+C` | Copy and close | -| `Escape` | Deselect / Dismiss | +| `Escape` | Deselect / Dismiss (auto-saves if enabled) | | `Delete` | Delete selected annotation | | `Cmd+Z` | Undo | | `Shift+Cmd+Z` | Redo | @@ -310,13 +356,15 @@ Access recently saved screenshots from the menu bar. | `A` / `3` | Arrow tool | | `T` / `4` | Text tool | | `C` | Toggle crop mode | +| `G` | Toggle recent captures gallery | -### In Selection Mode +### In Selection/Window Mode | Shortcut | Action | |----------|--------| | `Escape` | Cancel selection | -| Click + Drag | Draw selection | +| Click + Drag | Draw selection (selection mode) | +| Click | Capture window (window mode) | | Release | Complete capture | --- @@ -325,7 +373,7 @@ Access recently saved screenshots from the menu bar. ### Quick Annotation Workflow -1. Capture with `Cmd+Shift+4` +1. Capture with `Cmd+Ctrl+4` 2. Draw selection 3. Press `R` for rectangle 4. Draw highlight @@ -333,6 +381,13 @@ Access recently saved screenshots from the menu bar. Total time: ~3 seconds! +### Quick Window Capture + +1. Press `Cmd+Ctrl+6` +2. Hover over target window (blue highlight appears) +3. Click to capture +4. Press `Escape` to dismiss (auto-saves) + ### Multi-Monitor Capture When capturing full screen with multiple monitors: @@ -348,13 +403,21 @@ Screenshots automatically capture at native resolution: File sizes will be larger for Retina captures. +### Re-edit Previous Captures + +Access any recent screenshot for further editing: +1. Press `G` in the editor to open gallery +2. Click a previous capture +3. Add more annotations or crop +4. Save again + ### Keyboard-Only Workflow Never touch the mouse: -1. `Cmd+Shift+3` - Capture +1. `Cmd+Ctrl+3` - Capture full screen 2. `R` - Rectangle tool 3. Use mouse to draw (unavoidable) -4. `Cmd+S` - Save +4. `Escape` - Auto-save and close ---