From fa3e7b0a4aac4ac587fea51c4b8ab22631b25134 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Tue, 1 Sep 2026 22:33:13 +0400 Subject: [PATCH] feature: user-selectable capture hotkey with recorder in Settings --- Sources/Shotdeck/AppModel.swift | 40 +++++-- Sources/Shotdeck/HotkeyPreference.swift | 151 ++++++++++++++++++++++++ Sources/Shotdeck/MenuBarView.swift | 4 +- Sources/Shotdeck/SettingsView.swift | 78 +++++++++++- 4 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 Sources/Shotdeck/HotkeyPreference.swift diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift index 050e971..9419436 100644 --- a/Sources/Shotdeck/AppModel.swift +++ b/Sources/Shotdeck/AppModel.swift @@ -1,5 +1,4 @@ import AppKit -import Carbon.HIToolbox import Foundation import Observation import SwiftUI @@ -37,6 +36,9 @@ public final class AppModel { public private(set) var outboxURL: URL /// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`. public private(set) var watchFolderURL: URL + /// Currently bound capture combo (the last one Carbon accepted, or the preferred load). + private(set) var captureHotkey: HotkeyPreference + var hotkeyDisplayString: String { captureHotkey.displayString } let paths: AppSupportPaths let spool: SpoolStore @@ -80,6 +82,7 @@ public final class AppModel { self.watchFolderURL = folders.watch self.outboxDisplayName = folders.outbox.lastPathComponent self.watchFolderDisplayName = folders.watch.lastPathComponent + self.captureHotkey = HotkeyPreference.load() } // MARK: Seam mutators — the only way a WP-4b/4c extension changes state. @@ -146,16 +149,39 @@ public final class AppModel { setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not watch the return folder.") } - let registered = hotkeys.register( + let pref = HotkeyPreference.load() + captureHotkey = pref + if !bindCaptureHotkey(pref) { + setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.") + } + } + + /// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new + /// combo, restores the previous preference (UserDefaults + Carbon) so the old one keeps working. + func reRegisterHotkey() { + let previous = captureHotkey + let next = HotkeyPreference.load() + hotkeys.unregister(id: "capture") + if bindCaptureHotkey(next) { + captureHotkey = next + return + } + setStatus("That combination is taken — pick another.") + previous.save() + if bindCaptureHotkey(previous) { + captureHotkey = previous + } + } + + @discardableResult + private func bindCaptureHotkey(_ pref: HotkeyPreference) -> Bool { + hotkeys.register( id: "capture", - keyCode: UInt32(kVK_ANSI_2), - modifiers: UInt32(optionKey | shiftKey) + keyCode: pref.keyCode, + modifiers: pref.modifiers ) { [weak self] in Task { await self?.captureNow() } } - if !registered { - setStatus("⌥⇧2 is already used by another app — capture only works from the menu.") - } } public func captureNow() async { diff --git a/Sources/Shotdeck/HotkeyPreference.swift b/Sources/Shotdeck/HotkeyPreference.swift new file mode 100644 index 0000000..e0c53ea --- /dev/null +++ b/Sources/Shotdeck/HotkeyPreference.swift @@ -0,0 +1,151 @@ +import AppKit +import Carbon.HIToolbox +import Foundation + +/// User-chosen capture hotkey. `modifiers` are Carbon bits (`cmdKey`, `optionKey`, +/// `shiftKey`, `controlKey`), matching `HotkeyCenter.register`. +struct HotkeyPreference: Codable, Equatable, Sendable { + var keyCode: UInt32 + var modifiers: UInt32 + + static let defaultsKey = "ai.flowmaster.shotdeck.hotkey" + + /// ⌥⇧2 — kVK_ANSI_2 (19) with optionKey|shiftKey. + static let `default` = HotkeyPreference( + keyCode: 19, + modifiers: UInt32(optionKey) | UInt32(shiftKey) + ) + + static func load(defaults: UserDefaults = .standard) -> HotkeyPreference { + guard let data = defaults.data(forKey: defaultsKey), + let decoded = try? JSONDecoder().decode(HotkeyPreference.self, from: data), + decoded.modifiers != 0 + else { return .default } + return decoded + } + + func save(defaults: UserDefaults = .standard) { + guard let data = try? JSONEncoder().encode(self) else { return } + defaults.set(data, forKey: Self.defaultsKey) + } + + /// ⌘⌥⇧⌃ in that order, then a name for common keycodes. + var displayString: String { + var s = "" + if modifiers & UInt32(cmdKey) != 0 { s += "⌘" } + if modifiers & UInt32(optionKey) != 0 { s += "⌥" } + if modifiers & UInt32(shiftKey) != 0 { s += "⇧" } + if modifiers & UInt32(controlKey) != 0 { s += "⌃" } + s += Self.keyName(for: keyCode) + return s + } + + /// `nil` when the event is modifier-only or has no ⌘⌥⇧⌃ flags. + static func fromKeyEvent(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) -> HotkeyPreference? { + switch Int(keyCode) { + case kVK_Shift, kVK_RightShift, + kVK_Command, kVK_RightCommand, + kVK_Option, kVK_RightOption, + kVK_Control, kVK_RightControl, + kVK_CapsLock, kVK_Function: + return nil + default: + break + } + var carbon: UInt32 = 0 + if modifierFlags.contains(.command) { carbon |= UInt32(cmdKey) } + if modifierFlags.contains(.option) { carbon |= UInt32(optionKey) } + if modifierFlags.contains(.shift) { carbon |= UInt32(shiftKey) } + if modifierFlags.contains(.control) { carbon |= UInt32(controlKey) } + guard carbon != 0 else { return nil } + return HotkeyPreference(keyCode: UInt32(keyCode), modifiers: carbon) + } + + private static func keyName(for keyCode: UInt32) -> String { + switch Int(keyCode) { + case kVK_ANSI_A: return "A" + case kVK_ANSI_B: return "B" + case kVK_ANSI_C: return "C" + case kVK_ANSI_D: return "D" + case kVK_ANSI_E: return "E" + case kVK_ANSI_F: return "F" + case kVK_ANSI_G: return "G" + case kVK_ANSI_H: return "H" + case kVK_ANSI_I: return "I" + case kVK_ANSI_J: return "J" + case kVK_ANSI_K: return "K" + case kVK_ANSI_L: return "L" + case kVK_ANSI_M: return "M" + case kVK_ANSI_N: return "N" + case kVK_ANSI_O: return "O" + case kVK_ANSI_P: return "P" + case kVK_ANSI_Q: return "Q" + case kVK_ANSI_R: return "R" + case kVK_ANSI_S: return "S" + case kVK_ANSI_T: return "T" + case kVK_ANSI_U: return "U" + case kVK_ANSI_V: return "V" + case kVK_ANSI_W: return "W" + case kVK_ANSI_X: return "X" + case kVK_ANSI_Y: return "Y" + case kVK_ANSI_Z: return "Z" + case kVK_ANSI_0: return "0" + case kVK_ANSI_1: return "1" + case kVK_ANSI_2: return "2" + case kVK_ANSI_3: return "3" + case kVK_ANSI_4: return "4" + case kVK_ANSI_5: return "5" + case kVK_ANSI_6: return "6" + case kVK_ANSI_7: return "7" + case kVK_ANSI_8: return "8" + case kVK_ANSI_9: return "9" + case kVK_ANSI_Equal: return "=" + case kVK_ANSI_Minus: return "-" + case kVK_ANSI_RightBracket: return "]" + case kVK_ANSI_LeftBracket: return "[" + case kVK_ANSI_Quote: return "'" + case kVK_ANSI_Semicolon: return ";" + case kVK_ANSI_Backslash: return "\\" + case kVK_ANSI_Comma: return "," + case kVK_ANSI_Slash: return "/" + case kVK_ANSI_Period: return "." + case kVK_ANSI_Grave: return "`" + case kVK_Space: return "Space" + case kVK_Return: return "Return" + case kVK_Tab: return "Tab" + case kVK_Delete: return "Delete" + case kVK_ForwardDelete: return "Fwd Delete" + case kVK_Escape: return "Esc" + case kVK_Home: return "Home" + case kVK_End: return "End" + case kVK_PageUp: return "Page Up" + case kVK_PageDown: return "Page Down" + case kVK_Help: return "Help" + case kVK_LeftArrow: return "←" + case kVK_RightArrow: return "→" + case kVK_DownArrow: return "↓" + case kVK_UpArrow: return "↑" + case kVK_F1: return "F1" + case kVK_F2: return "F2" + case kVK_F3: return "F3" + case kVK_F4: return "F4" + case kVK_F5: return "F5" + case kVK_F6: return "F6" + case kVK_F7: return "F7" + case kVK_F8: return "F8" + case kVK_F9: return "F9" + case kVK_F10: return "F10" + case kVK_F11: return "F11" + case kVK_F12: return "F12" + case kVK_F13: return "F13" + case kVK_F14: return "F14" + case kVK_F15: return "F15" + case kVK_F16: return "F16" + case kVK_F17: return "F17" + case kVK_F18: return "F18" + case kVK_F19: return "F19" + case kVK_F20: return "F20" + default: return "Key \(keyCode)" + } + } +} diff --git a/Sources/Shotdeck/MenuBarView.swift b/Sources/Shotdeck/MenuBarView.swift index d0a16d9..2218cbc 100644 --- a/Sources/Shotdeck/MenuBarView.swift +++ b/Sources/Shotdeck/MenuBarView.swift @@ -49,7 +49,7 @@ struct MenuBarView: View { private var defaultStatusText: String { guard let region = model.region else { - return "No region yet — press ⌥⇧2 to pick one." + return "No region yet — press \(model.hotkeyDisplayString) to pick one." } let w = Int(region.rect.width) let h = Int(region.rect.height) @@ -89,7 +89,7 @@ struct MenuBarView: View { Button { Task { await model.captureNow() } } label: { - actionLabel("Capture now", trailing: "⌥⇧2") + actionLabel("Capture now", trailing: model.hotkeyDisplayString) } .disabled(model.isCapturing) diff --git a/Sources/Shotdeck/SettingsView.swift b/Sources/Shotdeck/SettingsView.swift index c671be1..4f33f99 100644 --- a/Sources/Shotdeck/SettingsView.swift +++ b/Sources/Shotdeck/SettingsView.swift @@ -4,6 +4,8 @@ import ShotdeckCore struct SettingsView: View { @Environment(AppModel.self) private var model + @State private var isRecordingHotkey = false + @State private var recorder = HotkeyRecorderBox() private let labelWidth: CGFloat = 104 @@ -16,13 +18,19 @@ struct SettingsView: View { .gridCellColumns(2) } - GridRow(alignment: .firstTextBaseline) { + GridRow(alignment: .center) { fieldLabel("Capture") - Text("⌥⇧2 — fixed in this version") - .foregroundStyle(.secondary) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(minHeight: 22, alignment: .leading) + HStack(spacing: 8) { + Button { + armHotkeyRecorder() + } label: { + Text(isRecordingHotkey ? "Press keys…" : model.hotkeyDisplayString) + .foregroundStyle(isRecordingHotkey ? .secondary : .primary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .frame(minHeight: 22) } GridRow { @@ -57,6 +65,35 @@ struct SettingsView: View { .padding(16) .frame(minWidth: 320, idealWidth: 360, maxWidth: 360, alignment: .leading) .controlSize(.small) + .onDisappear { disarmHotkeyRecorder() } + } + + private func armHotkeyRecorder() { + guard !isRecordingHotkey else { return } + isRecordingHotkey = true + recorder.onKey = { keyCode, flags in + handleRecorderKey(keyCode: keyCode, flags: flags) + } + recorder.arm() + } + + private func handleRecorderKey(keyCode: UInt16, flags: NSEvent.ModifierFlags) { + if keyCode == 53 { // kVK_Escape + disarmHotkeyRecorder() + return + } + guard let pref = HotkeyPreference.fromKeyEvent(keyCode: keyCode, modifierFlags: flags) else { + return + } + pref.save() + model.reRegisterHotkey() + disarmHotkeyRecorder() + } + + private func disarmHotkeyRecorder() { + recorder.disarm() + recorder.onKey = nil + isRecordingHotkey = false } private func fieldLabel(_ title: String) -> some View { @@ -80,6 +117,35 @@ struct SettingsView: View { } } +/// Local keyDown monitor for the Settings capture-hotkey recorder. Callbacks hop onto the +/// main actor the same way `RegionPickerController` does — local monitors fire on the +/// main run loop during `NSApp.sendEvent`. +@MainActor +private final class HotkeyRecorderBox { + var onKey: ((UInt16, NSEvent.ModifierFlags) -> Void)? + private var monitor: Any? + + func arm() { + disarm() + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self else { return event } + let keyCode = event.keyCode + let rawFlags = event.modifierFlags.rawValue + MainActor.assumeIsolated { + self.onKey?(keyCode, NSEvent.ModifierFlags(rawValue: rawFlags)) + } + return nil + } + } + + func disarm() { + if let monitor { + NSEvent.removeMonitor(monitor) + } + monitor = nil + } +} + extension AppModel: SettingsWindowPresenting { private static var settingsWindowController: NSWindowController? -- 2.54.0