From 16b876807d946fbaea022959080d894041661935 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Sun, 23 Aug 2026 13:21:22 +0800 Subject: [PATCH] Add freehand drawing and fix residual capture pixelation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screenshot window now supports toggling a pen to draw annotations that are flattened into copied and saved images. Captures are also cropped with a pixel-exact CGImage.cropping instead of a CGContext redraw, removing residual blur on retina displays. 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-flash-0731 --- Sources/Kapture/CaptureController.swift | 14 +-- Sources/Kapture/ResultWindow.swift | 139 +++++++++++++++++++++--- build-app.sh | 4 +- 3 files changed, 132 insertions(+), 25 deletions(-) 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