diff --git a/Wave.xcodeproj/project.pbxproj b/Wave.xcodeproj/project.pbxproj index a08651b..bf52d73 100644 --- a/Wave.xcodeproj/project.pbxproj +++ b/Wave.xcodeproj/project.pbxproj @@ -46,9 +46,9 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 3CA5A7912F5063F600DBB766 /* wave */ = { + 3CA5A7912F5063F600DBB766 /* Wave */ = { isa = PBXFileSystemSynchronizedRootGroup; - path = wave; + path = Wave; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ diff --git a/Wave/AppState.swift b/Wave/AppState.swift index 87a647d..f4b9590 100644 --- a/Wave/AppState.swift +++ b/Wave/AppState.swift @@ -57,6 +57,12 @@ final class AppState { if hideIdlePill { overlayPanel?.orderOut(nil) } else { startPersistentOverlay() } } } + var showInDock: Bool { + didSet { + UserDefaults.standard.set(showInDock, forKey: "showInDock") + updateDockVisibility() + } + } var customVocabulary: [String] { didSet { UserDefaults.standard.set(customVocabulary, forKey: "customVocabulary") } } @@ -121,6 +127,18 @@ final class AppState { var aiModeModifiers: UInt64 { didSet { UserDefaults.standard.set(aiModeModifiers, forKey: "aiModeModifiers") } } + var cancelHotkeyKeyCode: UInt16 { + didSet { UserDefaults.standard.set(Int(cancelHotkeyKeyCode), forKey: "cancelHotkeyKeyCode") } + } + var cancelHotkeyModifiers: UInt64 { + didSet { UserDefaults.standard.set(cancelHotkeyModifiers, forKey: "cancelHotkeyModifiers") } + } + var pasteLastHotkeyKeyCode: UInt16 { + didSet { UserDefaults.standard.set(Int(pasteLastHotkeyKeyCode), forKey: "pasteLastHotkeyKeyCode") } + } + var pasteLastHotkeyModifiers: UInt64 { + didSet { UserDefaults.standard.set(pasteLastHotkeyModifiers, forKey: "pasteLastHotkeyModifiers") } + } var aiModel: String { didSet { UserDefaults.standard.set(aiModel, forKey: "aiModel") } } @@ -130,6 +148,8 @@ final class AppState { let transcriptionService = TranscriptionService() let hotkeyService = HotkeyService() let aiHotkeyService = HotkeyService() + let cancelHotkeyService = HotkeyService() + let pasteLastHotkeyService = HotkeyService() let historyManager = HistoryManager() let snippetManager = SnippetManager() let microphoneManager = MicrophoneManager() @@ -167,6 +187,20 @@ final class AppState { ) } + var cancelShortcutDisplayString: String { + KeyCodeMapping.displayString( + keyCode: cancelHotkeyKeyCode, + modifiers: CGEventFlags(rawValue: cancelHotkeyModifiers) + ) + } + + var pasteLastShortcutDisplayString: String { + KeyCodeMapping.displayString( + keyCode: pasteLastHotkeyKeyCode, + modifiers: CGEventFlags(rawValue: pasteLastHotkeyModifiers) + ) + } + init() { isOnboardingComplete = UserDefaults.standard.bool(forKey: "isOnboardingComplete") dictationMode = DictationMode(rawValue: UserDefaults.standard.string(forKey: "dictationMode") ?? "") ?? .pushToTalk @@ -179,6 +213,7 @@ final class AppState { } muteSystemAudio = UserDefaults.standard.bool(forKey: "muteSystemAudio") hideIdlePill = UserDefaults.standard.bool(forKey: "hideIdlePill") + showInDock = UserDefaults.standard.bool(forKey: "showInDock") customVocabulary = UserDefaults.standard.stringArray(forKey: "customVocabulary") ?? [] transcriptionProvider = TranscriptionProvider(rawValue: UserDefaults.standard.string(forKey: "transcriptionProvider") ?? "") ?? .local groqAPIKey = UserDefaults.standard.string(forKey: "groqAPIKey") ?? "" @@ -187,6 +222,10 @@ final class AppState { selectedMicUID = UserDefaults.standard.string(forKey: "selectedMicUID") ?? "" aiModeKeyCode = UInt16(UserDefaults.standard.integer(forKey: "aiModeKeyCode")) aiModeModifiers = UInt64(UserDefaults.standard.integer(forKey: "aiModeModifiers")) + cancelHotkeyKeyCode = UInt16(UserDefaults.standard.integer(forKey: "cancelHotkeyKeyCode")) + cancelHotkeyModifiers = UInt64(UserDefaults.standard.integer(forKey: "cancelHotkeyModifiers")) + pasteLastHotkeyKeyCode = UInt16(UserDefaults.standard.integer(forKey: "pasteLastHotkeyKeyCode")) + pasteLastHotkeyModifiers = UInt64(UserDefaults.standard.integer(forKey: "pasteLastHotkeyModifiers")) aiModel = UserDefaults.standard.string(forKey: "aiModel") ?? "openai/gpt-oss-20b" groqFetchedModels = UserDefaults.standard.stringArray(forKey: "groqFetchedModels") ?? [] whisperPrompt = UserDefaults.standard.string(forKey: "whisperPrompt") ?? "" @@ -224,6 +263,18 @@ final class AppState { #endif } + // Default dismiss shortcut: Control + Option + Esc + if cancelHotkeyKeyCode == 0 && cancelHotkeyModifiers == 0 { + cancelHotkeyKeyCode = 53 // kVK_Escape + cancelHotkeyModifiers = CGEventFlags.maskControl.rawValue | CGEventFlags.maskAlternate.rawValue + } + + // Default paste-last shortcut: Control + Option + V + if pasteLastHotkeyKeyCode == 0 && pasteLastHotkeyModifiers == 0 { + pasteLastHotkeyKeyCode = 9 // kVK_ANSI_V + pasteLastHotkeyModifiers = CGEventFlags.maskControl.rawValue | CGEventFlags.maskAlternate.rawValue + } + if !isOnboardingComplete { showOnboarding = true } @@ -248,6 +299,8 @@ final class AppState { DispatchQueue.main.async { self.hotkeyService.stop() self.aiHotkeyService.stop() + self.cancelHotkeyService.stop() + self.pasteLastHotkeyService.stop() self.setupHotkey() } } @@ -256,9 +309,17 @@ final class AppState { } func setupHotkey() { + hotkeyService.stop() + aiHotkeyService.stop() + cancelHotkeyService.stop() + pasteLastHotkeyService.stop() + + let isToggleMode = dictationMode == .toggle + // Normal dictation hotkey hotkeyService.targetKeyCode = CGKeyCode(hotkeyKeyCode) hotkeyService.targetModifiers = CGEventFlags(rawValue: hotkeyModifiers) + hotkeyService.isToggleMode = isToggleMode hotkeyService.onKeyDown = { [weak self] in DispatchQueue.main.async { @@ -295,6 +356,7 @@ final class AppState { // AI mode hotkey aiHotkeyService.targetKeyCode = CGKeyCode(aiModeKeyCode) aiHotkeyService.targetModifiers = CGEventFlags(rawValue: aiModeModifiers) + aiHotkeyService.isToggleMode = isToggleMode aiHotkeyService.onKeyDown = { [weak self] in DispatchQueue.main.async { @@ -332,6 +394,32 @@ final class AppState { aiHotkeyService.start() + // Dismiss current prompt hotkey + cancelHotkeyService.targetKeyCode = CGKeyCode(cancelHotkeyKeyCode) + cancelHotkeyService.targetModifiers = CGEventFlags(rawValue: cancelHotkeyModifiers) + + cancelHotkeyService.onKeyDown = { [weak self] in + DispatchQueue.main.async { + guard let self = self else { return } + Task { await self.dismissVoicePrompt() } + } + } + + cancelHotkeyService.start() + + // Paste last transcription hotkey + pasteLastHotkeyService.targetKeyCode = CGKeyCode(pasteLastHotkeyKeyCode) + pasteLastHotkeyService.targetModifiers = CGEventFlags(rawValue: pasteLastHotkeyModifiers) + + pasteLastHotkeyService.onKeyDown = { [weak self] in + DispatchQueue.main.async { + guard let self = self else { return } + self.pasteLastTranscription() + } + } + + pasteLastHotkeyService.start() + overlayPanel?.setShortcutLabel(shortcutDisplayString) setupPillPressAndHold() } @@ -340,20 +428,41 @@ final class AppState { overlayPanel?.onMouseDown = { [weak self] in guard let self else { return } self.isAIMode = false - if self.status == .idle { - self.isKeyHeld = true - Task { await self.startDictation() } + switch self.dictationMode { + case .pushToTalk: + if self.status == .idle { + self.isKeyHeld = true + Task { await self.startDictation() } + } + case .toggle: + if self.status == .idle { + Task { await self.startDictation() } + } else if self.status == .recording { + Task { await self.stopDictationAndPaste() } + } } } overlayPanel?.onMouseUp = { [weak self] in guard let self else { return } - if self.isKeyHeld && self.status == .recording { + if self.dictationMode == .pushToTalk && self.isKeyHeld && self.status == .recording { self.isKeyHeld = false Task { await self.stopDictationAndPaste() } } } } + func updateDockVisibility() { + let mainWindow = NSApp.windows.first { window in + window.title == "Wave" || window.identifier?.rawValue.contains("main") == true + } + let windowVisible = mainWindow?.isVisible == true + if windowVisible || showInDock { + NSApp.setActivationPolicy(.regular) + } else { + NSApp.setActivationPolicy(.accessory) + } + } + func startDictation() async { guard isReady else { status = .error(transcriptionProvider == .groq ? "Groq API key required" : "No model loaded") @@ -392,6 +501,29 @@ final class AppState { } } + func dismissVoicePrompt() async { + guard status == .recording || status == .transcribing else { return } + + if status == .recording { + await transcriptionService.stopRecordingAndDiscard() + } + if muteSystemAudio { SystemAudioDucker.restore() } + + status = .idle + isKeyHeld = false + isAIMode = false + selectedContext = nil + overlayPanel?.setAIMode(false) + overlayPanel?.hideAnswer() + hideOverlayIfIdle() + } + + func pasteLastTranscription() { + guard status == .idle else { return } + guard let text = historyManager.records.first?.text else { return } + PasteService.paste(text: text) + } + func stopDictationAndPaste() async { status = .transcribing updateOverlay() diff --git a/Wave/Services/HotkeyService.swift b/Wave/Services/HotkeyService.swift index 7be7895..bd86eca 100644 --- a/Wave/Services/HotkeyService.swift +++ b/Wave/Services/HotkeyService.swift @@ -5,6 +5,7 @@ import Carbon.HIToolbox final class HotkeyService { var targetKeyCode: CGKeyCode = CGKeyCode(kVK_Space) var targetModifiers: CGEventFlags = .maskControl + var isToggleMode = false var onKeyDown: (() -> Void)? var onKeyUp: (() -> Void)? @@ -13,9 +14,11 @@ final class HotkeyService { private var modifierShortcutIsPressed = false // Tracks which specific modifier keyCodes are currently held (distinguishes L vs R keys) private var heldModifierKeyCodes: Set = [] + private var previousModifierFlags: CGEventFlags = [] func start() { modifierShortcutIsPressed = false + previousModifierFlags = [] let eventMask: CGEventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) | (1 << CGEventType.flagsChanged.rawValue) let callback: CGEventTapCallBack = { proxy, type, event, refcon in @@ -47,6 +50,7 @@ final class HotkeyService { func stop() { modifierShortcutIsPressed = false heldModifierKeyCodes.removeAll() + previousModifierFlags = [] if let tap = eventTap { CGEvent.tapEnable(tap: tap, enable: false) if let source = runLoopSource { @@ -78,13 +82,13 @@ final class HotkeyService { return Unmanaged.passRetained(event) } - // Track which specific modifier keys are held to distinguish L vs R - if currentMods.rawValue > (heldModifierKeyCodes.isEmpty ? 0 : 1) { + if currentMods.rawValue > previousModifierFlags.rawValue { heldModifierKeyCodes.insert(keyCode) - } else { + } else if currentMods.rawValue < previousModifierFlags.rawValue { heldModifierKeyCodes.remove(keyCode) } if currentMods.isEmpty { heldModifierKeyCodes.removeAll() } + previousModifierFlags = currentMods // Flags must match AND the target key must actually be held let isPressed = currentMods == targetMods && !currentMods.isEmpty @@ -98,7 +102,7 @@ final class HotkeyService { if !isPressed && modifierShortcutIsPressed { modifierShortcutIsPressed = false - onKeyUp?() + if !isToggleMode { onKeyUp?() } return nil } @@ -110,10 +114,13 @@ final class HotkeyService { } if type == .keyDown { + if isToggleMode && event.getIntegerValueField(.keyboardEventAutorepeat) != 0 { + return Unmanaged.passRetained(event) + } onKeyDown?() - return nil // suppress the event + return nil } else if type == .keyUp { - onKeyUp?() + if !isToggleMode { onKeyUp?() } return nil } diff --git a/Wave/Services/TranscriptionService.swift b/Wave/Services/TranscriptionService.swift index 07ebd7a..94ac40d 100644 --- a/Wave/Services/TranscriptionService.swift +++ b/Wave/Services/TranscriptionService.swift @@ -33,6 +33,11 @@ final class TranscriptionService: NSObject, AVAudioRecorderDelegate { try await recorder.startRecording(toOutputFile: file, delegate: self) } + func stopRecordingAndDiscard() async { + await recorder.stopRecording() + recordedFile = nil + } + enum RecordingError: LocalizedError { case microphonePermissionDenied var errorDescription: String? { "Microphone access denied" } diff --git a/Wave/Utilities/WindowConfigurator.swift b/Wave/Utilities/WindowConfigurator.swift index acf824c..f39edce 100644 --- a/Wave/Utilities/WindowConfigurator.swift +++ b/Wave/Utilities/WindowConfigurator.swift @@ -5,12 +5,12 @@ extension Color { static let brand = Color(red: 0x7B / 255, green: 0x6E / 255, blue: 0xF6 / 255) } -// Configures the NSWindow: transparent titlebar, traffic lights, no title text, -// draggable by background, and handles close-to-hide + activation policy. final class WaveWindowDelegate: NSObject, NSWindowDelegate { + var onWindowClosed: (() -> Void)? + func windowShouldClose(_ sender: NSWindow) -> Bool { sender.orderOut(nil) - NSApp.setActivationPolicy(.accessory) + onWindowClosed?() return false } } @@ -25,9 +25,22 @@ final class ConfiguratorNSView: NSView { window.isMovableByWindowBackground = true window.delegate = windowDelegate } + + func setOnWindowClosed(_ handler: (() -> Void)?) { + windowDelegate.onWindowClosed = handler + } } struct WindowConfigurator: NSViewRepresentable { - func makeNSView(context: Context) -> ConfiguratorNSView { ConfiguratorNSView() } - func updateNSView(_ nsView: ConfiguratorNSView, context: Context) {} -} + var onWindowClosed: () -> Void + + func makeNSView(context: Context) -> ConfiguratorNSView { + let view = ConfiguratorNSView() + view.setOnWindowClosed(onWindowClosed) + return view + } + + func updateNSView(_ nsView: ConfiguratorNSView, context: Context) { + nsView.setOnWindowClosed(onWindowClosed) + } +} \ No newline at end of file diff --git a/Wave/Views/HomeView.swift b/Wave/Views/HomeView.swift index 4fcef7f..697ca04 100644 --- a/Wave/Views/HomeView.swift +++ b/Wave/Views/HomeView.swift @@ -76,7 +76,7 @@ struct HomeView: View { .toolbarBackground(.hidden, for: .windowToolbar) .accentColor(.brand) .frame(minWidth: 520, maxWidth: 520, minHeight: 500, maxHeight: 500) - .background(WindowConfigurator().frame(width: 0, height: 0)) + .background(WindowConfigurator(onWindowClosed: { appState.updateDockVisibility() }).frame(width: 0, height: 0)) .sheet(isPresented: Binding( get: { appState.showOnboarding }, set: { appState.showOnboarding = $0 } diff --git a/Wave/Views/Settings/GeneralSettingsView.swift b/Wave/Views/Settings/GeneralSettingsView.swift index 1729cdc..af0f6c6 100644 --- a/Wave/Views/Settings/GeneralSettingsView.swift +++ b/Wave/Views/Settings/GeneralSettingsView.swift @@ -18,6 +18,8 @@ struct GeneralSettingsView: View { .font(.system(size: 13)) Toggle("Hide pill when idle", isOn: $state.hideIdlePill) .font(.system(size: 13)) + Toggle("Show in Dock", isOn: $state.showInDock) + .font(.system(size: 13)) Picker("Language", selection: $state.transcriptionLanguage) { ForEach(Self.languages, id: \.code) { lang in Text(lang.label).tag(lang.code) diff --git a/Wave/Views/Settings/ShortcutSettingsView.swift b/Wave/Views/Settings/ShortcutSettingsView.swift index 08769af..8c907a7 100644 --- a/Wave/Views/Settings/ShortcutSettingsView.swift +++ b/Wave/Views/Settings/ShortcutSettingsView.swift @@ -16,6 +16,12 @@ struct ShortcutSettingsView: View { .labelsHidden() .pickerStyle(.segmented) .onChange(of: appState.dictationMode) { appState.setupHotkey() } + + Text(appState.dictationMode == .toggle + ? "Press the dictation shortcut once to start, again to stop." + : "Hold the dictation shortcut to record, release to transcribe.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) } section("Shortcuts") { @@ -26,6 +32,14 @@ struct ShortcutSettingsView: View { shortcutRow("AI Mode", keyCode: $state.aiModeKeyCode, modifiers: $state.aiModeModifiers) { isRecording in if isRecording { appState.aiHotkeyService.stop() } else { appState.aiHotkeyService.start() } } onChange: { appState.setupHotkey() } + + shortcutRow("Dismiss", keyCode: $state.cancelHotkeyKeyCode, modifiers: $state.cancelHotkeyModifiers) { isRecording in + if isRecording { appState.cancelHotkeyService.stop() } else { appState.cancelHotkeyService.start() } + } onChange: { appState.setupHotkey() } + + shortcutRow("Paste Last", keyCode: $state.pasteLastHotkeyKeyCode, modifiers: $state.pasteLastHotkeyModifiers) { isRecording in + if isRecording { appState.pasteLastHotkeyService.stop() } else { appState.pasteLastHotkeyService.start() } + } onChange: { appState.setupHotkey() } } } .padding(16) diff --git a/Wave/Views/ShortcutRecorderView.swift b/Wave/Views/ShortcutRecorderView.swift index 6fc9be8..c84f52e 100644 --- a/Wave/Views/ShortcutRecorderView.swift +++ b/Wave/Views/ShortcutRecorderView.swift @@ -5,145 +5,237 @@ struct ShortcutRecorderView: View { @Binding var keyCode: UInt16 @Binding var modifiers: UInt64 var onRecordingChanged: ((Bool) -> Void)? = nil - @State private var isRecording = false - @State private var pendingKeyCode: UInt16? = nil - @State private var pendingModifiers: UInt64? = nil var body: some View { - Button(action: { - isRecording.toggle() - if !isRecording { - pendingKeyCode = nil - pendingModifiers = nil - } - onRecordingChanged?(isRecording) - }) { - HStack(spacing: 6) { - if isRecording { - if let pk = pendingKeyCode, let pm = pendingModifiers { - Text(KeyCodeMapping.displayString(keyCode: pk, modifiers: CGEventFlags(rawValue: pm))) - .foregroundStyle(.primary) - } else { - Text("Press shortcut…") - .foregroundStyle(.secondary) - } - } else { - Text(KeyCodeMapping.displayString( - keyCode: keyCode, - modifiers: CGEventFlags(rawValue: modifiers) - )) - } - } - .font(.system(size: 13, weight: .medium, design: .rounded)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isRecording ? Color.accentColor : Color.clear, lineWidth: 2) - ) - } - .buttonStyle(.plain) - .background(isRecording ? ShortcutCaptureRepresentable( + ShortcutRecorderRepresentable( keyCode: $keyCode, modifiers: $modifiers, - isRecording: $isRecording, - pendingKeyCode: $pendingKeyCode, - pendingModifiers: $pendingModifiers, onRecordingChanged: onRecordingChanged - ) : nil) + ) + .fixedSize() } } -private struct ShortcutCaptureRepresentable: NSViewRepresentable { +private struct ShortcutRecorderRepresentable: NSViewRepresentable { @Binding var keyCode: UInt16 @Binding var modifiers: UInt64 - @Binding var isRecording: Bool - @Binding var pendingKeyCode: UInt16? - @Binding var pendingModifiers: UInt64? var onRecordingChanged: ((Bool) -> Void)? - func makeNSView(context: Context) -> ShortcutCaptureNSView { - let view = ShortcutCaptureNSView() - view.onCapture = { code, mods in - keyCode = code - modifiers = mods - pendingKeyCode = nil - pendingModifiers = nil - isRecording = false - onRecordingChanged?(false) - } - view.onPending = { code, mods in - pendingKeyCode = code - pendingModifiers = mods - } - view.onCancel = { - pendingKeyCode = nil - pendingModifiers = nil - isRecording = false - onRecordingChanged?(false) - } + func makeCoordinator() -> Coordinator { + Coordinator( + keyCode: $keyCode, + modifiers: $modifiers, + onRecordingChanged: onRecordingChanged + ) + } + + func makeNSView(context: Context) -> ShortcutRecorderNSView { + let view = ShortcutRecorderNSView() + view.coordinator = context.coordinator + context.coordinator.view = view + view.syncFromBindings(keyCode: keyCode, modifiers: modifiers) return view } - func updateNSView(_ nsView: ShortcutCaptureNSView, context: Context) {} + func updateNSView(_ nsView: ShortcutRecorderNSView, context: Context) { + context.coordinator.view = nsView + nsView.syncFromBindings(keyCode: keyCode, modifiers: modifiers) + } + + final class Coordinator { + @Binding var keyCode: UInt16 + @Binding var modifiers: UInt64 + var onRecordingChanged: ((Bool) -> Void)? + weak var view: ShortcutRecorderNSView? + + init(keyCode: Binding, modifiers: Binding, onRecordingChanged: ((Bool) -> Void)?) { + _keyCode = keyCode + _modifiers = modifiers + self.onRecordingChanged = onRecordingChanged + } + + func capture(keyCode: UInt16, modifiers: UInt64) { + self.keyCode = keyCode + self.modifiers = modifiers + } + } } -private final class ShortcutCaptureNSView: NSView { - var onCapture: ((UInt16, UInt64) -> Void)? - var onPending: ((UInt16, UInt64) -> Void)? - var onCancel: (() -> Void)? +private final class ShortcutRecorderNSView: NSControl { + weak var coordinator: ShortcutRecorderRepresentable.Coordinator? + + private static weak var activeRecorder: ShortcutRecorderNSView? + + private let label = NSTextField(labelWithString: "") private var monitor: Any? + private var isRecording = false + private var storedKeyCode: UInt16 = 0 + private var storedModifiers: UInt64 = 0 + private var pendingKeyCode: UInt16? + private var pendingModifiers: UInt64? private var currentModifierFlags: UInt64 = 0 - // Saved when modifiers are pressed so Enter can confirm even after keys are released private var savedCombo: (keyCode: UInt16, flags: UInt64)? + private var skipNextSync = false - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - if window != nil { - monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in - guard let self = self else { return event } - - if event.type == .keyDown { - if event.keyCode == UInt16(kVK_Escape) { - self.currentModifierFlags = 0 - self.savedCombo = nil - self.onCancel?() - } else if event.keyCode == UInt16(kVK_Return) || event.keyCode == UInt16(kVK_ANSI_KeypadEnter) { - // Confirm saved modifier-only combo (user may have released keys before pressing Enter) - if let combo = self.savedCombo { - self.savedCombo = nil - self.currentModifierFlags = 0 - self.onCapture?(combo.keyCode, combo.flags) - } - } else { - let flags = self.captureFlags(from: event) - self.savedCombo = nil - self.currentModifierFlags = 0 - self.onCapture?(event.keyCode, flags) - } - return nil - } + override var acceptsFirstResponder: Bool { true } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.cornerRadius = 8 + + label.font = .systemFont(ofSize: 13, weight: .medium) + label.usesSingleLineMode = true + label.lineBreakMode = .byTruncatingTail + label.translatesAutoresizingMaskIntoConstraints = false + addSubview(label) + + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), + label.topAnchor.constraint(equalTo: topAnchor, constant: 6), + label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -6), + ]) + } + + required init?(coder: NSCoder) { nil } + + func syncFromBindings(keyCode: UInt16, modifiers: UInt64) { + guard !isRecording else { return } + if skipNextSync { + skipNextSync = false + return + } + guard keyCode != storedKeyCode || modifiers != storedModifiers else { return } + storedKeyCode = keyCode + storedModifiers = modifiers + refreshLabel() + } + + override func mouseDown(with event: NSEvent) { + if isRecording { + endRecording(cancel: true) + } else { + beginRecording() + } + } + + override func keyDown(with event: NSEvent) { + guard isRecording else { + super.keyDown(with: event) + return + } + handleCapturedEvent(event) + } + + override func flagsChanged(with event: NSEvent) { + guard isRecording else { + super.flagsChanged(with: event) + return + } + handleCapturedEvent(event) + } - if event.type == .flagsChanged { - let newFlags = self.captureFlags(from: event) - let addedFlags = newFlags & ~self.currentModifierFlags - self.currentModifierFlags = newFlags - - if addedFlags != 0 { - // New modifier pressed — save combo and show as pending - self.savedCombo = (keyCode: event.keyCode, flags: newFlags) - self.onPending?(event.keyCode, newFlags) - } - // Don't clear savedCombo on release — user confirms with Enter - return nil + private func beginRecording() { + Self.activeRecorder?.endRecording(cancel: true) + Self.activeRecorder = self + + isRecording = true + pendingKeyCode = nil + pendingModifiers = nil + currentModifierFlags = 0 + savedCombo = nil + refreshChrome() + installMonitor() + window?.makeFirstResponder(self) + coordinator?.onRecordingChanged?(true) + } + + private func endRecording(cancel: Bool) { + guard isRecording else { return } + + isRecording = false + pendingKeyCode = nil + pendingModifiers = nil + removeMonitor() + + if Self.activeRecorder === self { + Self.activeRecorder = nil + } + + refreshChrome() + refreshLabel() + coordinator?.onRecordingChanged?(false) + + if cancel { + window?.makeFirstResponder(nil) + } + } + + private func installMonitor() { + guard monitor == nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in + guard let self, self.isRecording else { return event } + self.handleCapturedEvent(event) + return nil + } + } + + private func removeMonitor() { + if let monitor { + NSEvent.removeMonitor(monitor) + } + monitor = nil + currentModifierFlags = 0 + savedCombo = nil + } + + private func handleCapturedEvent(_ event: NSEvent) { + if event.type == .keyDown { + if event.keyCode == UInt16(kVK_Delete) { + endRecording(cancel: true) + return + } + if event.keyCode == UInt16(kVK_Return) || event.keyCode == UInt16(kVK_ANSI_KeypadEnter) { + if let combo = savedCombo { + commitCapture(keyCode: combo.keyCode, modifiers: combo.flags) } + return + } - return event + commitCapture( + keyCode: event.keyCode, + modifiers: captureFlags(from: event) + ) + return + } + + if event.type == .flagsChanged { + let newFlags = captureFlags(from: event) + let addedFlags = newFlags & ~currentModifierFlags + currentModifierFlags = newFlags + + if addedFlags != 0 { + savedCombo = (keyCode: event.keyCode, flags: newFlags) + pendingKeyCode = event.keyCode + pendingModifiers = newFlags + refreshLabel() } } } + private func commitCapture(keyCode: UInt16, modifiers: UInt64) { + storedKeyCode = keyCode + storedModifiers = modifiers + skipNextSync = true + coordinator?.capture(keyCode: keyCode, modifiers: modifiers) + pendingKeyCode = nil + pendingModifiers = nil + refreshLabel() + endRecording(cancel: false) + window?.makeFirstResponder(nil) + } + private func captureFlags(from event: NSEvent) -> UInt64 { var flags: UInt64 = 0 if event.modifierFlags.contains(.control) { flags |= CGEventFlags.maskControl.rawValue } @@ -154,9 +246,50 @@ private final class ShortcutCaptureNSView: NSView { return flags } + private func refreshLabel() { + let text: String + let secondary: Bool + + if isRecording { + if let pendingKeyCode, let pendingModifiers { + text = KeyCodeMapping.displayString( + keyCode: pendingKeyCode, + modifiers: CGEventFlags(rawValue: pendingModifiers) + ) + secondary = false + } else { + text = "Press shortcut…" + secondary = true + } + } else { + text = KeyCodeMapping.displayString( + keyCode: storedKeyCode, + modifiers: CGEventFlags(rawValue: storedModifiers) + ) + secondary = false + } + + label.stringValue = text + label.textColor = secondary ? .secondaryLabelColor : .labelColor + } + + private func refreshChrome() { + if isRecording { + layer?.backgroundColor = NSColor.quaternaryLabelColor.cgColor + layer?.borderWidth = 2 + layer?.borderColor = NSColor.controlAccentColor.cgColor + } else { + layer?.backgroundColor = NSColor.quaternaryLabelColor.cgColor + layer?.borderWidth = 0 + layer?.borderColor = nil + } + } + override func removeFromSuperview() { - if let monitor = monitor { NSEvent.removeMonitor(monitor) } - monitor = nil + if Self.activeRecorder === self { + Self.activeRecorder = nil + } + removeMonitor() super.removeFromSuperview() } -} +} \ No newline at end of file diff --git a/Wave/waveApp.swift b/Wave/waveApp.swift index b7c8ffc..9018569 100644 --- a/Wave/waveApp.swift +++ b/Wave/waveApp.swift @@ -106,6 +106,8 @@ struct WaveApp: App { window.title == "Wave" || window.identifier?.rawValue.contains("main") == true } + NSApp.setActivationPolicy(.regular) + if let window = existing { if window.isMiniaturized { window.deminiaturize(nil) } window.makeKeyAndOrderFront(nil) @@ -118,9 +120,11 @@ struct WaveApp: App { } final class AppDelegate: NSObject, NSApplicationDelegate { - func applicationDidFinishLaunching(_ notification: Notification) { - guard enforceSingleInstance() else { return } + func applicationWillFinishLaunching(_ notification: Notification) { + enforceSingleInstance() + } + func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.regular) Task { @MainActor in UpdaterService.shared.start() @@ -143,21 +147,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { false } - @discardableResult - private func enforceSingleInstance() -> Bool { - guard let bundleID = Bundle.main.bundleIdentifier else { return true } + private func enforceSingleInstance() { + #if DEBUG + return + #endif - let running = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID) - guard running.count > 1 else { return true } + guard let bundleID = Bundle.main.bundleIdentifier, + !bundleID.hasSuffix(".debug") else { return } let currentPID = ProcessInfo.processInfo.processIdentifier - if let existing = running.first(where: { $0.processIdentifier != currentPID }) { - existing.activate(options: [.activateAllWindows]) - } + let others = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID) + .filter { $0.processIdentifier != currentPID } + guard let existing = others.first else { return } DispatchQueue.main.async { - NSApp.terminate(nil) + existing.activate(options: [.activateAllWindows]) + exit(0) } - return false } }