diff --git a/README.md b/README.md index 9d08066..a1c8ae4 100644 --- a/README.md +++ b/README.md @@ -67,19 +67,21 @@ The contributor build is named `ZoomIt (Dev).app` (bundle id `com.sysinternals.z ## Default Hotkeys -| Action | Shortcut | -| --- | --- | -| Static zoom | `Control+1` | -| Draw without zoom | `Control+2` | -| Break timer | `Control+3` | -| Live zoom | `Control+4` | -| Record screen | `Control+5` | -| Record region | `Control+Shift+5` | -| Snip region to clipboard | `Control+6` | -| Snip region to file | `Control+Shift+6` | -| OCR region to clipboard | `Control+Option+6` | -| Panorama to clipboard | `Control+8` | -| Panorama to file | `Control+Shift+8` | +| Action | Shortcut | +| ------------------------------------ | ------------------ | +| Static zoom | `Control+1` | +| Draw without zoom | `Control+2` | +| Break timer | `Control+3` | +| Live zoom | `Control+4` | +| Record screen | `Control+5` | +| Record region | `Control+Shift+5` | +| Snip region to clipboard | `Control+6` | +| Snip region to file | `Control+Shift+6` | +| OCR region to clipboard | `Control+Option+6` | +| Panorama to clipboard | `Control+8` | +| Panorama to file | `Control+Shift+8` | +| Capture previous region to clipboard | `Control+9` | +| Capture previous region to file | `Control+Shift+9` | All global hotkeys are configurable in Settings. While zoomed, use `Option+Up` and `Option+Down` to change zoom level, mouse wheel to zoom or resize tools depending on mode, `Command+S` / `Command+C` to save or copy the viewport, and `Esc` or right click to exit the active overlay mode. @@ -97,6 +99,8 @@ Press the snip shortcut (`Control+6`) and drag a rectangle to copy that region o Press the OCR shortcut (`Control+Option+6`) and drag a rectangle to recognize the text inside it and copy that text to the clipboard. OCR uses Apple's on-device Vision text recognition, runs entirely on-device, and needs no extra permissions. Both shortcuts are configurable on the Snip tab in Settings. +ZoomItMac also remembers the last successful region snip. Use the menu command **Capture Previous Region** or press `Control+9` to repeat that capture without reopening the region selector. Hold `Shift` (`Control+Shift+9`) to save to a file instead of copying. + ## Recording Recording uses ScreenCaptureKit and AVAssetWriter. Static zoom and drawing overlays are captured even when ScreenCaptureKit omits ZoomIt's own windows; live zoom remains excluded from its own capture to avoid feedback. Webcam picture-in-picture stays fixed in the recorded viewport and is composited into overlay recordings instead of being zoomed into the source image. diff --git a/Sources/ZoomItMacCore/App/AppController.swift b/Sources/ZoomItMacCore/App/AppController.swift index 345ef91..0e36ead 100644 --- a/Sources/ZoomItMacCore/App/AppController.swift +++ b/Sources/ZoomItMacCore/App/AppController.swift @@ -52,6 +52,10 @@ final class AppController: NSObject { modeCoordinator.handle(.startPanorama(save: false)) } + @objc func capturePreviousRegion() { + modeCoordinator.handle(.snipPreviousRegion(save: false)) + } + @objc func toggleBreakTimer() { modeCoordinator.handle(.toggleBreakTimer) } diff --git a/Sources/ZoomItMacCore/Capture/SnipController.swift b/Sources/ZoomItMacCore/Capture/SnipController.swift index 9be3038..f5b927e 100644 --- a/Sources/ZoomItMacCore/Capture/SnipController.swift +++ b/Sources/ZoomItMacCore/Capture/SnipController.swift @@ -198,6 +198,25 @@ enum SnipAction { /// overlay, and copies or saves the chosen region when the drag is released. @MainActor final class SnipController { + private struct PreviousSelection { + let displayID: CGDirectDisplayID + let displaySize: CGSize + let rect: CGRect + +func rectForCurrentDisplay(_ display: DisplayDescriptor) -> CGRect { + guard displaySize.width > 0, displaySize.height > 0 else { return rect } + + let widthScale = display.frame.width / displaySize.width + let heightScale = display.frame.height / displaySize.height + return CGRect( + x: rect.minX * widthScale, + y: rect.minY * heightScale, + width: rect.width * widthScale, + height: rect.height * heightScale + ) +} + } + private let captureService: ScreenCaptureService private let displayManager: DisplayManager private let permissionService: PermissionService @@ -208,6 +227,7 @@ final class SnipController { private var action: SnipAction = .copyImage private var onFinished: (() -> Void)? private var cursorLease: CrosshairCursorLease? + private var previousSelection: PreviousSelection? init( captureService: ScreenCaptureService, @@ -257,6 +277,41 @@ final class SnipController { } } + /// Repeats the last successful region snip with no selection UI. + /// Returns false if there is no previous region to reuse. + @discardableResult + func capturePrevious(action: SnipAction, onFinished: @escaping () -> Void) -> Bool { + guard let previousSelection else { + return false + } + + self.action = action + self.onFinished = onFinished + + guard ScreenRecordingPrompt.ensureGranted(permissionService) else { + finish() + return true + } + guard let display = displayManager.activeDisplay() else { + NSSound.beep() + finish() + return true + } + + Task { @MainActor in + do { + let frame = try await captureService.captureDisplay(display) + let rect = previousSelection.rectForCurrentDisplay(frame.display) + _ = self.handleSelection(rect, in: frame) + self.finish() + } catch { + NSSound.beep() + self.finish() + } + } + return true + } + private func show(frame: CapturedFrame) { capturedFrame = frame @@ -299,19 +354,39 @@ final class SnipController { return } + _ = handleSelection(rect, in: frame) + finish() + } + + @discardableResult + private func handleSelection(_ rect: CGRect, in frame: CapturedFrame) -> Bool { + guard rect.width >= 3, rect.height >= 3 else { + return false + } + + let boundedRect = rect.intersection(CGRect(origin: .zero, size: frame.display.frame.size)) + guard boundedRect.width >= 3, boundedRect.height >= 3 else { + return false + } + let scale = frame.display.scaleFactor let pixelRect = CGRect( - x: rect.minX * scale, - y: rect.minY * scale, - width: rect.width * scale, - height: rect.height * scale + x: boundedRect.minX * scale, + y: boundedRect.minY * scale, + width: boundedRect.width * scale, + height: boundedRect.height * scale ).integral guard let cropped = frame.image.cropping(to: pixelRect) else { - finish() - return + return false } + previousSelection = PreviousSelection( + displayID: frame.display.id, + displaySize: frame.display.frame.size, + rect: boundedRect + ) + switch action { case .saveImage: ImageExporter.saveImage(cropped, settings: settingsStore.load()) @@ -320,7 +395,7 @@ final class SnipController { case .recognizeText: OcrService.recognizeAndCopy(cropped) } - finish() + return true } private func closeWindow() { diff --git a/Sources/ZoomItMacCore/Core/AppCommand.swift b/Sources/ZoomItMacCore/Core/AppCommand.swift index 57110ee..b997f69 100644 --- a/Sources/ZoomItMacCore/Core/AppCommand.swift +++ b/Sources/ZoomItMacCore/Core/AppCommand.swift @@ -15,6 +15,7 @@ enum AppCommand: Equatable { case undo case clear case snipRegion(save: Bool) + case snipPreviousRegion(save: Bool) case snipOcr case startPanorama(save: Bool) case toggleRecording(region: Bool) diff --git a/Sources/ZoomItMacCore/Core/ModeCoordinator.swift b/Sources/ZoomItMacCore/Core/ModeCoordinator.swift index 72fb9e5..72b4db3 100644 --- a/Sources/ZoomItMacCore/Core/ModeCoordinator.swift +++ b/Sources/ZoomItMacCore/Core/ModeCoordinator.swift @@ -75,7 +75,7 @@ final class ModeCoordinator { // Ignore activation and snip hotkeys while a region selection is on // screen so a second trigger can't stack overlays. switch command { - case .activateStaticZoom, .activateLiveZoom, .activateDrawWithoutZoom, .snipRegion, .zoomIn, .zoomOutOrExit: + case .activateStaticZoom, .activateLiveZoom, .activateDrawWithoutZoom, .snipRegion, .snipPreviousRegion, .zoomIn, .zoomOutOrExit: return default: break @@ -115,6 +115,8 @@ final class ModeCoordinator { overlayController.requestRedraw() case .snipRegion(let save): startSnip(action: save ? .saveImage : .copyImage) + case .snipPreviousRegion(let save): + startSnip(action: save ? .saveImage : .copyImage, reusePreviousRegion: true) case .snipOcr: startSnip(action: .recognizeText) case .toggleRecording(let region): @@ -412,18 +414,40 @@ final class ModeCoordinator { /// Starts a region snip. From idle it captures the screen; while zoomed it /// selects within the current viewport so ZoomIt's own overlay is reused /// rather than captured. - private func startSnip(action: SnipAction) { + private func startSnip(action: SnipAction, reusePreviousRegion: Bool = false) { guard !isSnipping else { NSSound.beep() return } switch mode { case .idle: + if reusePreviousRegion { + isSnipping = true + let started = snipController.capturePrevious(action: action) { [weak self] in + self?.isSnipping = false + } + if !started { + isSnipping = false + NSSound.beep() + } + return + } isSnipping = true snipController.begin(action: action) { [weak self] in self?.isSnipping = false } case .staticZoom, .liveZoom, .drawOnly, .typing: + if reusePreviousRegion { + isSnipping = true + let started = overlayController.capturePreviousRegionSnip(action: action) { [weak self] in + self?.isSnipping = false + } + if !started { + isSnipping = false + NSSound.beep() + } + return + } isSnipping = true overlayController.beginRegionSnip(action: action) { [weak self] in self?.isSnipping = false diff --git a/Sources/ZoomItMacCore/Hotkeys/HotkeyService.swift b/Sources/ZoomItMacCore/Hotkeys/HotkeyService.swift index f78e4ff..293e4f0 100644 --- a/Sources/ZoomItMacCore/Hotkeys/HotkeyService.swift +++ b/Sources/ZoomItMacCore/Hotkeys/HotkeyService.swift @@ -11,6 +11,8 @@ final class HotkeyService { private var snipCopyHotKeyRef: EventHotKeyRef? private var snipSaveHotKeyRef: EventHotKeyRef? private var snipOcrHotKeyRef: EventHotKeyRef? + private var snipPreviousHotKeyRef: EventHotKeyRef? + private var snipPreviousSaveHotKeyRef: EventHotKeyRef? private var recordHotKeyRef: EventHotKeyRef? private var recordRegionHotKeyRef: EventHotKeyRef? private var demoTypeHotKeyRef: EventHotKeyRef? @@ -131,6 +133,8 @@ final class HotkeyService { case 13: command = .snipOcr case 14: command = .startDemoType case 15: command = .resetDemoType + case 16: command = .snipPreviousRegion(save: false) + case 17: command = .snipPreviousRegion(save: true) default: return noErr } @@ -220,6 +224,30 @@ final class HotkeyService { ) } + // Capture Previous Region: reuses the last successful region snip. + // The base shortcut copies; the same shortcut with Shift toggled saves. + if settings.snipPreviousHotKeyCode != 0 { + let snipPreviousModifiers = NSEvent.ModifierFlags(rawValue: settings.snipPreviousHotKeyModifiers) + RegisterEventHotKey( + UInt32(settings.snipPreviousHotKeyCode), + carbonModifiers(from: snipPreviousModifiers), + EventHotKeyID(signature: signature, id: 16), + target, + 0, + &snipPreviousHotKeyRef + ) + + let snipPreviousSaveModifiers = NSEvent.ModifierFlags(rawValue: settings.snipPreviousHotKeyModifiers ^ NSEvent.ModifierFlags.shift.rawValue) + RegisterEventHotKey( + UInt32(settings.snipPreviousHotKeyCode), + carbonModifiers(from: snipPreviousSaveModifiers), + EventHotKeyID(signature: signature, id: 17), + target, + 0, + &snipPreviousSaveHotKeyRef + ) + } + // Recording: the base shortcut records the whole screen; the same // shortcut with Shift toggled records a selected region. let recordModifiers = NSEvent.ModifierFlags(rawValue: settings.recordHotKeyModifiers) @@ -322,6 +350,14 @@ final class HotkeyService { UnregisterEventHotKey(snipOcrHotKeyRef) } snipOcrHotKeyRef = nil + if let snipPreviousHotKeyRef { + UnregisterEventHotKey(snipPreviousHotKeyRef) + } + snipPreviousHotKeyRef = nil + if let snipPreviousSaveHotKeyRef { + UnregisterEventHotKey(snipPreviousSaveHotKeyRef) + } + snipPreviousSaveHotKeyRef = nil if let recordHotKeyRef { UnregisterEventHotKey(recordHotKeyRef) } diff --git a/Sources/ZoomItMacCore/Overlay/OverlayWindowController.swift b/Sources/ZoomItMacCore/Overlay/OverlayWindowController.swift index 7900414..ecc21bd 100644 --- a/Sources/ZoomItMacCore/Overlay/OverlayWindowController.swift +++ b/Sources/ZoomItMacCore/Overlay/OverlayWindowController.swift @@ -136,6 +136,18 @@ final class OverlayWindowController { } canvasView.beginRegionSnip(action: action, onFinished: onFinished) } + + /// Captures using the last successful viewport-region snip, without showing + /// the drag selection UI. Returns false if no previous region exists. + func capturePreviousRegionSnip(action: SnipAction, onFinished: @escaping () -> Void) -> Bool { + guard let canvasView else { + onFinished() + return false + } + let captured = canvasView.capturePreviousRegionSnip(action: action) + onFinished() + return captured + } /// The overlay's window number, used to exclude it from live screen capture /// so the magnified overlay is never captured back into itself. var overlayWindowNumber: Int? { window?.windowNumber } diff --git a/Sources/ZoomItMacCore/Overlay/ZoomCanvasView.swift b/Sources/ZoomItMacCore/Overlay/ZoomCanvasView.swift index 63d2254..69718a3 100644 --- a/Sources/ZoomItMacCore/Overlay/ZoomCanvasView.swift +++ b/Sources/ZoomItMacCore/Overlay/ZoomCanvasView.swift @@ -29,6 +29,7 @@ final class ZoomCanvasView: NSView { private var regionRect: CGRect = .zero private var regionCursorLease: CrosshairCursorLease? private var onRegionSnipFinished: (() -> Void)? + private var previousRegionRect: CGRect? private var scrollZoomAccumulator: CGFloat = 0 private let smoothImage: Bool @@ -799,20 +800,17 @@ final class ZoomCanvasView: NSView { /// directly to the configured directory instead of showing a dialog. private func presentSavePanelOverOverlay(_ image: CGImage) { let settings = UserDefaultsSettingsStore().load() - if settings.copySnipToClipboardOnSave { - ImageExporter.copyToPasteboard(image) - } - if settings.saveSnipToDirectory { - ImageExporter.writeToDirectory(image, directoryPath: settings.snipSaveDirectory) - return - } let savedLevel = window?.level let wasCursorHidden = cursorHidden - window?.level = NSWindow.Level(rawValue: NSWindow.Level.normal.rawValue - 1) - if wasCursorHidden { showSystemCursor() } - ImageExporter.presentSavePanel(for: image) - if let savedLevel { window?.level = savedLevel } - if wasCursorHidden { hideSystemCursor() } + ImageExporter.saveImage(image, settings: settings) { [weak self] in + guard let self else { return } + self.window?.level = NSWindow.Level(rawValue: NSWindow.Level.normal.rawValue - 1) + if wasCursorHidden { self.showSystemCursor() } + } + if !settings.saveSnipToDirectory { + if let savedLevel { window?.level = savedLevel } + if wasCursorHidden { hideSystemCursor() } + } } /// Snapshots exactly what the overlay is displaying (magnified image plus @@ -858,6 +856,12 @@ final class ZoomCanvasView: NSView { needsDisplay = true } + /// Captures with the previously successful region snip, if any. + func capturePreviousRegionSnip(action: SnipAction) -> Bool { + guard let previousRegionRect else { return false } + return performRegionSnip(rect: previousRegionRect, action: action) + } + private func updateRegionRect(to point: CGPoint) { guard let anchor = regionAnchor else { return } regionRect = CGRect( @@ -876,28 +880,39 @@ final class ZoomCanvasView: NSView { regionAnchor = nil popRegionCursor() - if rect.width >= 3, rect.height >= 3, let full = captureViewportImage() { - let scale = window?.backingScaleFactor ?? capturedFrame.display.scaleFactor - let pixelRect = CGRect( - x: rect.minX * scale, - y: rect.minY * scale, - width: rect.width * scale, - height: rect.height * scale - ).integral - if let cropped = full.cropping(to: pixelRect) { - switch action { - case .saveImage: - presentSavePanelOverOverlay(cropped) - case .copyImage: - ImageExporter.copyToPasteboard(cropped) - case .recognizeText: - OcrService.recognizeAndCopy(cropped) - } - } - } + _ = performRegionSnip(rect: rect, action: action) endRegionSnip() } + private func performRegionSnip(rect: CGRect, action: SnipAction) -> Bool { + guard rect.width >= 3, rect.height >= 3, let full = captureViewportImage() else { + return false + } + + let scale = window?.backingScaleFactor ?? capturedFrame.display.scaleFactor + let pixelRect = CGRect( + x: rect.minX * scale, + y: rect.minY * scale, + width: rect.width * scale, + height: rect.height * scale + ).integral + + guard let cropped = full.cropping(to: pixelRect) else { + return false + } + + previousRegionRect = rect + switch action { + case .saveImage: + presentSavePanelOverOverlay(cropped) + case .copyImage: + ImageExporter.copyToPasteboard(cropped) + case .recognizeText: + OcrService.recognizeAndCopy(cropped) + } + return true + } + private func cancelRegionSnip() { isSelectingRegion = false regionRect = .zero diff --git a/Sources/ZoomItMacCore/Settings/SettingsStore.swift b/Sources/ZoomItMacCore/Settings/SettingsStore.swift index 4a2b763..cb807fc 100644 --- a/Sources/ZoomItMacCore/Settings/SettingsStore.swift +++ b/Sources/ZoomItMacCore/Settings/SettingsStore.swift @@ -36,6 +36,11 @@ struct AppSettings: Equatable { /// SnipOcrToggleKey behavior. var snipOcrHotKeyCode: Int var snipOcrHotKeyModifiers: UInt + /// Virtual key code and modifier-flag raw value for re-running the last + /// successful region snip without opening the selector UI. A key code of 0 + /// disables the hotkey. + var snipPreviousHotKeyCode: Int + var snipPreviousHotKeyModifiers: UInt var recordHotKeyCode: Int var recordHotKeyModifiers: UInt /// Virtual key code and modifier-flag raw value for DemoType. A key code of @@ -134,6 +139,10 @@ struct AppSettings: Equatable { // copies it to the clipboard, matching ZoomIt's Ctrl+Alt+6 default. snipOcrHotKeyCode: 22, snipOcrHotKeyModifiers: (1 << 18) | (1 << 19), + // Control+9 (kVK_ANSI_9 = 25) repeats the last region snip; + // Control+Shift+9 repeats it and saves to a file. + snipPreviousHotKeyCode: 25, + snipPreviousHotKeyModifiers: 1 << 18, // Control+5 (kVK_ANSI_5 = 23) records the screen; // Control+Shift+5 records a selected region. recordHotKeyCode: 23, @@ -204,6 +213,8 @@ final class UserDefaultsSettingsStore: SettingsStore { static let snipHotKeyModifiers = "snipHotKeyModifiers" static let snipOcrHotKeyCode = "snipOcrHotKeyCode" static let snipOcrHotKeyModifiers = "snipOcrHotKeyModifiers" + static let snipPreviousHotKeyCode = "snipPreviousHotKeyCode" + static let snipPreviousHotKeyModifiers = "snipPreviousHotKeyModifiers" static let recordHotKeyCode = "recordHotKeyCode" static let recordHotKeyModifiers = "recordHotKeyModifiers" static let demoTypeHotKeyCode = "demoTypeHotKeyCode" @@ -329,6 +340,14 @@ final class UserDefaultsSettingsStore: SettingsStore { settings.snipOcrHotKeyModifiers = UInt(bitPattern: defaults.integer(forKey: Key.snipOcrHotKeyModifiers)) } + if defaults.object(forKey: Key.snipPreviousHotKeyCode) != nil { + settings.snipPreviousHotKeyCode = defaults.integer(forKey: Key.snipPreviousHotKeyCode) + } + + if defaults.object(forKey: Key.snipPreviousHotKeyModifiers) != nil { + settings.snipPreviousHotKeyModifiers = UInt(bitPattern: defaults.integer(forKey: Key.snipPreviousHotKeyModifiers)) + } + if defaults.object(forKey: Key.recordHotKeyCode) != nil { settings.recordHotKeyCode = defaults.integer(forKey: Key.recordHotKeyCode) } @@ -488,6 +507,8 @@ final class UserDefaultsSettingsStore: SettingsStore { defaults.set(Int(bitPattern: settings.snipHotKeyModifiers), forKey: Key.snipHotKeyModifiers) defaults.set(settings.snipOcrHotKeyCode, forKey: Key.snipOcrHotKeyCode) defaults.set(Int(bitPattern: settings.snipOcrHotKeyModifiers), forKey: Key.snipOcrHotKeyModifiers) + defaults.set(settings.snipPreviousHotKeyCode, forKey: Key.snipPreviousHotKeyCode) + defaults.set(Int(bitPattern: settings.snipPreviousHotKeyModifiers), forKey: Key.snipPreviousHotKeyModifiers) defaults.set(settings.recordHotKeyCode, forKey: Key.recordHotKeyCode) defaults.set(Int(bitPattern: settings.recordHotKeyModifiers), forKey: Key.recordHotKeyModifiers) defaults.set(settings.demoTypeHotKeyCode, forKey: Key.demoTypeHotKeyCode) diff --git a/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift b/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift index d67e799..0f369e6 100644 --- a/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift +++ b/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift @@ -76,6 +76,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { case breakTimer case snip case snipOcr + case snipPrevious case record case demoType case panorama @@ -86,6 +87,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { private weak var breakHotKeyButton: NSButton? private weak var snipHotKeyButton: NSButton? private weak var snipOcrHotKeyButton: NSButton? + private weak var snipPreviousHotKeyButton: NSButton? private weak var recordHotKeyButton: NSButton? private weak var demoTypeHotKeyButton: NSButton? private weak var panoramaHotKeyButton: NSButton? @@ -127,6 +129,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { breakHotKeyButton?.title = breakHotKeyDisplayString() snipHotKeyButton?.title = snipHotKeyDisplayString() snipOcrHotKeyButton?.title = snipOcrHotKeyDisplayString() + snipPreviousHotKeyButton?.title = snipPreviousHotKeyDisplayString() recordHotKeyButton?.title = recordHotKeyDisplayString() demoTypeHotKeyButton?.title = demoTypeHotKeyDisplayString() panoramaHotKeyButton?.title = panoramaHotKeyDisplayString() @@ -544,6 +547,10 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { beginRecording(target: .snipOcr, sender: sender) } + @objc private func toggleSnipPreviousHotKeyRecording(_ sender: NSButton) { + beginRecording(target: .snipPrevious, sender: sender) + } + @objc private func toggleRecordHotKeyRecording(_ sender: NSButton) { beginRecording(target: .record, sender: sender) } @@ -636,6 +643,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { conflictsWithDraw(code: newCode, modifiers: newModifiers) || conflictsWithLive(code: newCode, modifiers: newModifiers) || conflictsWithBreak(code: newCode, modifiers: newModifiers) || + conflictsWithSnipPrevious(code: newCode, modifiers: newModifiers) || conflictsWithDemoType(code: newCode, modifiers: newModifiers) { NSSound.beep() return nil @@ -648,17 +656,34 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { conflictsWithLive(code: newCode, modifiers: newModifiers) || conflictsWithBreak(code: newCode, modifiers: newModifiers) || conflictsWithSnip(code: newCode, modifiers: newModifiers) || + conflictsWithSnipPrevious(code: newCode, modifiers: newModifiers) || conflictsWithDemoType(code: newCode, modifiers: newModifiers) { NSSound.beep() return nil } settings.snipOcrHotKeyCode = newCode settings.snipOcrHotKeyModifiers = newModifiers + case .snipPrevious: + if conflictsWithZoom(code: newCode, modifiers: newModifiers) || + conflictsWithDraw(code: newCode, modifiers: newModifiers) || + conflictsWithLive(code: newCode, modifiers: newModifiers) || + conflictsWithBreak(code: newCode, modifiers: newModifiers) || + conflictsWithSnip(code: newCode, modifiers: newModifiers) || + conflictsWithSnipOcr(code: newCode, modifiers: newModifiers) || + conflictsWithRecord(code: newCode, modifiers: newModifiers) || + conflictsWithDemoType(code: newCode, modifiers: newModifiers) || + conflictsWithPanorama(code: newCode, modifiers: newModifiers) { + NSSound.beep() + return nil + } + settings.snipPreviousHotKeyCode = newCode + settings.snipPreviousHotKeyModifiers = newModifiers case .record: if conflictsWithZoom(code: newCode, modifiers: newModifiers) || conflictsWithDraw(code: newCode, modifiers: newModifiers) || conflictsWithLive(code: newCode, modifiers: newModifiers) || conflictsWithBreak(code: newCode, modifiers: newModifiers) || + conflictsWithSnipPrevious(code: newCode, modifiers: newModifiers) || conflictsWithDemoType(code: newCode, modifiers: newModifiers) { NSSound.beep() return nil @@ -672,6 +697,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { conflictsWithBreak(code: newCode, modifiers: newModifiers) || conflictsWithSnip(code: newCode, modifiers: newModifiers) || conflictsWithSnipOcr(code: newCode, modifiers: newModifiers) || + conflictsWithSnipPrevious(code: newCode, modifiers: newModifiers) || conflictsWithRecord(code: newCode, modifiers: newModifiers) || conflictsWithPanorama(code: newCode, modifiers: newModifiers) { NSSound.beep() @@ -684,6 +710,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { conflictsWithDraw(code: newCode, modifiers: newModifiers) || conflictsWithLive(code: newCode, modifiers: newModifiers) || conflictsWithBreak(code: newCode, modifiers: newModifiers) || + conflictsWithSnipPrevious(code: newCode, modifiers: newModifiers) || conflictsWithDemoType(code: newCode, modifiers: newModifiers) { NSSound.beep() return nil @@ -724,6 +751,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { code == settings.snipOcrHotKeyCode && modifiers == settings.snipOcrHotKeyModifiers } + private func conflictsWithSnipPrevious(code: Int, modifiers: UInt) -> Bool { + settings.snipPreviousHotKeyCode != 0 && + code == settings.snipPreviousHotKeyCode && modifiers == settings.snipPreviousHotKeyModifiers + } + private func conflictsWithRecord(code: Int, modifiers: UInt) -> Bool { code == settings.recordHotKeyCode && modifiers == settings.recordHotKeyModifiers } @@ -749,6 +781,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { breakHotKeyButton?.title = breakHotKeyDisplayString() snipHotKeyButton?.title = snipHotKeyDisplayString() snipOcrHotKeyButton?.title = snipOcrHotKeyDisplayString() + snipPreviousHotKeyButton?.title = snipPreviousHotKeyDisplayString() recordHotKeyButton?.title = recordHotKeyDisplayString() demoTypeHotKeyButton?.title = demoTypeHotKeyDisplayString() panoramaHotKeyButton?.title = panoramaHotKeyDisplayString() @@ -779,6 +812,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { return Self.describe(keyCode: settings.snipOcrHotKeyCode, modifiers: NSEvent.ModifierFlags(rawValue: settings.snipOcrHotKeyModifiers)) } + private func snipPreviousHotKeyDisplayString() -> String { + guard settings.snipPreviousHotKeyCode != 0 else { return "None" } + return Self.describe(keyCode: settings.snipPreviousHotKeyCode, modifiers: NSEvent.ModifierFlags(rawValue: settings.snipPreviousHotKeyModifiers)) + } + private func recordHotKeyDisplayString() -> String { Self.describe(keyCode: settings.recordHotKeyCode, modifiers: NSEvent.ModifierFlags(rawValue: settings.recordHotKeyModifiers)) } @@ -1091,6 +1129,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { The OCR shortcut works the same way, but recognizes the text in the selected region and copies that text to the clipboard. + Capture Previous Region repeats the last successful region snip without showing the drag selector. Hold Shift with that shortcut to save to a file. + Saved images are PNG files named with the current date and time. """, wraps: true @@ -1110,6 +1150,13 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { self.snipOcrHotKeyButton = snipOcrHotKeyButton let snipOcrHotKeyRow = makeRow([makeLabel("Text Toggle:"), snipOcrHotKeyButton]) + let snipPreviousHotKeyButton = NSButton(title: snipPreviousHotKeyDisplayString(), target: self, action: #selector(toggleSnipPreviousHotKeyRecording(_:))) + snipPreviousHotKeyButton.bezelStyle = .rounded + snipPreviousHotKeyButton.setButtonType(.momentaryPushIn) + snipPreviousHotKeyButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 140).isActive = true + self.snipPreviousHotKeyButton = snipPreviousHotKeyButton + let snipPreviousHotKeyRow = makeRow([makeLabel("Capture previous region:"), snipPreviousHotKeyButton]) + let copyOnSaveCheck = makeCheckbox( "Also copy to clipboard when saving to a file:", action: #selector(copySnipToClipboardOnSaveChanged(_:)), @@ -1134,7 +1181,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { updateSnipSaveDirectoryControlsEnabled() - return makeColumn([help, snipHotKeyRow, snipOcrHotKeyRow, makeCheckboxColumn([copyOnSaveCheck, saveToDirectoryCheck]), directoryRow]) + return makeColumn([help, snipHotKeyRow, snipOcrHotKeyRow, makeCheckboxColumn([copyOnSaveCheck, saveToDirectoryCheck]), directoryRow, snipPreviousHotKeyRow]) } private func snipSaveDirectoryDisplayPath() -> String {