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
|
||||