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
This commit is contained in:
@@ -33,6 +33,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
}
|
||||
HotkeyManager.shared.onTrigger = { CaptureController.shared.begin() }
|
||||
HotkeyManager.shared.onFrozenTrigger = { CaptureController.shared.begin(frozen: true) }
|
||||
HotkeyManager.shared.register()
|
||||
}
|
||||
}
|
||||
@@ -44,6 +45,9 @@ struct MenuView: View {
|
||||
Button("Capture & OCR") { CaptureController.shared.begin() }
|
||||
.keyboardShortcut("s", modifiers: [.command, .shift])
|
||||
|
||||
Button("Capture Frozen Screen") { CaptureController.shared.begin(frozen: true) }
|
||||
.keyboardShortcut("s", modifiers: [.command, .shift, .control])
|
||||
|
||||
Button("Extract from Image File…") { ResultWindow.shared.showOpenPanel() }
|
||||
|
||||
Divider()
|
||||
|
||||
@@ -76,13 +76,19 @@ 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
|
||||
private var frozenSnapshots: [ScreenCapture.DisplaySnapshot] = []
|
||||
|
||||
func begin(frozen: Bool = false) {
|
||||
guard !isSelecting else { return }
|
||||
guard PermissionManager.isGranted else {
|
||||
PermissionManager.request()
|
||||
return
|
||||
}
|
||||
isSelecting = true
|
||||
isFrozenMode = frozen
|
||||
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),
|
||||
@@ -92,12 +98,31 @@ final class CaptureController: NSObject {
|
||||
if !usesTap {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
CaptureController.log("begin: usesTap=\(usesTap) active=\(NSApp.isActive)")
|
||||
CaptureController.log("begin: frozen=\(frozen) 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 }
|
||||
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
|
||||
}
|
||||
// orderFrontRegardless: show without taking key/focus.
|
||||
@@ -126,11 +151,30 @@ final class CaptureController: NSObject {
|
||||
|
||||
private func finishSelection(_ rect: NSRect) {
|
||||
let ids = overlayIDs
|
||||
let frozen = frozenSnapshots
|
||||
let wasFrozen = isFrozenMode
|
||||
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
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [self] in
|
||||
capture(rect, excludingWindowIDs: ids)
|
||||
}
|
||||
@@ -149,6 +193,8 @@ final class CaptureController: NSObject {
|
||||
marqueeRect = nil
|
||||
hoverWindow = nil
|
||||
marqueeStart = nil
|
||||
frozenSnapshots = []
|
||||
isFrozenMode = false
|
||||
}
|
||||
|
||||
private func restorePreviousApp() {
|
||||
@@ -306,6 +352,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
|
||||
@@ -345,6 +393,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
|
||||
@@ -506,6 +557,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)
|
||||
|
||||
@@ -4,11 +4,14 @@ 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)?
|
||||
|
||||
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 +20,36 @@ 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?()
|
||||
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))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,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 }
|
||||
|
||||
@@ -56,9 +59,42 @@ 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 = "Drag to select · hover + click a window to capture it · Esc to cancel"
|
||||
@@ -79,6 +115,33 @@ final class OverlayView: NSView {
|
||||
|
||||
guard let controller else { return }
|
||||
|
||||
// Frozen mode: no window highlight; the marquee is cut straight out
|
||||
// of the still image so it shows the frozen pixels.
|
||||
if frozenImage != nil {
|
||||
if let sel = controller.marqueeRect, sel.width > 0, sel.height > 0 {
|
||||
let vf = viewRect(sel)
|
||||
// Dim everything except the selection (even-odd fill).
|
||||
let maskPath = CGMutablePath()
|
||||
maskPath.addRect(bounds)
|
||||
maskPath.addRect(vf)
|
||||
NSGraphicsContext.current?.saveGraphicsState()
|
||||
let ctx = NSGraphicsContext.current?.cgContext
|
||||
ctx?.addPath(maskPath)
|
||||
ctx?.fillPath(using: .evenOdd)
|
||||
NSColor.black.withAlphaComponent(0.35).setFill()
|
||||
ctx?.addPath(maskPath)
|
||||
ctx?.fillPath(using: .evenOdd)
|
||||
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)
|
||||
|
||||
+2
-2
@@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Icon</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.9.2</string>
|
||||
<string>1.10.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>92</string>
|
||||
<string>100</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
|
||||
Reference in New Issue
Block a user