Compare commits

...
11 Commits
Author SHA1 Message Date
TZGyn ce89d34654 Make crop a Photos-style tool with undo and actual-size view
Crop is now a mode like Draw/Redact: a draggable rect with corner and
edge handles, rule-of-thirds grid, dimmed surroundings, Cancel (Esc) and
Crop Image (Return). Re-entering crop after a crop shows the full
original with the handles on the current crop region, so it can be
dragged back outward; every crop is undoable from the toolbar.

The result window also gains an Actual Size view (Cmd+0): one image
pixel per screen pixel, scrollable, so retina captures stay crisp
instead of being resampled down to the window size.

Generated with OpenCode

Assisted-by: Kimi:k3
2026-10-26 20:45:48 +08:00
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
TZGyn 04b84e188a Retry OCR on upscaled images when nothing is read
Tiny UI text (around 7pt) reads as nothing at native size — verified
locally. When a pass returns fewer than three characters, OCR retries at
2x and 3x and keeps the fullest result. The smoke test covers a tiny-text
case so the fallback stays working.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-30 11:44:26 +08:00
TZGyn 2e971d5f8d Darken the screen in frozen capture mode
Frozen mode showed the still image at full brightness until a selection
existed, unlike live mode's dark veil. It now draws the same 35% veil over
everything and re-draws the selected area from the snapshot, keeping the
selection bright. The hint text matches the mode.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-25 17:43:59 +08:00
TZGyn e4e20c51d3 Add frozen-screen capture on Ctrl+Shift+Cmd+S
A second hotkey snapshots every display the moment it is pressed and
shows that still image as the overlay, so the pointer and hover states
(tooltips, open menus) are captured. Selection is marquee-only — no
window detection — and the result is cropped from the snapshot rather
than recaptured, keeping the frame exactly as it was.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-25 17:40:12 +08:00
TZGyn 26700c59f4 Make exported stroke thickness match the preview
Strokes were sized in view points but exported in image pixels, so on
retina captures the saved/copied annotation looked several times thinner.
Stroke widths are now converted into image pixels when drawn and scaled
back for the preview, keeping preview and export identical.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-23 17:28:38 +08:00
TZGyn 195c6a9b1f Enable automatic language detection so CJK text is recognized
Vision defaulted to the first listed language and turned Chinese into
Latin garbage (你好世界 became 177WR). automaticallyDetectsLanguage fixes
it; the smoke test now covers Chinese so it cannot regress.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-22 10:54:54 +08:00
TZGyn ecac7885dd Keep the captured app focused during selection
The overlay used to activate Kapture, pushing the target app into
background mode so its chrome (title bar, toolbar, tabs) was captured
dimmed. Input no longer needs activation — the event tap handles it — so
the overlay is a non-activating panel shown without taking focus, and the
app keeps its active appearance in the capture.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-20 14:46:25 +08:00
TZGyn ef44ef7ac6 Let system dialogs take clicks during capture; hint Esc on the overlay
The event tap consumes every click while selecting, which made permission
prompts unclickable when they appeared mid-capture. Clicks now pass
through when a system dialog (SecurityAgent / System Settings) is
frontmost, and the overlay shows a visible Esc hint as an escape hatch.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-12 22:12:48 +08:00
TZGyn 715bb28c1b Fix supersample blur and click-cancellation on micro-drags
The 3x supersample resized the whole capture (blurring content); only the
corner mask is supersampled now, applied through clip(to😷) with the
image drawn once at native scale. The tap's drag branch also clears the
hovered window on sub-4px jitter, cancelling click-to-capture; the
threshold is applied there too.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 15:26:19 +08:00
TZGyn 07642ca408 Auto-detect window corner radius; anti-alias the mask
Measure the picked window's real corner radius from the capture itself
(the crop's corner pixels are the desktop background; app content starts
where the color departs), correct for arc anti-aliasing, and fall back to
10pt when ambiguous. The clip mask is supersampled 3x for smooth edges.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 15:20:21 +08:00
9 changed files with 1459 additions and 221 deletions
+59 -14
View File
@@ -33,16 +33,42 @@ 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() }
@@ -119,33 +145,52 @@ enum LoginItem {
}
}
func runOCRSelfTest() {
let text = "Kapture OCR test 1234, hello world"
let size = NSSize(width: 1600, height: 220)
func renderTestImage(_ text: String, fontSize: CGFloat = 56, width: CGFloat = 1900, height: CGFloat = 220) -> CGImage? {
let size = NSSize(width: width, height: 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 { exit(1) }
) else { return nil }
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
NSColor.white.setFill()
NSRect(origin: .zero, size: size).fill()
let attrs: [NSAttributedString.Key: Any] = [
.font: NSFont.boldSystemFont(ofSize: 56),
.font: NSFont.systemFont(ofSize: fontSize),
.foregroundColor: NSColor.black,
]
text.draw(at: NSPoint(x: 60, y: 75), withAttributes: attrs)
// Centre the text vertically so tiny canvases still contain it.
let textHeight = ("Ag" as NSString).size(withAttributes: attrs).height
let y = max(4, (size.height - textHeight) / 2)
text.draw(at: NSPoint(x: 20, y: y), withAttributes: attrs)
NSGraphicsContext.restoreGraphicsState()
return rep.cgImage
}
guard let cgImage = rep.cgImage else { exit(1) }
OCRService.shared.recognize(cgImage) { recognized in
print("expected: \(text)")
print("got: \(recognized)")
let ok = recognized == text
print(ok ? "PASS" : "FAIL")
exit(ok ? 0 : 1)
func runOCRSelfTest() {
let cases: [(expected: String, contains: String, fontSize: CGFloat, width: CGFloat, height: CGFloat)] = [
("Kapture OCR test 1234, hello world", "Kapture OCR test 1234", 56, 1900, 220),
("你好世界,这是一个测试", "你好世界", 56, 1900, 220),
// Tiny text: needs the upscale retry to read anything at all.
("会话导出并发冲突报错 export fail", "导出", 7, 320, 40),
]
var index = 0
func runNext() {
guard index < cases.count else { exit(0) }
let current = cases[index]
index += 1
guard let image = renderTestImage(current.expected, fontSize: current.fontSize, width: current.width, height: current.height) else { exit(1) }
OCRService.shared.recognize(image) { recognized in
print("case: \(current.expected) @\(Int(current.fontSize))pt")
print("got: \(recognized)")
let ok = recognized.contains(current.contains)
print(ok ? "PASS" : "FAIL")
if !ok { exit(1) }
runNext()
}
}
runNext()
RunLoop.main.run()
}
+246 -16
View File
@@ -76,29 +76,65 @@ final class CaptureController: NSObject {
// MARK: - Session lifecycle
func begin() {
/// 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, 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 && !recording
isRecordingMode = recording
previousApp = NSWorkspace.shared.frontmostApplication
// Activation is async on macOS 26; wait a tick so the app is active
// before the overlays appear (otherwise clicks pass through).
NSApp.activate(ignoringOtherApps: true)
CaptureController.log("begin: activate issued, active=\(NSApp.isActive)")
// With the event tap driving input we must NOT activate: stealing
// focus turns the target app into background mode (dimmed chrome),
// and that is what would end up in the screenshot. Activate only on
// the fallback path, where panel events are the only input.
let usesTap = AXIsProcessTrusted()
if !usesTap {
NSApp.activate(ignoringOtherApps: true)
}
CaptureController.log("begin: frozen=\(isFrozenMode) recording=\(recording) usesTap=\(usesTap) active=\(NSApp.isActive)")
if frozen {
ScreenCapture.shared.snapshotAllDisplays(includeCursor: true) { [weak self] snapshots in
DispatchQueue.main.async {
guard let self, self.isSelecting else { return }
self.frozenSnapshots = snapshots
CaptureController.log("frozen: captured \(snapshots.count) displays")
self.showOverlays()
}
}
} else {
showOverlays()
}
}
private func showOverlays() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self, self.isSelecting else { return }
CaptureController.log("begin: active=\(NSApp.isActive) — showing overlays")
self.overlays = NSScreen.screens.map { screen in
let panel = OverlayWindow(screen: screen, controller: self)
(panel.contentView as? OverlayView)?.controller = self
if let view = panel.contentView as? OverlayView {
view.controller = self
view.frozenImage = self.frozenSnapshots.first(where: { $0.frame == screen.frame })?.image
}
return panel
}
self.overlays.forEach { $0.makeKeyAndOrderFront(nil) }
// orderFrontRegardless: show without taking key/focus.
self.overlays.forEach { $0.orderFrontRegardless() }
self.overlayIDs = self.overlays.map { $0.windowNumber }
CaptureController.log("begin: \(self.overlays.count) overlays, ids=\(self.overlayIDs), active=\(NSApp.isActive)")
CaptureController.log("begin: \(self.overlays.count) overlays, ids=\(self.overlayIDs) — prior app stays focused")
self.installEventTap()
}
}
@@ -107,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)
@@ -121,11 +168,38 @@ final class CaptureController: NSObject {
private func finishSelection(_ rect: NSRect) {
let ids = overlayIDs
let frozen = frozenSnapshots
let wasFrozen = isFrozenMode
let recording = isRecordingMode
hideOverlays()
guard rect.width >= 2, rect.height >= 2 else {
cancelSelection()
return
}
if wasFrozen {
let midpoint = NSPoint(x: rect.midX, y: rect.midY)
let snapshot = frozen.first(where: { $0.frame.contains(midpoint) }) ?? frozen.first
isSelecting = false
restorePreviousApp()
DispatchQueue.main.async { [self] in
if let snapshot, let image = ScreenCapture.cropFrozen(snapshot, to: rect) {
CaptureController.log("frozen: cropped \(Int(rect.width))x\(Int(rect.height)) -> \(image.width)x\(image.height)")
ResultWindow.shared.show(image: image)
} else {
ResultWindow.shared.show(image: ResultWindow.failureImage("Could not crop the frozen screen."))
}
}
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)
}
@@ -144,6 +218,9 @@ final class CaptureController: NSObject {
marqueeRect = nil
hoverWindow = nil
marqueeStart = nil
frozenSnapshots = []
isFrozenMode = false
isRecordingMode = false
}
private func restorePreviousApp() {
@@ -262,9 +339,21 @@ final class CaptureController: NSObject {
return nil
}
/// System dialogs (permission prompts, System Settings) must always
/// receive their clicks even while we're selecting.
private func isSystemDialogFrontmost() -> Bool {
guard let front = NSWorkspace.shared.frontmostApplication else { return false }
let id = front.bundleIdentifier ?? ""
return id.hasPrefix("com.apple.security") || id.hasPrefix("com.apple.SystemSettings") || id == "com.apple.systempreferences"
}
/// Returns the event to pass through, or nil to consume it.
func handleTap(_ type: CGEventType, _ event: CGEvent) -> CGEvent? {
guard isSelecting else { return event }
if isSystemDialogFrontmost() {
CaptureController.log("tap: system dialog frontmost — passing through \(type)")
return event
}
switch type {
case .tapDisabledByUserInput, .tapDisabledByTimeout:
@@ -289,6 +378,8 @@ final class CaptureController: NSObject {
}
let appKitPoint = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y))
if type == .mouseMoved {
// Frozen mode is marquee-only: no window hover detection.
if isFrozenMode { return event }
let window = topmostWindowContaining(appKitPoint)
if window?.frame != hoverFrame {
hoverWindow = window
@@ -301,8 +392,11 @@ final class CaptureController: NSObject {
}
return event
}
// Drag: grow the marquee
// Drag: grow the marquee — but only after real movement, so a
// click's micro-jitter keeps the hovered window eligible.
guard let start = marqueeStart else { return nil }
let distance = hypot(appKitPoint.x - start.x, appKitPoint.y - start.y)
guard distance >= 4 else { return nil }
let rect = NSRect(
x: min(start.x, appKitPoint.x),
y: min(start.y, appKitPoint.y),
@@ -325,6 +419,9 @@ final class CaptureController: NSObject {
if let rect = marqueeRect, dragDistance >= 4, rect.width >= 2, rect.height >= 2 {
CaptureController.log("tap: marquee complete \(rect)")
finishSelection(rect)
} else if isFrozenMode {
CaptureController.log("tap: frozen click without drag — cancel")
cancelSelection()
} else if let hover = hoverWindow {
// An overlapping window from the same app means this is an
// owned popup — desktopIndependentWindow would composite the
@@ -372,6 +469,41 @@ final class ScreenCapture {
private init() {}
/// Estimate the window's corner radius (in image pixels) by scanning the
/// top-left corner: the crop's corner pixels are the desktop background;
/// app content starts where the color departs from it.
static func detectCornerRadius(of image: CGImage) -> CGFloat? {
let w = image.width, h = image.height
guard w > 30, h > 30 else { return nil }
var px = [UInt8](repeating: 0, count: w * h * 4)
guard let c = CGContext(data: &px, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
c.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))
func pixel(x: Int, yTop: Int) -> (r: Int, g: Int, b: Int) {
let row = yTop * w * 4 + x * 4
return (Int(px[row]), Int(px[row + 1]), Int(px[row + 2]))
}
let bg = pixel(x: 0, yTop: 0)
func diffFromBg(_ p: (r: Int, g: Int, b: Int)) -> Int {
abs(p.r - bg.r) + abs(p.g - bg.g) + abs(p.b - bg.b)
}
var radii: [CGFloat] = []
for (limit, axis) in [(h / 3, 0), (w / 3, 1)] {
for i in 0..<limit {
let p = axis == 0 ? pixel(x: 0, yTop: i) : pixel(x: i, yTop: 0)
if diffFromBg(p) > 24 {
radii.append(CGFloat(i))
break
}
}
}
guard radii.count == 2 else { return nil }
let r = (radii[0] + radii[1]) / 2
return (r >= 3 && r <= 40) ? r : nil
}
@available(macOS 14.0, *)
private func crop(_ image: CGImage, to cgRect: CGRect, display: SCDisplay, roundedCorners: Bool = false) -> CGImage? {
// cgRect is in SCDisplay space (bottom-left origin, Y flipped vs AppKit).
@@ -402,17 +534,47 @@ final class ScreenCapture {
// Apply a rounded-corner alpha mask for window captures, matching
// macOS window chrome (≈10pt radius, scaled to image pixels).
if roundedCorners {
// Clip to a rounded rect and redraw — deterministic corner alpha
// (masking() can fail depending on the source pixel format).
let radius = 10 * scaleX
guard let ctx = CGContext(
// The screen crop contains the app's real rounded corners against
// the desktop — measure the radius from the corner pixels, then
// mask with it (supersampled for smooth edges).
let detected = Self.detectCornerRadius(of: result)
let radius = min(max((detected.map { $0 * 1.23 + 2.6 }) ?? (10 * scaleX), 4), 40)
CaptureController.log("corners: detected=\(detected.map { String(format: "%.1f", $0) } ?? "nil") using=\(Int(radius))px")
// Anti-aliased gray mask: supersample 3x, downsample, then draw
// the image through it ONCE at 1x (no content resampling blur).
let ss = 3
let W = result.width * ss, H = result.height * ss
guard let hiMask = CGContext(
data: nil, width: W, height: H,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceGray(),
bitmapInfo: CGImageAlphaInfo.none.rawValue
) else { return result }
hiMask.setFillColor(CGColor(gray: 1, alpha: 1))
hiMask.fill(CGRect(x: 0, y: 0, width: W, height: H))
hiMask.setFillColor(CGColor(gray: 0, alpha: 1))
let cornerPath = CGMutablePath()
cornerPath.addRect(CGRect(x: 0, y: 0, width: W, height: H))
cornerPath.addRoundedRect(in: CGRect(x: 0, y: 0, width: W, height: H), cornerWidth: radius * CGFloat(ss), cornerHeight: radius * CGFloat(ss), transform: .identity)
hiMask.addPath(cornerPath)
hiMask.fillPath(using: .evenOdd)
guard let hiMaskImg = hiMask.makeImage(),
let downMask = CGContext(
data: nil, width: result.width, height: result.height,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceGray(),
bitmapInfo: CGImageAlphaInfo.none.rawValue
) else { return result }
downMask.interpolationQuality = .high
downMask.draw(hiMaskImg, in: CGRect(x: 0, y: 0, width: result.width, height: result.height))
guard let mask1x = downMask.makeImage(),
let ctx = CGContext(
data: nil, width: result.width, height: result.height,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return result }
ctx.addPath(CGPath(roundedRect: CGRect(x: 0, y: 0, width: result.width, height: result.height), cornerWidth: radius, cornerHeight: radius, transform: nil))
ctx.clip()
ctx.clip(to: CGRect(x: 0, y: 0, width: result.width, height: result.height), mask: mask1x)
ctx.draw(result, in: CGRect(x: 0, y: 0, width: result.width, height: result.height))
if let masked = ctx.makeImage() {
result = masked
@@ -421,6 +583,74 @@ final class ScreenCapture {
return result
}
/// Screen frame (AppKit global points) + full-resolution image per display.
struct DisplaySnapshot {
let frame: NSRect
let image: CGImage
}
/// Snapshot every display right now. Used by frozen mode: the images are
/// taken before the overlay appears, so the pointer and hover states
/// (tooltips, menus) are baked in.
func snapshotAllDisplays(includeCursor: Bool, completion: @escaping ([DisplaySnapshot]) -> Void) {
if #available(macOS 14.0, *) {
Task {
guard let content = try? await SCShareableContent.current else {
completion([])
return
}
var out: [DisplaySnapshot] = []
for display in content.displays {
guard let screen = NSScreen.screens.first(where: {
(($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0) == display.displayID
}) else { continue }
let config = SCStreamConfiguration()
let scale = screen.backingScaleFactor
config.width = Int(CGFloat(display.width) * scale)
config.height = Int(CGFloat(display.height) * scale)
config.showsCursor = includeCursor
let filter = SCContentFilter(display: display, excludingWindows: [])
if let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) {
out.append(DisplaySnapshot(frame: screen.frame, image: image))
}
}
completion(out)
}
} else {
var out: [DisplaySnapshot] = []
for screen in NSScreen.screens {
let cgRect = CGRect(
x: screen.frame.minX,
y: (NSScreen.screens.first(where: { $0.frame.origin == .zero })?.frame.height ?? screen.frame.height) - screen.frame.maxY,
width: screen.frame.width,
height: screen.frame.height
)
if let image = CGWindowListCreateImage(cgRect, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution, .boundsIgnoreFraming]) {
out.append(DisplaySnapshot(frame: screen.frame, image: image))
}
}
completion(out)
}
}
/// Crop a selection out of a frozen snapshot (top-left pixel space).
static func cropFrozen(_ snapshot: DisplaySnapshot, to rect: NSRect) -> CGImage? {
let scaleX = CGFloat(snapshot.image.width) / snapshot.frame.width
let scaleY = CGFloat(snapshot.image.height) / snapshot.frame.height
guard scaleX > 0, scaleY > 0 else { return nil }
let localX = rect.minX - snapshot.frame.minX
let localTopY = snapshot.frame.maxY - rect.maxY
let px = CGRect(
x: localX * scaleX,
y: localTopY * scaleY,
width: rect.width * scaleX,
height: rect.height * scaleY
)
let safe = px.intersection(CGRect(x: 0, y: 0, width: CGFloat(snapshot.image.width), height: CGFloat(snapshot.image.height))).integral
guard safe.width >= 1, safe.height >= 1 else { return nil }
return snapshot.image.cropping(to: safe)
}
@available(macOS 14.0, *)
private func displayContaining(_ rect: NSRect, in content: SCShareableContent) -> SCDisplay? {
let mid = NSPoint(x: rect.midX, y: rect.midY)
+37 -12
View File
@@ -4,11 +4,16 @@ import AppKit
final class HotkeyManager {
static let shared = HotkeyManager()
/// ⌘⇧S — live capture (ScreenCaptureKit at capture time).
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 hotKeyRef: EventHotKeyRef?
private var hotKeyRefs: [EventHotKeyRef?] = []
private var eventHandlerRef: EventHandlerRef?
private var hotKeyID = EventHotKeyID(signature: 0x4B505452, id: 1)
private let signature: OSType = 0x4B505452
private init() {}
@@ -17,18 +22,38 @@ final class HotkeyManager {
eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed)
)
InstallEventHandler(GetApplicationEventTarget(), { _, _, _ in
HotkeyManager.shared.onTrigger?()
InstallEventHandler(GetApplicationEventTarget(), { _, event, _ in
var hotKeyID = EventHotKeyID()
let status = GetEventParameter(
event,
EventParamName(kEventParamDirectObject),
EventParamType(typeEventHotKeyID),
nil,
MemoryLayout<EventHotKeyID>.size,
nil,
&hotKeyID
)
guard status == noErr else { return noErr }
DispatchQueue.main.async {
switch hotKeyID.id {
case 1: HotkeyManager.shared.onTrigger?()
case 2: HotkeyManager.shared.onFrozenTrigger?()
case 3: HotkeyManager.shared.onRecordTrigger?()
default: break
}
}
return noErr
}, 1, &eventType, nil, &eventHandlerRef)
RegisterEventHotKey(
UInt32(kVK_ANSI_S),
UInt32(cmdKey | shiftKey),
hotKeyID,
GetApplicationEventTarget(),
0,
&hotKeyRef
)
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) {
var ref: EventHotKeyRef?
let hotKeyID = EventHotKeyID(signature: signature, id: id)
RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &ref)
hotKeyRefs.append(ref)
}
}
+58 -21
View File
@@ -8,29 +8,66 @@ final class OCRService {
func recognize(_ cgImage: CGImage, completion: @escaping (String) -> Void) {
queue.async {
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
request.recognitionLanguages = ["en-US", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR"]
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
completion("")
let text = Self.perform(cgImage)
// Tiny text (small UI labels) often reads as nothing at native
// size; retry upscaled and keep whichever pass read more.
guard text.count < 3 else {
completion(text)
return
}
let observations = request.results ?? []
let lines = observations
.sorted { a, b in
let ay = a.boundingBox.midY
let by = b.boundingBox.midY
if abs(ay - by) > 0.02 { return ay > by }
return a.boundingBox.minX < b.boundingBox.minX
}
.compactMap { $0.topCandidates(1).first?.string }
completion(lines.joined(separator: "\n"))
var best = text
for factor in [CGFloat(2), CGFloat(3)] {
guard let upscaled = Self.upscaled(cgImage, factor: factor) else { continue }
let candidate = Self.perform(upscaled)
if candidate.count > best.count { best = candidate }
if best.count >= 3 { break }
}
completion(best)
}
}
private static func perform(_ cgImage: CGImage) -> String {
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
request.recognitionLanguages = ["en-US", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR"]
// Without this, Vision defaults to the first listed language and
// renders CJK as Latin garbage (verified: 你好世界 -> "177WR").
if #available(macOS 13.0, *) {
request.automaticallyDetectsLanguage = true
}
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
return ""
}
let observations = request.results ?? []
let lines = observations
.sorted { a, b in
let ay = a.boundingBox.midY
let by = b.boundingBox.midY
if abs(ay - by) > 0.02 { return ay > by }
return a.boundingBox.minX < b.boundingBox.minX
}
.compactMap { $0.topCandidates(1).first?.string }
return lines.joined(separator: "\n")
}
private static func upscaled(_ cgImage: CGImage, factor: CGFloat) -> CGImage? {
let width = Int(CGFloat(cgImage.width) * factor)
let height = Int(CGFloat(cgImage.height) * factor)
guard width > 0, height > 0, width <= 20000, height <= 20000 else { return nil }
guard let ctx = CGContext(
data: nil, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
ctx.interpolationQuality = .high
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)))
return ctx.makeImage()
}
}
+88 -3
View File
@@ -6,8 +6,11 @@ final class OverlayWindow: NSPanel {
init(screen: NSScreen, controller: CaptureController) {
let content = OverlayView()
super.init(
// Non-activating: the overlay must NOT steal focus, so the app
// being captured keeps its active appearance (undimmed chrome).
// Input comes from the CGEventTap, not panel events.
contentRect: screen.frame,
styleMask: [.borderless],
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
@@ -27,6 +30,9 @@ final class OverlayWindow: NSPanel {
final class OverlayView: NSView {
weak var controller: CaptureController?
/// In frozen mode: the still image to display for this screen (already
/// full resolution). Nil = live mode (dim the real screen).
var frozenImage: CGImage?
override var acceptsFirstResponder: Bool { true }
@@ -53,12 +59,91 @@ final class OverlayView: NSView {
)
}
/// Draw the frozen snapshot filling the screen, then replace its centre
/// hole with the live... no: the image IS the content, drawn opaque.
private func drawFrozen(_ image: CGImage) {
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
ctx.saveGState()
ctx.interpolationQuality = .high
// Image is in pixels, view in points — draw into the view bounds.
ctx.draw(image, in: bounds)
ctx.restoreGState()
}
private func drawSizeLabel(for sel: NSRect, in vf: NSRect) {
let label = "\(Int(sel.width)) × \(Int(sel.height))"
let attrs: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium),
.foregroundColor: NSColor.white,
]
let labelSize = label.size(withAttributes: attrs)
let labelRect = NSRect(
x: vf.maxX - labelSize.width - 8,
y: vf.minY - labelSize.height - 10,
width: labelSize.width + 8,
height: labelSize.height + 6
)
NSColor.black.withAlphaComponent(0.6).setFill()
NSBezierPath(roundedRect: labelRect, xRadius: 4, yRadius: 4).fill()
label.draw(at: NSPoint(x: labelRect.minX + 4, y: labelRect.minY + 3), withAttributes: attrs)
}
override func draw(_ dirtyRect: NSRect) {
NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill()
if let frozenImage {
drawFrozen(frozenImage)
} else {
NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill()
}
// Esc hint — the only way out once clicks are consumed by the tap.
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),
]
let hintSize = hint.size(withAttributes: hintAttrs)
let hintRect = NSRect(
x: (bounds.width - hintSize.width) / 2 - 10,
y: bounds.height - 46,
width: hintSize.width + 20,
height: hintSize.height + 12
)
NSColor.black.withAlphaComponent(0.65).setFill()
NSBezierPath(roundedRect: hintRect, xRadius: 8, yRadius: 8).fill()
hint.draw(at: NSPoint(x: hintRect.minX + 10, y: hintRect.minY + 6), withAttributes: hintAttrs)
guard let controller else { return }
// Frozen mode: same look as live mode — a dark veil over everything,
// with the selection showing the frozen pixels undimmed.
if let frozenImage {
NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill()
if let sel = controller.marqueeRect, sel.width > 0, sel.height > 0 {
let vf = viewRect(sel)
NSGraphicsContext.current?.saveGraphicsState()
NSBezierPath(rect: vf).addClip()
drawFrozen(frozenImage)
NSGraphicsContext.current?.restoreGraphicsState()
let border = NSBezierPath(rect: vf)
border.lineWidth = 1.5
NSColor.controlAccentColor.setStroke()
border.stroke()
drawSizeLabel(for: sel, in: vf)
}
return
}
// Highlight the hovered window (rounded, like real window corners)
if let frame = controller.hoverFrame {
let vf = viewRect(frame)
+181
View File
@@ -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)))
}
}
+585 -153
View File
@@ -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?
@@ -90,12 +90,24 @@ final class CaptureModel: ObservableObject {
@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)
@@ -105,6 +117,7 @@ final class CaptureModel: ObservableObject {
case select
case draw
case redact
case crop
}
struct Stroke {
@@ -126,6 +139,53 @@ final class CaptureModel: ObservableObject {
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 }
@@ -195,14 +255,15 @@ final class CaptureModel: ObservableObject {
return rep.cgImage ?? image
}
func addStroke(_ points: [CGPoint]) {
/// `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, as thick as the
// current stroke width (drawn as a rect for clean edges).
strokes.append(Stroke(points: points, width: max(strokeWidth, 12), color: .black, isRedaction: true))
// 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: strokeWidth, color: drawColor))
strokes.append(Stroke(points: points, width: widthPx, color: drawColor))
}
redoStack = []
}
@@ -214,8 +275,42 @@ 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 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) {
@@ -297,6 +392,141 @@ 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
var shortcut: KeyboardShortcut? = nil
let action: () -> Void
var body: some View {
Button(action: action) {
Label(title, systemImage: icon)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(primary ? Color.white : Color.primary.opacity(0.85))
.padding(.horizontal, 11)
.padding(.vertical, 5.5)
.background(Capsule().fill(primary ? CapStyle.blue : CapStyle.track))
.contentShape(Capsule())
}
.buttonStyle(CapPressableStyle())
.keyboardShortcut(shortcut)
.disabled(disabled)
.opacity(disabled ? 0.5 : 1)
.help(help)
}
}
/// Circular icon button with hover fill and a blue-tinted active state.
private struct CapIconButton: View {
let icon: String
var active = false
var disabled = false
var help: String
var shortcut: KeyboardShortcut? = nil
let action: () -> Void
@State private var hovering = false
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(active ? CapStyle.blue : Color.secondary)
.frame(width: 29, height: 29)
.background(Circle().fill(active ? CapStyle.blueTint : (hovering ? CapStyle.track : Color.clear)))
.contentShape(Circle())
}
.buttonStyle(CapPressableStyle())
.keyboardShortcut(shortcut)
.onHover { hovering = $0 }
.animation(.easeOut(duration: 0.12), value: hovering)
.disabled(disabled)
.opacity(disabled ? 0.4 : 1)
.help(help)
}
}
// MARK: - Result window
struct ResultView: View {
@ObservedObject var model: CaptureModel
let onClose: () -> Void
@@ -304,96 +534,88 @@ 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)
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() }
}
.pickerStyle(.segmented)
.fixedSize()
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() }
}
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")
model.cropRect = nil
}
.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")
model.cropRect = nil
}
.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")
CapIconButton(
icon: "crop",
active: model.tool == .crop,
help: "Crop the image"
) {
if model.tool == .crop {
model.cancelCrop()
} else {
model.tool = .crop
model.beginCropMode()
}
}
.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)
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) {
@@ -404,10 +626,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) }
@@ -415,68 +636,71 @@ struct ResultView: View {
.labelsHidden()
.frame(width: 28)
.help("Drawing color")
} else {
} 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()
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)")
if model.tool == .crop {
CapCapsuleButton(
title: "Cancel", icon: "xmark",
help: "Cancel cropping (Esc)",
shortcut: .cancelAction
) { model.cancelCrop() }
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)")
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() }
Button {
model.clearAnnotations()
} label: {
Label("Clear", systemImage: "eraser")
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() }
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(model.strokes.isEmpty)
.help("Remove all drawn annotations")
}
.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
@@ -486,7 +710,11 @@ 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.tool == .crop
? "Adjust the crop — Return to apply, Esc to cancel."
: (model.hasSelection
? "Region selected — OCR it, or adjust it with Crop."
: (model.isActualSize ? "Actual size — scroll to pan, drag to select." : "Drag to select a region, or Get All Text.")))
: "Selectable text"))
.font(.caption)
.foregroundStyle(.secondary)
@@ -495,28 +723,37 @@ 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)
}
}
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: model.image.width, height: model.image.height)
CGSize(width: displayedImage.width, height: displayedImage.height)
}
private func geometry(for geo: GeometryProxy) -> (scale: CGFloat, drawSize: CGSize, origin: CGPoint) {
@@ -542,18 +779,150 @@ struct ScreenshotPane: View {
}
@State private var currentStroke: [CGPoint] = []
@State private var cropDrag: CropDrag?
@State private var cropStartRect = CGRect.zero
/// Which part of the crop rect a drag is manipulating.
private enum CropDrag {
case move, n, s, e, w, ne, nw, se, sw
}
/// Hit-test the drag start against the crop handles (corners, edge
/// midpoints), then the interior (move). Nil = outside the crop rect.
private func cropHandle(at viewPoint: CGPoint, cropView: CGRect) -> CropDrag? {
let points: [(CropDrag, CGPoint)] = [
(.nw, CGPoint(x: cropView.minX, y: cropView.minY)),
(.n, CGPoint(x: cropView.midX, y: cropView.minY)),
(.ne, CGPoint(x: cropView.maxX, y: cropView.minY)),
(.e, CGPoint(x: cropView.maxX, y: cropView.midY)),
(.se, CGPoint(x: cropView.maxX, y: cropView.maxY)),
(.s, CGPoint(x: cropView.midX, y: cropView.maxY)),
(.sw, CGPoint(x: cropView.minX, y: cropView.maxY)),
(.w, CGPoint(x: cropView.minX, y: cropView.midY)),
]
for (drag, point) in points where hypot(point.x - viewPoint.x, point.y - viewPoint.y) <= 16 {
return drag
}
return cropView.contains(viewPoint) ? .move : nil
}
/// Compute the crop rect for a drag, clamped to the image and with a
/// minimum size. The dragged edge/corner follows the pointer; the
/// opposite side stays put.
private static func resizedCropRect(from start: CGRect, drag: CropDrag, delta: CGSize, bounds: CGRect) -> CGRect {
let minSize: CGFloat = 10
switch drag {
case .move:
var rect = start.offsetBy(dx: delta.width, dy: delta.height)
rect.origin.x = min(max(rect.origin.x, bounds.minX), bounds.maxX - rect.width)
rect.origin.y = min(max(rect.origin.y, bounds.minY), bounds.maxY - rect.height)
return rect
case .n, .s, .e, .w:
var minX = start.minX, maxX = start.maxX
var minY = start.minY, maxY = start.maxY
if drag == .w { minX = min(max(start.minX + delta.width, bounds.minX), start.maxX - minSize) }
if drag == .e { maxX = max(min(start.maxX + delta.width, bounds.maxX), start.minX + minSize) }
if drag == .n { minY = min(max(start.minY + delta.height, bounds.minY), start.maxY - minSize) }
if drag == .s { maxY = max(min(start.maxY + delta.height, bounds.maxY), start.minY + minSize) }
return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY)
case .nw, .ne, .sw, .se:
// Corner drag: the opposite corner is the anchor; normalize so
// the anchor stays fixed whichever way the pointer goes.
let anchor = CGPoint(
x: (drag == .nw || drag == .sw) ? start.maxX : start.minX,
y: (drag == .nw || drag == .ne) ? start.maxY : start.minY
)
let moved = CGPoint(
x: min(max(((drag == .nw || drag == .sw) ? start.minX : start.maxX) + delta.width, bounds.minX), bounds.maxX),
y: min(max(((drag == .nw || drag == .ne) ? start.minY : start.maxY) + delta.height, bounds.minY), bounds.maxY)
)
let rect = CGRect(
x: min(anchor.x, moved.x), y: min(anchor.y, moved.y),
width: abs(moved.x - anchor.x), height: abs(moved.y - anchor.y)
)
return (rect.width >= minSize && rect.height >= minSize) ? rect : start
}
}
private func updateCropDrag(_ value: DragGesture.Value, scale: CGFloat, origin: CGPoint) {
guard let current = model.cropRect else { return }
let bounds = CGRect(origin: .zero, size: imageSize)
if cropDrag == nil {
let cropView = CGRect(
x: origin.x + current.minX * scale, y: origin.y + current.minY * scale,
width: current.width * scale, height: current.height * scale
)
if let handle = cropHandle(at: value.startLocation, cropView: cropView) {
cropDrag = handle
cropStartRect = current
} else {
// Started outside the crop rect: draw a fresh one anchored
// where the drag began (handled by the .se anchor math).
let anchor = toImagePoint(value.startLocation, scale: scale, origin: origin)
guard bounds.contains(anchor) else { return }
cropDrag = .se
cropStartRect = CGRect(origin: anchor, size: .zero)
}
}
guard let drag = cropDrag else { return }
let delta = CGSize(width: value.translation.width / scale, height: value.translation.height / scale)
let newRect = Self.resizedCropRect(from: cropStartRect, drag: drag, delta: delta, bounds: bounds)
guard newRect.width >= 1, newRect.height >= 1 else { return }
model.cropRect = newRect
}
@Environment(\.displayScale) private var displayScale
var body: some View {
GeometryReader { geo in
let (scale, drawSize, origin) = geometry(for: geo)
if model.isActualSize {
// Actual size: 1 image pixel per screen pixel (view scale =
// 1/displayScale). Scrollable when larger than the viewport;
// centred when smaller.
let scale = 1 / max(displayScale, 0.01)
let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
let origin = CGPoint(
x: max(0, (geo.size.width - drawSize.width) / 2),
y: max(0, (geo.size.height - drawSize.height) / 2)
)
ScrollView([.horizontal, .vertical]) {
imageContent(scale: scale, drawSize: drawSize, origin: origin)
.frame(
width: max(drawSize.width, geo.size.width),
height: max(drawSize.height, geo.size.height)
)
}
} else {
let (scale, drawSize, origin) = geometry(for: geo)
imageContent(scale: scale, drawSize: drawSize, origin: origin)
.frame(width: geo.size.width, height: geo.size.height)
}
}
}
ZStack {
Image(nsImage: NSImage(cgImage: model.image, size: .zero))
/// 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(imageSize.width >= geo.size.width ? .high : .none)
.interpolation(scale < 1 ? .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
@@ -563,22 +932,24 @@ struct ScreenshotPane: View {
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,
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 * scale, y: origin.y + stroke.points[0].y * scale))
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 * scale, y: origin.y + point.y * scale))
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: stroke.color)), lineWidth: stroke.width)
// 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
@@ -586,13 +957,71 @@ struct ScreenshotPane: View {
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))
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 * scale, y: origin.y + point.y * scale))
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)
}
@@ -604,8 +1033,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)
@@ -615,7 +1044,9 @@ struct ScreenshotPane: View {
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
if model.tool != .select {
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) {
@@ -634,9 +1065,11 @@ struct ScreenshotPane: View {
}
}
.onEnded { _ in
if model.tool != .select {
if model.tool == .crop {
cropDrag = nil
} else if model.tool != .select {
if currentStroke.count > 1 {
model.addStroke(currentStroke)
model.addStroke(currentStroke, widthPx: model.strokeWidth / max(scale, 0.01))
}
currentStroke = []
} else {
@@ -646,6 +1079,5 @@ struct ScreenshotPane: View {
}
}
)
}
}
}
+203
View File
@@ -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())
}
}
+2 -2
View File
@@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
<key>CFBundleIconFile</key>
<string>Icon</string>
<key>CFBundleShortVersionString</key>
<string>1.8.4</string>
<string>1.11.1</string>
<key>CFBundleVersion</key>
<string>84</string>
<string>111</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>