import SwiftUI import AppKit import UniformTypeIdentifiers final class ResultWindow { static let shared = ResultWindow() private var windows: [NSWindow] = [] private var nextOrigin = NSPoint(x: 60, y: 60) private var openCount = 0 @MainActor func show(image: CGImage) { let model = CaptureModel(image: image) let w = NSWindow() w.contentViewController = NSHostingController(rootView: ResultView(model: model) { w.close() }) w.title = "Kapture — Screenshot" + (openCount > 0 ? " \(openCount + 1)" : "") w.level = .floating w.styleMask = [.titled, .closable, .resizable] w.setContentSize(NSSize(width: 694, height: 520)) w.isReleasedWhenClosed = false w.setFrameOrigin(nextOrigin) w.makeKeyAndOrderFront(nil) windows.append(w) openCount += 1 nextOrigin = NSPoint(x: nextOrigin.x + 28, y: nextOrigin.y - 28) windowCleanup(for: w) } private func windowCleanup(for window: NSWindow) { NotificationCenter.default.addObserver( forName: NSWindow.willCloseNotification, object: window, queue: .main ) { [weak self] _ in Task { @MainActor in guard let self else { return } self.windows.removeAll { $0 === window } self.openCount = max(0, self.openCount - 1) } } } @MainActor func showOpenPanel() { let panel = NSOpenPanel() panel.allowedContentTypes = [.image] panel.allowsMultipleSelection = false guard panel.runModal() == .OK, let url = panel.url else { return } guard let image = NSImage(contentsOf: url), let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { self.show(image: Self.failureImage("Could not load the selected image.")) return } show(image: cgImage) } static func failureImage(_ message: String) -> CGImage { let size = NSSize(width: 800, height: 200) 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 NSImage(size: size).cgImage(forProposedRect: nil, context: nil, hints: nil)! } NSGraphicsContext.saveGraphicsState() NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) NSColor.white.setFill() NSRect(origin: .zero, size: size).fill() let attrs: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: 30), .foregroundColor: NSColor.systemRed, ] message.draw(at: NSPoint(x: 40, y: 80), withAttributes: attrs) NSGraphicsContext.restoreGraphicsState() return rep.cgImage! } } @MainActor final class CaptureModel: ObservableObject { @Published var image: CGImage @Published var mode: CaptureMode = .screenshot @Published var extractedText: String? @Published var isOCRRunning = false @Published var statusMessage: String? /// Drag selection rect in IMAGE point space (nil = none) @Published var selection: CGRect? /// Crop rect while the crop tool is active, in the DISPLAYED image's /// pixel space (the original image once a crop exists). @Published var cropRect: CGRect? /// The uncropped original image — set on the first crop so crop mode can /// show it and the crop can be adjusted back outward (like Photos). @Published private(set) var originalImage: CGImage? /// Origin of the current image within the original image (pixels). @Published private(set) var cropOffset: CGPoint = .zero /// Freehand drawing strokes in IMAGE point space @Published var strokes: [Stroke] = [] /// Redo stack of removed strokes @Published var redoStack: [Stroke] = [] /// Undo stack of pre-crop states (image + annotations + offset) @Published private(set) var cropHistory: [(image: CGImage, strokes: [Stroke], redoStack: [Stroke], offset: CGPoint)] = [] /// Whether the pen is active (drag draws instead of selecting) @Published var tool: Tool = .select /// Actual-size view: 1 image pixel per screen pixel, scrollable. @Published var isActualSize = false /// 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 case crop } struct Stroke { let points: [CGPoint] let width: CGFloat let color: NSColor var isRedaction: Bool = false } enum CaptureMode { case screenshot case text } init(image: CGImage) { self.image = image } var hasSelection: Bool { selection != nil && selection!.width >= 2 && selection!.height >= 2 } var canUndo: Bool { !strokes.isEmpty } var canRedo: Bool { !redoStack.isEmpty } var canUndoCrop: Bool { !cropHistory.isEmpty } /// The image shown in crop mode: the original once a crop exists, so the /// handles sit on the cropped region and can be dragged back outward. var cropModeImage: CGImage { originalImage ?? image } /// Enter crop mode: start from the current selection or the current /// crop region, mapped into the displayed image's space. func beginCropMode() { if hasSelection { // The selection is in current-image space; map it into the // displayed (original) space. cropRect = selection!.offsetBy(dx: cropOffset.x, dy: cropOffset.y) selection = nil } else { cropRect = originalImage != nil ? CGRect(origin: cropOffset, size: CGSize(width: image.width, height: image.height)) : CGRect(origin: .zero, size: CGSize(width: image.width, height: image.height)) } } func cancelCrop() { cropRect = nil if tool == .crop { tool = .select } } /// Apply the pending crop rect and leave crop mode. func applyCrop() { guard let rect = cropRect else { return } cropRect = nil tool = .select crop(to: rect) } /// Restore the image and annotations from before the most recent crop. func undoCrop() { guard let snapshot = cropHistory.popLast() else { return } image = snapshot.image strokes = snapshot.strokes redoStack = snapshot.redoStack cropOffset = snapshot.offset selection = nil if tool == .crop { cropRect = CGRect(origin: cropOffset, size: CGSize(width: image.width, height: image.height)) } statusMessage = "Crop undone." } func undoStroke() { guard let last = strokes.popLast() else { return } redoStack.append(last) } func redoStroke() { guard let last = redoStack.popLast() else { return } strokes.append(last) } func clearAnnotations() { strokes = [] redoStack = [] } /// 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)) // Strokes are stored in TOP-LEFT image coordinates (from the SwiftUI // canvas), but the CGContext uses a BOTTOM-LEFT origin. Flip Y so the // strokes land where they were drawn. ctx.saveGState() ctx.translateBy(x: 0, y: size.height) ctx.scaleBy(x: 1, y: -1) ctx.setLineCap(.round) ctx.setLineJoin(.round) for stroke in strokes { guard stroke.points.count > 1 else { continue } if stroke.isRedaction { // Redaction bar: filled black rect covering the stroke bounds, // padded so it fully covers text. let xs = stroke.points.map { $0.x } let ys = stroke.points.map { $0.y } let bar = NSRect( x: xs.min()! - 2, y: ys.min()! - 2, width: (xs.max()! - xs.min()!) + 4, height: max(stroke.width, ys.max()! - ys.min()!) + 4 ) NSColor.black.setFill() NSBezierPath(rect: bar).fill() } else { stroke.color.setStroke() ctx.setLineWidth(stroke.width) ctx.beginPath() ctx.move(to: stroke.points[0]) for point in stroke.points.dropFirst() { ctx.addLine(to: point) } ctx.strokePath() } } ctx.restoreGState() NSGraphicsContext.restoreGraphicsState() return rep.cgImage ?? image } /// `widthPx` is the stroke thickness in IMAGE PIXELS (the preview /// converts the slider value so exports match what was drawn). func addStroke(_ points: [CGPoint], widthPx: CGFloat) { guard points.count > 1 else { return } if tool == .redact { // Redaction: a filled black bar from start to end. strokes.append(Stroke(points: points, width: max(widthPx, 12), color: .black, isRedaction: true)) } else { strokes.append(Stroke(points: points, width: widthPx, color: drawColor)) } redoStack = [] } var displayText: String { if isOCRRunning { return "Recognizing text…" } return extractedText ?? "Select text in the screenshot, then click “Get Text”." } /// 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. 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) // 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 } cropHistory.append((image: image, strokes: strokes, redoStack: redoStack, offset: cropOffset)) image = cropped if originalImage == nil { originalImage = source } // Strokes live in current-image space: a point p maps into the // original as p + old offset, then into the new crop as -rect.origin. let dx = cropOffset.x - rect.minX let dy = cropOffset.y - rect.minY cropOffset = rect.origin let newBounds = CGRect(origin: .zero, size: rect.size) func translated(_ stroke: Stroke) -> Stroke? { let points = stroke.points.map { CGPoint(x: $0.x + dx, y: $0.y + dy) } let xs = points.map { $0.x } let ys = points.map { $0.y } let strokeBounds = CGRect( x: xs.min()!, y: ys.min()!, width: xs.max()! - xs.min()!, height: ys.max()! - ys.min()! ).insetBy(dx: -stroke.width, dy: -stroke.width) guard strokeBounds.intersects(newBounds) else { return nil } return Stroke(points: points, width: stroke.width, color: stroke.color, isRedaction: stroke.isRedaction) } strokes = strokes.compactMap(translated) redoStack = redoStack.compactMap(translated) selection = nil statusMessage = "Cropped to \(Int(rect.width))×\(Int(rect.height))." } func runOCR(over selectionOnly: CGRect? = nil) { guard !isOCRRunning else { return } isOCRRunning = true statusMessage = nil let targetImage: CGImage if let sel = selectionOnly { guard let cropped = image.cropping(to: sel) else { self.isOCRRunning = false self.statusMessage = "Selection too small." return } targetImage = cropped } else { targetImage = image } OCRService.shared.recognize(targetImage) { [weak self] text in Task { @MainActor in guard let self else { return } self.isOCRRunning = false self.mode = .text if text.isEmpty { self.extractedText = "No text found in the selection." } else { self.extractedText = text let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(text, forType: .string) self.statusMessage = "Copied to clipboard." } } } } func runOCRSelection() { guard let sel = selection, hasSelection else { return } runOCR(over: sel) } func copyText() { guard let text = extractedText else { return } let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(text, forType: .string) statusMessage = "Copied to clipboard." } func copyImage() { let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.writeObjects([NSImage(cgImage: annotatedImage, size: .zero)]) statusMessage = "Image copied to clipboard." } func savePNG() { let panel = NSSavePanel() panel.allowedContentTypes = [.png] panel.nameFieldStringValue = "Kapture-\(Self.timestamp()).png" guard panel.runModal() == .OK, let url = panel.url else { return } guard let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else { statusMessage = "Could not create PNG file." return } CGImageDestinationAddImage(dest, annotatedImage, nil) if CGImageDestinationFinalize(dest) { statusMessage = "Saved PNG." } else { statusMessage = "Could not save PNG." } } private static func timestamp() -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd-HHmmss" return formatter.string(from: Date()) } } // MARK: - Cap-style design tokens /// Visual language modeled after the Cap screen recorder (cap.so): /// white card surfaces on a light canvas, capsule controls, circular /// icon buttons, and Cap's brand blue for primary actions. private enum CapStyle { /// Cap's brand blue (#538EFF). static let blue = Color(red: 83 / 255, green: 142 / 255, blue: 1) private static func adaptive(_ light: NSColor, _ dark: NSColor) -> Color { Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light })) } /// Pill/track fill (segmented control track, capsule buttons). static let track = adaptive(NSColor(white: 0.93, alpha: 1), NSColor(white: 1, alpha: 0.1)) /// Card/content surfaces. static let card = adaptive(.white, NSColor(white: 0.17, alpha: 1)) /// Canvas behind the content card. static let canvas = adaptive(NSColor(white: 0.96, alpha: 1), NSColor(white: 0.12, alpha: 1)) /// Hairline borders. static let border = adaptive(NSColor(white: 0, alpha: 0.08), NSColor(white: 1, alpha: 0.12)) /// Soft blue highlight for active tools (Cap's "On" pill). static let blueTint = adaptive( NSColor(red: 218 / 255, green: 233 / 255, blue: 1, alpha: 1), NSColor(red: 83 / 255, green: 142 / 255, blue: 1, alpha: 0.28) ) } private struct CapPressableStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .opacity(configuration.isPressed ? 0.7 : 1) .scaleEffect(configuration.isPressed ? 0.96 : 1) } } /// Sliding-pill segmented control (Cap's Screen/Window switcher). private struct CapModePicker: View { @Binding var mode: CaptureModel.CaptureMode @Namespace private var selection var body: some View { HStack(spacing: 2) { item(.screenshot, title: "Screenshot") item(.text, title: "Text") } .padding(2.5) .background(Capsule().fill(CapStyle.track)) } private func item(_ value: CaptureModel.CaptureMode, title: String) -> some View { let isSelected = mode == value return Button { withAnimation(.spring(response: 0.28, dampingFraction: 0.85)) { mode = value } } label: { Text(title) .font(.system(size: 12, weight: .medium)) .foregroundStyle(isSelected ? Color.primary : Color.secondary) .padding(.horizontal, 13) .padding(.vertical, 4.5) .background { if isSelected { Capsule() .fill(CapStyle.card) .shadow(color: Color.black.opacity(0.14), radius: 1.5, y: 1) .matchedGeometryEffect(id: "mode", in: selection) } } .contentShape(Capsule()) } .buttonStyle(CapPressableStyle()) } } /// Capsule button; primary renders in Cap blue. private struct CapCapsuleButton: View { let title: String let icon: String var primary = false var disabled = false var help: String var shortcut: KeyboardShortcut? = nil let action: () -> Void var body: some View { Button(action: action) { Label(title, systemImage: icon) .font(.system(size: 12, weight: .medium)) .foregroundStyle(primary ? Color.white : Color.primary.opacity(0.85)) .padding(.horizontal, 11) .padding(.vertical, 5.5) .background(Capsule().fill(primary ? CapStyle.blue : CapStyle.track)) .contentShape(Capsule()) } .buttonStyle(CapPressableStyle()) .keyboardShortcut(shortcut) .disabled(disabled) .opacity(disabled ? 0.5 : 1) .help(help) } } /// Circular icon button with hover fill and a blue-tinted active state. private struct CapIconButton: View { let icon: String var active = false var disabled = false var help: String var shortcut: KeyboardShortcut? = nil let action: () -> Void @State private var hovering = false var body: some View { Button(action: action) { Image(systemName: icon) .font(.system(size: 13, weight: .medium)) .foregroundStyle(active ? CapStyle.blue : Color.secondary) .frame(width: 29, height: 29) .background(Circle().fill(active ? CapStyle.blueTint : (hovering ? CapStyle.track : Color.clear))) .contentShape(Circle()) } .buttonStyle(CapPressableStyle()) .keyboardShortcut(shortcut) .onHover { hovering = $0 } .animation(.easeOut(duration: 0.12), value: hovering) .disabled(disabled) .opacity(disabled ? 0.4 : 1) .help(help) } } // MARK: - Result window struct ResultView: View { @ObservedObject var model: CaptureModel let onClose: () -> Void var body: some View { VStack(spacing: 0) { // Toolbar HStack(spacing: 6) { CapModePicker(mode: $model.mode) CapIconButton( icon: "1.magnifyingglass", active: model.isActualSize, help: "Actual size — 1 image pixel per screen pixel (⌘0)", shortcut: KeyboardShortcut("0", modifiers: .command) ) { model.isActualSize.toggle() } if model.canUndoCrop { CapIconButton( icon: "arrow.uturn.backward", help: "Undo the last crop" ) { model.undoCrop() } } Spacer() if model.hasSelection { CapCapsuleButton( title: "OCR Selection", icon: "text.magnifyingglass", primary: true, disabled: model.isOCRRunning, help: "Recognize text inside the selected region" ) { model.runOCRSelection() } } CapCapsuleButton( title: "Get All Text", icon: "text.viewfinder", primary: !model.hasSelection, disabled: model.isOCRRunning, help: "Recognize all text in the screenshot" ) { model.runOCR() } CapIconButton( icon: model.tool == .draw ? "pencil.tip.crop.circle.fill" : "pencil.tip.crop.circle", active: model.tool == .draw, help: "Draw on the screenshot" ) { model.tool = model.tool == .draw ? .select : .draw if model.tool != .select { model.selection = nil } model.cropRect = nil } CapIconButton( icon: model.tool == .redact ? "rectangle.fill.on.rectangle.fill" : "rectangle.dashed", active: model.tool == .redact, help: "Cover sensitive areas with black bars" ) { model.tool = model.tool == .redact ? .select : .redact if model.tool != .select { model.selection = nil } model.cropRect = nil } CapIconButton( icon: "crop", active: model.tool == .crop, help: "Crop the image" ) { if model.tool == .crop { model.cancelCrop() } else { model.tool = .crop model.beginCropMode() } } CapIconButton(icon: "doc.on.doc", help: "Copy image to clipboard") { model.copyImage() } CapIconButton(icon: "square.and.arrow.down", help: "Save screenshot as PNG") { model.savePNG() } CapIconButton(icon: "xmark", help: "Close") { onClose() } } .padding(.horizontal, 12) .padding(.vertical, 9) // Tool options (only when a draw tool is active) if model.tool != .select { HStack(spacing: 14) { Label( model.tool == .redact ? "Redact" : model.tool == .crop ? "Crop" : "Draw", systemImage: model.tool == .redact ? "rectangle.fill" : model.tool == .crop ? "crop" : "pencil.tip" ) .font(.caption) .foregroundStyle(.secondary) if model.tool == .draw { HStack(spacing: 6) { Image(systemName: "circle.fill").font(.system(size: 4)) Image(systemName: "circle.fill").font(.system(size: 7)) Image(systemName: "circle.fill").font(.system(size: 10)) } .foregroundStyle(.secondary) Slider(value: $model.strokeWidth, in: 1...12, step: 0.5) .frame(width: 120) .tint(CapStyle.blue) .help("Stroke thickness") ColorPicker("", selection: Binding( get: { Color(nsColor: model.drawColor) }, set: { model.drawColor = NSColor($0) } ), supportsOpacity: false) .labelsHidden() .frame(width: 28) .help("Drawing color") } else if model.tool == .redact { Label("Drag to cover sensitive text", systemImage: "eye.slash") .font(.caption) .foregroundStyle(.secondary) } else { Label("Drag the corners or edges to adjust the crop", systemImage: "hand.draw") .font(.caption) .foregroundStyle(.secondary) } Spacer() if model.tool == .crop { CapCapsuleButton( title: "Cancel", icon: "xmark", help: "Cancel cropping (Esc)", shortcut: .cancelAction ) { model.cancelCrop() } CapCapsuleButton( title: "Crop Image", icon: "checkmark", primary: true, help: "Apply the crop (Return)", shortcut: .defaultAction ) { model.applyCrop() } } else { CapIconButton( icon: "arrow.uturn.backward", disabled: !model.canUndo, help: "Undo last stroke (⌘Z)", shortcut: KeyboardShortcut("z", modifiers: .command) ) { model.undoStroke() } CapIconButton( icon: "arrow.uturn.forward", disabled: !model.canRedo, help: "Redo stroke (⌘⇧Z)", shortcut: KeyboardShortcut("z", modifiers: [.command, .shift]) ) { model.redoStroke() } CapIconButton( icon: "eraser", disabled: model.strokes.isEmpty, help: "Remove all drawn annotations" ) { model.clearAnnotations() } } } .padding(.horizontal, 12) .padding(.vertical, 6) } // Content if model.mode == .screenshot { ScreenshotPane(model: model) .padding(10) .background(CapStyle.canvas) } else { ScrollView { Text(model.displayText) .font(.system(.body, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(14) } .background(CapStyle.card, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(CapStyle.border, lineWidth: 1)) .padding(10) .background(CapStyle.canvas) } // Status bar HStack(spacing: 8) { if model.isOCRRunning { ProgressView() .controlSize(.small) } Text(model.statusMessage ?? (model.mode == .screenshot ? (model.tool == .crop ? "Adjust the crop — Return to apply, Esc to cancel." : (model.hasSelection ? "Region selected — OCR it, or adjust it with Crop." : (model.isActualSize ? "Actual size — scroll to pan, drag to select." : "Drag to select a region, or Get All Text."))) : "Selectable text")) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) Spacer() if model.mode == .text, model.extractedText != nil { CapCapsuleButton( title: "Copy Text", icon: "doc.on.doc", help: "Copy recognized text to the clipboard" ) { model.copyText() } } } .padding(.horizontal, 12) .padding(.vertical, 7) } .background(Color(nsColor: .windowBackgroundColor)) .frame(minWidth: 600, minHeight: 420) } } struct ScreenshotPane: View { @ObservedObject var model: CaptureModel /// In crop mode after a crop: the original image is displayed so the /// crop handles sit on the cropped region and can be dragged back out. private var displayedImage: CGImage { model.tool == .crop ? model.cropModeImage : model.image } /// Strokes are stored in current-image space; when the original is /// displayed in crop mode they shift by the cumulative crop offset. private var strokeDisplayOffset: CGPoint { model.tool == .crop ? model.cropOffset : .zero } private var imageSize: CGSize { CGSize(width: displayedImage.width, height: displayedImage.height) } private func geometry(for geo: GeometryProxy) -> (scale: CGFloat, drawSize: CGSize, origin: CGPoint) { let size = imageSize let scale = min(geo.size.width / size.width, geo.size.height / size.height) let drawSize = CGSize(width: size.width * scale, height: size.height * scale) let origin = CGPoint( x: (geo.size.width - drawSize.width) / 2, y: (geo.size.height - drawSize.height) / 2 ) return (scale, drawSize, origin) } private func toImagePoint(_ viewPoint: CGPoint, scale: CGFloat, origin: CGPoint) -> CGPoint { CGPoint( x: (viewPoint.x - origin.x) / scale, y: (viewPoint.y - origin.y) / scale ) } private func clampSelection(_ rect: CGRect) -> CGRect { rect.intersection(CGRect(origin: .zero, size: imageSize)) } @State private var currentStroke: [CGPoint] = [] @State private var cropDrag: CropDrag? @State private var cropStartRect = CGRect.zero /// Which part of the crop rect a drag is manipulating. private enum CropDrag { case move, n, s, e, w, ne, nw, se, sw } /// Hit-test the drag start against the crop handles (corners, edge /// midpoints), then the interior (move). Nil = outside the crop rect. private func cropHandle(at viewPoint: CGPoint, cropView: CGRect) -> CropDrag? { let points: [(CropDrag, CGPoint)] = [ (.nw, CGPoint(x: cropView.minX, y: cropView.minY)), (.n, CGPoint(x: cropView.midX, y: cropView.minY)), (.ne, CGPoint(x: cropView.maxX, y: cropView.minY)), (.e, CGPoint(x: cropView.maxX, y: cropView.midY)), (.se, CGPoint(x: cropView.maxX, y: cropView.maxY)), (.s, CGPoint(x: cropView.midX, y: cropView.maxY)), (.sw, CGPoint(x: cropView.minX, y: cropView.maxY)), (.w, CGPoint(x: cropView.minX, y: cropView.midY)), ] for (drag, point) in points where hypot(point.x - viewPoint.x, point.y - viewPoint.y) <= 16 { return drag } return cropView.contains(viewPoint) ? .move : nil } /// Compute the crop rect for a drag, clamped to the image and with a /// minimum size. The dragged edge/corner follows the pointer; the /// opposite side stays put. private static func resizedCropRect(from start: CGRect, drag: CropDrag, delta: CGSize, bounds: CGRect) -> CGRect { let minSize: CGFloat = 10 switch drag { case .move: var rect = start.offsetBy(dx: delta.width, dy: delta.height) rect.origin.x = min(max(rect.origin.x, bounds.minX), bounds.maxX - rect.width) rect.origin.y = min(max(rect.origin.y, bounds.minY), bounds.maxY - rect.height) return rect case .n, .s, .e, .w: var minX = start.minX, maxX = start.maxX var minY = start.minY, maxY = start.maxY if drag == .w { minX = min(max(start.minX + delta.width, bounds.minX), start.maxX - minSize) } if drag == .e { maxX = max(min(start.maxX + delta.width, bounds.maxX), start.minX + minSize) } if drag == .n { minY = min(max(start.minY + delta.height, bounds.minY), start.maxY - minSize) } if drag == .s { maxY = max(min(start.maxY + delta.height, bounds.maxY), start.minY + minSize) } return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) case .nw, .ne, .sw, .se: // Corner drag: the opposite corner is the anchor; normalize so // the anchor stays fixed whichever way the pointer goes. let anchor = CGPoint( x: (drag == .nw || drag == .sw) ? start.maxX : start.minX, y: (drag == .nw || drag == .ne) ? start.maxY : start.minY ) let moved = CGPoint( x: min(max(((drag == .nw || drag == .sw) ? start.minX : start.maxX) + delta.width, bounds.minX), bounds.maxX), y: min(max(((drag == .nw || drag == .ne) ? start.minY : start.maxY) + delta.height, bounds.minY), bounds.maxY) ) let rect = CGRect( x: min(anchor.x, moved.x), y: min(anchor.y, moved.y), width: abs(moved.x - anchor.x), height: abs(moved.y - anchor.y) ) return (rect.width >= minSize && rect.height >= minSize) ? rect : start } } private func updateCropDrag(_ value: DragGesture.Value, scale: CGFloat, origin: CGPoint) { guard let current = model.cropRect else { return } let bounds = CGRect(origin: .zero, size: imageSize) if cropDrag == nil { let cropView = CGRect( x: origin.x + current.minX * scale, y: origin.y + current.minY * scale, width: current.width * scale, height: current.height * scale ) if let handle = cropHandle(at: value.startLocation, cropView: cropView) { cropDrag = handle cropStartRect = current } else { // 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 } 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) guard newRect.width >= 1, newRect.height >= 1 else { return } model.cropRect = newRect } @Environment(\.displayScale) private var displayScale var body: some View { GeometryReader { geo in if model.isActualSize { // Actual size: 1 image pixel per screen pixel (view scale = // 1/displayScale). Scrollable when larger than the viewport; // centred when smaller. let scale = 1 / max(displayScale, 0.01) let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale) let origin = CGPoint( x: max(0, (geo.size.width - drawSize.width) / 2), y: max(0, (geo.size.height - drawSize.height) / 2) ) ScrollView([.horizontal, .vertical]) { imageContent(scale: scale, drawSize: drawSize, origin: origin) .frame( width: max(drawSize.width, geo.size.width), height: max(drawSize.height, geo.size.height) ) } } else { let (scale, drawSize, origin) = geometry(for: geo) imageContent(scale: scale, drawSize: drawSize, origin: origin) .frame(width: geo.size.width, height: geo.size.height) } } } /// The image card with annotations, selection and crop overlays, plus /// the drag gesture. Parameterized by scale/origin so it works both /// fit-to-window and at actual size inside the ScrollView. private func imageContent(scale: CGFloat, drawSize: CGSize, origin: CGPoint) -> some View { let cardRect = CGRect(origin: origin, size: drawSize) let cardClip = RoundedRectangle(cornerRadius: 10).path(in: cardRect) 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) Image(nsImage: NSImage(cgImage: displayedImage, size: .zero)) .resizable() .interpolation(scale < 1 ? .high : .none) .aspectRatio(contentMode: .fit) .frame(width: drawSize.width, height: drawSize.height) .clipShape(RoundedRectangle(cornerRadius: 10)) .position(x: cardRect.midX, y: cardRect.midY) // Completed strokes Canvas { context, _ in for stroke in model.strokes { guard stroke.points.count > 1 else { continue } if stroke.isRedaction { let xs = stroke.points.map { $0.x } let ys = stroke.points.map { $0.y } let bar = CGRect( x: origin.x + (xs.min()! + strokeOffset.x - 2) * scale, y: origin.y + (ys.min()! + strokeOffset.y - 2) * scale, width: ((xs.max()! - xs.min()!) + 4) * scale, height: (max(stroke.width, ys.max()! - ys.min()!) + 4) * scale ) context.fill(Path(bar), with: .color(.black)) } else { var path = Path() path.move(to: CGPoint(x: origin.x + (stroke.points[0].x + strokeOffset.x) * scale, y: origin.y + (stroke.points[0].y + strokeOffset.y) * scale)) for point in stroke.points.dropFirst() { path.addLine(to: CGPoint(x: origin.x + (point.x + strokeOffset.x) * scale, y: origin.y + (point.y + strokeOffset.y) * scale)) } // stroke.width is in image pixels; scale to view points context.stroke(path, with: .color(Color(nsColor: stroke.color)), lineWidth: stroke.width * scale) } } } .clipShape(cardClip) .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 + strokeOffset.x) * scale, y: origin.y + (currentStroke[0].y + strokeOffset.y) * scale)) for point in currentStroke.dropFirst() { path.addLine(to: CGPoint(x: origin.x + (point.x + strokeOffset.x) * scale, y: origin.y + (point.y + strokeOffset.y) * scale)) } } context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: model.strokeWidth) } .clipShape(cardClip) .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) 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 ) Canvas { context, size in // Dim everything outside the crop rect. var dim = Path() dim.addRect(CGRect(origin: .zero, size: size)) dim.addRect(cropView) context.fill(dim, with: .color(.black.opacity(0.45)), style: FillStyle(eoFill: true)) // Rule-of-thirds grid. var grid = Path() for i in 1..<3 { let x = cropView.minX + cropView.width * CGFloat(i) / 3 grid.move(to: CGPoint(x: x, y: cropView.minY)) grid.addLine(to: CGPoint(x: x, y: cropView.maxY)) let y = cropView.minY + cropView.height * CGFloat(i) / 3 grid.move(to: CGPoint(x: cropView.minX, y: y)) grid.addLine(to: CGPoint(x: cropView.maxX, y: y)) } context.stroke(grid, with: .color(.white.opacity(0.35)), lineWidth: 1) // Border. context.stroke(Path(cropView), with: .color(.white), lineWidth: 1.5) // Handles: white dots at corners and edge midpoints. let handles: [CGPoint] = [ CGPoint(x: cropView.minX, y: cropView.minY), CGPoint(x: cropView.midX, y: cropView.minY), CGPoint(x: cropView.maxX, y: cropView.minY), CGPoint(x: cropView.maxX, y: cropView.midY), CGPoint(x: cropView.maxX, y: cropView.maxY), CGPoint(x: cropView.midX, y: cropView.maxY), CGPoint(x: cropView.minX, y: cropView.maxY), CGPoint(x: cropView.minX, y: cropView.midY), ] for point in handles { let dot = Path(ellipseIn: CGRect(x: point.x - 4, y: point.y - 4, width: 8, height: 8)) context.fill(dot, with: .color(.white)) context.stroke(dot, with: .color(.black.opacity(0.4)), lineWidth: 1) } } .clipShape(cardClip) .allowsHitTesting(false) } if model.tool == .select, let sel = model.selection { let viewRect = CGRect( x: origin.x + sel.minX * scale, y: origin.y + sel.minY * scale, width: sel.width * scale, height: sel.height * scale ) Rectangle() .fill(CapStyle.blue.opacity(0.15)) .border(CapStyle.blue, width: 1.5) .frame(width: viewRect.width, height: viewRect.height) .position(x: viewRect.midX, y: viewRect.midY) .allowsHitTesting(false) } } .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in if model.tool == .crop { updateCropDrag(value, scale: scale, origin: origin) } else if model.tool != .select { 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 model.tool == .crop { cropDrag = nil } else if model.tool != .select { if currentStroke.count > 1 { model.addStroke(currentStroke, widthPx: model.strokeWidth / max(scale, 0.01)) } currentStroke = [] } else { if let s = model.selection, (s.width < 2 || s.height < 2) { model.selection = nil } } } ) } }