import AppKit import SwiftUI 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 var body: some View { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) { GridRow { Text("Hotkey") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading) .gridCellColumns(2) } GridRow(alignment: .center) { fieldLabel("Capture") 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 { Text("Folders") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading) .gridCellColumns(2) .padding(.top, 6) } GridRow(alignment: .center) { fieldLabel("Watch folder") folderValue(path: model.watchFolderURL.path) { model.chooseWatchFolder() } } GridRow(alignment: .center) { fieldLabel("Output folder") folderValue(path: model.outboxURL.path) { model.chooseOutboxFolder() } } GridRow { Button("Reveal spool folder") { model.openSpoolFolder() } .gridCellColumns(2) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 4) } } .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 { Text(title) .lineLimit(1) .frame(width: labelWidth, alignment: .trailing) .gridColumnAlignment(.trailing) .frame(minHeight: 22, alignment: .trailing) } private func folderValue(path: String, choose: @escaping () -> Void) -> some View { HStack(spacing: 8) { Text(path) .lineLimit(1) .truncationMode(.middle) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) Button("Choose…") { choose() } } .frame(minHeight: 22) } } /// 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? public func presentSettingsWindow() { if let existing = Self.settingsWindowController { existing.window?.makeKeyAndOrderFront(nil) NSApp.activate() return } let hosting = NSHostingController(rootView: SettingsView().environment(self)) let window = NSWindow(contentViewController: hosting) window.title = "Redline Settings" window.styleMask = [.titled, .closable] window.isReleasedWhenClosed = false window.center() let controller = NSWindowController(window: window) Self.settingsWindowController = controller controller.showWindow(nil) NSApp.activate() } func chooseOutboxFolder() { guard let url = chooseDirectory(startingAt: outboxURL) else { return } FolderSettings.setOutbox(url) setFolderURLs(outbox: url, watch: watchFolderURL) setStatus("Output folder set to \(url.lastPathComponent).") } func chooseWatchFolder() { guard let url = chooseDirectory(startingAt: watchFolderURL) else { return } FolderSettings.setWatchFolder(url) setFolderURLs(outbox: outboxURL, watch: url) Task { do { try await watcher.updateWatchFolder(url) setStatus("Watch folder set to \(url.lastPathComponent).") } catch { setStatus( (error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder." ) } } } private func chooseDirectory(startingAt directory: URL) -> URL? { let panel = NSOpenPanel() panel.canChooseDirectories = true panel.canChooseFiles = false panel.allowsMultipleSelection = false panel.canCreateDirectories = true panel.prompt = "Choose" panel.directoryURL = directory guard panel.runModal() == .OK else { return nil } return panel.url } }