diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 1a5d3ec3..19878293 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -77,6 +77,7 @@ enum ShortcutRecordingTarget: Hashable { case command case edit case cancel + case pasteLast case dictationPrompt(String) case newPrompt @@ -92,6 +93,8 @@ enum ShortcutRecordingTarget: Hashable { return "Edit Mode" case .cancel: return "Cancel Recording" + case .pasteLast: + return "Paste Last Transcription" case .dictationPrompt: return "Prompt Shortcut" case .newPrompt: @@ -101,7 +104,7 @@ enum ShortcutRecordingTarget: Hashable { var enablesFeatureOnAssignment: Bool { switch self { - case .secondaryDictation, .command, .edit: + case .secondaryDictation, .command, .edit, .pasteLast: return true case .primaryDictation, .cancel, .dictationPrompt, .newPrompt: return false @@ -114,7 +117,12 @@ enum ShortcutRecordingTarget: Hashable { } var allowsMouseShortcut: Bool { - self.isPrimaryDictation + switch self { + case .primaryDictation, .pasteLast: + return true + case .secondaryDictation, .command, .edit, .cancel, .dictationPrompt, .newPrompt: + return false + } } var isPrimaryDictation: Bool { @@ -182,6 +190,8 @@ struct ContentView: View { @State private var commandModeHotkeyShortcut: HotkeyShortcut? = SettingsStore.shared.commandModeHotkeyShortcut @State private var rewriteModeHotkeyShortcut: HotkeyShortcut = SettingsStore.shared.rewriteModeHotkeyShortcut @State private var cancelRecordingHotkeyShortcut: HotkeyShortcut = SettingsStore.shared.cancelRecordingHotkeyShortcut + @State private var pasteLastTranscriptionHotkeyShortcut: HotkeyShortcut? = SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut + @State private var isPasteLastTranscriptionShortcutEnabled: Bool = SettingsStore.shared.pasteLastTranscriptionShortcutEnabled @State private var isPromptModeShortcutEnabled: Bool = SettingsStore.shared.promptModeShortcutEnabled @State private var isCommandModeShortcutEnabled: Bool = SettingsStore.shared.commandModeShortcutEnabled @State private var isRewriteModeShortcutEnabled: Bool = SettingsStore.shared.rewriteModeShortcutEnabled @@ -456,6 +466,20 @@ struct ContentView: View { .onChange(of: self.isRewriteModeShortcutEnabled) { newValue in self.handleRewriteShortcutEnabledChange(newValue) } + .onChange(of: self.pasteLastTranscriptionHotkeyShortcut) { _, newValue in + // The hotkey manager reads this value live from SettingsStore, so persisting is enough. + SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut = newValue + } + .onChange(of: self.isPasteLastTranscriptionShortcutEnabled) { newValue in + self.handlePasteLastTranscriptionShortcutEnabledChange(newValue) + } + } + + private func handlePasteLastTranscriptionShortcutEnabledChange(_ isEnabled: Bool) { + SettingsStore.shared.pasteLastTranscriptionShortcutEnabled = isEnabled + if !isEnabled, self.activeShortcutRecordingTarget == .pasteLast { + self.clearShortcutRecordingMode() + } } private func handlePromptShortcutEnabledChange(_ isEnabled: Bool) { @@ -974,7 +998,7 @@ struct ContentView: View { private func shortcutConflictMessage(for shortcut: HotkeyShortcut, target: ShortcutRecordingTarget) -> String? { if shortcut.isMouseShortcut { guard target.allowsMouseShortcut else { - return "Mouse clicks can only be assigned to Primary Dictation Shortcut" + return "Mouse clicks can only be assigned to Primary Dictation or Paste Last Transcription" } if shortcut.isUnmodifiedLeftOrRightClick, let mouseButton = shortcut.mouseButton { @@ -999,6 +1023,7 @@ struct ContentView: View { ] let optionalConfiguredShortcuts: [(ShortcutRecordingTarget, HotkeyShortcut?)] = [ (.command, self.commandModeHotkeyShortcut), + (.pasteLast, self.pasteLastTranscriptionHotkeyShortcut), ] for (otherTarget, configuredShortcut) in configuredShortcuts where otherTarget != target { @@ -1064,6 +1089,10 @@ struct ContentView: View { case .cancel: self.cancelRecordingHotkeyShortcut = shortcut SettingsStore.shared.cancelRecordingHotkeyShortcut = shortcut + case .pasteLast: + // The hotkey manager reads this shortcut directly from SettingsStore, so no manager update is needed. + self.pasteLastTranscriptionHotkeyShortcut = shortcut + SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut = shortcut case let .dictationPrompt(key): guard let selection = SettingsStore.shared.dictationPromptSelection(forConfigurationKey: key) else { return } var configuration = SettingsStore.shared.dictationPromptConfiguration(for: selection) @@ -1108,6 +1137,9 @@ struct ContentView: View { self.isRewriteModeShortcutEnabled = enabled SettingsStore.shared.rewriteModeShortcutEnabled = enabled self.hotkeyManager?.updateRewriteModeShortcutEnabled(enabled) + case .pasteLast: + self.isPasteLastTranscriptionShortcutEnabled = enabled + SettingsStore.shared.pasteLastTranscriptionShortcutEnabled = enabled case .primaryDictation, .cancel, .dictationPrompt, .newPrompt: break } @@ -1435,8 +1467,10 @@ struct ContentView: View { commandModeShortcut: self.$commandModeHotkeyShortcut, rewriteShortcut: self.$rewriteModeHotkeyShortcut, cancelRecordingShortcut: self.$cancelRecordingHotkeyShortcut, + pasteLastTranscriptionShortcut: self.$pasteLastTranscriptionHotkeyShortcut, commandModeShortcutEnabled: self.$isCommandModeShortcutEnabled, rewriteShortcutEnabled: self.$isRewriteModeShortcutEnabled, + pasteLastTranscriptionShortcutEnabled: self.$isPasteLastTranscriptionShortcutEnabled, hotkeyManagerInitialized: self.$hotkeyManagerInitialized, hotkeyMode: self.$hotkeyMode, enableStreamingPreview: self.$enableStreamingPreview, @@ -2478,6 +2512,91 @@ struct ContentView: View { DebugLogger.shared.info("Actions: Copied latest transcription to clipboard", source: "ContentView") } + /// Re-inserts the most recent transcription into the focused text field using the same + /// clipboard-free insertion path as live dictation. Unlike copy, this never touches the + /// system clipboard, and unlike reprocess, it pastes the existing text verbatim (no new + /// history entry, no reformatting). Useful when the original auto-insert dropped the tail. + private func pasteLastDictationFromHistory() { + guard let last = TranscriptionHistoryStore.shared.entries.first else { + DebugLogger.shared.info("Actions: Paste requested but history is empty", source: "ContentView") + return + } + + // Prefer the processed text (what was actually delivered, possibly AI-enhanced), + // falling back to raw for older entries or when enhancement was off. + let processed = last.processedText.trimmingCharacters(in: .whitespacesAndNewlines) + let raw = last.rawText.trimmingCharacters(in: .whitespacesAndNewlines) + let text = processed.isEmpty ? raw : processed + guard !text.isEmpty else { + DebugLogger.shared.info("Actions: Paste skipped because latest history text is empty", source: "ContentView") + return + } + + Task { @MainActor in + // Only one paste may be pending at a time. Because the paste waits for the modifier keys + // to release, a quick double/triple-tap of the chord would otherwise queue several Tasks + // that all insert at once on release. This collapses them to a single paste while still + // allowing a deliberate repeat (press, it lands, then press again). + guard !Self.isPasteLastInProgress else { + DebugLogger.shared.info("Actions: Paste skipped - a paste is already pending", source: "ContentView") + return + } + Self.isPasteLastInProgress = true + defer { Self.isPasteLastInProgress = false } + + // The hotkey fires on key-down while its own modifier keys (e.g. ⌘⌃) are still + // physically held. Synthesizing text in that state makes the target app treat the + // characters as keyboard shortcuts and drop them, so the paste lands once the keys are + // released — effectively "paste when you let go". The timeout is generous so a normal + // hold (or a quick repeated press) still pastes on release; it only aborts if a modifier + // is genuinely stuck, rather than typing a corrupted/destructive shortcut sequence. + guard await Self.waitForHotkeyModifiersReleased(timeout: 5) else { + DebugLogger.shared.info("Actions: Paste aborted - modifier keys still held after timeout", source: "ContentView") + return + } + + // Re-check here rather than only at the hotkey trigger: the overlay menu entry point + // has no pre-check, and the wait above may have elapsed since the trigger fired. + guard !self.asr.isRunning else { + DebugLogger.shared.info("Actions: Paste skipped - recording in progress", source: "ContentView") + return + } + + let typingTarget = self.resolveTypingTargetPID() + guard typingTarget.pid != nil else { + DebugLogger.shared.info("Actions: Paste skipped - no external target field available", source: "ContentView") + return + } + if typingTarget.shouldRestoreOriginalFocus { + await self.restoreFocusToRecordingTarget() + } + self.asr.typeTextToActiveField(text, preferredTargetPID: typingTarget.pid) + DebugLogger.shared.info("Actions: Pasted latest transcription into focused field", source: "ContentView") + } + } + + /// Guards against overlapping paste insertions: only one "paste last transcription" may be + /// pending at a time (see pasteLastDictationFromHistory). A rapid re-tap while one is still + /// waiting for the modifier keys to release is ignored rather than queuing a duplicate insert. + /// Only ever touched on the main actor. + private static var isPasteLastInProgress = false + + /// Polls until the keyboard modifier keys are released, returning `true` once they are, or + /// `false` if the timeout elapses with keys still held. Used before synthesizing a paste so the + /// inserted characters aren't swallowed as modifier+key shortcuts. + private static func waitForHotkeyModifiersReleased(timeout: TimeInterval) async -> Bool { + let relevant: CGEventFlags = [.maskCommand, .maskControl, .maskAlternate, .maskShift, .maskSecondaryFn] + let start = Date() + while Date().timeIntervalSince(start) < timeout { + let flags = CGEventSource.flagsState(.combinedSessionState) + if flags.isDisjoint(with: relevant) { + return true + } + try? await Task.sleep(nanoseconds: 15_000_000) // 15ms + } + return false + } + private func undoLastAIProcessingFromHistory() { guard let last = TranscriptionHistoryStore.shared.entries.first else { DebugLogger.shared.info("Actions: Undo AI requested but history is empty", source: "ContentView") @@ -2999,6 +3118,9 @@ struct ContentView: View { NotchContentState.shared.onCopyLastRequested = { self.copyLastDictationFromHistory() } + NotchContentState.shared.onPasteLastRequested = { + self.pasteLastDictationFromHistory() + } NotchContentState.shared.onUndoLastAIRequested = { self.undoLastAIProcessingFromHistory() } @@ -3169,6 +3291,11 @@ struct ContentView: View { return handled } + // Re-insert the most recent transcription on demand (no clipboard involved). + self.hotkeyManager?.setPasteLastTranscriptionCallback { + self.pasteLastDictationFromHistory() + } + // Monitor initialization status Task { // Give some time for initialization diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index 2927dfd7..9ca1ced3 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -37,6 +37,9 @@ struct SettingsBackupPayload: Codable, Equatable { let rewriteModeSelectedProviderID: String let rewriteModeLinkedToGlobal: Bool let cancelRecordingHotkeyShortcut: HotkeyShortcut + // Optional so older backup files (which predate this setting) still decode. + let pasteLastTranscriptionHotkeyShortcut: HotkeyShortcut? + let pasteLastTranscriptionShortcutEnabled: Bool? let showThinkingTokens: Bool let hideFromDockAndAppSwitcher: Bool let showMainWindowAtLoginLaunch: Bool? diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 82d0fbec..a1ed50b8 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -2399,6 +2399,43 @@ final class SettingsStore: ObservableObject { } } + // MARK: - Paste Last Transcription Settings + + /// Whether the "Paste Last Transcription" global hotkey is active. Opt-in and off by default. + var pasteLastTranscriptionShortcutEnabled: Bool { + get { + let value = self.defaults.object(forKey: Keys.pasteLastTranscriptionShortcutEnabled) + return value as? Bool ?? false + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.pasteLastTranscriptionShortcutEnabled) + } + } + + /// The shortcut that re-inserts the most recent transcription into the focused field. + /// Unbound (nil) by default so it never collides with an existing shortcut until the user assigns one. + var pasteLastTranscriptionHotkeyShortcut: HotkeyShortcut? { + get { + if let data = defaults.data(forKey: Keys.pasteLastTranscriptionHotkeyShortcut), + let shortcut = try? JSONDecoder().decode(HotkeyShortcut.self, from: data) + { + return shortcut + } + return nil + } + set { + objectWillChange.send() + guard let newValue else { + self.defaults.removeObject(forKey: Keys.pasteLastTranscriptionHotkeyShortcut) + return + } + if let data = try? JSONEncoder().encode(newValue) { + self.defaults.set(data, forKey: Keys.pasteLastTranscriptionHotkeyShortcut) + } + } + } + var commandModeConfirmBeforeExecute: Bool { get { // Default to true (safer - ask before running commands) @@ -2725,6 +2762,8 @@ final class SettingsStore: ObservableObject { rewriteModeSelectedProviderID: self.rewriteModeSelectedProviderID, rewriteModeLinkedToGlobal: self.rewriteModeLinkedToGlobal, cancelRecordingHotkeyShortcut: self.cancelRecordingHotkeyShortcut, + pasteLastTranscriptionHotkeyShortcut: self.pasteLastTranscriptionHotkeyShortcut, + pasteLastTranscriptionShortcutEnabled: self.pasteLastTranscriptionShortcutEnabled, showThinkingTokens: self.showThinkingTokens, hideFromDockAndAppSwitcher: self.hideFromDockAndAppSwitcher, showMainWindowAtLoginLaunch: self.showMainWindowAtLoginLaunch, @@ -2816,6 +2855,14 @@ final class SettingsStore: ObservableObject { self.rewriteModeSelectedProviderID = payload.rewriteModeSelectedProviderID self.rewriteModeLinkedToGlobal = payload.rewriteModeLinkedToGlobal self.cancelRecordingHotkeyShortcut = payload.cancelRecordingHotkeyShortcut + // Both guarded so restoring an older backup (which predates these fields) doesn't wipe a + // currently-configured shortcut or leave the feature enabled with no shortcut bound. + if let pasteLastTranscriptionHotkeyShortcut = payload.pasteLastTranscriptionHotkeyShortcut { + self.pasteLastTranscriptionHotkeyShortcut = pasteLastTranscriptionHotkeyShortcut + } + if let pasteLastTranscriptionShortcutEnabled = payload.pasteLastTranscriptionShortcutEnabled { + self.pasteLastTranscriptionShortcutEnabled = pasteLastTranscriptionShortcutEnabled + } self.showThinkingTokens = payload.showThinkingTokens self.hideFromDockAndAppSwitcher = payload.hideFromDockAndAppSwitcher self.showMainWindowAtLoginLaunch = payload.showMainWindowAtLoginLaunch ?? true @@ -4367,6 +4414,8 @@ private extension SettingsStore { static let commandModeHotkeyShortcut = "CommandModeHotkeyShortcut" static let commandModeConfirmBeforeExecute = "CommandModeConfirmBeforeExecute" static let cancelRecordingHotkeyShortcut = "CancelRecordingHotkeyShortcut" + static let pasteLastTranscriptionHotkeyShortcut = "PasteLastTranscriptionHotkeyShortcut" + static let pasteLastTranscriptionShortcutEnabled = "PasteLastTranscriptionShortcutEnabled" static let commandModeLinkedToGlobal = "CommandModeLinkedToGlobal" static let commandModeShortcutEnabled = "CommandModeShortcutEnabled" diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index 2e1cef4b..c0272aa5 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -69,6 +69,7 @@ final class GlobalHotkeyManager: NSObject { private var isRewriteRecordingProvider: (() -> Bool)? private var isShortcutCaptureActiveProvider: (() -> Bool)? private var cancelCallback: (() -> Bool)? // Returns true if handled + private var pasteLastTranscriptionCallback: (() -> Void)? private var hotkeyMode: HotkeyActivationMode = SettingsStore.shared.hotkeyMode private let automaticTapThresholdSeconds: TimeInterval = 0.4 @@ -414,6 +415,10 @@ final class GlobalHotkeyManager: NSObject { self.cancelCallback = callback } + func setPasteLastTranscriptionCallback(_ callback: @escaping () -> Void) { + self.pasteLastTranscriptionCallback = callback + } + private func setupGlobalHotkeyWithRetry() { for attempt in 1...self.maxRetryAttempts { DebugLogger.shared.debug("Setup attempt \(attempt)/\(self.maxRetryAttempts)", source: "GlobalHotkeyManager") @@ -649,6 +654,18 @@ final class GlobalHotkeyManager: NSObject { } } + // Check the "paste last transcription" shortcut (a one-shot action, like cancel). + if SettingsStore.shared.pasteLastTranscriptionShortcutEnabled, + let pasteShortcut = SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut, + pasteShortcut.matches(keyCode: keyCode, modifiers: eventModifiers) + { + // Holding the chord emits auto-repeat key-downs; because the paste waits for the + // modifiers to release, every repeat would otherwise queue another insertion and + // paste N times. triggerPasteLastTranscription ignores repeats. + self.triggerPasteLastTranscription(isAutorepeat: event.getIntegerValueField(.keyboardEventAutorepeat) != 0) + return nil + } + if let assignment = self.promptShortcutAssignments.first(where: { $0.shortcut.matches(keyCode: keyCode, modifiers: eventModifiers) }) { switch self.hotkeyMode { case .hold: @@ -885,18 +902,14 @@ final class GlobalHotkeyManager: NSObject { case .leftMouseDown, .rightMouseDown, .otherMouseDown: self.markOtherInputDuringModifierOnly() - let mouseButton = self.mouseButton(from: event) - if self.primaryShortcuts.contains(where: { $0.matchesMouse(button: mouseButton, modifiers: eventModifiers) }) { - guard self.beginPrimaryShortcutPress(.mouse(mouseButton)) else { return nil } - self.handlePrimaryDictationTriggerDown() + if self.handleMouseShortcutDown(event, modifiers: eventModifiers) { return nil } case .leftMouseUp, .rightMouseUp, .otherMouseUp: - let mouseButton = self.mouseButton(from: event) - guard self.finishPrimaryShortcutPress(.mouse(mouseButton)) else { break } - self.handlePrimaryDictationTriggerUp() - return nil + if self.handleMouseShortcutUp(event) { + return nil + } case .flagsChanged: if HotkeyShortcut.modifierFlag(forKeyCode: keyCode) != nil { @@ -1652,6 +1665,66 @@ final class GlobalHotkeyManager: NSObject { } } + /// Handles a mouse-button down event against the configured mouse shortcuts. Returns true when + /// the event was consumed. "Paste Last Transcription" is a one-shot trigger (mirrors the keyboard + /// path); primary dictation begins a press here and ends it on mouse-up. + private func handleMouseShortcutDown(_ event: CGEvent, modifiers eventModifiers: NSEvent.ModifierFlags) -> Bool { + let mouseButton = self.mouseButton(from: event) + + if SettingsStore.shared.pasteLastTranscriptionShortcutEnabled, + let pasteShortcut = SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut, + pasteShortcut.matchesMouse(button: mouseButton, modifiers: eventModifiers) + { + self.triggerPasteLastTranscription(isAutorepeat: false) + return true + } + + if self.primaryShortcuts.contains(where: { $0.matchesMouse(button: mouseButton, modifiers: eventModifiers) }) { + guard self.beginPrimaryShortcutPress(.mouse(mouseButton)) else { return true } + self.handlePrimaryDictationTriggerDown() + return true + } + + return false + } + + /// Handles a mouse-button up event. Swallows the up that pairs with a consumed paste mouse-down + /// so the focused app never sees an orphaned mouse-up; otherwise ends a primary dictation press. + private func handleMouseShortcutUp(_ event: CGEvent) -> Bool { + let mouseButton = self.mouseButton(from: event) + + if SettingsStore.shared.pasteLastTranscriptionShortcutEnabled, + let pasteShortcut = SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut, + pasteShortcut.isMouseShortcut, + pasteShortcut.mouseButton == mouseButton + { + return true + } + + guard self.finishPrimaryShortcutPress(.mouse(mouseButton)) else { return false } + self.handlePrimaryDictationTriggerUp() + return true + } + + private func triggerPasteLastTranscription(isAutorepeat: Bool) { + Task { @MainActor [weak self] in + guard let self = self else { return } + // Holding the chord auto-repeats the key-down; act only on the initial press. + guard !isAutorepeat else { return } + guard self.canTriggerRecordingAction("Paste last transcription hotkey") else { return } + // Re-pasting mid-recording would be surprising; ignore while capture is active. + guard !self.asrService.isRunning else { + DebugLogger.shared.info( + "Paste last transcription hotkey ignored - recording in progress", + source: "GlobalHotkeyManager" + ) + return + } + DebugLogger.shared.info("Paste last transcription hotkey triggered", source: "GlobalHotkeyManager") + self.pasteLastTranscriptionCallback?() + } + } + private func triggerDictationMode() { Task { @MainActor [weak self] in guard let self = self else { return } diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index d9b280e1..aab5624d 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -40,8 +40,10 @@ struct SettingsView: View { @Binding var commandModeShortcut: HotkeyShortcut? @Binding var rewriteShortcut: HotkeyShortcut @Binding var cancelRecordingShortcut: HotkeyShortcut + @Binding var pasteLastTranscriptionShortcut: HotkeyShortcut? @Binding var commandModeShortcutEnabled: Bool @Binding var rewriteShortcutEnabled: Bool + @Binding var pasteLastTranscriptionShortcutEnabled: Bool @Binding var hotkeyManagerInitialized: Bool @Binding var hotkeyMode: HotkeyActivationMode @Binding var enableStreamingPreview: Bool @@ -757,6 +759,35 @@ struct SettingsView: View { self.activeShortcutRecordingTarget = .cancel } ) + Divider().opacity(0.2).padding(.vertical, 4) + + self.shortcutRow( + content: .init( + icon: "arrow.down.doc", + iconColor: .secondary, + title: "Paste Last Transcription", + description: "Re-insert your most recent transcription without using the clipboard" + ), + shortcut: self.pasteLastTranscriptionShortcut, + isRecording: self.isRecording(.pasteLast), + isAnyRecordingActive: self.isRecordingAnyShortcut, + recordingMessage: self.isRecording(.pasteLast) ? self.shortcutRecordingMessage : nil, + isEnabled: self.$pasteLastTranscriptionShortcutEnabled, + requiresShortcutToEnable: true, + onChangePressed: { + DebugLogger.shared.debug("Starting to record new paste last transcription shortcut", source: "SettingsView") + self.shortcutRecordingMessage = nil + self.activeShortcutRecordingTarget = .pasteLast + }, + onRemovePressed: { + if self.activeShortcutRecordingTarget == .pasteLast { + self.shortcutRecordingMessage = nil + self.activeShortcutRecordingTarget = nil + } + self.pasteLastTranscriptionShortcut = nil + self.pasteLastTranscriptionShortcutEnabled = false + } + ) } .padding(12) .background( @@ -2254,7 +2285,7 @@ struct SettingsView: View { Color.clear .frame(width: 20) - if isRecording && enabledValue { + if isRecording { self.shortcutCapturePill() } else { self.shortcutDisplayPill(shortcut?.displayString ?? "Not set") diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index 6cca58a1..ee0211d3 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -1539,6 +1539,10 @@ private struct BottomOverlayActionsMenuView: View { return !(processed.isEmpty && raw.isEmpty) } + private var canPasteLast: Bool { + self.canCopyLast + } + private var canUndoLastAI: Bool { guard !self.contentState.isProcessing else { return false } guard let latest = self.latestEntry else { return false } @@ -1629,6 +1633,15 @@ private struct BottomOverlayActionsMenuView: View { self.contentState.onCopyLastRequested?() } + self.actionRow( + title: "Paste Last Transcription", + icon: "arrow.down.doc", + rowID: "paste_last", + enabled: self.canPasteLast + ) { + self.contentState.onPasteLastRequested?() + } + Divider() .padding(.vertical, 4) diff --git a/Sources/Fluid/Views/NotchContentViews.swift b/Sources/Fluid/Views/NotchContentViews.swift index a3696319..6b32e15f 100644 --- a/Sources/Fluid/Views/NotchContentViews.swift +++ b/Sources/Fluid/Views/NotchContentViews.swift @@ -157,6 +157,8 @@ class NotchContentState: ObservableObject { var onReprocessLastRequested: (() -> Void)? /// Called when the user requests copying the latest saved transcription entry. var onCopyLastRequested: (() -> Void)? + /// Called when the user requests re-pasting the latest saved transcription entry. + var onPasteLastRequested: (() -> Void)? /// Called when the user requests undoing AI processing for the latest entry. var onUndoLastAIRequested: (() -> Void)? /// Called when the user requests opening Preferences. diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index a0e3f4ec..b7e0f8ee 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -6,6 +6,8 @@ import XCTest final class HotkeyShortcutTests: XCTestCase { private let legacyHotkeyShortcutKey = "HotkeyShortcutKey" private let primaryDictationShortcutsKey = "PrimaryDictationShortcuts" + private let pasteLastTranscriptionShortcutKey = "PasteLastTranscriptionHotkeyShortcut" + private let pasteLastTranscriptionEnabledKey = "PasteLastTranscriptionShortcutEnabled" func testLegacyKeyboardShortcutPayloadDefaultsToKeyboardKind() throws { let json = #"{"keyCode":61,"modifierFlagsRawValue":0}"# @@ -110,6 +112,49 @@ final class HotkeyShortcutTests: XCTestCase { } } + func testPasteLastTranscriptionShortcutDefaultsToUnboundAndDisabled() throws { + try self.withRestoredDefaults(keys: [ + self.pasteLastTranscriptionShortcutKey, + self.pasteLastTranscriptionEnabledKey, + ]) { + UserDefaults.standard.removeObject(forKey: self.pasteLastTranscriptionShortcutKey) + UserDefaults.standard.removeObject(forKey: self.pasteLastTranscriptionEnabledKey) + + XCTAssertNil(SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut) + XCTAssertFalse(SettingsStore.shared.pasteLastTranscriptionShortcutEnabled) + } + } + + func testPasteLastTranscriptionShortcutPersistsAndClears() throws { + try self.withRestoredDefaults(keys: [ + self.pasteLastTranscriptionShortcutKey, + self.pasteLastTranscriptionEnabledKey, + ]) { + let shortcut = HotkeyShortcut(keyCode: 9, modifierFlags: [.command, .shift]) + SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut = shortcut + SettingsStore.shared.pasteLastTranscriptionShortcutEnabled = true + + XCTAssertEqual(SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut, shortcut) + XCTAssertTrue(SettingsStore.shared.pasteLastTranscriptionShortcutEnabled) + + // Removing the shortcut returns to the unbound state. + SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut = nil + XCTAssertNil(SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut) + } + } + + func testPasteLastTranscriptionShortcutSupportsMouseButton() throws { + try self.withRestoredDefaults(keys: [self.pasteLastTranscriptionShortcutKey]) { + let mouseShortcut = HotkeyShortcut(mouseButton: 3, modifierFlags: [.option]) + SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut = mouseShortcut + + let stored = SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut + XCTAssertEqual(stored, mouseShortcut) + XCTAssertTrue(stored?.isMouseShortcut ?? false) + XCTAssertTrue(stored?.matchesMouse(button: 3, modifiers: [.option]) ?? false) + } + } + private func withRestoredDefaults(keys: [String], run: () throws -> Void) rethrows { let defaults = UserDefaults.standard var snapshot: [String: Any] = [:]