diff --git a/Sources/Kapture/App.swift b/Sources/Kapture/App.swift index d6ff6d3..939a157 100644 --- a/Sources/Kapture/App.swift +++ b/Sources/Kapture/App.swift @@ -33,6 +33,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } HotkeyManager.shared.onTrigger = { CaptureController.shared.begin() } + HotkeyManager.shared.onFrozenTrigger = { CaptureController.shared.begin(frozen: true) } HotkeyManager.shared.register() } } @@ -44,6 +45,9 @@ struct MenuView: View { Button("Capture & OCR") { CaptureController.shared.begin() } .keyboardShortcut("s", modifiers: [.command, .shift]) + Button("Capture Frozen Screen") { CaptureController.shared.begin(frozen: true) } + .keyboardShortcut("s", modifiers: [.command, .shift, .control]) + Button("Extract from Image File…") { ResultWindow.shared.showOpenPanel() } Divider() diff --git a/Sources/Kapture/CaptureController.swift b/Sources/Kapture/CaptureController.swift index df653d2..5b9eccb 100644 --- a/Sources/Kapture/CaptureController.swift +++ b/Sources/Kapture/CaptureController.swift @@ -76,13 +76,19 @@ final class CaptureController: NSObject { // MARK: - Session lifecycle - func begin() { + /// Frozen mode: the screen is snapshotted at hotkey press, so the pointer + /// and any hover state (tooltips, open menus) end up in the capture. + private(set) var isFrozenMode = false + private var frozenSnapshots: [ScreenCapture.DisplaySnapshot] = [] + + func begin(frozen: Bool = false) { guard !isSelecting else { return } guard PermissionManager.isGranted else { PermissionManager.request() return } isSelecting = true + isFrozenMode = frozen previousApp = NSWorkspace.shared.frontmostApplication // With the event tap driving input we must NOT activate: stealing // focus turns the target app into background mode (dimmed chrome), @@ -92,12 +98,31 @@ final class CaptureController: NSObject { if !usesTap { NSApp.activate(ignoringOtherApps: true) } - CaptureController.log("begin: usesTap=\(usesTap) active=\(NSApp.isActive)") + CaptureController.log("begin: frozen=\(frozen) usesTap=\(usesTap) active=\(NSApp.isActive)") + + if frozen { + ScreenCapture.shared.snapshotAllDisplays(includeCursor: true) { [weak self] snapshots in + DispatchQueue.main.async { + guard let self, self.isSelecting else { return } + self.frozenSnapshots = snapshots + CaptureController.log("frozen: captured \(snapshots.count) displays") + self.showOverlays() + } + } + } else { + showOverlays() + } + } + + private func showOverlays() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in guard let self, self.isSelecting else { return } self.overlays = NSScreen.screens.map { screen in let panel = OverlayWindow(screen: screen, controller: self) - (panel.contentView as? OverlayView)?.controller = self + if let view = panel.contentView as? OverlayView { + view.controller = self + view.frozenImage = self.frozenSnapshots.first(where: { $0.frame == screen.frame })?.image + } return panel } // orderFrontRegardless: show without taking key/focus. @@ -126,11 +151,30 @@ final class CaptureController: NSObject { private func finishSelection(_ rect: NSRect) { let ids = overlayIDs + let frozen = frozenSnapshots + let wasFrozen = isFrozenMode hideOverlays() guard rect.width >= 2, rect.height >= 2 else { cancelSelection() return } + + if wasFrozen { + let midpoint = NSPoint(x: rect.midX, y: rect.midY) + let snapshot = frozen.first(where: { $0.frame.contains(midpoint) }) ?? frozen.first + isSelecting = false + restorePreviousApp() + DispatchQueue.main.async { [self] in + if let snapshot, let image = ScreenCapture.cropFrozen(snapshot, to: rect) { + CaptureController.log("frozen: cropped \(Int(rect.width))x\(Int(rect.height)) -> \(image.width)x\(image.height)") + ResultWindow.shared.show(image: image) + } else { + ResultWindow.shared.show(image: ResultWindow.failureImage("Could not crop the frozen screen.")) + } + } + return + } + DispatchQueue.main.async { [self] in capture(rect, excludingWindowIDs: ids) } @@ -149,6 +193,8 @@ final class CaptureController: NSObject { marqueeRect = nil hoverWindow = nil marqueeStart = nil + frozenSnapshots = [] + isFrozenMode = false } private func restorePreviousApp() { @@ -306,6 +352,8 @@ final class CaptureController: NSObject { } let appKitPoint = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y)) if type == .mouseMoved { + // Frozen mode is marquee-only: no window hover detection. + if isFrozenMode { return event } let window = topmostWindowContaining(appKitPoint) if window?.frame != hoverFrame { hoverWindow = window @@ -345,6 +393,9 @@ final class CaptureController: NSObject { if let rect = marqueeRect, dragDistance >= 4, rect.width >= 2, rect.height >= 2 { CaptureController.log("tap: marquee complete \(rect)") finishSelection(rect) + } else if isFrozenMode { + CaptureController.log("tap: frozen click without drag — cancel") + cancelSelection() } else if let hover = hoverWindow { // An overlapping window from the same app means this is an // owned popup — desktopIndependentWindow would composite the @@ -506,6 +557,74 @@ final class ScreenCapture { return result } + /// Screen frame (AppKit global points) + full-resolution image per display. + struct DisplaySnapshot { + let frame: NSRect + let image: CGImage + } + + /// Snapshot every display right now. Used by frozen mode: the images are + /// taken before the overlay appears, so the pointer and hover states + /// (tooltips, menus) are baked in. + func snapshotAllDisplays(includeCursor: Bool, completion: @escaping ([DisplaySnapshot]) -> Void) { + if #available(macOS 14.0, *) { + Task { + guard let content = try? await SCShareableContent.current else { + completion([]) + return + } + var out: [DisplaySnapshot] = [] + for display in content.displays { + guard let screen = NSScreen.screens.first(where: { + (($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0) == display.displayID + }) else { continue } + let config = SCStreamConfiguration() + let scale = screen.backingScaleFactor + config.width = Int(CGFloat(display.width) * scale) + config.height = Int(CGFloat(display.height) * scale) + config.showsCursor = includeCursor + let filter = SCContentFilter(display: display, excludingWindows: []) + if let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) { + out.append(DisplaySnapshot(frame: screen.frame, image: image)) + } + } + completion(out) + } + } else { + var out: [DisplaySnapshot] = [] + for screen in NSScreen.screens { + let cgRect = CGRect( + x: screen.frame.minX, + y: (NSScreen.screens.first(where: { $0.frame.origin == .zero })?.frame.height ?? screen.frame.height) - screen.frame.maxY, + width: screen.frame.width, + height: screen.frame.height + ) + if let image = CGWindowListCreateImage(cgRect, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution, .boundsIgnoreFraming]) { + out.append(DisplaySnapshot(frame: screen.frame, image: image)) + } + } + completion(out) + } + } + + /// Crop a selection out of a frozen snapshot (top-left pixel space). + static func cropFrozen(_ snapshot: DisplaySnapshot, to rect: NSRect) -> CGImage? { + let scaleX = CGFloat(snapshot.image.width) / snapshot.frame.width + let scaleY = CGFloat(snapshot.image.height) / snapshot.frame.height + guard scaleX > 0, scaleY > 0 else { return nil } + let localX = rect.minX - snapshot.frame.minX + let localTopY = snapshot.frame.maxY - rect.maxY + let px = CGRect( + x: localX * scaleX, + y: localTopY * scaleY, + width: rect.width * scaleX, + height: rect.height * scaleY + ) + let safe = px.intersection(CGRect(x: 0, y: 0, width: CGFloat(snapshot.image.width), height: CGFloat(snapshot.image.height))).integral + guard safe.width >= 1, safe.height >= 1 else { return nil } + return snapshot.image.cropping(to: safe) + } + @available(macOS 14.0, *) private func displayContaining(_ rect: NSRect, in content: SCShareableContent) -> SCDisplay? { let mid = NSPoint(x: rect.midX, y: rect.midY) diff --git a/Sources/Kapture/HotkeyManager.swift b/Sources/Kapture/HotkeyManager.swift index 500e724..788c6b1 100644 --- a/Sources/Kapture/HotkeyManager.swift +++ b/Sources/Kapture/HotkeyManager.swift @@ -4,11 +4,14 @@ import AppKit final class HotkeyManager { static let shared = HotkeyManager() + /// ⌘⇧S — live capture (ScreenCaptureKit at capture time). var onTrigger: (() -> Void)? + /// ⌃⇧⌘S — frozen capture (snapshot at hotkey press). + var onFrozenTrigger: (() -> Void)? - private var hotKeyRef: EventHotKeyRef? + private var hotKeyRefs: [EventHotKeyRef?] = [] private var eventHandlerRef: EventHandlerRef? - private var hotKeyID = EventHotKeyID(signature: 0x4B505452, id: 1) + private let signature: OSType = 0x4B505452 private init() {} @@ -17,18 +20,36 @@ final class HotkeyManager { eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed) ) - InstallEventHandler(GetApplicationEventTarget(), { _, _, _ in - HotkeyManager.shared.onTrigger?() + InstallEventHandler(GetApplicationEventTarget(), { _, event, _ in + var hotKeyID = EventHotKeyID() + let status = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &hotKeyID + ) + guard status == noErr else { return noErr } + DispatchQueue.main.async { + switch hotKeyID.id { + case 1: HotkeyManager.shared.onTrigger?() + case 2: HotkeyManager.shared.onFrozenTrigger?() + default: break + } + } return noErr }, 1, &eventType, nil, &eventHandlerRef) - RegisterEventHotKey( - UInt32(kVK_ANSI_S), - UInt32(cmdKey | shiftKey), - hotKeyID, - GetApplicationEventTarget(), - 0, - &hotKeyRef - ) + registerHotkey(id: 1, keyCode: UInt32(kVK_ANSI_S), modifiers: UInt32(cmdKey | shiftKey)) + registerHotkey(id: 2, keyCode: UInt32(kVK_ANSI_S), modifiers: UInt32(cmdKey | shiftKey | controlKey)) + } + + private func registerHotkey(id: UInt32, keyCode: UInt32, modifiers: UInt32) { + var ref: EventHotKeyRef? + let hotKeyID = EventHotKeyID(signature: signature, id: id) + RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &ref) + hotKeyRefs.append(ref) } } diff --git a/Sources/Kapture/OverlayView.swift b/Sources/Kapture/OverlayView.swift index 1e9192c..b526372 100644 --- a/Sources/Kapture/OverlayView.swift +++ b/Sources/Kapture/OverlayView.swift @@ -30,6 +30,9 @@ final class OverlayWindow: NSPanel { final class OverlayView: NSView { weak var controller: CaptureController? + /// In frozen mode: the still image to display for this screen (already + /// full resolution). Nil = live mode (dim the real screen). + var frozenImage: CGImage? override var acceptsFirstResponder: Bool { true } @@ -56,9 +59,42 @@ final class OverlayView: NSView { ) } + /// Draw the frozen snapshot filling the screen, then replace its centre + /// hole with the live... no: the image IS the content, drawn opaque. + private func drawFrozen(_ image: CGImage) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + ctx.saveGState() + ctx.interpolationQuality = .high + // Image is in pixels, view in points — draw into the view bounds. + ctx.draw(image, in: bounds) + ctx.restoreGState() + } + + private func drawSizeLabel(for sel: NSRect, in vf: NSRect) { + let label = "\(Int(sel.width)) × \(Int(sel.height))" + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium), + .foregroundColor: NSColor.white, + ] + let labelSize = label.size(withAttributes: attrs) + let labelRect = NSRect( + x: vf.maxX - labelSize.width - 8, + y: vf.minY - labelSize.height - 10, + width: labelSize.width + 8, + height: labelSize.height + 6 + ) + NSColor.black.withAlphaComponent(0.6).setFill() + NSBezierPath(roundedRect: labelRect, xRadius: 4, yRadius: 4).fill() + label.draw(at: NSPoint(x: labelRect.minX + 4, y: labelRect.minY + 3), withAttributes: attrs) + } + override func draw(_ dirtyRect: NSRect) { - NSColor.black.withAlphaComponent(0.35).setFill() - NSBezierPath(rect: bounds).fill() + if let frozenImage { + drawFrozen(frozenImage) + } else { + NSColor.black.withAlphaComponent(0.35).setFill() + NSBezierPath(rect: bounds).fill() + } // Esc hint — the only way out once clicks are consumed by the tap. let hint = "Drag to select · hover + click a window to capture it · Esc to cancel" @@ -79,6 +115,33 @@ final class OverlayView: NSView { guard let controller else { return } + // Frozen mode: no window highlight; the marquee is cut straight out + // of the still image so it shows the frozen pixels. + if frozenImage != nil { + if let sel = controller.marqueeRect, sel.width > 0, sel.height > 0 { + let vf = viewRect(sel) + // Dim everything except the selection (even-odd fill). + let maskPath = CGMutablePath() + maskPath.addRect(bounds) + maskPath.addRect(vf) + NSGraphicsContext.current?.saveGraphicsState() + let ctx = NSGraphicsContext.current?.cgContext + ctx?.addPath(maskPath) + ctx?.fillPath(using: .evenOdd) + NSColor.black.withAlphaComponent(0.35).setFill() + ctx?.addPath(maskPath) + ctx?.fillPath(using: .evenOdd) + NSGraphicsContext.current?.restoreGraphicsState() + + let border = NSBezierPath(rect: vf) + border.lineWidth = 1.5 + NSColor.controlAccentColor.setStroke() + border.stroke() + drawSizeLabel(for: sel, in: vf) + } + return + } + // Highlight the hovered window (rounded, like real window corners) if let frame = controller.hoverFrame { let vf = viewRect(frame) diff --git a/build-app.sh b/build-app.sh index 2b7dd9f..2a188d9 100755 --- a/build-app.sh +++ b/build-app.sh @@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' CFBundleIconFile Icon CFBundleShortVersionString - 1.9.2 + 1.10.0 CFBundleVersion - 92 + 100 LSUIElement NSHighResolutionCapable