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