diff --git a/Sources/Kapture/CaptureController.swift b/Sources/Kapture/CaptureController.swift
index a12fffd..7b15ce1 100644
--- a/Sources/Kapture/CaptureController.swift
+++ b/Sources/Kapture/CaptureController.swift
@@ -134,15 +134,11 @@ final class ScreenCapture {
// display's frame (e.g. secondary monitor coordinate quirks).
let safeRect = pxRect.intersection(CGRect(x: 0, y: 0, width: CGFloat(image.width), height: CGFloat(image.height)))
guard safeRect.width >= 1, safeRect.height >= 1 else { return nil }
- guard let cropCtx = CGContext(
- data: nil, width: Int(safeRect.width), height: Int(safeRect.height),
- bitsPerComponent: 8, bytesPerRow: 0,
- space: CGColorSpaceCreateDeviceRGB(),
- bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
- ) else { return nil }
- cropCtx.interpolationQuality = .high
- cropCtx.draw(image, in: CGRect(x: -safeRect.minX, y: -safeRect.minY, width: CGFloat(image.width), height: CGFloat(image.height)))
- return cropCtx.makeImage()
+ // Use a pixel-exact subimage crop instead of redrawing through a
+ // 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)
}
@available(macOS 14.0, *)
diff --git a/Sources/Kapture/ResultWindow.swift b/Sources/Kapture/ResultWindow.swift
index 6d09073..cda74bd 100644
--- a/Sources/Kapture/ResultWindow.swift
+++ b/Sources/Kapture/ResultWindow.swift
@@ -90,6 +90,10 @@ final class CaptureModel: ObservableObject {
@Published var statusMessage: String?
/// Drag selection rect in IMAGE point space (nil = none)
@Published var selection: CGRect?
+ /// Freehand drawing strokes in IMAGE point space
+ @Published var strokes: [[CGPoint]] = []
+ /// Whether the pen is active (drag draws instead of selecting)
+ @Published var isDrawing = false
enum CaptureMode {
case screenshot
@@ -102,6 +106,46 @@ final class CaptureModel: ObservableObject {
var hasSelection: Bool { selection != nil && selection!.width >= 2 && selection!.height >= 2 }
+ /// Image with drawn strokes flattened in, for copy/save/export.
+ var annotatedImage: CGImage {
+ guard !strokes.isEmpty else { return image }
+ let size = NSSize(width: image.width, height: image.height)
+ guard let rep = NSBitmapImageRep(
+ bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height),
+ bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
+ colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
+ ) else { return image }
+ NSGraphicsContext.saveGraphicsState()
+ NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
+ let ctx = NSGraphicsContext.current!.cgContext
+ // CGImage draws with a flipped Y in NSGraphicsContext
+ ctx.draw(image, in: NSRect(origin: .zero, size: size))
+ NSColor.systemRed.setStroke()
+ ctx.setLineWidth(3)
+ ctx.setLineCap(.round)
+ ctx.setLineJoin(.round)
+ for stroke in strokes {
+ guard stroke.count > 1 else { continue }
+ ctx.beginPath()
+ ctx.move(to: stroke[0])
+ for point in stroke.dropFirst() {
+ ctx.addLine(to: point)
+ }
+ ctx.strokePath()
+ }
+ NSGraphicsContext.restoreGraphicsState()
+ return rep.cgImage ?? image
+ }
+
+ func addStroke(_ points: [CGPoint]) {
+ guard points.count > 1 else { return }
+ strokes.append(points)
+ }
+
+ func clearAnnotations() {
+ strokes = []
+ }
+
var displayText: String {
if isOCRRunning {
return "Recognizing text…"
@@ -164,7 +208,7 @@ final class CaptureModel: ObservableObject {
func copyImage() {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
- pasteboard.writeObjects([NSImage(cgImage: image, size: .zero)])
+ pasteboard.writeObjects([NSImage(cgImage: annotatedImage, size: .zero)])
statusMessage = "Image copied to clipboard."
}
@@ -177,7 +221,7 @@ final class CaptureModel: ObservableObject {
statusMessage = "Could not create PNG file."
return
}
- CGImageDestinationAddImage(dest, image, nil)
+ CGImageDestinationAddImage(dest, annotatedImage, nil)
if CGImageDestinationFinalize(dest) {
statusMessage = "Saved PNG."
} else {
@@ -231,6 +275,27 @@ struct ResultView: View {
Divider().frame(height: 18)
+ Button {
+ model.isDrawing.toggle()
+ if model.isDrawing { model.selection = nil }
+ } label: {
+ Label("Draw", systemImage: model.isDrawing ? "pencil.tip.crop.circle.fill" : "pencil.tip.crop.circle")
+ }
+ .buttonStyle(.bordered)
+ .foregroundStyle(model.isDrawing ? .primary : .secondary)
+ .help("Toggle drawing on the screenshot")
+
+ if !model.strokes.isEmpty {
+ Button {
+ model.clearAnnotations()
+ } label: {
+ Label("Clear", systemImage: "eraser")
+ }
+ .buttonStyle(.bordered)
+ .foregroundStyle(.secondary)
+ .help("Remove all drawn annotations")
+ }
+
Button {
model.copyImage()
} label: {
@@ -340,6 +405,8 @@ struct ScreenshotPane: View {
rect.intersection(CGRect(origin: .zero, size: imageSize))
}
+ @State private var currentStroke: [CGPoint] = []
+
var body: some View {
GeometryReader { geo in
let (scale, drawSize, origin) = geometry(for: geo)
@@ -352,7 +419,36 @@ struct ScreenshotPane: View {
.frame(width: drawSize.width, height: drawSize.height)
.position(x: origin.x + drawSize.width / 2, y: origin.y + drawSize.height / 2)
- if let sel = model.selection {
+ // Completed strokes
+ Canvas { context, _ in
+ var path = Path()
+ for stroke in model.strokes {
+ guard stroke.count > 1 else { continue }
+ path.move(to: CGPoint(x: origin.x + stroke[0].x * scale, y: origin.y + stroke[0].y * scale))
+ for point in stroke.dropFirst() {
+ path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale))
+ }
+ }
+ context.stroke(path, with: .color(.red), lineWidth: 2.5)
+ }
+ .allowsHitTesting(false)
+
+ // In-progress stroke
+ if !currentStroke.isEmpty {
+ Canvas { context, _ in
+ var path = Path()
+ if currentStroke.count > 1 {
+ path.move(to: CGPoint(x: origin.x + currentStroke[0].x * scale, y: origin.y + currentStroke[0].y * scale))
+ for point in currentStroke.dropFirst() {
+ path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale))
+ }
+ }
+ context.stroke(path, with: .color(.red), lineWidth: 2.5)
+ }
+ .allowsHitTesting(false)
+ }
+
+ if !model.isDrawing, let sel = model.selection {
let viewRect = CGRect(
x: origin.x + sel.minX * scale,
y: origin.y + sel.minY * scale,
@@ -371,19 +467,34 @@ struct ScreenshotPane: View {
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
- let start = toImagePoint(value.startLocation, scale: scale, origin: origin)
- let current = toImagePoint(value.location, scale: scale, origin: origin)
- let rect = CGRect(
- x: min(start.x, current.x),
- y: min(start.y, current.y),
- width: abs(current.x - start.x),
- height: abs(current.y - start.y)
- )
- model.selection = clampSelection(rect)
+ if model.isDrawing {
+ let p = toImagePoint(value.location, scale: scale, origin: origin)
+ let imgRect = CGRect(origin: .zero, size: imageSize)
+ if imgRect.contains(p) {
+ currentStroke.append(p)
+ }
+ } else {
+ let start = toImagePoint(value.startLocation, scale: scale, origin: origin)
+ let current = toImagePoint(value.location, scale: scale, origin: origin)
+ let rect = CGRect(
+ x: min(start.x, current.x),
+ y: min(start.y, current.y),
+ width: abs(current.x - start.x),
+ height: abs(current.y - start.y)
+ )
+ model.selection = clampSelection(rect)
+ }
}
.onEnded { _ in
- if let s = model.selection, (s.width < 2 || s.height < 2) {
- model.selection = nil
+ if model.isDrawing {
+ if currentStroke.count > 1 {
+ model.addStroke(currentStroke)
+ }
+ currentStroke = []
+ } else {
+ if let s = model.selection, (s.width < 2 || s.height < 2) {
+ model.selection = nil
+ }
}
}
)
diff --git a/build-app.sh b/build-app.sh
index 82f12ae..531be5d 100755
--- a/build-app.sh
+++ b/build-app.sh
@@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
CFBundleIconFile
Icon
CFBundleShortVersionString
- 1.2.1
+ 1.3.0
CFBundleVersion
- 21
+ 30
LSUIElement
NSHighResolutionCapable