diff --git a/Sources/Kapture/CaptureController.swift b/Sources/Kapture/CaptureController.swift index 30b0f72..910aa0c 100644 --- a/Sources/Kapture/CaptureController.swift +++ b/Sources/Kapture/CaptureController.swift @@ -59,13 +59,25 @@ final class CaptureController: NSObject { OverlayWindow( screen: screen, onComplete: { [weak self] rect in self?.finishSelection(rect) }, - onCancel: { [weak self] in self?.cancelSelection() } + onCancel: { [weak self] in self?.cancelSelection() }, + onWindowPick: { [weak self] rect in self?.finishWindowPick(rect) } ) } overlays.forEach { $0.makeKeyAndOrderFront(nil) } overlayIDs = overlays.map { $0.windowNumber } } + /// A window was picked from hover — capture it with rounded corners. + private func finishWindowPick(_ rect: NSRect) { + let ids = overlayIDs + hideOverlays() + isSelecting = false + restorePreviousApp() + DispatchQueue.main.async { [self] in + capture(rect, excludingWindowIDs: ids, roundedCorners: true) + } + } + private func finishSelection(_ rect: NSRect) { let ids = overlayIDs hideOverlays() @@ -94,8 +106,8 @@ final class CaptureController: NSObject { previousApp = nil } - private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int]) { - ScreenCapture.shared.capture(rect, excludingWindowIDs: ids) { [weak self] image in + private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int], roundedCorners: Bool = false) { + ScreenCapture.shared.capture(rect, excludingWindowIDs: ids, roundedCorners: roundedCorners) { [weak self] image in DispatchQueue.main.async { self?.isSelecting = false self?.restorePreviousApp() @@ -117,7 +129,7 @@ final class ScreenCapture { private init() {} @available(macOS 14.0, *) - private func crop(_ image: CGImage, to cgRect: CGRect, display: SCDisplay) -> CGImage? { + private func crop(_ image: CGImage, to cgRect: CGRect, display: SCDisplay, roundedCorners: Bool = false) -> CGImage? { // cgRect is in SCDisplay space (bottom-left origin, Y flipped vs AppKit). // The captured image has a TOP-LEFT origin. So within the display, // we must flip Y: pixelY = displayHeight - (cgY - displayMinY) - cgHeight. @@ -141,7 +153,32 @@ final class ScreenCapture { // CGContext, which resamples and loses quality on retina captures. let rounded = safeRect.integral guard rounded.width >= 1, rounded.height >= 1 else { return nil } - return image.cropping(to: rounded) + guard var result = image.cropping(to: rounded) else { return nil } + + // Apply a rounded-corner alpha mask for window captures, matching + // macOS window chrome (≈10pt radius, scaled to image pixels). + if roundedCorners { + let radius = 10 * scaleX + let maskRect = CGRect(x: 0, y: 0, width: result.width, height: result.height) + guard let maskCtx = CGContext( + data: nil, width: result.width, height: result.height, + bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return result } + maskCtx.setFillColor(CGColor(gray: 1, alpha: 1)) + maskCtx.fill(maskRect) + maskCtx.setBlendMode(.clear) + maskCtx.setFillColor(CGColor(gray: 0, alpha: 1)) + maskCtx.addPath(CGPath(roundedRect: maskRect, cornerWidth: radius, cornerHeight: radius, transform: nil)) + maskCtx.fillPath() + maskCtx.setBlendMode(.normal) + if let mask = maskCtx.makeImage(), + let masked = result.masking(mask) { + result = masked + } + } + return result } @available(macOS 14.0, *) @@ -154,7 +191,7 @@ final class ScreenCapture { return content.displays.first(where: { $0.displayID == cgID }) ?? content.displays.first } - func capture(_ rect: NSRect, excludingWindowIDs excludedIDs: [Int], completion: @escaping (CGImage?) -> Void) { + func capture(_ rect: NSRect, excludingWindowIDs excludedIDs: [Int], roundedCorners: Bool = false, completion: @escaping (CGImage?) -> Void) { if #available(macOS 14.0, *) { Task { guard let content = try? await SCShareableContent.current, @@ -193,7 +230,7 @@ final class ScreenCapture { completion(nil) return } - completion(self.crop(image, to: scRect, display: display)) + completion(self.crop(image, to: scRect, display: display, roundedCorners: roundedCorners)) } } else { guard let primary = NSScreen.screens.first(where: { $0.frame.origin == .zero }) ?? NSScreen.screens.first else { diff --git a/Sources/Kapture/OverlayView.swift b/Sources/Kapture/OverlayView.swift index a72d9b6..903ea1d 100644 --- a/Sources/Kapture/OverlayView.swift +++ b/Sources/Kapture/OverlayView.swift @@ -1,8 +1,8 @@ import AppKit final class OverlayWindow: NSPanel { - init(screen: NSScreen, onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void) { - let content = OverlayView(onComplete: onComplete, onCancel: onCancel) + init(screen: NSScreen, onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void, onWindowPick: @escaping (NSRect) -> Void) { + let content = OverlayView(onComplete: onComplete, onCancel: onCancel, onWindowPick: onWindowPick) super.init( contentRect: screen.frame, styleMask: [.borderless, .nonactivatingPanel], @@ -25,13 +25,21 @@ final class OverlayWindow: NSPanel { final class OverlayView: NSView { private let onComplete: (NSRect) -> Void private let onCancel: () -> Void + private let onWindowPick: (NSRect) -> Void private var startPoint: NSPoint? private(set) var selection: NSRect? + private var isDragging = false - init(onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void) { + // Window detection + private var trackedWindows: [(id: CGWindowID, frame: NSRect)] = [] + private var hoveredWindowFrame: NSRect? + private var excludedIDs: [Int] = [] + + init(onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void, onWindowPick: @escaping (NSRect) -> Void) { self.onComplete = onComplete self.onCancel = onCancel + self.onWindowPick = onWindowPick super.init(frame: .zero) } @@ -47,6 +55,85 @@ final class OverlayView: NSView { addCursorRect(bounds, cursor: .crosshair) } + // MARK: - Window detection + + /// Rebuild the list of capturable windows intersecting this screen. + /// Called lazily on mouse moves; cheap enough for interactive hover. + private func refreshTrackedWindows() { + guard let window = window else { return } + let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size)) + // Exclude this overlay's own window (the other overlays don't intersect + // this screen, so the on-screen filter handles them). + let ownID = window.windowNumber + + guard let list = CGWindowListCopyWindowInfo( + [ .optionOnScreenOnly, .excludeDesktopElements ], kCGNullWindowID + ) as? [[String: Any]] else { trackedWindows = []; return } + + var found: [(CGWindowID, NSRect)] = [] + for info in list { + guard let cid = info[kCGWindowNumber as String] as? CGWindowID, + let bounds = info[kCGWindowBounds as String] as? [String: CGFloat], + let layer = info[kCGWindowLayer as String] as? Int, + layer == 0 + else { continue } + // Skip our own overlays + if Int(cid) == ownID { continue } + let rect = NSRect( + x: bounds["X"] ?? 0, + y: bounds["Y"] ?? 0, + width: bounds["Width"] ?? 0, + height: bounds["Height"] ?? 0 + ) + guard rect.width >= 80, rect.height >= 60 else { continue } + // Skip tiny/utility windows + guard rect.width < screenFrame.width || rect.height < screenFrame.height else { continue } + // Only windows intersecting this screen + guard rect.intersects(screenFrame) else { continue } + found.append((cid, rect)) + } + trackedWindows = found + } + + private func windowFrame(at point: NSPoint) -> NSRect? { + guard let window = window else { return nil } + let screenPoint = window.convertToScreen(NSRect(origin: point, size: .zero)) + // CGWindowList bounds use a TOP-LEFT origin; convert the cursor point. + guard let mainScreen = NSScreen.screens.first else { return nil } + let topLeftPoint = NSPoint( + x: screenPoint.origin.x, + y: mainScreen.frame.height - screenPoint.origin.y + ) + // Pick the frontmost (list order) window containing the point + for (_, frame) in trackedWindows { + if frame.contains(topLeftPoint) { + return frame + } + } + return nil + } + + /// Convert a CGWindowList (top-left origin) frame into AppKit screen + /// coordinates (bottom-left origin) for capture. + private static func cgToAppKit(rect: NSRect) -> NSRect { + guard let mainScreen = NSScreen.screens.first else { return rect } + let y = mainScreen.frame.height - rect.origin.y - rect.height + return NSRect(x: rect.origin.x, y: y, width: rect.width, height: rect.height) + } + + // MARK: - Events + + override func mouseMoved(with event: NSEvent) { + guard !isDragging else { return } + refreshTrackedWindows() + let p = convert(event.locationInWindow, from: nil) + let frame = windowFrame(at: p) + if frame != hoveredWindowFrame { + hoveredWindowFrame = frame + needsDisplay = true + } + } + override func mouseDown(with event: NSEvent) { let p = convert(event.locationInWindow, from: nil) startPoint = p @@ -56,6 +143,8 @@ final class OverlayView: NSView { override func mouseDragged(with event: NSEvent) { guard let start = startPoint else { return } + isDragging = true + hoveredWindowFrame = nil let p = convert(event.locationInWindow, from: nil) selection = NSRect( x: min(start.x, p.x), @@ -67,9 +156,20 @@ final class OverlayView: NSView { } override func mouseUp(with event: NSEvent) { - guard let sel = selection else { return } - if sel.width >= 2, sel.height >= 2 { - onComplete(window!.convertToScreen(sel)) + if isDragging { + guard let sel = selection else { return } + if sel.width >= 2, sel.height >= 2 { + onComplete(window!.convertToScreen(sel)) + } else { + onCancel() + } + return + } + + // No drag: it was a click. If a window is hovered, capture it with + // rounded corners; otherwise treat as an empty marquee (cancel). + if let frame = hoveredWindowFrame { + onWindowPick(Self.cgToAppKit(rect: frame)) } else { onCancel() } @@ -87,6 +187,30 @@ final class OverlayView: NSView { NSColor.black.withAlphaComponent(0.35).setFill() NSBezierPath(rect: bounds).fill() + // Highlight the hovered window (rounded, like the real window corners) + if let frame = hoveredWindowFrame { + // Convert CGWindowList (top-left) frame to view (bottom-left) space + guard let window = window, let mainScreen = NSScreen.screens.first else { return } + let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size)) + let viewFrame = NSRect( + x: frame.origin.x - screenFrame.origin.x, + y: mainScreen.frame.height - frame.origin.y - frame.height - screenFrame.origin.y, + width: frame.width, + height: frame.height + ) + NSGraphicsContext.current?.saveGraphicsState() + NSGraphicsContext.current?.compositingOperation = .clear + NSColor.black.setFill() + NSBezierPath(roundedRect: viewFrame, xRadius: 10, yRadius: 10).fill() + NSGraphicsContext.current?.restoreGraphicsState() + + let border = NSBezierPath(roundedRect: viewFrame, xRadius: 10, yRadius: 10) + border.lineWidth = 2 + NSColor.controlAccentColor.setStroke() + border.stroke() + return + } + guard let sel = selection, sel.width > 0, sel.height > 0 else { return } NSGraphicsContext.current?.saveGraphicsState() diff --git a/build-app.sh b/build-app.sh index d05b6ff..914c75e 100755 --- a/build-app.sh +++ b/build-app.sh @@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' CFBundleIconFile Icon CFBundleShortVersionString - 1.4.3 + 1.5.0 CFBundleVersion - 43 + 50 LSUIElement NSHighResolutionCapable