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
This commit is contained in:
+257
-132
@@ -82,7 +82,7 @@ final class ResultWindow {
|
||||
|
||||
@MainActor
|
||||
final class CaptureModel: ObservableObject {
|
||||
let image: CGImage
|
||||
@Published var image: CGImage
|
||||
|
||||
@Published var mode: CaptureMode = .screenshot
|
||||
@Published var extractedText: String?
|
||||
@@ -215,8 +215,36 @@ final class CaptureModel: ObservableObject {
|
||||
return extractedText ?? "Select text in the screenshot, then click “Get Text”."
|
||||
}
|
||||
|
||||
func cropImage(to rectInImage: CGRect) -> CGImage? {
|
||||
return image.cropping(to: rectInImage)
|
||||
/// 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) {
|
||||
@@ -298,6 +326,139 @@ final class CaptureModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -305,97 +466,62 @@ struct ResultView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Toolbar
|
||||
HStack(spacing: 10) {
|
||||
Picker("", selection: $model.mode) {
|
||||
Text("Screenshot").tag(CaptureModel.CaptureMode.screenshot)
|
||||
Text("Text").tag(CaptureModel.CaptureMode.text)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.fixedSize()
|
||||
HStack(spacing: 6) {
|
||||
CapModePicker(mode: $model.mode)
|
||||
|
||||
Spacer()
|
||||
|
||||
if model.hasSelection {
|
||||
Button {
|
||||
model.runOCRSelection()
|
||||
} label: {
|
||||
Label("OCR Selection", systemImage: "text.magnifyingglass")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(model.isOCRRunning)
|
||||
.help("Recognize text inside the selected region")
|
||||
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() }
|
||||
}
|
||||
|
||||
Button {
|
||||
model.runOCR()
|
||||
} label: {
|
||||
Label("Get All Text", systemImage: "text.viewfinder")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(model.isOCRRunning)
|
||||
.help("Recognize all text in the screenshot")
|
||||
CapCapsuleButton(
|
||||
title: "Get All Text", icon: "text.viewfinder",
|
||||
primary: !model.hasSelection, disabled: model.isOCRRunning,
|
||||
help: "Recognize all text in the screenshot"
|
||||
) { model.runOCR() }
|
||||
|
||||
Divider().frame(height: 18)
|
||||
|
||||
Button {
|
||||
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 }
|
||||
} label: {
|
||||
Label("Draw", systemImage: model.tool == .draw ? "pencil.tip.crop.circle.fill" : "pencil.tip.crop.circle")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.foregroundStyle(model.tool == .draw ? .primary : .secondary)
|
||||
.help("Draw on the screenshot")
|
||||
|
||||
Button {
|
||||
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 }
|
||||
} label: {
|
||||
Label("Redact", systemImage: model.tool == .redact ? "rectangle.fill.on.rectangle.fill" : "rectangle.dashed")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.foregroundStyle(model.tool == .redact ? .primary : .secondary)
|
||||
.help("Cover sensitive areas with black bars")
|
||||
|
||||
Button {
|
||||
model.copyImage()
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.help("Copy image to clipboard")
|
||||
|
||||
Button {
|
||||
model.savePNG()
|
||||
} label: {
|
||||
Label("Save", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.help("Save screenshot as PNG");
|
||||
|
||||
Button {
|
||||
onClose()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Close")
|
||||
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, 14)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
|
||||
Divider()
|
||||
|
||||
// Tool toolbar (only when a draw tool is active)
|
||||
// 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)
|
||||
|
||||
Divider().frame(height: 18)
|
||||
|
||||
if model.tool == .draw {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "circle.fill").font(.system(size: 4))
|
||||
@@ -405,10 +531,9 @@ struct ResultView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
Slider(value: $model.strokeWidth, in: 1...12, step: 0.5)
|
||||
.frame(width: 120)
|
||||
.tint(CapStyle.blue)
|
||||
.help("Stroke thickness")
|
||||
|
||||
Divider().frame(height: 18)
|
||||
|
||||
ColorPicker("", selection: Binding(
|
||||
get: { Color(nsColor: model.drawColor) },
|
||||
set: { model.drawColor = NSColor($0) }
|
||||
@@ -424,60 +549,44 @@ struct ResultView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
model.undoStroke()
|
||||
} label: {
|
||||
Label("Undo", systemImage: "arrow.uturn.backward")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(!model.canUndo)
|
||||
.keyboardShortcut("z", modifiers: .command)
|
||||
.help("Undo last stroke (⌘Z)")
|
||||
CapIconButton(
|
||||
icon: "arrow.uturn.backward", disabled: !model.canUndo,
|
||||
help: "Undo last stroke (⌘Z)",
|
||||
shortcut: KeyboardShortcut("z", modifiers: .command)
|
||||
) { model.undoStroke() }
|
||||
|
||||
Button {
|
||||
model.redoStroke()
|
||||
} label: {
|
||||
Label("Redo", systemImage: "arrow.uturn.forward")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(!model.canRedo)
|
||||
.keyboardShortcut("z", modifiers: [.command, .shift])
|
||||
.help("Redo stroke (⌘⇧Z)")
|
||||
CapIconButton(
|
||||
icon: "arrow.uturn.forward", disabled: !model.canRedo,
|
||||
help: "Redo stroke (⌘⇧Z)",
|
||||
shortcut: KeyboardShortcut("z", modifiers: [.command, .shift])
|
||||
) { model.redoStroke() }
|
||||
|
||||
Button {
|
||||
model.clearAnnotations()
|
||||
} label: {
|
||||
Label("Clear", systemImage: "eraser")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(model.strokes.isEmpty)
|
||||
.help("Remove all drawn annotations")
|
||||
CapIconButton(
|
||||
icon: "eraser", disabled: model.strokes.isEmpty,
|
||||
help: "Remove all drawn annotations"
|
||||
) { model.clearAnnotations() }
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(.bar)
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
// Content
|
||||
if model.mode == .screenshot {
|
||||
ScreenshotPane(model: model)
|
||||
.padding(12)
|
||||
.padding(10)
|
||||
.background(CapStyle.canvas)
|
||||
} else {
|
||||
ZStack {
|
||||
ScrollView {
|
||||
Text(model.displayText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
}
|
||||
ScrollView {
|
||||
Text(model.displayText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(CapStyle.card, in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(CapStyle.border, lineWidth: 1))
|
||||
.padding(10)
|
||||
.background(CapStyle.canvas)
|
||||
}
|
||||
|
||||
// Status bar
|
||||
@@ -487,7 +596,7 @@ struct ResultView: View {
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text(model.statusMessage ?? (model.mode == .screenshot
|
||||
? (model.hasSelection ? "Region selected — click OCR Selection." : "Drag to select a region, or Get All Text.")
|
||||
? (model.hasSelection ? "Region selected — OCR it or Crop." : "Drag to select a region, or Get All Text.")
|
||||
: "Selectable text"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -496,20 +605,17 @@ struct ResultView: View {
|
||||
Spacer()
|
||||
|
||||
if model.mode == .text, model.extractedText != nil {
|
||||
Button {
|
||||
model.copyText()
|
||||
} label: {
|
||||
Label("Copy Text", systemImage: "doc.on.doc")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
CapCapsuleButton(
|
||||
title: "Copy Text", icon: "doc.on.doc",
|
||||
help: "Copy recognized text to the clipboard"
|
||||
) { model.copyText() }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(.bar)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
}
|
||||
.frame(minWidth: 520, minHeight: 420)
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
.frame(minWidth: 600, minHeight: 420)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,14 +653,24 @@ struct ScreenshotPane: View {
|
||||
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)
|
||||
.position(x: origin.x + drawSize.width / 2, y: origin.y + drawSize.height / 2)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.position(x: cardRect.midX, y: cardRect.midY)
|
||||
|
||||
// Completed strokes
|
||||
Canvas { context, _ in
|
||||
@@ -581,6 +697,7 @@ struct ScreenshotPane: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(cardClip)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
// In-progress stroke
|
||||
@@ -595,9 +712,17 @@ struct ScreenshotPane: View {
|
||||
}
|
||||
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,
|
||||
@@ -606,8 +731,8 @@ struct ScreenshotPane: View {
|
||||
height: sel.height * scale
|
||||
)
|
||||
Rectangle()
|
||||
.fill(Color.accentColor.opacity(0.15))
|
||||
.border(Color.accentColor, width: 1.5)
|
||||
.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)
|
||||
|
||||
Reference in New Issue
Block a user