From d95dcfa552b7a28eb9bad0815a3277688fe53110 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Fri, 22 May 2026 15:19:46 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Add quick screenshot capture mode","authority":"manual"} --- .../CaptureOverlayController.swift | 49 ++ ...ureSessionController+QuickScreenshot.swift | 255 ++++++++ .../GlobalHotKeyCenter.swift | 142 +++++ .../HotKeyBindingCoordinator.swift | 9 + .../RsnapNativeHostKit/NativeHostApp.swift | 46 +- .../NativeHostSettings.swift | 34 +- .../NativeHostSettingsView.swift | 79 ++- .../QuickScreenshotController.swift | 592 ++++++++++++++++++ 8 files changed, 1199 insertions(+), 7 deletions(-) create mode 100644 native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+QuickScreenshot.swift create mode 100644 native/macos-host/Sources/RsnapNativeHostKit/QuickScreenshotController.swift diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureOverlayController.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureOverlayController.swift index 4a5283f0..1108af5f 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/CaptureOverlayController.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureOverlayController.swift @@ -122,6 +122,55 @@ final class CaptureOverlayController { } } + func showFrozenFirstFrame( + scene: SceneSnapshot, + chrome: CaptureChromeState, + settings: NativeHostSettings, + focusPoint: CGPoint + ) { + close() + var targetWindow: CaptureOverlayWindow? + for screen in NSScreen.screens { + let window = CaptureOverlayWindow( + screen: screen, + controller: controller, + initialScene: scene, + initialChrome: chrome, + initialSettings: settings + ) + window.hostView.update( + scene: scene, + chrome: chrome, + settings: settings + ) + windows.append(window) + if targetWindow == nil, screen.frame.inclusivelyContains(focusPoint) { + targetWindow = window + } + } + + let focusedWindow = targetWindow ?? windows.first + for window in windows { + window.orderFrontRegardless() + if window === focusedWindow { + window.makeKey() + window.makeFirstResponder(window.hostView) + focusedWindowNumber = window.windowNumber + (NSApp.delegate as? NativeHostApplicationController)?.window = window + } + } + collapsedForFrozen = false + for window in windows { + window.displayIfNeeded() + } + presentFrozenFirstFrame( + scene: scene, + chrome: chrome, + settings: settings + ) + primaryWindow?.displayIfNeeded() + } + func prepareCaptureStreamsNow(trigger: String) { guard let prepareCaptureStreams = pendingCaptureStreamPreparation else { return diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+QuickScreenshot.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+QuickScreenshot.swift new file mode 100644 index 00000000..f7650673 --- /dev/null +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+QuickScreenshot.swift @@ -0,0 +1,255 @@ +import AppKit +import CoreGraphics +import Foundation +import RsnapHostBridge + +extension CaptureSessionController { + func startQuickScreenshotFrozenCapture( + selection: QuickScreenshotSelection, + capturableOwnWindowIDs: Set + ) { + if session != nil { + overlayController?.focusWindow(at: selection.current) + return + } + + let captureID = allocateCaptureTelemetryID() + activeCaptureTelemetryID = captureID + let captureStartedAt = ProcessInfo.processInfo.systemUptime + guard ensureCapturePermissions() else { + NativeHostTelemetry.captureWarning( + "capture.quick_screenshot_start_blocked", + captureID: captureID, + stage: "screen_recording_permission", + error: "permission_denied" + ) + activeCaptureTelemetryID = nil + captureStateDidChange?() + return + } + + do { + try startQuickScreenshotSession( + captureID: captureID, + captureStartedAt: captureStartedAt, + selection: selection, + capturableOwnWindowIDs: capturableOwnWindowIDs + ) + } catch { + NativeHostTelemetry.captureWarning( + "capture.quick_screenshot_start_failed", + captureID: captureID, + stage: "exception", + error: String(describing: error) + ) + tearDownCapture() + } + } + + private func startQuickScreenshotSession( + captureID: UInt64, + captureStartedAt: TimeInterval, + selection: QuickScreenshotSelection, + capturableOwnWindowIDs: Set + ) throws { + let startPoint = selection.anchor + let endPoint = selection.current + let sessionSetupStartedAt = ProcessInfo.processInfo.systemUptime + let session = try RsnapHostSession(configuration: settingsStore.sessionConfiguration) + self.session = session + liveFrameStream.updateSelfCaptureExceptionWindowIDs(capturableOwnWindowIDs) + + try session.enterLive() + let startInputs = currentLiveInputs(at: startPoint) + try session.send( + event: .pointerMoved( + point: startPoint, + rgb: startInputs.rgb, + activeMonitor: startInputs.activeMonitor, + highlightedWindow: nil + ) + ) + try session.send( + event: .primaryInteractionStarted( + point: startPoint, + activeMonitor: startInputs.activeMonitor, + highlightedWindow: nil + ) + ) + let endInputs = currentLiveInputs(at: endPoint) + try session.send( + event: .primaryInteractionUpdated( + point: endPoint, + activeMonitor: endInputs.activeMonitor, + highlightedWindow: nil + ) + ) + try session.send( + event: .primaryInteractionCompleted( + point: endPoint, + activeMonitor: endInputs.activeMonitor, + highlightedWindow: nil + ) + ) + let pendingRequests = try session.drainRequests() + let initialScene = try session.currentScene() + scene = initialScene + chromeState.rgbSample = endInputs.rgb + guard + let freezeRequest = quickScreenshotFreezeRequest(in: pendingRequests) + else { + NativeHostTelemetry.captureWarning( + "capture.quick_screenshot_commit_failed", + captureID: captureID, + stage: "host_request", + error: "missing_freeze_request" + ) + tearDownCapture() + return + } + let frozenFrame = quickFrozenFrame(from: selection) + let frozenScene = prepareQuickScreenshotFrozenPresentation( + selection: freezeRequest.selection, + editable: freezeRequest.editable, + frozenFrame: frozenFrame + ) + let sessionSetupMilliseconds = + NativeHostTelemetry.milliseconds(since: sessionSetupStartedAt) + + let overlayController = CaptureOverlayController( + controller: self, + liveFrameStream: liveFrameStream, + frameRgbSampler: { [frozenFrameAuthority] point in + frozenFrameAuthority.liveRgbSample(containing: point) + }, + framePatchSampler: { [frozenFrameAuthority] point, sidePixels in + frozenFrameAuthority.loupePatch(containing: point, sidePixels: sidePixels) + } + ) + self.overlayController = overlayController + overlayController.showFrozenFirstFrame( + scene: frozenScene, + chrome: chromeState, + settings: settingsStore.settings, + focusPoint: CGPoint( + x: freezeRequest.selection.midX, + y: freezeRequest.selection.midY + ) + ) + (NSApp.delegate as? NativeHostApplicationController)?.window = + overlayController.primaryWindow + captureStateDidChange?() + + try finishQuickScreenshotRequests( + pendingRequests, + frozenFrame: frozenFrame, + captureID: captureID, + commitStartedAt: captureStartedAt + ) + NativeHostTelemetry.captureStartTiming( + captureID: captureID, + totalMilliseconds: NativeHostTelemetry.milliseconds(since: captureStartedAt), + warmMilliseconds: 0, + windowSnapshotMilliseconds: 0, + sessionSetupMilliseconds: sessionSetupMilliseconds, + overlayShowMilliseconds: 0, + initialSampleReady: endInputs.rgb != nil, + screenCount: NSScreen.screens.count, + windowCount: 0 + ) + } + + private func finishQuickScreenshotRequests( + _ requests: [HostRequest], + frozenFrame: FrozenFrameSnapshot, + captureID: UInt64, + commitStartedAt: TimeInterval + ) throws { + for request in requests { + switch request { + case .requestFreezeSnapshot(let requestedSelection, let selectionEditable): + try finishFrozenCommit( + captureID: captureID, + selection: requestedSelection, + editable: selectionEditable, + frozenFrame: frozenFrame, + commitStartedAt: commitStartedAt, + snapshotWaitMilliseconds: 0, + hadLatchToken: false, + syncAfterReport: true + ) + NativeHostTelemetry.captureEvent( + "capture.quick_screenshot_committed", + captureID: captureID, + detail: + "x=\(Int(requestedSelection.minX.rounded())) y=\(Int(requestedSelection.minY.rounded())) w=\(Int(requestedSelection.width.rounded())) h=\(Int(requestedSelection.height.rounded()))" + ) + return + default: + try handle(request: request) + } + } + + NativeHostTelemetry.captureWarning( + "capture.quick_screenshot_commit_failed", + captureID: captureID, + stage: "host_request", + error: "missing_freeze_request" + ) + tearDownCapture() + } + + private func quickScreenshotFreezeRequest(in requests: [HostRequest]) + -> (selection: CGRect, editable: Bool)? + { + for request in requests { + if case .requestFreezeSnapshot(let selection, let selectionEditable) = request { + return (selection, selectionEditable) + } + } + return nil + } + + private func prepareQuickScreenshotFrozenPresentation( + selection: CGRect, + editable: Bool, + frozenFrame: FrozenFrameSnapshot + ) -> SceneSnapshot { + chromeState.resetFrozenChrome() + chromeState.frozenSelectionSnapshot = selection + chromeState.frozenSelectionEditable = editable + chromeState.frozenSelectionInteraction = nil + let frameSource = captureFrameSource( + for: selection, + editable: editable + ) + chromeState.captureFrameSource = frameSource + chromeState.captureFrameWindowID = + frameSource == .window ? scene.highlightedWindow?.windowID : nil + chromeState.frozenDisplayFrame = frozenFrame.displayFrame + chromeState.frozenDisplayImage = frozenFrame.image + let frozenScene = hostOwnedFrozenPresentationScene( + for: selection, + editable: editable + ) + scene = frozenScene + return frozenScene + } + + private func quickFrozenFrame(from selection: QuickScreenshotSelection) + -> FrozenFrameSnapshot + { + frozenSnapshotGeneration &+= 1 + return FrozenFrameSnapshot( + displayID: selection.displayFrame.displayID, + displayFrame: selection.displayFrame.frame, + image: selection.displayFrame.image, + generation: frozenSnapshotGeneration, + sequence: 0, + capturedAtUptime: selection.displayFrame.capturedAtUptime, + source: "quick_screenshot_mouse_down", + selfCaptureSafe: true, + selfCaptureFilterComplete: true + ) + } +} diff --git a/native/macos-host/Sources/RsnapNativeHostKit/GlobalHotKeyCenter.swift b/native/macos-host/Sources/RsnapNativeHostKit/GlobalHotKeyCenter.swift index 466abf92..8fbf9032 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/GlobalHotKeyCenter.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/GlobalHotKeyCenter.swift @@ -1,5 +1,6 @@ import AppKit import Carbon +@preconcurrency import CoreGraphics import Foundation import RsnapHostBridge @@ -10,16 +11,38 @@ final class GlobalHotKeyCenter { case cancel = 2 case loupe = 3 case save = 4 + case quickScreenshot = 5 } private struct HotKeyDefinition: Equatable { let keyCode: UInt32 let modifiers: UInt32 + + var eventFlags: CGEventFlags { + var flags = CGEventFlags() + if modifiers & UInt32(optionKey) != 0 { + flags.insert(.maskAlternate) + } + if modifiers & UInt32(cmdKey) != 0 { + flags.insert(.maskCommand) + } + if modifiers & UInt32(controlKey) != 0 { + flags.insert(.maskControl) + } + if modifiers & UInt32(shiftKey) != 0 { + flags.insert(.maskShift) + } + return flags + } } private static let signature = OSType(0x5253_4E50) // RSNP + private static let trackedEventFlags: CGEventFlags = [ + .maskAlternate, .maskCommand, .maskControl, .maskShift, + ] var onCaptureRequested: (() -> Void)? + var onQuickScreenshotRequested: (() -> Void)? var onCancelRequested: (() -> Void)? var onToggleLoupeRequested: (() -> Void)? var onSaveRequested: (() -> Void)? @@ -27,6 +50,9 @@ final class GlobalHotKeyCenter { private var handlerRef: EventHandlerRef? private var hotKeyRefs: [Binding: EventHotKeyRef?] = [:] private var registeredDefinitions: [Binding: HotKeyDefinition] = [:] + private var quickScreenshotEventTap: CFMachPort? + private var quickScreenshotEventTapSource: CFRunLoopSource? + private var quickScreenshotEventTapDefinition: HotKeyDefinition? init() { var eventType = EventTypeSpec( @@ -51,6 +77,7 @@ final class GlobalHotKeyCenter { } func invalidate() { + unregisterQuickScreenshotEventTap() for binding in Binding.allCases { unregister(binding) } @@ -62,6 +89,7 @@ final class GlobalHotKeyCenter { func updateBindings( captureHotKey: String, + quickScreenshotHotKey: String, sceneMode: SceneKind ) -> Bool { var allRequestedBindingsRegistered = true @@ -69,6 +97,16 @@ final class GlobalHotKeyCenter { allRequestedBindingsRegistered = register(.capture, definition: captureDefinition) && allRequestedBindingsRegistered + let quickScreenshotDefinition = + Self.parseCaptureHotKey(quickScreenshotHotKey) ?? Self.defaultQuickScreenshotHotKey + if registerQuickScreenshotEventTap(definition: quickScreenshotDefinition) { + unregister(.quickScreenshot) + } else { + allRequestedBindingsRegistered = + register(.quickScreenshot, definition: quickScreenshotDefinition) + && allRequestedBindingsRegistered + } + let wantsCancel = sceneMode != .hidden let wantsLoupe = sceneMode == .live let wantsFrozen = sceneMode == .frozen @@ -147,6 +185,104 @@ final class GlobalHotKeyCenter { registeredDefinitions.removeValue(forKey: binding) } + private func registerQuickScreenshotEventTap(definition: HotKeyDefinition) -> Bool { + if quickScreenshotEventTapDefinition == definition, quickScreenshotEventTap != nil { + return true + } + unregisterQuickScreenshotEventTap() + + let mask = CGEventMask(1) << CGEventType.keyDown.rawValue + let callback: CGEventTapCallBack = { _, type, event, userInfo in + guard let userInfo else { + return Unmanaged.passUnretained(event) + } + return MainActor.assumeIsolated { + let center = Unmanaged + .fromOpaque(userInfo) + .takeUnretainedValue() + return center.handleQuickScreenshotEventTap(type: type, event: event) + } + } + guard + let eventTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: mask, + callback: callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) + else { + NativeHostTelemetry.lifecycleWarning( + "native_host.quick_screenshot_event_tap_failed", + detail: + "keyCode=\(definition.keyCode),modifiers=\(definition.modifiers)" + ) + return false + } + + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: eventTap, enable: true) + quickScreenshotEventTap = eventTap + quickScreenshotEventTapSource = source + quickScreenshotEventTapDefinition = definition + NativeHostTelemetry.lifecycleEvent( + "native_host.quick_screenshot_event_tap_registered", + detail: + "keyCode=\(definition.keyCode),modifiers=\(definition.modifiers)" + ) + return true + } + + private func unregisterQuickScreenshotEventTap() { + if let quickScreenshotEventTap { + CGEvent.tapEnable(tap: quickScreenshotEventTap, enable: false) + } + if let quickScreenshotEventTapSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), quickScreenshotEventTapSource, .commonModes) + } + quickScreenshotEventTap = nil + quickScreenshotEventTapSource = nil + quickScreenshotEventTapDefinition = nil + } + + private func handleQuickScreenshotEventTap(type: CGEventType, event: CGEvent) + -> Unmanaged? + { + switch type { + case .tapDisabledByTimeout, .tapDisabledByUserInput: + if let quickScreenshotEventTap { + CGEvent.tapEnable(tap: quickScreenshotEventTap, enable: true) + } + return Unmanaged.passUnretained(event) + case .keyDown: + guard matchesQuickScreenshotEvent(event) else { + return Unmanaged.passUnretained(event) + } + if event.getIntegerValueField(.keyboardEventAutorepeat) == 0 { + DispatchQueue.main.async { [weak self] in + self?.onQuickScreenshotRequested?() + } + } + return nil + default: + return Unmanaged.passUnretained(event) + } + } + + private func matchesQuickScreenshotEvent(_ event: CGEvent) -> Bool { + guard let definition = quickScreenshotEventTapDefinition else { + return false + } + let keyCode = UInt32(event.getIntegerValueField(.keyboardEventKeycode)) + guard keyCode == definition.keyCode else { + return false + } + return event.flags.intersection(Self.trackedEventFlags) + == definition.eventFlags.intersection(Self.trackedEventFlags) + } + private func handleHotKey(_ eventRef: EventRef) -> OSStatus { var hotKeyID = EventHotKeyID() let status = GetEventParameter( @@ -167,6 +303,8 @@ final class GlobalHotKeyCenter { switch binding { case .capture: onCaptureRequested?() + case .quickScreenshot: + onQuickScreenshotRequested?() case .cancel: onCancelRequested?() case .loupe: @@ -181,6 +319,10 @@ final class GlobalHotKeyCenter { keyCode: 7, modifiers: UInt32(optionKey) ) + private static let defaultQuickScreenshotHotKey = HotKeyDefinition( + keyCode: 7, + modifiers: UInt32(optionKey | shiftKey) + ) private static func parseCaptureHotKey(_ raw: String) -> HotKeyDefinition? { let tokens = NativeHostSettings.captureHotKeyTokens(from: raw) diff --git a/native/macos-host/Sources/RsnapNativeHostKit/HotKeyBindingCoordinator.swift b/native/macos-host/Sources/RsnapNativeHostKit/HotKeyBindingCoordinator.swift index ae35c389..3e7c5240 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/HotKeyBindingCoordinator.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/HotKeyBindingCoordinator.swift @@ -4,6 +4,7 @@ import RsnapHostBridge final class HotKeyBindingCoordinator { private struct BindingState: Equatable { let captureHotKey: String + let quickScreenshotHotKey: String let sceneMode: SceneKind } @@ -12,6 +13,11 @@ final class HotKeyBindingCoordinator { set { hotKeys.onCaptureRequested = newValue } } + var onQuickScreenshotRequested: (() -> Void)? { + get { hotKeys.onQuickScreenshotRequested } + set { hotKeys.onQuickScreenshotRequested = newValue } + } + var onCancelRequested: (() -> Void)? { get { hotKeys.onCancelRequested } set { hotKeys.onCancelRequested = newValue } @@ -32,10 +38,12 @@ final class HotKeyBindingCoordinator { func update( captureHotKey: String, + quickScreenshotHotKey: String, sceneMode: SceneKind ) { let state = BindingState( captureHotKey: captureHotKey, + quickScreenshotHotKey: quickScreenshotHotKey, sceneMode: sceneMode ) guard state != appliedState else { @@ -43,6 +51,7 @@ final class HotKeyBindingCoordinator { } let didApply = hotKeys.updateBindings( captureHotKey: state.captureHotKey, + quickScreenshotHotKey: state.quickScreenshotHotKey, sceneMode: state.sceneMode ) appliedState = didApply ? state : nil diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift index 97b60940..f6489105 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift @@ -104,6 +104,7 @@ package func scrollCaptureMinimapPlan( public final class NativeHostApplicationController: NSObject, NSApplicationDelegate { private let settingsStore = NativeHostSettingsStore() private let hotKeyCoordinator = HotKeyBindingCoordinator() + private let quickScreenshotController = QuickScreenshotController() private var lifecycleActivity: NSObjectProtocol? private var selfCaptureRegistrationWindow: NSWindow? private var didBootstrap = false @@ -122,6 +123,7 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg }() private var statusItem: NSStatusItem? private weak var captureMenuItem: NSMenuItem? + private weak var quickScreenshotMenuItem: NSMenuItem? private lazy var permissionRecoveryWindowController = PermissionRecoveryGuideWindowController() private lazy var settingsWindowController = SettingsWindowController( settingsStore: settingsStore, @@ -147,6 +149,9 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg configureStatusItem() _ = softwareUpdater configureGlobalHotKeys() + quickScreenshotController.onStateChanged = { [weak self] in + self?.refreshStatusMenuState() + } showSelfCaptureRegistrationWindow() NotificationCenter.default.addObserver( self, @@ -173,6 +178,7 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg public func applicationWillTerminate(_ notification: Notification) { hotKeyCoordinator.invalidate() + quickScreenshotController.cancel() sessionController.releaseScreenCaptureStreams(immediate: true) } @@ -226,6 +232,20 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg capturableOwnWindowIDs: settingsWindowController.captureExceptionWindowIDs) } + @objc + private func startQuickScreenshot(_ sender: Any?) { + if presentPermissionRecoveryIfNeeded(source: "quick_screenshot") { + return + } + let source = sender == nil ? "hotkey" : "menu" + quickScreenshotController.startInteractiveFrozenCapture( + captureController: sessionController, + capturableOwnWindowIDs: settingsWindowController.captureExceptionWindowIDs, + source: source + ) + refreshStatusMenuState() + } + @objc private func cancelCapture(_ sender: Any?) { sessionController.cancelCapture() @@ -335,6 +355,11 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg action: #selector(startCapture(_:)), keyEquivalent: "" ) + let quickScreenshotItem = menu.addItem( + withTitle: "Quick Screenshot", + action: #selector(startQuickScreenshot(_:)), + keyEquivalent: "" + ) menu.addItem(.separator()) menu.addItem( withTitle: "Open Screenshots Folder", @@ -356,7 +381,9 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg item.menu = menu statusItem = item captureMenuItem = captureItem + self.quickScreenshotMenuItem = quickScreenshotItem updateCaptureMenuShortcut() + updateQuickScreenshotMenuShortcut() NativeHostTelemetry.lifecycleEvent( "native_host.status_item_installed", detail: "visible=\(item.isVisible),hasMenu=\(item.menu != nil)" @@ -367,6 +394,9 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg hotKeyCoordinator.onCaptureRequested = { [weak self] in self?.startCapture(nil) } + hotKeyCoordinator.onQuickScreenshotRequested = { [weak self] in + self?.startQuickScreenshot(nil) + } hotKeyCoordinator.onCancelRequested = { [weak self] in self?.cancelCapture(nil) } @@ -379,8 +409,10 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg } fileprivate func refreshStatusMenuState() { - let isCaptureActive = sessionController.isCaptureActive + let isCaptureActive = + sessionController.isCaptureActive || quickScreenshotController.isActive captureMenuItem?.isEnabled = !isCaptureActive + quickScreenshotMenuItem?.isEnabled = !isCaptureActive } private func updateCaptureMenuShortcut() { @@ -393,9 +425,20 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg captureMenuItem.keyEquivalentModifierMask = shortcut.modifierMask } + private func updateQuickScreenshotMenuShortcut() { + guard let quickScreenshotMenuItem else { + return + } + let shortcut = NativeHostSettings.quickScreenshotHotKeyPresentation( + for: settingsStore.settings.quickScreenshotHotkey) + quickScreenshotMenuItem.keyEquivalent = shortcut.keyEquivalent + quickScreenshotMenuItem.keyEquivalentModifierMask = shortcut.modifierMask + } + private func refreshHotKeyBindings(for mode: SceneKind) { hotKeyCoordinator.update( captureHotKey: settingsStore.settings.captureHotkey, + quickScreenshotHotKey: settingsStore.settings.quickScreenshotHotkey, sceneMode: mode ) } @@ -404,6 +447,7 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg private func settingsDidChange() { refreshHotKeyBindings(for: sessionController.currentSceneMode) updateCaptureMenuShortcut() + updateQuickScreenshotMenuShortcut() } private static func statusItemImage() -> NSImage? { diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift index a1e71fd4..39051ab2 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift @@ -8,6 +8,7 @@ final class NativeHostSettingsStore { private enum DefaultsKey { static let captureHotkey = "captureHotkey" + static let quickScreenshotHotkey = "quickScreenshotHotkey" static let outputDirectory = "outputDirectory" static let outputFilenamePrefix = "outputFilenamePrefix" static let outputNaming = "outputNaming" @@ -41,6 +42,8 @@ final class NativeHostSettingsStore { let settings = NativeHostSettings( captureHotkey: defaults.string(forKey: DefaultsKey.captureHotkey) ?? baseSettings.captureHotkey, + quickScreenshotHotkey: defaults.string(forKey: DefaultsKey.quickScreenshotHotkey) + ?? baseSettings.quickScreenshotHotkey, outputDirectory: defaults.url(forKey: DefaultsKey.outputDirectory) ?? baseSettings.outputDirectory, outputFilenamePrefix: defaults.string(forKey: DefaultsKey.outputFilenamePrefix) @@ -110,6 +113,9 @@ final class NativeHostSettingsStore { private static func persist(_ settings: NativeHostSettings, into defaults: UserDefaults) { defaults.set(settings.outputDirectory, forKey: DefaultsKey.outputDirectory) defaults.set(settings.captureHotkey, forKey: DefaultsKey.captureHotkey) + defaults.set( + settings.quickScreenshotHotkey, + forKey: DefaultsKey.quickScreenshotHotkey) defaults.set(settings.outputFilenamePrefix, forKey: DefaultsKey.outputFilenamePrefix) defaults.set(settings.outputNaming.rawValue, forKey: DefaultsKey.outputNaming) defaults.set(settings.toolbarPlacement.rawValue, forKey: DefaultsKey.toolbarPlacement) @@ -140,7 +146,10 @@ final class NativeHostSettingsStore { } struct NativeHostSettings: Equatable { + static let defaultQuickScreenshotHotkey = "Option-Shift-X" + var captureHotkey: String + var quickScreenshotHotkey: String var outputDirectory: URL var outputFilenamePrefix: String var outputNaming: OutputNamingPreference @@ -164,6 +173,7 @@ struct NativeHostSettings: Equatable { static var defaults: NativeHostSettings { NativeHostSettings( captureHotkey: "Option-X", + quickScreenshotHotkey: defaultQuickScreenshotHotkey, outputDirectory: FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Desktop", isDirectory: true), outputFilenamePrefix: NativeHostBrand.defaultFilenamePrefix, @@ -193,6 +203,10 @@ struct NativeHostSettings: Equatable { copy.outputDirectory = Self.defaults.outputDirectory } copy.captureHotkey = Self.sanitizeCaptureHotkey(copy.captureHotkey) + copy.quickScreenshotHotkey = Self.sanitizeHotkey( + copy.quickScreenshotHotkey, + fallback: Self.defaultQuickScreenshotHotkey + ) copy.outputFilenamePrefix = Self.sanitizeFilenamePrefix(copy.outputFilenamePrefix) copy.hudOpacity = copy.hudOpacity.clamped(to: 0...1) copy.hudBlur = copy.hudBlur.clamped(to: 0...1) @@ -220,16 +234,30 @@ struct NativeHostSettings: Equatable { } private static func sanitizeCaptureHotkey(_ raw: String) -> String { + sanitizeHotkey(raw, fallback: defaults.captureHotkey) + } + + private static func sanitizeHotkey(_ raw: String, fallback: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.isEmpty == false else { - return defaults.captureHotkey + return fallback } - return captureHotKeyPresentation(for: trimmed).displayTitle + return hotKeyPresentation(for: trimmed, fallback: fallback).displayTitle } static func captureHotKeyPresentation(for raw: String) -> CaptureHotKeyPresentation { + hotKeyPresentation(for: raw, fallback: defaults.captureHotkey) + } + + static func quickScreenshotHotKeyPresentation(for raw: String) -> CaptureHotKeyPresentation { + hotKeyPresentation(for: raw, fallback: defaultQuickScreenshotHotkey) + } + + static func hotKeyPresentation(for raw: String, fallback: String) + -> CaptureHotKeyPresentation + { parseCaptureHotKeyPresentation(raw) - ?? parseCaptureHotKeyPresentation(defaults.captureHotkey) + ?? parseCaptureHotKeyPresentation(fallback) ?? CaptureHotKeyPresentation( displayTitle: "Option-X", keyEquivalent: "x", diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift index 94652369..8c243760 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift @@ -4,8 +4,8 @@ import SwiftUI enum NativeHostSettingsWindowMetrics { static let width: CGFloat = 620 - static let minHeight: CGFloat = 304 - static let idealHeight: CGFloat = 304 + static let minHeight: CGFloat = 332 + static let idealHeight: CGFloat = 332 static let cornerRadius: CGFloat = 18 } @@ -333,7 +333,7 @@ private struct SettingsDashboard: View { ) ) .padding(.trailing, 8) - .padding(.bottom, 6) + .padding(.bottom, 2) } .scrollIndicators(.hidden) .frame(maxWidth: .infinity, alignment: .topLeading) @@ -479,6 +479,13 @@ private struct CaptureInspector: View { title: NativeHostSettings.captureHotKeyPresentation(for: settings.captureHotkey) .displayTitle ) + InspectorMetric( + title: "Quick", + value: NativeHostSettings.quickScreenshotHotKeyPresentation( + for: settings.quickScreenshotHotkey + ).displayTitle, + symbolName: "bolt.fill" + ) InspectorMetric( title: "Toolbar", value: settings.toolbarPlacement.title, @@ -1756,6 +1763,8 @@ private struct SettingsHeroControlTile: View { VStack(alignment: .leading, spacing: 2) { Text(title) .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.86) Text(subtitle) .font(.system(size: 10.8, weight: .medium)) .foregroundStyle(.secondary) @@ -1999,6 +2008,14 @@ private struct CaptureSettingsPanel: View { CaptureHotKeyField(model: model) } + SettingsHeroControlTile( + symbolName: "bolt.fill", + title: "Quick Screenshot Shortcut", + subtitle: "Current: \(quickScreenshotShortcutPresentation.displayTitle)." + ) { + QuickScreenshotHotKeyField(model: model) + } + VStack(spacing: 0) { SettingsControlTile( symbolName: "rectangle.bottomthird.inset.filled", @@ -2056,6 +2073,11 @@ private struct CaptureSettingsPanel: View { private var shortcutPresentation: CaptureHotKeyPresentation { NativeHostSettings.captureHotKeyPresentation(for: model.settings.captureHotkey) } + + private var quickScreenshotShortcutPresentation: CaptureHotKeyPresentation { + NativeHostSettings.quickScreenshotHotKeyPresentation( + for: model.settings.quickScreenshotHotkey) + } } private struct CaptureHotKeyField: View { @@ -2108,6 +2130,57 @@ private struct CaptureHotKeyField: View { } } +private struct QuickScreenshotHotKeyField: View { + @ObservedObject var model: NativeHostSettingsViewModel + @FocusState private var isFocused: Bool + @State private var draft = "" + + var body: some View { + TextField("Option-Shift-X", text: $draft) + .font(.system(size: 10.5, weight: .semibold, design: .monospaced)) + .textFieldStyle(.plain) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.070), in: .rect(cornerRadius: 9)) + .overlay { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .stroke(Color.primary.opacity(0.075), lineWidth: 1) + } + .frame(width: SettingsControlLayout.controlColumnWidth) + .focused($isFocused) + .onAppear(perform: syncDraft) + .onSubmit(commitDraft) + .onChange(of: isFocused) { _, focused in + if focused { + syncDraft() + } else { + commitDraft() + } + } + .onChange(of: model.settings.quickScreenshotHotkey) { _, _ in + if isFocused == false { + syncDraft() + } + } + } + + private func syncDraft() { + draft = + NativeHostSettings.quickScreenshotHotKeyPresentation( + for: model.settings.quickScreenshotHotkey + ).displayTitle + } + + private func commitDraft() { + let committed = NativeHostSettings.quickScreenshotHotKeyPresentation(for: draft) + .displayTitle + if committed != model.settings.quickScreenshotHotkey { + model.update { $0.quickScreenshotHotkey = committed } + } + draft = committed + } +} + private struct PermissionsSettingsPanel: View { @ObservedObject var model: NativeHostSettingsViewModel @State private var refreshID = 0 diff --git a/native/macos-host/Sources/RsnapNativeHostKit/QuickScreenshotController.swift b/native/macos-host/Sources/RsnapNativeHostKit/QuickScreenshotController.swift new file mode 100644 index 00000000..753c71ea --- /dev/null +++ b/native/macos-host/Sources/RsnapNativeHostKit/QuickScreenshotController.swift @@ -0,0 +1,592 @@ +import AppKit +@preconcurrency import CoreGraphics +import Darwin +import Foundation + +struct QuickScreenshotDisplayFrame { + let displayID: CGDirectDisplayID + let frame: CGRect + let image: CGImage + let capturedAtUptime: TimeInterval +} + +struct QuickScreenshotSelection { + let anchor: CGPoint + let current: CGPoint + let rect: CGRect + let displayFrame: QuickScreenshotDisplayFrame +} + +@MainActor +final class QuickScreenshotController { + private var acquisitionController: QuickScreenshotAcquisitionController? + + var onStateChanged: (() -> Void)? + + var isActive: Bool { + acquisitionController != nil + } + + func startInteractiveFrozenCapture( + captureController: CaptureSessionController, + capturableOwnWindowIDs: Set, + source: String + ) { + guard acquisitionController == nil else { + NativeHostTelemetry.lifecycleEvent( + "native_host.quick_screenshot_already_active", + detail: "source=\(source)" + ) + return + } + guard captureController.isCaptureActive == false else { + NativeHostTelemetry.lifecycleEvent( + "native_host.quick_screenshot_ignored", + detail: "source=\(source),reason=capture_active" + ) + return + } + + let controller = QuickScreenshotAcquisitionController( + source: source, + onComplete: { [weak self, weak captureController] selection in + self?.acquisitionController = nil + self?.onStateChanged?() + captureController?.startQuickScreenshotFrozenCapture( + selection: selection, + capturableOwnWindowIDs: capturableOwnWindowIDs + ) + }, + onCancel: { [weak self] reason in + self?.acquisitionController = nil + self?.onStateChanged?() + NativeHostTelemetry.lifecycleEvent( + "native_host.quick_screenshot_canceled", + detail: "source=\(source),reason=\(reason)" + ) + } + ) + guard controller.start() else { + return + } + acquisitionController = controller + onStateChanged?() + } + + func cancel() { + acquisitionController?.cancel(reason: "app_terminate") + acquisitionController = nil + onStateChanged?() + } +} + +@MainActor +private final class QuickScreenshotAcquisitionController { + private enum State { + case armed + case selecting( + anchor: CGPoint, + current: CGPoint, + displayFrame: QuickScreenshotDisplayFrame + ) + case finishing + case canceled + } + + private static let minimumSelectionSide: CGFloat = 2 + private static let eventMask = + (CGEventMask(1) << CGEventType.leftMouseDown.rawValue) + | (CGEventMask(1) << CGEventType.leftMouseDragged.rawValue) + | (CGEventMask(1) << CGEventType.leftMouseUp.rawValue) + | (CGEventMask(1) << CGEventType.rightMouseDown.rawValue) + | (CGEventMask(1) << CGEventType.keyDown.rawValue) + + private let source: String + private let onComplete: (QuickScreenshotSelection) -> Void + private let onCancel: (String) -> Void + private var state = State.armed + private var eventTap: CFMachPort? + private var eventTapSource: CFRunLoopSource? + private var preparedDisplayFrames: [QuickScreenshotDisplayFrame] = [] + private var overlayController: QuickScreenshotSelectionOverlayController? + + init( + source: String, + onComplete: @escaping (QuickScreenshotSelection) -> Void, + onCancel: @escaping (String) -> Void + ) { + self.source = source + self.onComplete = onComplete + self.onCancel = onCancel + } + + func start() -> Bool { + let callback: CGEventTapCallBack = { _, type, event, userInfo in + guard let userInfo else { + return Unmanaged.passUnretained(event) + } + return MainActor.assumeIsolated { + let controller = Unmanaged + .fromOpaque(userInfo) + .takeUnretainedValue() + return controller.handleEventTap(type: type, event: event) + } + } + guard + let eventTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: Self.eventMask, + callback: callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) + else { + NativeHostTelemetry.lifecycleWarning( + "native_host.quick_screenshot_acquisition_failed", + detail: "source=\(source),stage=event_tap" + ) + return false + } + + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: eventTap, enable: true) + self.eventTap = eventTap + eventTapSource = source + NativeHostTelemetry.lifecycleEvent( + "native_host.quick_screenshot_armed", + detail: "source=\(self.source)" + ) + prepareSelectionOverlay() + return true + } + + func cancel(reason: String) { + guard case .canceled = state else { + state = .canceled + close() + onCancel(reason) + return + } + } + + private func handleEventTap(type: CGEventType, event: CGEvent) -> Unmanaged? { + switch type { + case .tapDisabledByTimeout, .tapDisabledByUserInput: + if let eventTap { + CGEvent.tapEnable(tap: eventTap, enable: true) + } + return nil + case .keyDown: + if event.getIntegerValueField(.keyboardEventKeycode) == 53 { + cancel(reason: "escape") + return nil + } + return nil + case .rightMouseDown: + cancel(reason: "right_mouse") + return nil + case .leftMouseDown: + beginSelection(at: Self.appKitPoint(from: event)) + return nil + case .leftMouseDragged: + updateSelection(to: Self.appKitPoint(from: event)) + return nil + case .leftMouseUp: + finishSelection(at: Self.appKitPoint(from: event)) + return nil + default: + return nil + } + } + + private func beginSelection(at point: CGPoint) { + guard case .armed = state else { + return + } + guard + let displayFrame = preparedDisplayFrame(containing: point) + ?? Self.captureDisplayFrame(containing: point) + else { + cancel(reason: "capture_failed") + return + } + state = .selecting(anchor: point, current: point, displayFrame: displayFrame) + let overlayController = + self.overlayController + ?? QuickScreenshotSelectionOverlayController(displayFrames: [displayFrame]) + self.overlayController = overlayController + overlayController.prepare() + let initialSelection = Self.normalizedRect( + anchor: point, + current: point, + in: displayFrame.frame + ) + overlayController.show(initialSelection: initialSelection) + updateSelection(to: point) + let frameAgeMilliseconds = + (ProcessInfo.processInfo.systemUptime - displayFrame.capturedAtUptime) + * 1_000 + NativeHostTelemetry.lifecycleEvent( + "native_host.quick_screenshot_selection_started", + detail: + "source=\(source),displayID=\(displayFrame.displayID),frameAgeMs=\(String(format: "%.2f", frameAgeMilliseconds)),x=\(Int(point.x.rounded())),y=\(Int(point.y.rounded()))" + ) + } + + private func updateSelection(to point: CGPoint) { + guard case .selecting(let anchor, _, let displayFrame) = state else { + return + } + let clampedPoint = Self.clamped(point, to: displayFrame.frame) + let rect = Self.normalizedRect( + anchor: anchor, + current: clampedPoint, + in: displayFrame.frame + ) + state = .selecting(anchor: anchor, current: clampedPoint, displayFrame: displayFrame) + overlayController?.update(selection: rect) + } + + private func finishSelection(at point: CGPoint) { + guard case .selecting(let anchor, _, let displayFrame) = state else { + cancel(reason: "mouse_up_without_selection") + return + } + state = .finishing + let clampedPoint = Self.clamped(point, to: displayFrame.frame) + let rect = Self.normalizedRect( + anchor: anchor, + current: clampedPoint, + in: displayFrame.frame + ) + guard + rect.width >= Self.minimumSelectionSide, + rect.height >= Self.minimumSelectionSide + else { + cancel(reason: "selection_too_small") + return + } + + close() + onComplete( + QuickScreenshotSelection( + anchor: anchor, + current: clampedPoint, + rect: rect, + displayFrame: displayFrame + ) + ) + } + + private func close() { + if let eventTap { + CGEvent.tapEnable(tap: eventTap, enable: false) + } + if let eventTapSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), eventTapSource, .commonModes) + } + eventTap = nil + eventTapSource = nil + overlayController?.close() + overlayController = nil + preparedDisplayFrames.removeAll() + } + + private static func appKitPoint(from event: CGEvent) -> CGPoint { + let quartzPoint = event.location + let desktopFrame = CaptureOverlayController.desktopFrame + return CGPoint( + x: quartzPoint.x, + y: desktopFrame.maxY - quartzPoint.y + ) + } + + private func prepareSelectionOverlay() { + let prepareStartedAt = ProcessInfo.processInfo.systemUptime + preparedDisplayFrames = Self.captureDisplayFrames() + if preparedDisplayFrames.isEmpty == false { + let overlayController = QuickScreenshotSelectionOverlayController( + displayFrames: preparedDisplayFrames + ) + overlayController.prepare() + self.overlayController = overlayController + } + let prepareMilliseconds = NativeHostTelemetry.milliseconds(since: prepareStartedAt) + NativeHostTelemetry.lifecycleEvent( + "native_host.quick_screenshot_prewarmed", + detail: + "source=\(source),frames=\(preparedDisplayFrames.count),prepareMs=\(String(format: "%.2f", prepareMilliseconds))" + ) + } + + private func preparedDisplayFrame(containing point: CGPoint) + -> QuickScreenshotDisplayFrame? + { + preparedDisplayFrames.first { $0.frame.inclusivelyContains(point) } + } + + private static func captureDisplayFrames() -> [QuickScreenshotDisplayFrame] { + let desktopFrame = CaptureOverlayController.desktopFrame + let capturedAtUptime = ProcessInfo.processInfo.systemUptime + return NSScreen.screens.compactMap { screen in + guard + let displayID = CaptureSessionController.displayID(for: screen), + let image = captureDisplayImage( + displayID: displayID, + rect: screen.frame, + desktopFrame: desktopFrame + ) + else { + return nil + } + return QuickScreenshotDisplayFrame( + displayID: displayID, + frame: screen.frame, + image: image, + capturedAtUptime: capturedAtUptime + ) + } + } + + private static func captureDisplayFrame(containing point: CGPoint) + -> QuickScreenshotDisplayFrame? + { + let desktopFrame = CaptureOverlayController.desktopFrame + let capturedAtUptime = ProcessInfo.processInfo.systemUptime + guard + let screen = NSScreen.screens.first(where: { + $0.frame.inclusivelyContains(point) + }), + let displayID = CaptureSessionController.displayID(for: screen), + let image = captureDisplayImage( + displayID: displayID, + rect: screen.frame, + desktopFrame: desktopFrame + ) + else { + return nil + } + return QuickScreenshotDisplayFrame( + displayID: displayID, + frame: screen.frame, + image: image, + capturedAtUptime: capturedAtUptime + ) + } + + private static func captureDisplayImage( + displayID: CGDirectDisplayID, + rect: CGRect, + desktopFrame: CGRect + ) -> CGImage? { + let quartzRect = CGRect( + x: rect.minX, + y: desktopFrame.maxY - rect.maxY, + width: rect.width, + height: rect.height + ) + guard quartzRect.isNull == false, quartzRect.width > 0, quartzRect.height > 0 else { + return nil + } + return displayCreateImageForRect?(displayID, quartzRect)? + .takeRetainedValue() + } + + private static func clamped(_ point: CGPoint, to frame: CGRect) -> CGPoint { + CGPoint( + x: point.x.clamped(to: frame.minX...frame.maxX), + y: point.y.clamped(to: frame.minY...frame.maxY) + ) + } + + private static func normalizedRect( + anchor: CGPoint, + current: CGPoint, + in frame: CGRect + ) -> CGRect { + let clampedAnchor = clamped(anchor, to: frame) + let clampedCurrent = clamped(current, to: frame) + return CGRect( + x: min(clampedAnchor.x, clampedCurrent.x), + y: min(clampedAnchor.y, clampedCurrent.y), + width: abs(clampedCurrent.x - clampedAnchor.x), + height: abs(clampedCurrent.y - clampedAnchor.y) + ) + } + + private typealias DisplayCreateImageForRect = + @convention(c) ( + CGDirectDisplayID, + CGRect + ) -> Unmanaged? + + private static let displayCreateImageForRect: DisplayCreateImageForRect? = { + guard + let coreGraphics = dlopen( + "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", + RTLD_LAZY + ) + else { + return nil + } + guard let symbol = dlsym(coreGraphics, "CGDisplayCreateImageForRect") else { + dlclose(coreGraphics) + return nil + } + return unsafeBitCast(symbol, to: DisplayCreateImageForRect.self) + }() +} + +@MainActor +private final class QuickScreenshotSelectionOverlayController { + private let displayFrames: [QuickScreenshotDisplayFrame] + private var windows: [QuickScreenshotSelectionWindow] = [] + + init(displayFrames: [QuickScreenshotDisplayFrame]) { + self.displayFrames = displayFrames + } + + func prepare() { + guard windows.isEmpty else { + return + } + for displayFrame in displayFrames { + let window = QuickScreenshotSelectionWindow(displayFrame: displayFrame) + window.contentView?.displayIfNeeded() + windows.append(window) + } + } + + func show(initialSelection: CGRect?) { + prepare() + for window in windows { + window.selectionView.selection = initialSelection + window.selectionView.needsDisplay = true + window.orderFrontRegardless() + window.displayIfNeeded() + } + } + + func update(selection: CGRect) { + for window in windows { + window.selectionView.selection = selection + window.selectionView.needsDisplay = true + window.displayIfNeeded() + } + } + + func close() { + for window in windows { + window.orderOut(nil) + } + windows.removeAll() + } +} + +@MainActor +private final class QuickScreenshotSelectionWindow: NSPanel { + let selectionView: QuickScreenshotSelectionView + private let rootView: NSView + + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } + + init(displayFrame: QuickScreenshotDisplayFrame) { + let contentFrame = CGRect(origin: .zero, size: displayFrame.frame.size) + selectionView = QuickScreenshotSelectionView(displayFrame: displayFrame) + rootView = NSView(frame: contentFrame) + super.init( + contentRect: displayFrame.frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + setFrame(displayFrame.frame, display: false) + let imageView = NSImageView(frame: contentFrame) + imageView.autoresizingMask = [.width, .height] + imageView.image = NSImage( + cgImage: displayFrame.image, + size: displayFrame.frame.size + ) + imageView.imageScaling = .scaleAxesIndependently + selectionView.frame = contentFrame + selectionView.autoresizingMask = [.width, .height] + rootView.addSubview(imageView) + rootView.addSubview(selectionView) + contentView = rootView + animationBehavior = .none + backgroundColor = .clear + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + hasShadow = false + hidesOnDeactivate = false + ignoresMouseEvents = true + isFloatingPanel = true + isMovable = false + isOpaque = false + isReleasedWhenClosed = false + level = .screenSaver + sharingType = .readOnly + titleVisibility = .hidden + titlebarAppearsTransparent = true + } +} + +@MainActor +private final class QuickScreenshotSelectionView: NSView { + private let displayFrame: QuickScreenshotDisplayFrame + var selection: CGRect? + + init(displayFrame: QuickScreenshotDisplayFrame) { + self.displayFrame = displayFrame + super.init(frame: CGRect(origin: .zero, size: displayFrame.frame.size)) + wantsLayer = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override var isFlipped: Bool { false } + override var isOpaque: Bool { false } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + NSGraphicsContext.current?.cgContext.clear(dirtyRect) + drawDimmedMask() + } + + private func drawDimmedMask() { + let maskPath = NSBezierPath(rect: bounds) + if let selection { + maskPath.append(NSBezierPath(rect: localRect(from: selection))) + maskPath.windingRule = .evenOdd + } + NSColor(calibratedWhite: 0, alpha: CaptureChrome.liveScrimAlpha).setFill() + maskPath.fill() + + guard let selection else { + return + } + let selectionRect = localRect(from: selection) + NSColor.white.withAlphaComponent(0.96).setStroke() + let stroke = NSBezierPath(rect: selectionRect) + stroke.lineWidth = 1.5 + stroke.stroke() + NSColor.systemBlue.withAlphaComponent(0.95).setStroke() + let innerStroke = NSBezierPath(rect: selectionRect.insetBy(dx: 1.5, dy: 1.5)) + innerStroke.lineWidth = 1 + innerStroke.stroke() + } + + private func localRect(from globalRect: CGRect) -> CGRect { + CGRect( + x: globalRect.minX - displayFrame.frame.minX, + y: globalRect.minY - displayFrame.frame.minY, + width: globalRect.width, + height: globalRect.height + ) + } +}