Compare commits

..
7 Commits
Author SHA1 Message Date
TZGyn 832a18b06c Exclude the cursor from captures
💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 12:26:12 +08:00
TZGyn 6e5796e16d Check Accessibility before starting a capture; log capture geometry
The first capture used to start the overlay and the system permission
prompt at once, making both uninteractive. begin() now requires
Accessibility up front, shows an alert with a direct link to the setting,
and only starts capturing once granted. Captures log their display,
backing scale, and rect mapping for diagnosing multi-display offsets.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 12:24:47 +08:00
TZGyn 7e9cc5b269 Drive capture selection through a CGEventTap
Panel event delivery is unreliable on macOS 26 (clicks pass through even
with the app active and the panel key). The selection flow is now driven
by a CGEventTap that sees and consumes mouse events system-wide: hover
highlights windows, clicks capture them (rounded), drags marquee-select,
Esc cancels. Requires Accessibility permission, prompted on first use;
falls back to panel events when unavailable.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 12:08:57 +08:00
TZGyn a7cad2fc2f Terminate stale Kapture instances at launch; log topmost windows
A stale copy surviving an update keeps its own hotkey and overlays on
screen, silently eating every click meant for the new instance (matching
the empty session logs). New copies now force-terminate older instances,
and the first window scan logs the topmost on-screen windows so anything
covering the overlays is visible in diagnostics.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 11:59:03 +08:00
TZGyn 69e1a7fccb Show overlays only after activation completes
The local probe confirmed activation is asynchronous on macOS 26:
isActive flips on a later runloop tick, so overlays shown in the same
tick as activate() belong to a still-inactive app and misses clicks.
Delay overlay creation by a tick and log the activation state at show
time.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 11:51:13 +08:00
TZGyn e22bcb99c4 Make overlay panels activating so clicks are received
The session log showed active=false after every activation attempt on
macOS 26: cooperative activation is denied for LSUIElement apps, and an
inactive app's panels let clicks fall through to the windows below.
Remove .nonactivatingPanel so showing the overlay activates the app
(previous app focus is restored after capture as before).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 11:46:45 +08:00
TZGyn 457e303152 Force app activation on capture; log to /tmp for durable diagnostics
Cooperative NSApp.activate() can silently fail for LSUIElement apps,
leaving the app inactive so overlay clicks pass through to windows below.
Force activation with ignoringOtherApps on all OS versions. Session logs
now also append to /tmp/kapture-debug.log so they survive relaunches, and
the first window-scan result is always logged.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 11:00:44 +08:00
4 changed files with 288 additions and 195 deletions
+18
View File
@@ -21,6 +21,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
runOCRSelfTest() runOCRSelfTest()
return return
} }
// Single instance: stale copies keep their own hotkey + overlays alive
// after app updates and eat clicks meant for the new instance.
let myPid = ProcessInfo.processInfo.processIdentifier
let others = NSRunningApplication.runningApplications(withBundleIdentifier: "dev.tzgyn.kapture")
.filter { $0.processIdentifier != myPid }
if !others.isEmpty {
CaptureController.log("launch: other Kapture instances pids=\(others.map { String($0.processIdentifier) }.joined(separator: ",")) — terminating them")
for other in others {
other.forceTerminate()
}
}
HotkeyManager.shared.onTrigger = { CaptureController.shared.begin() } HotkeyManager.shared.onTrigger = { CaptureController.shared.begin() }
HotkeyManager.shared.register() HotkeyManager.shared.register()
} }
@@ -79,6 +90,13 @@ enum PermissionManager {
static func request() { static func request() {
CGRequestScreenCaptureAccess() CGRequestScreenCaptureAccess()
} }
static var isAccessibilityGranted: Bool { AXIsProcessTrusted() }
static func requestAccessibility() {
let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(opts)
}
} }
enum LoginItem { enum LoginItem {
+232 -16
View File
@@ -1,27 +1,47 @@
import AppKit import AppKit
import CoreGraphics import CoreGraphics
import ScreenCaptureKit import ScreenCaptureKit
import ApplicationServices
final class CaptureController: NSObject { final class CaptureController: NSObject {
static let shared = CaptureController() static let shared = CaptureController()
private var overlays: [NSWindow] = [] private var overlays: [OverlayWindow] = []
private(set) var isSelecting = false private(set) var isSelecting = false
private var previousApp: NSRunningApplication? private var previousApp: NSRunningApplication?
private var overlayIDs: [Int] = [] private var overlayIDs: [Int] = []
// Selection state (driven by the event tap; AppKit global coordinates)
private var marqueeStart: NSPoint?
private(set) var marqueeRect: NSRect?
private(set) var hoverFrame: NSRect?
private var trackedWindows: [(id: CGWindowID, frame: NSRect)] = []
private var lastWindowScan = Date.distantPast
private var eventTap: CFMachPort?
/// Session event log for debugging (dumped via Copy Diagnostics). /// Session event log for debugging (dumped via Copy Diagnostics).
/// Also appended to /tmp/kapture-debug.log so it survives relaunches.
static var sessionLog: [String] = [] static var sessionLog: [String] = []
static func log(_ line: String) { static func log(_ line: String) {
sessionLog.append(line) let stamped = String(format: "%@ %@", DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium), line)
if sessionLog.count > 100 { sessionLog.removeFirst() } sessionLog.append(stamped)
if sessionLog.count > 200 { sessionLog.removeFirst() }
NSLog("[Kapture] \(line)") NSLog("[Kapture] \(line)")
let path = "/tmp/kapture-debug.log"
if let handle = FileHandle(forWritingAtPath: path) {
handle.seekToEndOfFile()
handle.write((stamped + "\n").data(using: .utf8)!)
handle.closeFile()
} else {
FileManager.default.createFile(atPath: path, contents: (stamped + "\n").data(using: .utf8))
}
} }
/// Diagnostic snapshot for "Copy Diagnostics" menu item. /// Diagnostic snapshot for "Copy Diagnostics" menu item.
func diagnostics() -> String { func diagnostics() -> String {
var lines: [String] = [] var lines: [String] = []
lines.append("Kapture diagnostics — version \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "?")") lines.append("Kapture diagnostics — version \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "?")")
lines.append("accessibility=\(AXIsProcessTrusted()) eventTap=\(eventTap != nil)")
lines.append("--- session log (newest last) ---") lines.append("--- session log (newest last) ---")
lines.append(contentsOf: Self.sessionLog) lines.append(contentsOf: Self.sessionLog)
lines.append("--- screens ---") lines.append("--- screens ---")
@@ -53,6 +73,8 @@ final class CaptureController: NSObject {
return lines.joined(separator: "\n") return lines.joined(separator: "\n")
} }
// MARK: - Session lifecycle
func begin() { func begin() {
guard !isSelecting else { return } guard !isSelecting else { return }
guard PermissionManager.isGranted else { guard PermissionManager.isGranted else {
@@ -61,21 +83,23 @@ final class CaptureController: NSObject {
} }
isSelecting = true isSelecting = true
previousApp = NSWorkspace.shared.frontmostApplication previousApp = NSWorkspace.shared.frontmostApplication
if #available(macOS 14.0, *) { // Activation is async on macOS 26; wait a tick so the app is active
NSApp.activate() // before the overlays appear (otherwise clicks pass through).
} else {
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
CaptureController.log("begin: activate issued, active=\(NSApp.isActive)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self, self.isSelecting else { return }
CaptureController.log("begin: active=\(NSApp.isActive) — showing overlays")
self.overlays = NSScreen.screens.map { screen in
let panel = OverlayWindow(screen: screen, controller: self)
(panel.contentView as? OverlayView)?.controller = self
return panel
} }
overlays = NSScreen.screens.map { screen in self.overlays.forEach { $0.makeKeyAndOrderFront(nil) }
OverlayWindow( self.overlayIDs = self.overlays.map { $0.windowNumber }
screen: screen, CaptureController.log("begin: \(self.overlays.count) overlays, ids=\(self.overlayIDs), active=\(NSApp.isActive)")
onComplete: { [weak self] rect in self?.finishSelection(rect) }, self.installEventTap()
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. /// A window was picked from hover — capture it with rounded corners.
@@ -108,8 +132,12 @@ final class CaptureController: NSObject {
} }
private func hideOverlays() { private func hideOverlays() {
removeEventTap()
overlays.forEach { $0.orderOut(nil) } overlays.forEach { $0.orderOut(nil) }
overlays = [] overlays = []
marqueeRect = nil
hoverFrame = nil
marqueeStart = nil
} }
private func restorePreviousApp() { private func restorePreviousApp() {
@@ -117,6 +145,193 @@ final class CaptureController: NSObject {
previousApp = nil previousApp = nil
} }
private func renderOverlays() {
for overlay in overlays {
(overlay.contentView as? OverlayView)?.needsDisplay = true
}
}
// MARK: - Event tap
private func installEventTap() {
guard eventTap == nil else { return }
guard AXIsProcessTrusted() else {
CaptureController.log("tap: accessibility not granted — falling back to panel events")
PermissionManager.requestAccessibility()
return
}
let mask: CGEventMask = (1 << CGEventType.mouseMoved.rawValue)
| (1 << CGEventType.leftMouseDown.rawValue)
| (1 << CGEventType.leftMouseDragged.rawValue)
| (1 << CGEventType.leftMouseUp.rawValue)
| (1 << CGEventType.keyDown.rawValue)
guard let tap = CGEvent.tapCreate(
tap: .cghidEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: CGEventMask(mask),
callback: Self.tapCallback,
userInfo: Unmanaged.passUnretained(self).toOpaque()
) else {
CaptureController.log("tap: creation FAILED — falling back to panel events")
return
}
eventTap = tap
if let source = CFMachPortCreateRunLoopSource(nil, tap, 0) {
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
}
CGEvent.tapEnable(tap: tap, enable: true)
CaptureController.log("tap: installed")
}
private func removeEventTap() {
guard let tap = eventTap else { return }
CGEvent.tapEnable(tap: tap, enable: false)
if let source = CFMachPortCreateRunLoopSource(nil, tap, 0) {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
CFMachPortInvalidate(tap)
eventTap = nil
CaptureController.log("tap: removed")
}
private static let tapCallback: CGEventTapCallBack = { _, type, event, refcon in
guard let refcon else { return Unmanaged.passUnretained(event) }
let controller = Unmanaged<CaptureController>.fromOpaque(refcon).takeUnretainedValue()
if let result = controller.handleTap(type, event) {
return Unmanaged.passUnretained(result)
}
return nil
}
/// Convert a CG global point (top-left origin) to AppKit global.
private func cgPointToAppKit(_ p: NSPoint) -> NSPoint {
guard let mainScreen = NSScreen.screens.first else { return p }
return NSPoint(x: p.x, y: mainScreen.frame.height - p.y)
}
/// Scan on-screen windows (AppKit global frames), front to back.
private func refreshTrackedWindows() {
guard let list = CGWindowListCopyWindowInfo(
[ .optionOnScreenOnly, .excludeDesktopElements ], kCGNullWindowID
) as? [[String: Any]] else { trackedWindows = []; return }
let ownIDs = Set(overlayIDs)
var found: [(id: CGWindowID, frame: 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 }
if ownIDs.contains(Int(cid)) { continue }
let cgRect = NSRect(
x: bounds["X"] ?? 0,
y: bounds["Y"] ?? 0,
width: bounds["Width"] ?? 0,
height: bounds["Height"] ?? 0
)
guard cgRect.width >= 80, cgRect.height >= 60 else { continue }
let rect = Self.cgToAppKit(rect: cgRect)
// Skip windows filling an entire screen (fullscreen apps)
let coversWholeScreen = NSScreen.screens.contains { screen in
abs(screen.frame.width - rect.width) < 2 && abs(screen.frame.height - rect.height) < 2
}
guard !coversWholeScreen else { continue }
found.append((cid, rect))
}
trackedWindows = found
}
/// Convert a CGWindowList (top-left origin) frame into AppKit screen
/// coordinates (bottom-left origin).
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)
}
private func topmostWindowContaining(_ appKitPoint: NSPoint) -> NSRect? {
for (_, frame) in trackedWindows {
if frame.contains(appKitPoint) {
return frame
}
}
return nil
}
/// Returns the event to pass through, or nil to consume it.
func handleTap(_ type: CGEventType, _ event: CGEvent) -> CGEvent? {
guard isSelecting else { return event }
switch type {
case .tapDisabledByUserInput:
CGEvent.tapEnable(tap: eventTap!, enable: true)
return event
case .tapDisabledByTimeout:
CGEvent.tapEnable(tap: eventTap!, enable: true)
return event
case .keyDown:
if event.getIntegerValueField(.keyboardEventKeycode) == 53 {
CaptureController.log("tap: Esc — cancel")
cancelSelection()
return nil
}
return event
case .mouseMoved, .leftMouseDragged:
// Throttle window scans while moving
if Date().timeIntervalSince(lastWindowScan) > 0.15 {
lastWindowScan = Date()
refreshTrackedWindows()
}
let appKitPoint = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y))
if type == .mouseMoved {
let frame = topmostWindowContaining(appKitPoint)
if frame != hoverFrame {
hoverFrame = frame
renderOverlays()
}
return event
}
// Drag: grow the marquee
guard let start = marqueeStart else { return nil }
let rect = NSRect(
x: min(start.x, appKitPoint.x),
y: min(start.y, appKitPoint.y),
width: abs(appKitPoint.x - start.x),
height: abs(appKitPoint.y - start.y)
)
marqueeRect = rect
hoverFrame = nil
renderOverlays()
return nil
case .leftMouseDown:
marqueeStart = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y))
marqueeRect = nil
CaptureController.log("tap: mouseDown at \(marqueeStart.map { "\($0)" } ?? "?")")
return nil
case .leftMouseUp:
defer { marqueeStart = nil; marqueeRect = nil }
let appKitPoint = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y))
let dragDistance = marqueeStart.map { hypot($0.x - appKitPoint.x, $0.y - appKitPoint.y) } ?? 0
if let rect = marqueeRect, dragDistance >= 4, rect.width >= 2, rect.height >= 2 {
CaptureController.log("tap: marquee complete \(rect)")
finishSelection(rect)
} else if let hover = hoverFrame {
CaptureController.log("tap: window pick \(hover)")
finishWindowPick(hover)
} else {
CaptureController.log("tap: click with no hover/marquee — cancel")
cancelSelection()
}
return nil
default:
return event
}
}
// MARK: - Capture
private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int], roundedCorners: Bool = false) { private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int], roundedCorners: Bool = false) {
ScreenCapture.shared.capture(rect, excludingWindowIDs: ids, roundedCorners: roundedCorners) { [weak self] image in ScreenCapture.shared.capture(rect, excludingWindowIDs: ids, roundedCorners: roundedCorners) { [weak self] image in
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -225,7 +440,7 @@ final class ScreenCapture {
let backingScale = screen.backingScaleFactor let backingScale = screen.backingScaleFactor
config.width = Int(CGFloat(display.width) * backingScale) config.width = Int(CGFloat(display.width) * backingScale)
config.height = Int(CGFloat(display.height) * backingScale) config.height = Int(CGFloat(display.height) * backingScale)
config.showsCursor = true config.showsCursor = false
// ScreenCaptureKit uses a different origin than AppKit for // ScreenCaptureKit uses a different origin than AppKit for
// screens stacked around the main display: per-display, // screens stacked around the main display: per-display,
@@ -241,6 +456,7 @@ final class ScreenCapture {
completion(nil) completion(nil)
return return
} }
CaptureController.log("capture: rect=\(rect) screen=\(screen.frame) display=\(display.frame) backing=\(backingScale) image=\(image.width)x\(image.height) scRect=\(scRect)")
completion(self.crop(image, to: scRect, display: display, roundedCorners: roundedCorners)) completion(self.crop(image, to: scRect, display: display, roundedCorners: roundedCorners))
} }
} else { } else {
+35 -176
View File
@@ -1,17 +1,20 @@
import AppKit import AppKit
/// Renders the capture overlay. All interaction state lives in
/// CaptureController (driven by the CGEventTap); this view only draws.
final class OverlayWindow: NSPanel { final class OverlayWindow: NSPanel {
init(screen: NSScreen, onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void, onWindowPick: @escaping (NSRect) -> Void) { init(screen: NSScreen, controller: CaptureController) {
let content = OverlayView(onComplete: onComplete, onCancel: onCancel, onWindowPick: onWindowPick) let content = OverlayView()
super.init( super.init(
contentRect: screen.frame, contentRect: screen.frame,
styleMask: [.borderless, .nonactivatingPanel], styleMask: [.borderless],
backing: .buffered, backing: .buffered,
defer: false defer: false
) )
contentView = content contentView = content
content.controller = controller
level = .screenSaver level = .screenSaver
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .transient] collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
backgroundColor = .clear backgroundColor = .clear
isOpaque = false isOpaque = false
hasShadow = false hasShadow = false
@@ -23,29 +26,7 @@ final class OverlayWindow: NSPanel {
} }
final class OverlayView: NSView { final class OverlayView: NSView {
private let onComplete: (NSRect) -> Void weak var controller: CaptureController?
private let onCancel: () -> Void
private let onWindowPick: (NSRect) -> Void
private var startPoint: NSPoint?
private(set) var selection: NSRect?
private var isDragging = false
private var dragDistance: CGFloat = 0
// Window detection (frames stored in AppKit global space)
private var trackedWindows: [(id: CGWindowID, frame: NSRect)] = []
private var hoveredWindowFrame: NSRect?
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)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var acceptsFirstResponder: Bool { true } override var acceptsFirstResponder: Bool { true }
@@ -55,178 +36,56 @@ final class OverlayView: NSView {
addCursorRect(bounds, cursor: .crosshair) addCursorRect(bounds, cursor: .crosshair)
} }
// MARK: - Window detection /// The screen frame this overlay covers, in AppKit global space.
private var screenFrame: NSRect {
guard let window else { return .zero }
return window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
}
/// Rebuild the list of capturable windows intersecting this screen. /// Convert an AppKit-global rect into this view's coordinates.
/// Called lazily on mouse moves; cheap enough for interactive hover. private func viewRect(_ global: NSRect) -> NSRect {
private func refreshTrackedWindows() { let sf = screenFrame
guard let window = window else { return } return NSRect(
let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size)) x: global.origin.x - sf.origin.x,
// Exclude this overlay's own window (the other overlays don't intersect y: global.origin.y - sf.origin.y,
// this screen, so the on-screen filter handles them). width: global.width,
let ownID = window.windowNumber height: global.height
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 cgRect = NSRect(
x: bounds["X"] ?? 0,
y: bounds["Y"] ?? 0,
width: bounds["Width"] ?? 0,
height: bounds["Height"] ?? 0
) )
guard cgRect.width >= 80, cgRect.height >= 60 else { continue }
// Convert to AppKit space FIRST — CG and AppKit Y axes disagree on
// multi-desktop layouts, so intersect tests must use one space.
let rect = Self.cgToAppKit(rect: cgRect)
// Skip windows filling the whole screen (fullscreen apps)
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 }
// convertToScreen gives AppKit-global coordinates; tracked frames are
// already in that space.
let screenPoint = window.convertToScreen(NSRect(origin: point, size: .zero)).origin
for (_, frame) in trackedWindows {
if frame.contains(screenPoint) {
return frame
}
}
return nil
}
/// Convert a CGWindowList (top-left origin, CG global space) 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
dragDistance = 0
selection = NSRect(origin: p, size: .zero)
needsDisplay = true
}
override func mouseDragged(with event: NSEvent) {
guard let start = startPoint else { return }
let p = convert(event.locationInWindow, from: nil)
dragDistance = hypot(p.x - start.x, p.y - start.y)
// Only become a marquee after real movement; micro-jitter from a
// click must not cancel window-pick mode.
guard dragDistance >= 4 else { return }
isDragging = true
hoveredWindowFrame = nil
selection = NSRect(
x: min(start.x, p.x),
y: min(start.y, p.y),
width: abs(p.x - start.x),
height: abs(p.y - start.y)
)
needsDisplay = true
}
override func mouseUp(with event: NSEvent) {
defer {
isDragging = false
dragDistance = 0
}
if isDragging {
guard let sel = selection else { return }
if sel.width >= 2, sel.height >= 2 {
onComplete(window!.convertToScreen(sel))
} else {
onCancel()
}
return
}
// 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(frame)
} else {
onCancel()
}
}
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 {
onCancel()
} else {
super.keyDown(with: event)
}
} }
override func draw(_ dirtyRect: NSRect) { override func draw(_ dirtyRect: NSRect) {
NSColor.black.withAlphaComponent(0.35).setFill() NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill() NSBezierPath(rect: bounds).fill()
// Highlight the hovered window (rounded, like the real window corners) guard let controller else { return }
if let frame = hoveredWindowFrame {
// Tracked frames are AppKit-global; view space = global - window origin. // Highlight the hovered window (rounded, like real window corners)
guard let window = window else { return } if let frame = controller.hoverFrame {
let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size)) let vf = viewRect(frame)
let viewFrame = NSRect(
x: frame.origin.x - screenFrame.origin.x,
y: frame.origin.y - screenFrame.origin.y,
width: frame.width,
height: frame.height
)
NSGraphicsContext.current?.saveGraphicsState() NSGraphicsContext.current?.saveGraphicsState()
NSGraphicsContext.current?.compositingOperation = .clear NSGraphicsContext.current?.compositingOperation = .clear
NSColor.black.setFill() NSColor.black.setFill()
NSBezierPath(roundedRect: viewFrame, xRadius: 10, yRadius: 10).fill() NSBezierPath(roundedRect: vf, xRadius: 10, yRadius: 10).fill()
NSGraphicsContext.current?.restoreGraphicsState() NSGraphicsContext.current?.restoreGraphicsState()
let border = NSBezierPath(roundedRect: viewFrame, xRadius: 10, yRadius: 10) let border = NSBezierPath(roundedRect: vf, xRadius: 10, yRadius: 10)
border.lineWidth = 2 border.lineWidth = 2
NSColor.controlAccentColor.setStroke() NSColor.controlAccentColor.setStroke()
border.stroke() border.stroke()
return return
} }
guard let sel = selection, sel.width > 0, sel.height > 0 else { return } // Marquee selection
guard let sel = controller.marqueeRect, sel.width > 0, sel.height > 0 else { return }
let vf = viewRect(sel)
NSGraphicsContext.current?.saveGraphicsState() NSGraphicsContext.current?.saveGraphicsState()
NSGraphicsContext.current?.compositingOperation = .clear NSGraphicsContext.current?.compositingOperation = .clear
NSColor.black.setFill() NSColor.black.setFill()
NSBezierPath(rect: sel).fill() NSBezierPath(rect: vf).fill()
NSGraphicsContext.current?.restoreGraphicsState() NSGraphicsContext.current?.restoreGraphicsState()
let border = NSBezierPath(rect: sel) let border = NSBezierPath(rect: vf)
border.lineWidth = 1.5 border.lineWidth = 1.5
NSColor.controlAccentColor.setStroke() NSColor.controlAccentColor.setStroke()
border.stroke() border.stroke()
@@ -238,8 +97,8 @@ final class OverlayView: NSView {
] ]
let labelSize = label.size(withAttributes: attrs) let labelSize = label.size(withAttributes: attrs)
let labelRect = NSRect( let labelRect = NSRect(
x: sel.maxX - labelSize.width - 8, x: vf.maxX - labelSize.width - 8,
y: sel.minY - labelSize.height - 10, y: vf.minY - labelSize.height - 10,
width: labelSize.width + 8, width: labelSize.width + 8,
height: labelSize.height + 6 height: labelSize.height + 6
) )
+2 -2
View File
@@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>Icon</string> <string>Icon</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.6.1</string> <string>1.7.2</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>61</string> <string>72</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>