diff --git a/Sources/Kapture/ResultWindow.swift b/Sources/Kapture/ResultWindow.swift index e24e4bf..08f1ffc 100644 --- a/Sources/Kapture/ResultWindow.swift +++ b/Sources/Kapture/ResultWindow.swift @@ -182,6 +182,12 @@ final class CaptureModel: ObservableObject { if tool == .crop { tool = .select } } + /// Reset the crop handles to the current image's region — undoes any + /// in-progress adjustment, including padding. + func resetCrop() { + cropRect = CGRect(origin: cropOffset, size: CGSize(width: image.width, height: image.height)) + } + /// Apply the pending crop rect and leave crop mode. func applyCrop() { guard let rect = cropRect else { return } @@ -293,17 +299,26 @@ final class CaptureModel: ObservableObject { } /// Crop to `rect` in the DISPLAYED image's pixel space (the original - /// image once a crop exists). Strokes are translated into the new - /// coordinate space; strokes fully outside the crop are dropped. The - /// previous state is pushed onto the crop undo stack. + /// image once a crop exists). The rect may extend past the image: the + /// outside becomes transparent padding. Strokes are translated into the + /// new coordinate space; strokes fully outside the crop are dropped. + /// The previous state is pushed onto the crop undo stack. func crop(to rectInDisplayedSpace: CGRect) { let source = originalImage ?? image - let bounds = CGRect(origin: .zero, size: CGSize(width: source.width, height: source.height)) - let rect = rectInDisplayedSpace.integral.intersection(bounds) + let sourceBounds = CGRect(origin: .zero, size: CGSize(width: source.width, height: source.height)) + let rect = rectInDisplayedSpace.integral // No-op when the crop matches what's already visible. let currentVisible = CGRect(origin: cropOffset, size: CGSize(width: image.width, height: image.height)) - guard rect.width >= 2, rect.height >= 2, rect != currentVisible, - let cropped = source.cropping(to: rect) else { return } + guard rect.width >= 2, rect.height >= 2, rect != currentVisible else { return } + + let cropped: CGImage + if sourceBounds.contains(rect) { + guard let cut = source.cropping(to: rect) else { return } + cropped = cut + } else { + guard let padded = Self.paddedCrop(source: source, rect: rect) else { return } + cropped = padded + } cropHistory.append((image: image, strokes: strokes, redoStack: redoStack, offset: cropOffset)) image = cropped if originalImage == nil { originalImage = source } @@ -330,6 +345,33 @@ final class CaptureModel: ObservableObject { statusMessage = "Cropped to \(Int(rect.width))×\(Int(rect.height))." } + /// Crop with padding: a transparent bitmap the size of the crop rect, + /// with the source image drawn at its offset — parts of the rect beyond + /// the source stay blank. + static func paddedCrop(source: CGImage, rect: CGRect) -> CGImage? { + let w = Int(rect.width), h = Int(rect.height) + guard w > 0, h > 0 else { return nil } + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: w, pixelsHigh: h, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 + ) else { return nil } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + // This context is y-up and a CGImage's top edge lands on the + // destination rect's maxY. The source's top-left maps to the + // output's (-rect.minX, -rect.minY) in top-left space. + let ctx = NSGraphicsContext.current!.cgContext + ctx.draw(source, in: NSRect( + x: -rect.minX, + y: rect.height + rect.minY - CGFloat(source.height), + width: CGFloat(source.width), + height: CGFloat(source.height) + )) + NSGraphicsContext.restoreGraphicsState() + return rep.cgImage + } + func runOCR(over selectionOnly: CGRect? = nil) { guard !isOCRRunning else { return } isOCRRunning = true @@ -711,7 +753,7 @@ struct ResultView: View { .font(.caption) .foregroundStyle(.secondary) } else { - Label("Drag the corners or edges to adjust the crop", systemImage: "hand.draw") + Label("Drag the corners or edges — past the edge for blank space", systemImage: "hand.draw") .font(.caption) .foregroundStyle(.secondary) } @@ -719,6 +761,11 @@ struct ResultView: View { Spacer() if model.tool == .crop { + CapCapsuleButton( + title: "Reset", icon: "arrow.counterclockwise", + help: "Reset the crop to the current image" + ) { model.resetCrop() } + CapCapsuleButton( title: "Cancel", icon: "xmark", help: "Cancel cropping (Esc)", @@ -914,9 +961,25 @@ struct ScreenshotPane: View { } } + /// Snap crop edges to the image borders when they get within + /// `threshold` pixels (view-scale adjusted by the caller). + private static func snapped(_ rect: CGRect, to bounds: CGRect, threshold: CGFloat) -> CGRect { + var r = rect + if abs(r.minX - bounds.minX) < threshold { r.origin.x = bounds.minX } + if abs(r.minY - bounds.minY) < threshold { r.origin.y = bounds.minY } + if abs(r.maxX - bounds.maxX) < threshold { r.origin.x = bounds.maxX - r.width } + if abs(r.maxY - bounds.maxY) < threshold { r.origin.y = bounds.maxY - r.height } + return r + } + private func updateCropDrag(_ value: DragGesture.Value, scale: CGFloat, origin: CGPoint) { guard let current = model.cropRect else { return } let bounds = CGRect(origin: .zero, size: imageSize) + // The crop may extend past the image (padding), capped at the + // image's largest side per edge so a runaway drag can't build a + // gigantic bitmap. + let cap = max(bounds.width, bounds.height) + let limit = bounds.insetBy(dx: -cap, dy: -cap) if cropDrag == nil { let cropView = CGRect( x: origin.x + current.minX * scale, y: origin.y + current.minY * scale, @@ -929,14 +992,15 @@ struct ScreenshotPane: View { // Started outside the crop rect: draw a fresh one anchored // where the drag began (handled by the .se anchor math). let anchor = toImagePoint(value.startLocation, scale: scale, origin: origin) - guard bounds.contains(anchor) else { return } + guard limit.contains(anchor) else { return } cropDrag = .se cropStartRect = CGRect(origin: anchor, size: .zero) } } guard let drag = cropDrag else { return } let delta = CGSize(width: value.translation.width / scale, height: value.translation.height / scale) - let newRect = Self.resizedCropRect(from: cropStartRect, drag: drag, delta: delta, bounds: bounds) + var newRect = Self.resizedCropRect(from: cropStartRect, drag: drag, delta: delta, bounds: limit) + newRect = Self.snapped(newRect, to: bounds, threshold: 10 / max(scale, 0.01)) guard newRect.width >= 1, newRect.height >= 1 else { return } model.cropRect = newRect } @@ -988,12 +1052,26 @@ struct ScreenshotPane: View { let strokeOffset = strokeDisplayOffset return ZStack { - // Card behind the image - RoundedRectangle(cornerRadius: 10) - .fill(CapStyle.card) - .shadow(color: Color.black.opacity(0.08), radius: 8, y: 2) - .frame(width: drawSize.width, height: drawSize.height) - .position(x: cardRect.midX, y: cardRect.midY) + // Backing: normally a rounded card behind the image; in crop + // mode a flat canvas covering the image AND any padding the + // crop rect extends into. + if model.tool == .crop, let crop = model.cropRect { + let cropView = CGRect( + x: origin.x + crop.minX * scale, y: origin.y + crop.minY * scale, + width: crop.width * scale, height: crop.height * scale + ) + let backing = cardRect.union(cropView) + Rectangle() + .fill(CapStyle.card) + .frame(width: backing.width, height: backing.height) + .position(x: backing.midX, y: backing.midY) + } else { + RoundedRectangle(cornerRadius: 10) + .fill(CapStyle.card) + .shadow(color: Color.black.opacity(0.08), radius: 8, y: 2) + .frame(width: drawSize.width, height: drawSize.height) + .position(x: cardRect.midX, y: cardRect.midY) + } Image(nsImage: NSImage(cgImage: displayedImage, size: .zero)) .resizable() @@ -1047,12 +1125,15 @@ struct ScreenshotPane: View { .allowsHitTesting(false) } - // Card hairline border - RoundedRectangle(cornerRadius: 10) - .strokeBorder(CapStyle.border, lineWidth: 1) - .frame(width: drawSize.width, height: drawSize.height) - .position(x: cardRect.midX, y: cardRect.midY) - .allowsHitTesting(false) + // Card hairline border (hidden in crop mode: the crop rect + // frames the canvas, and the border would cut across padding) + if model.tool != .crop { + RoundedRectangle(cornerRadius: 10) + .strokeBorder(CapStyle.border, lineWidth: 1) + .frame(width: drawSize.width, height: drawSize.height) + .position(x: cardRect.midX, y: cardRect.midY) + .allowsHitTesting(false) + } if model.tool == .crop, let crop = model.cropRect { let cropView = CGRect( @@ -1100,7 +1181,8 @@ struct ScreenshotPane: View { context.stroke(dot, with: .color(.black.opacity(0.4)), lineWidth: 1) } } - .clipShape(cardClip) + // Not clipped to the card: the crop rect can extend past + // the image into padding, and its handles must show. .allowsHitTesting(false) } diff --git a/build-app.sh b/build-app.sh index 052d0ca..787dc51 100755 --- a/build-app.sh +++ b/build-app.sh @@ -111,9 +111,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' CFBundleIconFile Icon CFBundleShortVersionString - 1.12.0 + 1.13.0 CFBundleVersion - 120 + 130 LSUIElement NSHighResolutionCapable