Three bugs made the toggle appear dead: the result window never activated the LSUIElement app, so the first click was consumed; the hosting controller's Auto Layout fitting size shrank the window when the layout branch changed; and SwiftUI's displayScale environment does not reliably track the window's display on macOS. The window now activates before ordering front, the hosting view is attached directly as the contentView (no fitting-size constraints), and the actual-size scale comes from the window's backingScaleFactor (updated when it moves between displays). Generated with OpenCode Assisted-by: Kimi:k3
1159 lines
49 KiB
Swift
1159 lines
49 KiB
Swift
import SwiftUI
|
||
import AppKit
|
||
import UniformTypeIdentifiers
|
||
|
||
final class ResultWindow {
|
||
static let shared = ResultWindow()
|
||
|
||
private var windows: [NSWindow] = []
|
||
/// Kept alive: the hosting controller drives the SwiftUI view we attach
|
||
/// directly as the window's contentView. Using contentViewController
|
||
/// would install Auto Layout constraints that force the window to the
|
||
/// view's fitting size — it shrank whenever the layout branch changed
|
||
/// (e.g. toggling Actual Size).
|
||
private var hosts: [ObjectIdentifier: NSHostingController<ResultView>] = [:]
|
||
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()
|
||
let host = NSHostingController(rootView: ResultView(model: model) {
|
||
w.close()
|
||
})
|
||
w.contentView = host.view
|
||
hosts[ObjectIdentifier(w)] = host
|
||
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)
|
||
// Activate so the FIRST click on any control (Actual Size, Copy, the
|
||
// mode picker…) reaches it. Without this, an inactive LSUIElement
|
||
// app's windows swallow the initial click just to activate.
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
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.hosts[ObjectIdentifier(window)] = nil
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// Reports the hosting window's backing scale factor. Used by the
|
||
/// actual-size view so one image pixel maps to exactly one device pixel.
|
||
/// SwiftUI's `displayScale` environment is unreliable on macOS and does not
|
||
/// follow the window when it moves between displays of different densities.
|
||
private struct WindowScaleReader: NSViewRepresentable {
|
||
let onChange: (CGFloat) -> Void
|
||
|
||
func makeNSView(context: Context) -> ScaleProbeView {
|
||
let view = ScaleProbeView()
|
||
view.onChange = onChange
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: ScaleProbeView, context: Context) {
|
||
nsView.onChange = onChange
|
||
}
|
||
|
||
final class ScaleProbeView: NSView {
|
||
var onChange: ((CGFloat) -> Void)?
|
||
private var observers: [NSObjectProtocol] = []
|
||
private var lastScale: CGFloat = 0
|
||
|
||
override func viewDidMoveToWindow() {
|
||
super.viewDidMoveToWindow()
|
||
observers.forEach(NotificationCenter.default.removeObserver)
|
||
observers = []
|
||
report()
|
||
guard let window else { return }
|
||
for name in [NSWindow.didChangeBackingPropertiesNotification,
|
||
NSWindow.didChangeScreenNotification] {
|
||
observers.append(NotificationCenter.default.addObserver(
|
||
forName: name, object: window, queue: .main
|
||
) { [weak self] _ in
|
||
self?.report()
|
||
})
|
||
}
|
||
}
|
||
|
||
private func report() {
|
||
let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2
|
||
guard scale != lastScale else { return }
|
||
lastScale = scale
|
||
// Defer: this runs during layout, and the callback writes @State.
|
||
DispatchQueue.main.async { [weak self] in
|
||
self?.onChange?(scale)
|
||
}
|
||
}
|
||
|
||
deinit {
|
||
observers.forEach(NotificationCenter.default.removeObserver)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 — 1:1 pixels, scroll to pan." : "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
|
||
}
|
||
|
||
@State private var windowScale: CGFloat = 2
|
||
|
||
/// Image pixels to window points. Actual size is 1 image pixel per
|
||
/// device pixel, i.e. 1/windowScale points.
|
||
private var actualSizeScale: CGFloat {
|
||
1 / max(windowScale, 0.01)
|
||
}
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
if model.isActualSize {
|
||
// Actual size: 1 image pixel per screen pixel (view scale =
|
||
// 1/windowScale). Scrollable when larger than the viewport;
|
||
// centred when smaller.
|
||
let scale = actualSizeScale
|
||
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)
|
||
}
|
||
}
|
||
.background(WindowScaleReader { scale in
|
||
windowScale = scale
|
||
})
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|