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