diff --git a/Sources/Kapture/App.swift b/Sources/Kapture/App.swift index 6951f31..fda2767 100644 --- a/Sources/Kapture/App.swift +++ b/Sources/Kapture/App.swift @@ -34,19 +34,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } HotkeyManager.shared.onTrigger = { CaptureController.shared.begin() } HotkeyManager.shared.onFrozenTrigger = { CaptureController.shared.begin(frozen: true) } + HotkeyManager.shared.onRecordTrigger = { + if RecordingController.shared.state.isRecording { + RecordingController.shared.stop() + } else { + CaptureController.shared.begin(recording: true) + } + } HotkeyManager.shared.register() } + + func applicationWillTerminate(_ notification: Notification) { + RecordingController.shared.finalizeForTermination() + } } struct MenuView: View { @State private var granted = PermissionManager.isGranted + @ObservedObject private var recordingState = RecordingController.shared.state var body: some View { Button("Capture & OCR") { CaptureController.shared.begin() } .keyboardShortcut("s", modifiers: [.command, .shift]) + .disabled(recordingState.isRecording) Button("Capture Frozen Screen") { CaptureController.shared.begin(frozen: true) } .keyboardShortcut("s", modifiers: [.command, .shift, .control]) + .disabled(recordingState.isRecording) + + if recordingState.isRecording { + Button("Stop Recording") { RecordingController.shared.stop() } + .keyboardShortcut("r", modifiers: [.command, .shift, .control]) + } else { + Button("Record Screen…") { CaptureController.shared.begin(recording: true) } + .keyboardShortcut("r", modifiers: [.command, .shift, .control]) + } Button("Extract from Image File…") { ResultWindow.shared.showOpenPanel() } diff --git a/Sources/Kapture/CaptureController.swift b/Sources/Kapture/CaptureController.swift index 5b9eccb..1704858 100644 --- a/Sources/Kapture/CaptureController.swift +++ b/Sources/Kapture/CaptureController.swift @@ -79,16 +79,22 @@ final class CaptureController: NSObject { /// Frozen mode: the screen is snapshotted at hotkey press, so the pointer /// and any hover state (tooltips, open menus) end up in the capture. private(set) var isFrozenMode = false + /// Recording mode: the selection flow ends in a recording, not a still. + private(set) var isRecordingMode = false private var frozenSnapshots: [ScreenCapture.DisplaySnapshot] = [] - func begin(frozen: Bool = false) { + func begin(frozen: Bool = false, recording: Bool = false) { guard !isSelecting else { return } + // No new captures while a recording is running (the selection + // overlays would end up in the video). + guard !RecordingController.shared.state.isRecording else { return } guard PermissionManager.isGranted else { PermissionManager.request() return } isSelecting = true - isFrozenMode = frozen + isFrozenMode = frozen && !recording + isRecordingMode = recording previousApp = NSWorkspace.shared.frontmostApplication // With the event tap driving input we must NOT activate: stealing // focus turns the target app into background mode (dimmed chrome), @@ -98,7 +104,7 @@ final class CaptureController: NSObject { if !usesTap { NSApp.activate(ignoringOtherApps: true) } - CaptureController.log("begin: frozen=\(frozen) usesTap=\(usesTap) active=\(NSApp.isActive)") + CaptureController.log("begin: frozen=\(isFrozenMode) recording=\(recording) usesTap=\(usesTap) active=\(NSApp.isActive)") if frozen { ScreenCapture.shared.snapshotAllDisplays(includeCursor: true) { [weak self] snapshots in @@ -137,9 +143,20 @@ final class CaptureController: NSObject { /// corners come from the window's own alpha). private func finishWindowPick(_ windowID: CGWindowID, _ rect: NSRect, ownedRisk: Bool) { let ids = overlayIDs + let recording = isRecordingMode hideOverlays() isSelecting = false restorePreviousApp() + if recording { + // Owned-popup risk: record the screen region instead of the + // window filter, which could composite the parent into the video. + RecordingController.shared.begin( + region: rect, + windowID: ownedRisk ? nil : windowID, + excludingWindowIDs: ids + ) + return + } DispatchQueue.main.async { [self] in if ownedRisk { capture(rect, excludingWindowIDs: ids, roundedCorners: true) @@ -153,6 +170,7 @@ final class CaptureController: NSObject { let ids = overlayIDs let frozen = frozenSnapshots let wasFrozen = isFrozenMode + let recording = isRecordingMode hideOverlays() guard rect.width >= 2, rect.height >= 2 else { cancelSelection() @@ -175,6 +193,13 @@ final class CaptureController: NSObject { return } + if recording { + isSelecting = false + restorePreviousApp() + RecordingController.shared.begin(region: rect, windowID: nil, excludingWindowIDs: ids) + return + } + DispatchQueue.main.async { [self] in capture(rect, excludingWindowIDs: ids) } @@ -195,6 +220,7 @@ final class CaptureController: NSObject { marqueeStart = nil frozenSnapshots = [] isFrozenMode = false + isRecordingMode = false } private func restorePreviousApp() { diff --git a/Sources/Kapture/HotkeyManager.swift b/Sources/Kapture/HotkeyManager.swift index 788c6b1..26d32ef 100644 --- a/Sources/Kapture/HotkeyManager.swift +++ b/Sources/Kapture/HotkeyManager.swift @@ -8,6 +8,8 @@ final class HotkeyManager { var onTrigger: (() -> Void)? /// ⌃⇧⌘S — frozen capture (snapshot at hotkey press). var onFrozenTrigger: (() -> Void)? + /// ⌃⇧⌘R — screen recording (region or window); toggles stop. + var onRecordTrigger: (() -> Void)? private var hotKeyRefs: [EventHotKeyRef?] = [] private var eventHandlerRef: EventHandlerRef? @@ -36,6 +38,7 @@ final class HotkeyManager { switch hotKeyID.id { case 1: HotkeyManager.shared.onTrigger?() case 2: HotkeyManager.shared.onFrozenTrigger?() + case 3: HotkeyManager.shared.onRecordTrigger?() default: break } } @@ -44,6 +47,7 @@ final class HotkeyManager { registerHotkey(id: 1, keyCode: UInt32(kVK_ANSI_S), modifiers: UInt32(cmdKey | shiftKey)) registerHotkey(id: 2, keyCode: UInt32(kVK_ANSI_S), modifiers: UInt32(cmdKey | shiftKey | controlKey)) + registerHotkey(id: 3, keyCode: UInt32(kVK_ANSI_R), modifiers: UInt32(cmdKey | shiftKey | controlKey)) } private func registerHotkey(id: UInt32, keyCode: UInt32, modifiers: UInt32) { diff --git a/Sources/Kapture/OverlayView.swift b/Sources/Kapture/OverlayView.swift index 457b1b6..a705a58 100644 --- a/Sources/Kapture/OverlayView.swift +++ b/Sources/Kapture/OverlayView.swift @@ -97,9 +97,14 @@ final class OverlayView: NSView { } // Esc hint — the only way out once clicks are consumed by the tap. - let hint = frozenImage != nil - ? "Frozen screen · drag to select · Esc to cancel" - : "Drag to select · hover + click a window to capture it · Esc to cancel" + let hint: String + if controller?.isRecordingMode == true { + hint = "Drag to select · hover + click a window to record · Esc to cancel" + } else if frozenImage != nil { + hint = "Frozen screen · drag to select · Esc to cancel" + } else { + hint = "Drag to select · hover + click a window to capture it · Esc to cancel" + } let hintAttrs: [NSAttributedString.Key: Any] = [ .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .medium), .foregroundColor: NSColor.white.withAlphaComponent(0.85), diff --git a/Sources/Kapture/RecordingController.swift b/Sources/Kapture/RecordingController.swift new file mode 100644 index 0000000..9f141a4 --- /dev/null +++ b/Sources/Kapture/RecordingController.swift @@ -0,0 +1,181 @@ +import AppKit +import SwiftUI + +/// Shared recording state, observed by the menu bar menu and control bar. +final class RecordingState: ObservableObject { + @Published var isRecording = false + @Published var elapsed: TimeInterval = 0 +} + +/// Owns a recording session: the red border around the recorded region and +/// the floating timer/stop bar. Both chrome windows are excluded from the +/// video. On stop, the file is auto-saved to ~/Movies and revealed in Finder. +final class RecordingController { + static let shared = RecordingController() + + let state = RecordingState() + + private var recorder: ScreenRecorder? + private var chromeWindows: [NSWindow] = [] + private var timer: Timer? + + /// `region` is in AppKit global points; `windowID` records that window + /// instead of the region. Called by CaptureController after the + /// selection overlays are hidden. + func begin(region: NSRect, windowID: CGWindowID?, excludingWindowIDs: [Int]) { + guard !state.isRecording else { return } + showChrome(region: region) + let excluded = excludingWindowIDs + chromeWindows.map { $0.windowNumber } + state.isRecording = true + + let recorder = ScreenRecorder() + self.recorder = recorder + // Give the window server a beat so the chrome shows up in the + // shareable-content snapshot and gets excluded from the video. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + guard let self, self.recorder === recorder else { return } + recorder.start(region: region, windowID: windowID, excludingWindowIDs: excluded) { [weak self] ok in + DispatchQueue.main.async { + guard let self else { return } + if ok { + self.startTimer() + } else { + self.finish(url: nil) + ResultWindow.shared.show(image: ResultWindow.failureImage("Could not start the recording. Check Screen Recording permission.")) + } + } + } + } + } + + func stop() { + guard state.isRecording else { return } + state.isRecording = false + timer?.invalidate() + timer = nil + recorder?.stop { [weak self] url in + DispatchQueue.main.async { self?.finish(url: url) } + } + } + + /// Best-effort finalize on quit so the .mov isn't left truncated. + /// Blocks briefly — only call from applicationWillTerminate. + func finalizeForTermination() { + guard let recorder else { return } + let semaphore = DispatchSemaphore(value: 0) + recorder.stop { _ in semaphore.signal() } + _ = semaphore.wait(timeout: .now() + 3) + } + + private func finish(url: URL?) { + state.isRecording = false + hideChrome() + recorder = nil + state.elapsed = 0 + if let url { + CaptureController.log("rec: saved \(url.path)") + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + } + + private func startTimer() { + let started = Date() + state.elapsed = 0 + timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.state.elapsed = Date().timeIntervalSince(started) + } + } + + // MARK: - Chrome + + private func showChrome(region: NSRect) { + // Red border around the recorded region. + let border = NSPanel( + contentRect: region, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + border.level = .screenSaver + border.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + border.backgroundColor = .clear + border.isOpaque = false + border.hasShadow = false + border.ignoresMouseEvents = true + border.contentView = RecordingBorderView() + border.orderFrontRegardless() + + // Floating timer/stop bar at the bottom centre of the region. + let bar = NSPanel( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + bar.level = .screenSaver + bar.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + bar.backgroundColor = .clear + bar.isOpaque = false + bar.hasShadow = false + let hosting = NSHostingView(rootView: RecordingBarView(state: state) { [weak self] in + self?.stop() + }) + let size = hosting.fittingSize + bar.setContentSize(size) + bar.setFrameOrigin(NSPoint(x: region.midX - size.width / 2, y: region.minY + 14)) + bar.contentView = hosting + bar.orderFrontRegardless() + + chromeWindows = [border, bar] + } + + private func hideChrome() { + chromeWindows.forEach { $0.orderOut(nil) } + chromeWindows = [] + } +} + +/// Draws the red rounded border around the recorded region. +private final class RecordingBorderView: NSView { + override func draw(_ dirtyRect: NSRect) { + let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 2, dy: 2), xRadius: 8, yRadius: 8) + NSColor.systemRed.setStroke() + path.lineWidth = 3 + path.stroke() + } +} + +/// The floating capsule bar: red dot, elapsed time, stop button. +struct RecordingBarView: View { + @ObservedObject var state: RecordingState + let onStop: () -> Void + + private var timeString: String { + let total = Int(state.elapsed) + return String(format: "%02d:%02d", total / 60, total % 60) + } + + var body: some View { + HStack(spacing: 10) { + Circle() + .fill(Color.red) + .frame(width: 8, height: 8) + Text(timeString) + .font(.system(size: 12, weight: .medium).monospacedDigit()) + .foregroundStyle(.white) + Button(action: onStop) { + Image(systemName: "stop.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 22, height: 22) + .background(Circle().fill(Color.red)) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .help("Stop recording (⌃⇧⌘R)") + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Capsule().fill(Color.black.opacity(0.78))) + } +} diff --git a/Sources/Kapture/ResultWindow.swift b/Sources/Kapture/ResultWindow.swift index 42cd8b2..9fad9d3 100644 --- a/Sources/Kapture/ResultWindow.swift +++ b/Sources/Kapture/ResultWindow.swift @@ -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) diff --git a/Sources/Kapture/ScreenRecorder.swift b/Sources/Kapture/ScreenRecorder.swift new file mode 100644 index 0000000..f29287b --- /dev/null +++ b/Sources/Kapture/ScreenRecorder.swift @@ -0,0 +1,203 @@ +import AppKit +import AVFoundation +import CoreMedia +import ScreenCaptureKit + +/// Records a screen region (or a single window) to an H.264 .mov via +/// SCStream -> AVAssetWriter. Works on macOS 13+ (no SCScreenshotManager). +/// +/// Video-only for now: the audio path is wired behind `includeAudio` +/// (off) — enabling sound later means setting the flag to true, which +/// turns on `capturesAudio`, adds an AAC track, and starts appending +/// audio sample buffers. That path is untested until enabled. +final class ScreenRecorder: NSObject, SCStreamOutput, SCStreamDelegate { + /// Future: also record system audio into the .mov. + var includeAudio = false + + private let queue = DispatchQueue(label: "dev.tzgyn.kapture.rec") + private var stream: SCStream? + private var writer: AVAssetWriter? + private var videoInput: AVAssetWriterInput? + private var audioInput: AVAssetWriterInput? + private var sessionStarted = false + private var outputURL: URL? + + /// `region` is in AppKit global points. When `windowID` is given the + /// stream records that window directly (and follows it if it moves); + /// otherwise it records `region` via a display filter + sourceRect. + func start(region: NSRect, windowID: CGWindowID?, excludingWindowIDs: [Int], completion: @escaping (Bool) -> Void) { + Task { + do { + try await prepare(region: region, windowID: windowID, excludingWindowIDs: excludingWindowIDs) + completion(true) + } catch { + CaptureController.log("rec: start failed: \(error.localizedDescription)") + completion(false) + } + } + } + + private func prepare(region: NSRect, windowID: CGWindowID?, excludingWindowIDs: [Int]) async throws { + let content = try await SCShareableContent.current + + let excludedSet = Set(excludingWindowIDs.map { CGWindowID($0) }) + let excluded = content.windows.filter { excludedSet.contains($0.windowID) } + + let config = SCStreamConfiguration() + let filter: SCContentFilter + let pixelWidth: Int + let pixelHeight: Int + + let regionCentre = NSPoint(x: region.midX, y: region.midY) + let screen = NSScreen.screens.first(where: { $0.frame.contains(regionCentre) }) ?? NSScreen.screens.first + let scale = screen?.backingScaleFactor ?? 2 + + if let windowID, let scWindow = content.windows.first(where: { $0.windowID == windowID }) { + filter = SCContentFilter(desktopIndependentWindow: scWindow) + pixelWidth = max(2, Int(scWindow.frame.width * scale) / 2 * 2) + pixelHeight = max(2, Int(scWindow.frame.height * scale) / 2 * 2) + CaptureController.log("rec: window id=\(windowID) \(pixelWidth)x\(pixelHeight)") + } else { + guard let screen else { + throw NSError(domain: "dev.tzgyn.kapture", code: 1, userInfo: [NSLocalizedDescriptionKey: "No screen for region"]) + } + let cgID = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0 + guard let display = content.displays.first(where: { $0.displayID == cgID }) ?? content.displays.first else { + throw NSError(domain: "dev.tzgyn.kapture", code: 2, userInfo: [NSLocalizedDescriptionKey: "No display for region"]) + } + filter = SCContentFilter(display: display, excludingWindows: excluded) + // sourceRect is display-local POINTS with a TOP-LEFT origin + // (same mapping cropFrozen uses for the frozen snapshots). + let local = CGRect( + x: region.minX - screen.frame.minX, + y: screen.frame.maxY - region.maxY, + width: region.width, + height: region.height + ).intersection(CGRect(x: 0, y: 0, width: display.frame.width, height: display.frame.height)) + guard local.width >= 2, local.height >= 2 else { + throw NSError(domain: "dev.tzgyn.kapture", code: 3, userInfo: [NSLocalizedDescriptionKey: "Region outside display"]) + } + config.sourceRect = local + pixelWidth = max(2, Int(local.width * scale) / 2 * 2) + pixelHeight = max(2, Int(local.height * scale) / 2 * 2) + CaptureController.log("rec: region \(region) sourceRect=\(local) \(pixelWidth)x\(pixelHeight)") + } + + config.width = pixelWidth + config.height = pixelHeight + config.minimumFrameInterval = CMTime(value: 1, timescale: 60) + config.pixelFormat = kCVPixelFormatType_32BGRA + // Recordings SHOULD include the pointer (unlike stills). + config.showsCursor = true + config.queueDepth = 6 + config.capturesAudio = includeAudio + + let movies = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory()) + let url = movies.appendingPathComponent("Kapture-\(Self.timestamp()).mov") + let writer = try AVAssetWriter(url: url, fileType: .mov) + + let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: pixelWidth, + AVVideoHeightKey: pixelHeight, + ]) + videoInput.expectsMediaDataInRealTime = true + writer.add(videoInput) + + var audioInput: AVAssetWriterInput? + if includeAudio { + let input = AVAssetWriterInput(mediaType: .audio, outputSettings: [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 48000, + AVNumberOfChannelsKey: 2, + ]) + input.expectsMediaDataInRealTime = true + writer.add(input) + audioInput = input + } + + guard writer.startWriting() else { + throw writer.error ?? NSError(domain: "dev.tzgyn.kapture", code: 4, userInfo: [NSLocalizedDescriptionKey: "AVAssetWriter failed to start"]) + } + + let stream = SCStream(filter: filter, configuration: config, delegate: self) + try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue) + if includeAudio { + try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: queue) + } + try await stream.startCapture() + + self.stream = stream + self.writer = writer + self.videoInput = videoInput + self.audioInput = audioInput + self.outputURL = url + CaptureController.log("rec: started -> \(url.lastPathComponent)") + } + + /// Stops the stream and finalizes the file. Completion gets the URL on + /// success (called on an internal queue — hop to main for UI). + func stop(completion: @escaping (URL?) -> Void) { + Task { + try? await stream?.stopCapture() + stream = nil + queue.async { + guard let writer = self.writer else { + completion(nil) + return + } + self.videoInput?.markAsFinished() + self.audioInput?.markAsFinished() + let url = self.outputURL + self.writer = nil + self.videoInput = nil + self.audioInput = nil + self.sessionStarted = false + writer.finishWriting { + CaptureController.log("rec: finished status=\(writer.status.rawValue)") + completion(writer.status == .completed ? url : nil) + } + } + } + } + + // MARK: SCStreamOutput (called on `queue`) + + func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { + guard CMSampleBufferDataIsReady(sampleBuffer), let writer, let videoInput else { return } + switch type { + case .screen: + // Skip incomplete frames (macOS 13.1+ exposes a frame status). + if #available(macOS 13.1, *) { + guard let attachments = (CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [NSDictionary])?.first, + let dict = attachments as? [String: Any], + let statusRaw = dict[SCStreamFrameInfo.status.rawValue] as? Int, + SCFrameStatus(rawValue: statusRaw) == .complete else { return } + } + guard videoInput.isReadyForMoreMediaData else { return } + if !sessionStarted { + sessionStarted = true + writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(sampleBuffer)) + } + videoInput.append(sampleBuffer) + case .audio: + guard includeAudio, sessionStarted, let audioInput, audioInput.isReadyForMoreMediaData else { return } + audioInput.append(sampleBuffer) + default: + break + } + } + + // MARK: SCStreamDelegate + + func stream(_ stream: SCStream, didStopWithError error: Error) { + CaptureController.log("rec: stream stopped with error: \(error.localizedDescription)") + } + + private static func timestamp() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmmss" + return formatter.string(from: Date()) + } +} diff --git a/build-app.sh b/build-app.sh index 61f6aba..50719a4 100755 --- a/build-app.sh +++ b/build-app.sh @@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' CFBundleIconFile Icon CFBundleShortVersionString - 1.10.2 + 1.11.0 CFBundleVersion - 102 + 110 LSUIElement NSHighResolutionCapable