Files
kapture/Sources/Kapture/ResultWindow.swift
T
TZGyn 6a2afef1d2 Add screen recording, crop, and a Cap-style result window
Recording: Ctrl+Shift+Cmd+R (or the menu) opens the same selection
flow — a marquee records that region, clicking a window records it
(following moves). Video is H.264 .mov at 60fps via SCStream ->
AVAssetWriter with the cursor included, auto-saved to ~/Movies and
revealed in Finder on stop. A red border and a floating timer/stop bar
show during recording, both excluded from the video. Audio is wired
behind a flag for later.

The result window gains a Crop button (crop to the selection;
annotations translate and stay editable) and is restyled after Cap:
sliding-pill Screenshot/Text switcher, capsule buttons in Cap blue,
circular icon buttons, and the content on a rounded card — light and
dark.

Generated with OpenCode

Assisted-by: Kimi:k3
2026-10-24 21:48:59 +08:00

779 lines
31 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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?
/// Freehand drawing strokes in IMAGE point space
@Published var strokes: [Stroke] = []
/// Redo stack of removed strokes
@Published var redoStack: [Stroke] = []
/// Whether the pen is active (drag draws instead of selecting)
@Published var tool: Tool = .select
/// 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
}
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 }
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 the image to `rectInImage` (image pixel space). Strokes are
/// translated into the new coordinate space; strokes fully outside the
/// crop are dropped. Selection is cleared.
func crop(to rectInImage: CGRect) {
let bounds = CGRect(origin: .zero, size: CGSize(width: image.width, height: image.height))
let rect = rectInImage.integral.intersection(bounds)
guard rect.width >= 2, rect.height >= 2, rect != bounds,
let cropped = image.cropping(to: rect) else { return }
image = cropped
let newBounds = CGRect(origin: .zero, size: rect.size)
func translated(_ stroke: Stroke) -> Stroke? {
let points = stroke.points.map { CGPoint(x: $0.x - rect.minX, y: $0.y - rect.minY) }
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 cropToSelection() {
guard let sel = selection, hasSelection else { return }
crop(to: sel)
}
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
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())
.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)
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: "Crop", icon: "crop",
help: "Crop the image to the selected region"
) { model.cropToSelection() }
}
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 }
}
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 }
}
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" : "Draw", systemImage: model.tool == .redact ? "rectangle.fill" : "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 {
Label("Drag to cover sensitive text", systemImage: "eye.slash")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
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.hasSelection ? "Region selected — OCR it or Crop." : "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
private var imageSize: CGSize {
CGSize(width: model.image.width, height: model.image.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] = []
var body: some View {
GeometryReader { geo in
let (scale, drawSize, origin) = geometry(for: geo)
let cardRect = CGRect(origin: origin, size: drawSize)
let cardClip = RoundedRectangle(cornerRadius: 10).path(in: cardRect)
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: model.image, size: .zero))
.resizable()
.interpolation(imageSize.width >= geo.size.width ? .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()! - 2) * scale,
y: origin.y + (ys.min()! - 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 * scale, y: origin.y + stroke.points[0].y * scale))
for point in stroke.points.dropFirst() {
path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.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 * 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(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 == .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 != .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 != .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
}
}
}
)
}
}
}