Compare commits

..
1 Commits
Author SHA1 Message Date
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
5 changed files with 226 additions and 19 deletions
+4
View File
@@ -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()
+122 -3
View File
@@ -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)
+33 -12
View File
@@ -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)
}
}
+65 -2
View File
@@ -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
View File
@@ -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>