From 45e4962ec3d3b799baf063a4a0ccf6a4b34551e9 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Wed, 9 Sep 2026 10:46:32 +0800 Subject: [PATCH] Fix multi-display window detection and click-to-capture; separate Redact tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window frames are now converted to AppKit space once at detection time, so hover highlighting and picking work on every display. A click is only treated as a drag after 4pt of movement, so clicking a detected window captures it instead of passing a stray marquee. Redact is now a standalone tool (Select/Draw/Redact) with its own toolbar hints instead of a toggle inside the draw bar. 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-flash-0731 --- Sources/Kapture/OverlayView.swift | 43 +++++++++++++--------- Sources/Kapture/ResultWindow.swift | 58 ++++++++++++++++++------------ build-app.sh | 4 +-- 3 files changed, 65 insertions(+), 40 deletions(-) diff --git a/Sources/Kapture/OverlayView.swift b/Sources/Kapture/OverlayView.swift index 47dd502..1924076 100644 --- a/Sources/Kapture/OverlayView.swift +++ b/Sources/Kapture/OverlayView.swift @@ -30,11 +30,11 @@ final class OverlayView: NSView { private var startPoint: NSPoint? private(set) var selection: NSRect? private var isDragging = false + private var dragDistance: CGFloat = 0 - // Window detection + // Window detection (frames stored in AppKit global space) 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 @@ -79,14 +79,17 @@ final class OverlayView: NSView { else { continue } // Skip our own overlays if Int(cid) == ownID { continue } - let rect = NSRect( + let cgRect = 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 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 } @@ -97,11 +100,11 @@ final class OverlayView: NSView { private func windowFrame(at point: NSPoint) -> NSRect? { guard let window = window else { return nil } - // convertToScreen gives AppKit-global coordinates; convert tracked - // CG frames to the same space and pick the frontmost containing it. + // 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 Self.cgToAppKit(rect: frame).contains(screenPoint) { + if frame.contains(screenPoint) { return frame } } @@ -132,15 +135,20 @@ final class OverlayView: NSView { 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 - let p = convert(event.locationInWindow, from: nil) selection = NSRect( x: min(start.x, p.x), y: min(start.y, p.y), @@ -151,6 +159,10 @@ final class OverlayView: NSView { } 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 { @@ -161,10 +173,10 @@ final class OverlayView: NSView { return } - // No drag: it was a click. If a window is hovered, capture it with - // rounded corners; otherwise treat as an empty marquee (cancel). + // 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)) + onWindowPick(frame) } else { onCancel() } @@ -184,13 +196,12 @@ final class OverlayView: NSView { // Highlight the hovered window (rounded, like the real window corners) if let frame = hoveredWindowFrame { - // Convert the CG frame to AppKit-global, then to this view's space. + // Tracked frames are AppKit-global; view space = global - window origin. guard let window = window else { return } let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size)) - let appKitFrame = Self.cgToAppKit(rect: frame) let viewFrame = NSRect( - x: appKitFrame.origin.x - screenFrame.origin.x, - y: appKitFrame.origin.y - screenFrame.origin.y, + x: frame.origin.x - screenFrame.origin.x, + y: frame.origin.y - screenFrame.origin.y, width: frame.width, height: frame.height ) diff --git a/Sources/Kapture/ResultWindow.swift b/Sources/Kapture/ResultWindow.swift index 57ce05a..b28702c 100644 --- a/Sources/Kapture/ResultWindow.swift +++ b/Sources/Kapture/ResultWindow.swift @@ -95,14 +95,18 @@ final class CaptureModel: ObservableObject { /// Redo stack of removed strokes @Published var redoStack: [Stroke] = [] /// Whether the pen is active (drag draws instead of selecting) - @Published var isDrawing = false - /// Whether drawing redacts (black bar) instead of pen strokes - @Published var isRedacting = false + @Published var tool: Tool = .select /// Current drawing color @Published var drawColor: NSColor = .systemRed /// Stroke thickness in points (image space) @Published var strokeWidth: CGFloat = 3.0 + enum Tool { + case select + case draw + case redact + } + struct Stroke { let points: [CGPoint] let width: CGFloat @@ -193,7 +197,7 @@ final class CaptureModel: ObservableObject { func addStroke(_ points: [CGPoint]) { guard points.count > 1 else { return } - if isRedacting { + if tool == .redact { // Redaction: a filled black bar from start to end, as thick as the // current stroke width (drawn as a rect for clean edges). strokes.append(Stroke(points: points, width: max(strokeWidth, 12), color: .black, isRedaction: true)) @@ -333,14 +337,24 @@ struct ResultView: View { Divider().frame(height: 18) Button { - model.isDrawing.toggle() - if model.isDrawing { model.selection = nil } + model.tool = model.tool == .draw ? .select : .draw + if model.tool != .select { model.selection = nil } } label: { - Label("Draw", systemImage: model.isDrawing ? "pencil.tip.crop.circle.fill" : "pencil.tip.crop.circle") + Label("Draw", systemImage: model.tool == .draw ? "pencil.tip.crop.circle.fill" : "pencil.tip.crop.circle") } .buttonStyle(.bordered) - .foregroundStyle(model.isDrawing ? .primary : .secondary) - .help("Toggle drawing on the screenshot") + .foregroundStyle(model.tool == .draw ? .primary : .secondary) + .help("Draw on the screenshot") + + Button { + model.tool = model.tool == .redact ? .select : .redact + if model.tool != .select { model.selection = nil } + } label: { + Label("Redact", systemImage: model.tool == .redact ? "rectangle.fill.on.rectangle.fill" : "rectangle.dashed") + } + .buttonStyle(.bordered) + .foregroundStyle(model.tool == .redact ? .primary : .secondary) + .help("Cover sensitive areas with black bars") Button { model.copyImage() @@ -372,20 +386,16 @@ struct ResultView: View { Divider() - // Draw toolbar (only when drawing) - if model.isDrawing { + // Tool toolbar (only when a draw tool is active) + if model.tool != .select { HStack(spacing: 14) { - Picker("", selection: $model.isRedacting) { - Label("Pen", systemImage: "pencil.tip").tag(false) - Label("Redact", systemImage: "rectangle.fill").tag(true) - } - .pickerStyle(.segmented) - .fixedSize() - .help("Pen draws strokes; Redact covers text with a black bar") + Label(model.tool == .redact ? "Redact" : "Draw", systemImage: model.tool == .redact ? "rectangle.fill" : "pencil.tip") + .font(.caption) + .foregroundStyle(.secondary) Divider().frame(height: 18) - if !model.isRedacting { + if model.tool == .draw { HStack(spacing: 6) { Image(systemName: "circle.fill").font(.system(size: 4)) Image(systemName: "circle.fill").font(.system(size: 7)) @@ -405,6 +415,10 @@ struct ResultView: View { .labelsHidden() .frame(width: 28) .help("Drawing color") + } else { + Label("Drag to cover sensitive text", systemImage: "eye.slash") + .font(.caption) + .foregroundStyle(.secondary) } Spacer() @@ -582,7 +596,7 @@ struct ScreenshotPane: View { .allowsHitTesting(false) } - if !model.isDrawing, let sel = model.selection { + if model.tool == .select, let sel = model.selection { let viewRect = CGRect( x: origin.x + sel.minX * scale, y: origin.y + sel.minY * scale, @@ -601,7 +615,7 @@ struct ScreenshotPane: View { .gesture( DragGesture(minimumDistance: 0) .onChanged { value in - if model.isDrawing { + if model.tool != .select { let p = toImagePoint(value.location, scale: scale, origin: origin) let imgRect = CGRect(origin: .zero, size: imageSize) if imgRect.contains(p) { @@ -620,7 +634,7 @@ struct ScreenshotPane: View { } } .onEnded { _ in - if model.isDrawing { + if model.tool != .select { if currentStroke.count > 1 { model.addStroke(currentStroke) } diff --git a/build-app.sh b/build-app.sh index fcb3585..e76d97d 100755 --- a/build-app.sh +++ b/build-app.sh @@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' CFBundleIconFile Icon CFBundleShortVersionString - 1.5.1 + 1.6.0 CFBundleVersion - 51 + 60 LSUIElement NSHighResolutionCapable