Compare commits

..
2 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
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
6 changed files with 235 additions and 26 deletions
+4
View File
@@ -33,6 +33,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
HotkeyManager.shared.onTrigger = { CaptureController.shared.begin() } HotkeyManager.shared.onTrigger = { CaptureController.shared.begin() }
HotkeyManager.shared.onFrozenTrigger = { CaptureController.shared.begin(frozen: true) }
HotkeyManager.shared.register() HotkeyManager.shared.register()
} }
} }
@@ -44,6 +45,9 @@ struct MenuView: View {
Button("Capture & OCR") { CaptureController.shared.begin() } Button("Capture & OCR") { CaptureController.shared.begin() }
.keyboardShortcut("s", modifiers: [.command, .shift]) .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() } Button("Extract from Image File…") { ResultWindow.shared.showOpenPanel() }
Divider() Divider()
+122 -3
View File
@@ -76,13 +76,19 @@ final class CaptureController: NSObject {
// MARK: - Session lifecycle // 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 !isSelecting else { return }
guard PermissionManager.isGranted else { guard PermissionManager.isGranted else {
PermissionManager.request() PermissionManager.request()
return return
} }
isSelecting = true isSelecting = true
isFrozenMode = frozen
previousApp = NSWorkspace.shared.frontmostApplication previousApp = NSWorkspace.shared.frontmostApplication
// With the event tap driving input we must NOT activate: stealing // With the event tap driving input we must NOT activate: stealing
// focus turns the target app into background mode (dimmed chrome), // focus turns the target app into background mode (dimmed chrome),
@@ -92,12 +98,31 @@ final class CaptureController: NSObject {
if !usesTap { if !usesTap {
NSApp.activate(ignoringOtherApps: true) 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 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self, self.isSelecting else { return } guard let self, self.isSelecting else { return }
self.overlays = NSScreen.screens.map { screen in self.overlays = NSScreen.screens.map { screen in
let panel = OverlayWindow(screen: screen, controller: self) 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 return panel
} }
// orderFrontRegardless: show without taking key/focus. // orderFrontRegardless: show without taking key/focus.
@@ -126,11 +151,30 @@ final class CaptureController: NSObject {
private func finishSelection(_ rect: NSRect) { private func finishSelection(_ rect: NSRect) {
let ids = overlayIDs let ids = overlayIDs
let frozen = frozenSnapshots
let wasFrozen = isFrozenMode
hideOverlays() hideOverlays()
guard rect.width >= 2, rect.height >= 2 else { guard rect.width >= 2, rect.height >= 2 else {
cancelSelection() cancelSelection()
return 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 DispatchQueue.main.async { [self] in
capture(rect, excludingWindowIDs: ids) capture(rect, excludingWindowIDs: ids)
} }
@@ -149,6 +193,8 @@ final class CaptureController: NSObject {
marqueeRect = nil marqueeRect = nil
hoverWindow = nil hoverWindow = nil
marqueeStart = nil marqueeStart = nil
frozenSnapshots = []
isFrozenMode = false
} }
private func restorePreviousApp() { private func restorePreviousApp() {
@@ -306,6 +352,8 @@ final class CaptureController: NSObject {
} }
let appKitPoint = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y)) let appKitPoint = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y))
if type == .mouseMoved { if type == .mouseMoved {
// Frozen mode is marquee-only: no window hover detection.
if isFrozenMode { return event }
let window = topmostWindowContaining(appKitPoint) let window = topmostWindowContaining(appKitPoint)
if window?.frame != hoverFrame { if window?.frame != hoverFrame {
hoverWindow = window hoverWindow = window
@@ -345,6 +393,9 @@ final class CaptureController: NSObject {
if let rect = marqueeRect, dragDistance >= 4, rect.width >= 2, rect.height >= 2 { if let rect = marqueeRect, dragDistance >= 4, rect.width >= 2, rect.height >= 2 {
CaptureController.log("tap: marquee complete \(rect)") CaptureController.log("tap: marquee complete \(rect)")
finishSelection(rect) finishSelection(rect)
} else if isFrozenMode {
CaptureController.log("tap: frozen click without drag — cancel")
cancelSelection()
} else if let hover = hoverWindow { } else if let hover = hoverWindow {
// An overlapping window from the same app means this is an // An overlapping window from the same app means this is an
// owned popup — desktopIndependentWindow would composite the // owned popup — desktopIndependentWindow would composite the
@@ -506,6 +557,74 @@ final class ScreenCapture {
return result 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, *) @available(macOS 14.0, *)
private func displayContaining(_ rect: NSRect, in content: SCShareableContent) -> SCDisplay? { private func displayContaining(_ rect: NSRect, in content: SCShareableContent) -> SCDisplay? {
let mid = NSPoint(x: rect.midX, y: rect.midY) let mid = NSPoint(x: rect.midX, y: rect.midY)
+33 -12
View File
@@ -4,11 +4,14 @@ import AppKit
final class HotkeyManager { final class HotkeyManager {
static let shared = HotkeyManager() static let shared = HotkeyManager()
/// ⌘⇧S — live capture (ScreenCaptureKit at capture time).
var onTrigger: (() -> Void)? 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 eventHandlerRef: EventHandlerRef?
private var hotKeyID = EventHotKeyID(signature: 0x4B505452, id: 1) private let signature: OSType = 0x4B505452
private init() {} private init() {}
@@ -17,18 +20,36 @@ final class HotkeyManager {
eventClass: OSType(kEventClassKeyboard), eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed) eventKind: UInt32(kEventHotKeyPressed)
) )
InstallEventHandler(GetApplicationEventTarget(), { _, _, _ in InstallEventHandler(GetApplicationEventTarget(), { _, event, _ in
HotkeyManager.shared.onTrigger?() 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 return noErr
}, 1, &eventType, nil, &eventHandlerRef) }, 1, &eventType, nil, &eventHandlerRef)
RegisterEventHotKey( registerHotkey(id: 1, keyCode: UInt32(kVK_ANSI_S), modifiers: UInt32(cmdKey | shiftKey))
UInt32(kVK_ANSI_S), registerHotkey(id: 2, keyCode: UInt32(kVK_ANSI_S), modifiers: UInt32(cmdKey | shiftKey | controlKey))
UInt32(cmdKey | shiftKey), }
hotKeyID,
GetApplicationEventTarget(), private func registerHotkey(id: UInt32, keyCode: UInt32, modifiers: UInt32) {
0, var ref: EventHotKeyRef?
&hotKeyRef 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 { final class OverlayView: NSView {
weak var controller: CaptureController? 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 } 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) { override func draw(_ dirtyRect: NSRect) {
NSColor.black.withAlphaComponent(0.35).setFill() if let frozenImage {
NSBezierPath(rect: bounds).fill() 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. // 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" 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 } 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) // Highlight the hovered window (rounded, like real window corners)
if let frame = controller.hoverFrame { if let frame = controller.hoverFrame {
let vf = viewRect(frame) let vf = viewRect(frame)
+9 -7
View File
@@ -195,14 +195,15 @@ final class CaptureModel: ObservableObject {
return rep.cgImage ?? image 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 } guard points.count > 1 else { return }
if tool == .redact { if tool == .redact {
// Redaction: a filled black bar from start to end, as thick as the // Redaction: a filled black bar from start to end.
// current stroke width (drawn as a rect for clean edges). strokes.append(Stroke(points: points, width: max(widthPx, 12), color: .black, isRedaction: true))
strokes.append(Stroke(points: points, width: max(strokeWidth, 12), color: .black, isRedaction: true))
} else { } else {
strokes.append(Stroke(points: points, width: strokeWidth, color: drawColor)) strokes.append(Stroke(points: points, width: widthPx, color: drawColor))
} }
redoStack = [] redoStack = []
} }
@@ -575,7 +576,8 @@ struct ScreenshotPane: View {
for point in stroke.points.dropFirst() { 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 * scale, y: origin.y + point.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)
} }
} }
} }
@@ -636,7 +638,7 @@ struct ScreenshotPane: View {
.onEnded { _ in .onEnded { _ in
if model.tool != .select { if model.tool != .select {
if currentStroke.count > 1 { if currentStroke.count > 1 {
model.addStroke(currentStroke) model.addStroke(currentStroke, widthPx: model.strokeWidth / max(scale, 0.01))
} }
currentStroke = [] currentStroke = []
} else { } else {
+2 -2
View File
@@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>Icon</string> <string>Icon</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.9.1</string> <string>1.10.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>91</string> <string>100</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>