Initial commit: Snipping Tool menu bar screenshot app
@@ -0,0 +1,7 @@
|
|||||||
|
build/
|
||||||
|
Distribution/
|
||||||
|
Generated/
|
||||||
|
SnippingTool.xcodeproj/
|
||||||
|
.DS_Store
|
||||||
|
xcuserdata/
|
||||||
|
*.xcuserstate
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#5B5CF0"/>
|
||||||
|
<stop offset="55%" stop-color="#8B5CF6"/>
|
||||||
|
<stop offset="100%" stop-color="#D946A8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="gloss" cx="50%" cy="18%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.30"/>
|
||||||
|
<stop offset="100%" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="lens" cx="38%" cy="35%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#6B7BAF"/>
|
||||||
|
<stop offset="55%" stop-color="#2B3358"/>
|
||||||
|
<stop offset="100%" stop-color="#12162B"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="ring" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.95"/>
|
||||||
|
<stop offset="100%" stop-color="#FFFFFF" stop-opacity="0.55"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect x="0" y="0" width="1024" height="1024" rx="224" ry="224" fill="url(#bg)"/>
|
||||||
|
<rect x="0" y="0" width="1024" height="1024" rx="224" ry="224" fill="url(#gloss)"/>
|
||||||
|
|
||||||
|
<!-- viewfinder corner brackets -->
|
||||||
|
<g fill="none" stroke="#FFFFFF" stroke-width="46" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M 246 402 L 246 246 L 402 246"/>
|
||||||
|
<path d="M 622 246 L 778 246 L 778 402"/>
|
||||||
|
<path d="M 246 622 L 246 778 L 402 778"/>
|
||||||
|
<path d="M 778 622 L 778 778 L 622 778"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- camera lens -->
|
||||||
|
<circle cx="512" cy="512" r="136" fill="none" stroke="url(#ring)" stroke-width="12"/>
|
||||||
|
<circle cx="512" cy="512" r="118" fill="url(#lens)"/>
|
||||||
|
<ellipse cx="466" cy="466" rx="34" ry="20" fill="#FFFFFF" fill-opacity="0.55" transform="rotate(-35 466 466)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,152 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Central state + logic for the app: persisted settings, hot key
|
||||||
|
/// registration, and running screencapture to save/copy screenshots.
|
||||||
|
final class AppCoordinator: ObservableObject {
|
||||||
|
private enum Keys {
|
||||||
|
static let outputAction = "outputAction"
|
||||||
|
static let imageFormat = "imageFormat"
|
||||||
|
static let saveFolder = "saveFolder"
|
||||||
|
static func hotKeyCode(_ mode: CaptureMode) -> String { "hotkey.\(mode.rawValue).keyCode" }
|
||||||
|
static func hotKeyMods(_ mode: CaptureMode) -> String { "hotkey.\(mode.rawValue).modifiers" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var outputAction: OutputAction {
|
||||||
|
didSet { UserDefaults.standard.set(outputAction.rawValue, forKey: Keys.outputAction) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var imageFormat: ImageFormat {
|
||||||
|
didSet { UserDefaults.standard.set(imageFormat.rawValue, forKey: Keys.imageFormat) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var saveFolder: URL {
|
||||||
|
didSet { UserDefaults.standard.set(saveFolder.path, forKey: Keys.saveFolder) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var hotKeyFullScreen: HotKeyCombo {
|
||||||
|
didSet { persistAndRegister(mode: .fullScreen, combo: hotKeyFullScreen) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var hotKeyWindow: HotKeyCombo {
|
||||||
|
didSet { persistAndRegister(mode: .window, combo: hotKeyWindow) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var hotKeyRegion: HotKeyCombo {
|
||||||
|
didSet { persistAndRegister(mode: .region, combo: hotKeyRegion) }
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
outputAction = OutputAction(rawValue: defaults.string(forKey: Keys.outputAction) ?? "") ?? .both
|
||||||
|
imageFormat = ImageFormat(rawValue: defaults.string(forKey: Keys.imageFormat) ?? "") ?? .png
|
||||||
|
|
||||||
|
if let path = defaults.string(forKey: Keys.saveFolder) {
|
||||||
|
saveFolder = URL(fileURLWithPath: path)
|
||||||
|
} else {
|
||||||
|
saveFolder = Self.defaultSaveFolder()
|
||||||
|
}
|
||||||
|
|
||||||
|
hotKeyFullScreen = Self.loadCombo(mode: .fullScreen, default: .defaultFullScreen)
|
||||||
|
hotKeyWindow = Self.loadCombo(mode: .window, default: .defaultWindow)
|
||||||
|
hotKeyRegion = Self.loadCombo(mode: .region, default: .defaultRegion)
|
||||||
|
|
||||||
|
registerHotKey(mode: .fullScreen, combo: hotKeyFullScreen)
|
||||||
|
registerHotKey(mode: .window, combo: hotKeyWindow)
|
||||||
|
registerHotKey(mode: .region, combo: hotKeyRegion)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultSaveFolder() -> URL {
|
||||||
|
let pictures = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first
|
||||||
|
?? FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
return pictures.appendingPathComponent("Screenshots", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadCombo(mode: CaptureMode, default defaultValue: HotKeyCombo) -> HotKeyCombo {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let codeKey = Keys.hotKeyCode(mode)
|
||||||
|
let modsKey = Keys.hotKeyMods(mode)
|
||||||
|
guard defaults.object(forKey: codeKey) != nil, defaults.object(forKey: modsKey) != nil else {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
let code = UInt32(defaults.integer(forKey: codeKey))
|
||||||
|
let mods = UInt32(defaults.integer(forKey: modsKey))
|
||||||
|
return HotKeyCombo(keyCode: code, carbonModifiers: mods)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistAndRegister(mode: CaptureMode, combo: HotKeyCombo) {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
defaults.set(Int(combo.keyCode), forKey: Keys.hotKeyCode(mode))
|
||||||
|
defaults.set(Int(combo.carbonModifiers), forKey: Keys.hotKeyMods(mode))
|
||||||
|
registerHotKey(mode: mode, combo: combo)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerHotKey(mode: CaptureMode, combo: HotKeyCombo) {
|
||||||
|
HotKeyManager.shared.register(
|
||||||
|
id: mode.hotKeyID,
|
||||||
|
keyCode: combo.keyCode,
|
||||||
|
modifiers: combo.carbonModifiers
|
||||||
|
) { [weak self] in
|
||||||
|
self?.capture(mode: mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capture
|
||||||
|
|
||||||
|
func capture(mode: CaptureMode) {
|
||||||
|
let saves = outputAction.savesToFile
|
||||||
|
let copies = outputAction.copiesToClipboard
|
||||||
|
let ext = imageFormat.fileExtension
|
||||||
|
|
||||||
|
let targetURL: URL
|
||||||
|
if saves {
|
||||||
|
try? FileManager.default.createDirectory(at: saveFolder, withIntermediateDirectories: true)
|
||||||
|
targetURL = saveFolder.appendingPathComponent(Self.filename(ext: ext))
|
||||||
|
} else {
|
||||||
|
targetURL = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||||
|
.appendingPathComponent(Self.filename(ext: ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
var arguments = mode.screencaptureArguments
|
||||||
|
arguments += ["-t", imageFormat.rawValue, targetURL.path]
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
|
||||||
|
process.arguments = arguments
|
||||||
|
process.terminationHandler = { [weak self] _ in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.handleCaptured(at: targetURL, saves: saves, copies: copies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
NSLog("SnippingTool: failed to launch screencapture: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCaptured(at url: URL, saves: Bool, copies: Bool) {
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
// User cancelled the interactive capture (Esc).
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if copies, let image = NSImage(contentsOf: url) {
|
||||||
|
let pasteboard = NSPasteboard.general
|
||||||
|
pasteboard.clearContents()
|
||||||
|
pasteboard.writeObjects([image])
|
||||||
|
}
|
||||||
|
|
||||||
|
if !saves {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func filename(ext: String) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd 'um' HH.mm.ss"
|
||||||
|
return "Bildschirmfoto \(formatter.string(from: Date())).\(ext)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_16.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_32.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_32.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_64.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_128.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_256.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_256.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_512.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_512.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_1024.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 713 B |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum CaptureMode: String, CaseIterable {
|
||||||
|
case fullScreen
|
||||||
|
case window
|
||||||
|
case region
|
||||||
|
|
||||||
|
/// Stable id used to register/unregister the matching global hot key.
|
||||||
|
var hotKeyID: UInt32 {
|
||||||
|
switch self {
|
||||||
|
case .fullScreen: return 1
|
||||||
|
case .window: return 2
|
||||||
|
case .region: return 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra arguments passed to /usr/sbin/screencapture for this mode.
|
||||||
|
var screencaptureArguments: [String] {
|
||||||
|
switch self {
|
||||||
|
case .fullScreen: return []
|
||||||
|
case .window: return ["-i", "-W"]
|
||||||
|
case .region: return ["-i", "-s"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import Carbon.HIToolbox
|
||||||
|
|
||||||
|
/// A recorded global keyboard shortcut: a virtual key code plus Carbon-style modifier flags.
|
||||||
|
struct HotKeyCombo: Codable, Equatable {
|
||||||
|
var keyCode: UInt32
|
||||||
|
var carbonModifiers: UInt32
|
||||||
|
|
||||||
|
var displayString: String {
|
||||||
|
var symbol = ""
|
||||||
|
if carbonModifiers & UInt32(controlKey) != 0 { symbol += "\u{2303}" }
|
||||||
|
if carbonModifiers & UInt32(optionKey) != 0 { symbol += "\u{2325}" }
|
||||||
|
if carbonModifiers & UInt32(shiftKey) != 0 { symbol += "\u{21E7}" }
|
||||||
|
if carbonModifiers & UInt32(cmdKey) != 0 { symbol += "\u{2318}" }
|
||||||
|
symbol += KeyCodeSymbols.symbol(for: keyCode)
|
||||||
|
return symbol
|
||||||
|
}
|
||||||
|
|
||||||
|
static let defaultModifiers = UInt32(cmdKey) | UInt32(optionKey) | UInt32(shiftKey)
|
||||||
|
|
||||||
|
static let defaultFullScreen = HotKeyCombo(keyCode: UInt32(kVK_ANSI_3), carbonModifiers: defaultModifiers)
|
||||||
|
static let defaultWindow = HotKeyCombo(keyCode: UInt32(kVK_ANSI_4), carbonModifiers: defaultModifiers)
|
||||||
|
static let defaultRegion = HotKeyCombo(keyCode: UInt32(kVK_ANSI_5), carbonModifiers: defaultModifiers)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Carbon.HIToolbox
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Thin wrapper around the Carbon RegisterEventHotKey API to register
|
||||||
|
/// system-wide keyboard shortcuts without requiring Accessibility/Input
|
||||||
|
/// Monitoring permission.
|
||||||
|
final class HotKeyManager {
|
||||||
|
static let shared = HotKeyManager()
|
||||||
|
|
||||||
|
private var hotKeyRefs: [UInt32: EventHotKeyRef] = [:]
|
||||||
|
private var handlers: [UInt32: () -> Void] = [:]
|
||||||
|
private var eventHandlerRef: EventHandlerRef?
|
||||||
|
|
||||||
|
private static let signature: FourCharCode = {
|
||||||
|
var result: FourCharCode = 0
|
||||||
|
for byte in "SNAP".utf8 {
|
||||||
|
result = (result << 8) + FourCharCode(byte)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}()
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
installEventHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installEventHandler() {
|
||||||
|
var eventType = EventTypeSpec(
|
||||||
|
eventClass: OSType(kEventClassKeyboard),
|
||||||
|
eventKind: UInt32(kEventHotKeyPressed)
|
||||||
|
)
|
||||||
|
|
||||||
|
let callback: EventHandlerUPP = { _, event, userData in
|
||||||
|
guard let event, let userData else { return noErr }
|
||||||
|
var hotKeyID = EventHotKeyID()
|
||||||
|
GetEventParameter(
|
||||||
|
event,
|
||||||
|
EventParamName(kEventParamDirectObject),
|
||||||
|
EventParamType(typeEventHotKeyID),
|
||||||
|
nil,
|
||||||
|
MemoryLayout<EventHotKeyID>.size,
|
||||||
|
nil,
|
||||||
|
&hotKeyID
|
||||||
|
)
|
||||||
|
let manager = Unmanaged<HotKeyManager>.fromOpaque(userData).takeUnretainedValue()
|
||||||
|
manager.handlers[hotKeyID.id]?()
|
||||||
|
return noErr
|
||||||
|
}
|
||||||
|
|
||||||
|
InstallEventHandler(
|
||||||
|
GetEventDispatcherTarget(),
|
||||||
|
callback,
|
||||||
|
1,
|
||||||
|
&eventType,
|
||||||
|
Unmanaged.passUnretained(self).toOpaque(),
|
||||||
|
&eventHandlerRef
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func register(id: UInt32, keyCode: UInt32, modifiers: UInt32, handler: @escaping () -> Void) -> Bool {
|
||||||
|
unregister(id: id)
|
||||||
|
|
||||||
|
var hotKeyRef: EventHotKeyRef?
|
||||||
|
let hotKeyID = EventHotKeyID(signature: Self.signature, id: id)
|
||||||
|
let status = RegisterEventHotKey(
|
||||||
|
keyCode,
|
||||||
|
modifiers,
|
||||||
|
hotKeyID,
|
||||||
|
GetEventDispatcherTarget(),
|
||||||
|
0,
|
||||||
|
&hotKeyRef
|
||||||
|
)
|
||||||
|
|
||||||
|
guard status == noErr, let hotKeyRef else { return false }
|
||||||
|
hotKeyRefs[id] = hotKeyRef
|
||||||
|
handlers[id] = handler
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func unregister(id: UInt32) {
|
||||||
|
if let ref = hotKeyRefs.removeValue(forKey: id) {
|
||||||
|
UnregisterEventHotKey(ref)
|
||||||
|
}
|
||||||
|
handlers.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct HotKeyRecorderView: View {
|
||||||
|
let title: String
|
||||||
|
@Binding var combo: HotKeyCombo
|
||||||
|
|
||||||
|
@State private var isRecording = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
Spacer()
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.secondary.opacity(isRecording ? 0.3 : 0.15))
|
||||||
|
Text(isRecording ? "Taste drücken… (Esc = Abbrechen)" : combo.displayString)
|
||||||
|
.font(isRecording ? .callout : .body)
|
||||||
|
.lineLimit(1)
|
||||||
|
KeyCaptureRepresentable(isRecording: $isRecording) { keyCode, modifiers in
|
||||||
|
combo = HotKeyCombo(keyCode: keyCode, carbonModifiers: modifiers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 190, height: 24)
|
||||||
|
.onTapGesture { isRecording = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ImageFormat: String, CaseIterable, Identifiable, Codable {
|
||||||
|
case png
|
||||||
|
case jpg
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
var label: String { rawValue.uppercased() }
|
||||||
|
var fileExtension: String { rawValue }
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import AppKit
|
||||||
|
import Carbon.HIToolbox
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Invisible NSView overlay that becomes first responder while recording
|
||||||
|
/// and reports the next key combo (key code + modifiers) it sees.
|
||||||
|
final class KeyCaptureView: NSView {
|
||||||
|
var onCapture: ((UInt32, UInt32) -> Void)?
|
||||||
|
var onCancel: (() -> Void)?
|
||||||
|
|
||||||
|
override var acceptsFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
if Int(event.keyCode) == kVK_Escape {
|
||||||
|
onCancel?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var modifiers: UInt32 = 0
|
||||||
|
if event.modifierFlags.contains(.control) { modifiers |= UInt32(controlKey) }
|
||||||
|
if event.modifierFlags.contains(.option) { modifiers |= UInt32(optionKey) }
|
||||||
|
if event.modifierFlags.contains(.shift) { modifiers |= UInt32(shiftKey) }
|
||||||
|
if event.modifierFlags.contains(.command) { modifiers |= UInt32(cmdKey) }
|
||||||
|
|
||||||
|
// Require at least one modifier so plain letters still work normally elsewhere.
|
||||||
|
guard modifiers != 0 else { return }
|
||||||
|
onCapture?(UInt32(event.keyCode), modifiers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct KeyCaptureRepresentable: NSViewRepresentable {
|
||||||
|
@Binding var isRecording: Bool
|
||||||
|
let onCapture: (UInt32, UInt32) -> Void
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> KeyCaptureView {
|
||||||
|
let view = KeyCaptureView()
|
||||||
|
view.onCapture = { keyCode, modifiers in
|
||||||
|
onCapture(keyCode, modifiers)
|
||||||
|
isRecording = false
|
||||||
|
}
|
||||||
|
view.onCancel = {
|
||||||
|
isRecording = false
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: KeyCaptureView, context: Context) {
|
||||||
|
if isRecording {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
nsView.window?.makeFirstResponder(nsView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Carbon.HIToolbox
|
||||||
|
|
||||||
|
/// Maps macOS virtual key codes to a short display symbol, for showing
|
||||||
|
/// recorded hot keys in the settings UI (e.g. "3", "F5", "Space").
|
||||||
|
enum KeyCodeSymbols {
|
||||||
|
static let map: [UInt32: String] = [
|
||||||
|
UInt32(kVK_ANSI_0): "0", UInt32(kVK_ANSI_1): "1", UInt32(kVK_ANSI_2): "2", UInt32(kVK_ANSI_3): "3",
|
||||||
|
UInt32(kVK_ANSI_4): "4", UInt32(kVK_ANSI_5): "5", UInt32(kVK_ANSI_6): "6", UInt32(kVK_ANSI_7): "7",
|
||||||
|
UInt32(kVK_ANSI_8): "8", UInt32(kVK_ANSI_9): "9",
|
||||||
|
UInt32(kVK_ANSI_A): "A", UInt32(kVK_ANSI_B): "B", UInt32(kVK_ANSI_C): "C", UInt32(kVK_ANSI_D): "D",
|
||||||
|
UInt32(kVK_ANSI_E): "E", UInt32(kVK_ANSI_F): "F", UInt32(kVK_ANSI_G): "G", UInt32(kVK_ANSI_H): "H",
|
||||||
|
UInt32(kVK_ANSI_I): "I", UInt32(kVK_ANSI_J): "J", UInt32(kVK_ANSI_K): "K", UInt32(kVK_ANSI_L): "L",
|
||||||
|
UInt32(kVK_ANSI_M): "M", UInt32(kVK_ANSI_N): "N", UInt32(kVK_ANSI_O): "O", UInt32(kVK_ANSI_P): "P",
|
||||||
|
UInt32(kVK_ANSI_Q): "Q", UInt32(kVK_ANSI_R): "R", UInt32(kVK_ANSI_S): "S", UInt32(kVK_ANSI_T): "T",
|
||||||
|
UInt32(kVK_ANSI_U): "U", UInt32(kVK_ANSI_V): "V", UInt32(kVK_ANSI_W): "W", UInt32(kVK_ANSI_X): "X",
|
||||||
|
UInt32(kVK_ANSI_Y): "Y", UInt32(kVK_ANSI_Z): "Z",
|
||||||
|
UInt32(kVK_F1): "F1", UInt32(kVK_F2): "F2", UInt32(kVK_F3): "F3", UInt32(kVK_F4): "F4",
|
||||||
|
UInt32(kVK_F5): "F5", UInt32(kVK_F6): "F6", UInt32(kVK_F7): "F7", UInt32(kVK_F8): "F8",
|
||||||
|
UInt32(kVK_F9): "F9", UInt32(kVK_F10): "F10", UInt32(kVK_F11): "F11", UInt32(kVK_F12): "F12",
|
||||||
|
UInt32(kVK_Space): "Space", UInt32(kVK_Return): "Return", UInt32(kVK_Tab): "Tab",
|
||||||
|
UInt32(kVK_Delete): "Delete", UInt32(kVK_Escape): "Esc"
|
||||||
|
]
|
||||||
|
|
||||||
|
static func symbol(for keyCode: UInt32) -> String {
|
||||||
|
map[keyCode] ?? "Code \(keyCode)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MenuBarContentView: View {
|
||||||
|
@EnvironmentObject var coordinator: AppCoordinator
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button("Ganzer Bildschirm") { coordinator.capture(mode: .fullScreen) }
|
||||||
|
Button("Fenster") { coordinator.capture(mode: .window) }
|
||||||
|
Button("Bereich auswählen") { coordinator.capture(mode: .region) }
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Einstellungen…") {
|
||||||
|
SettingsWindowController.shared.show(coordinator: coordinator)
|
||||||
|
}
|
||||||
|
.keyboardShortcut(",")
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Beenden") {
|
||||||
|
NSApplication.shared.terminate(nil)
|
||||||
|
}
|
||||||
|
.keyboardShortcut("q")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum OutputAction: String, CaseIterable, Identifiable, Codable {
|
||||||
|
case saveOnly
|
||||||
|
case clipboardOnly
|
||||||
|
case both
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .saveOnly: return "Nur speichern"
|
||||||
|
case .clipboardOnly: return "Nur in Zwischenablage kopieren"
|
||||||
|
case .both: return "Speichern und in Zwischenablage kopieren"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var savesToFile: Bool { self == .saveOnly || self == .both }
|
||||||
|
var copiesToClipboard: Bool { self == .clipboardOnly || self == .both }
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@EnvironmentObject var coordinator: AppCoordinator
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Nach der Aufnahme") {
|
||||||
|
Picker("Aktion:", selection: $coordinator.outputAction) {
|
||||||
|
ForEach(OutputAction.allCases) { action in
|
||||||
|
Text(action.label).tag(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.radioGroup)
|
||||||
|
|
||||||
|
Picker("Format:", selection: $coordinator.imageFormat) {
|
||||||
|
ForEach(ImageFormat.allCases) { format in
|
||||||
|
Text(format.label).tag(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(width: 160)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Speicherort:")
|
||||||
|
Text(coordinator.saveFolder.path)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
Spacer()
|
||||||
|
Button("Ändern…") { chooseFolder() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Tastenkürzel") {
|
||||||
|
HotKeyRecorderView(title: "Ganzer Bildschirm", combo: $coordinator.hotKeyFullScreen)
|
||||||
|
HotKeyRecorderView(title: "Fenster", combo: $coordinator.hotKeyWindow)
|
||||||
|
HotKeyRecorderView(title: "Bereich auswählen", combo: $coordinator.hotKeyRegion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 440)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chooseFolder() {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = true
|
||||||
|
panel.canChooseFiles = false
|
||||||
|
panel.canCreateDirectories = true
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.directoryURL = coordinator.saveFolder
|
||||||
|
panel.prompt = "Auswählen"
|
||||||
|
|
||||||
|
if panel.runModal() == .OK, let url = panel.url {
|
||||||
|
coordinator.saveFolder = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Manages the settings window manually instead of relying on SwiftUI's
|
||||||
|
/// `Settings` scene, which is unreliable for LSUIElement (menu bar-only,
|
||||||
|
/// no dock icon) apps: there's no existing window for the app to focus,
|
||||||
|
/// so the scene sometimes never becomes key.
|
||||||
|
final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||||
|
static let shared = SettingsWindowController()
|
||||||
|
|
||||||
|
private convenience init() {
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 440, height: 420),
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.title = "Einstellungen"
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.center()
|
||||||
|
self.init(window: window)
|
||||||
|
window.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func show(coordinator: AppCoordinator) {
|
||||||
|
window?.contentView = NSHostingView(
|
||||||
|
rootView: SettingsView().environmentObject(coordinator)
|
||||||
|
)
|
||||||
|
NSApplication.shared.setActivationPolicy(.regular)
|
||||||
|
NSApplication.shared.activate(ignoringOtherApps: true)
|
||||||
|
window?.makeKeyAndOrderFront(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowWillClose(_ notification: Notification) {
|
||||||
|
// Menu bar-only apps should not keep a Dock icon after the window closes.
|
||||||
|
NSApplication.shared.setActivationPolicy(.accessory)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct SnippingToolApp: App {
|
||||||
|
@StateObject private var coordinator = AppCoordinator()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
MenuBarExtra("Snipping Tool", systemImage: "camera.viewfinder") {
|
||||||
|
MenuBarContentView()
|
||||||
|
.environmentObject(coordinator)
|
||||||
|
}
|
||||||
|
.menuBarExtraStyle(.menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: SnippingTool
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: it.wende
|
||||||
|
deploymentTarget:
|
||||||
|
macOS: "14.0"
|
||||||
|
createIntermediateGroups: true
|
||||||
|
targets:
|
||||||
|
SnippingTool:
|
||||||
|
type: application
|
||||||
|
platform: macOS
|
||||||
|
deploymentTarget: "14.0"
|
||||||
|
sources:
|
||||||
|
- path: Sources
|
||||||
|
info:
|
||||||
|
path: Generated/Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleDisplayName: Snipping Tool
|
||||||
|
CFBundleName: Snipping Tool
|
||||||
|
LSUIElement: true
|
||||||
|
NSHumanReadableCopyright: "Copyright © 2026"
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: it.wende.snippingtool
|
||||||
|
PRODUCT_NAME: Snipping Tool
|
||||||
|
MARKETING_VERSION: "1.0"
|
||||||
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
|
SWIFT_VERSION: "5.0"
|
||||||
|
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||||
|
ENABLE_HARDENED_RUNTIME: false
|
||||||
|
CODE_SIGN_IDENTITY: "-"
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
|
schemes:
|
||||||
|
SnippingTool:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
SnippingTool: all
|
||||||
|
run:
|
||||||
|
config: Debug
|
||||||