From 05806f4248ae236e0a7a1038ee40589f2715007c Mon Sep 17 00:00:00 2001 From: Beingpax Date: Mon, 30 Jun 2025 18:23:24 +0545 Subject: [PATCH] Add transcription fallback system for paste failures --- VoiceInk/CursorPaster.swift | 1 - .../Services/PasteEligibilityService.swift | 31 ++++ .../TranscriptionFallbackManager.swift | 158 ++++++++++++++++++ .../Common/TranscriptionFallbackView.swift | 110 ++++++++++++ VoiceInk/Whisper/WhisperState.swift | 18 +- 5 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 VoiceInk/Services/PasteEligibilityService.swift create mode 100644 VoiceInk/Services/TranscriptionFallbackManager.swift create mode 100644 VoiceInk/Views/Common/TranscriptionFallbackView.swift diff --git a/VoiceInk/CursorPaster.swift b/VoiceInk/CursorPaster.swift index 6dcc3b6..4062f2b 100644 --- a/VoiceInk/CursorPaster.swift +++ b/VoiceInk/CursorPaster.swift @@ -5,7 +5,6 @@ class CursorPaster { private static let pasteCompletionDelay: TimeInterval = 0.3 static func pasteAtCursor(_ text: String, shouldPreserveClipboard: Bool = true) { - let pasteboard = NSPasteboard.general var savedContents: [(NSPasteboard.PasteboardType, Data)] = [] diff --git a/VoiceInk/Services/PasteEligibilityService.swift b/VoiceInk/Services/PasteEligibilityService.swift new file mode 100644 index 0000000..c183302 --- /dev/null +++ b/VoiceInk/Services/PasteEligibilityService.swift @@ -0,0 +1,31 @@ +import Cocoa + +class PasteEligibilityService { + static func isPastePossible() -> Bool { + guard AXIsProcessTrustedWithOptions(nil) else { + return true + } + + guard let frontmostApp = NSWorkspace.shared.frontmostApplication else { + return false + } + + let appElement = AXUIElementCreateApplication(frontmostApp.processIdentifier) + + var focusedElement: AnyObject? + let result = AXUIElementCopyAttributeValue(appElement, kAXFocusedUIElementAttribute as CFString, &focusedElement) + + guard result == .success, let element = focusedElement else { + return false + } + + var isWritable: DarwinBoolean = false + let isSettableResult = AXUIElementIsAttributeSettable(element as! AXUIElement, kAXValueAttribute as CFString, &isWritable) + + if isSettableResult == .success && isWritable.boolValue { + return true + } + + return false + } +} \ No newline at end of file diff --git a/VoiceInk/Services/TranscriptionFallbackManager.swift b/VoiceInk/Services/TranscriptionFallbackManager.swift new file mode 100644 index 0000000..d5f812d --- /dev/null +++ b/VoiceInk/Services/TranscriptionFallbackManager.swift @@ -0,0 +1,158 @@ +import SwiftUI +import AppKit + +/// Custom NSPanel that can become key window for text editing +class EditablePanel: NSPanel { + override var canBecomeKey: Bool { + return true + } + + override var canBecomeMain: Bool { + return false + } +} + +/// Manages the presentation and dismissal of the `TranscriptionFallbackView`. +class TranscriptionFallbackManager { + static let shared = TranscriptionFallbackManager() + + private var fallbackWindow: NSPanel? + + private init() {} + + /// Displays the fallback window with the provided transcription text. + @MainActor + func showFallback(for text: String) { + dismiss() + + let fallbackView = TranscriptionFallbackView( + transcriptionText: text, + onCopy: { [weak self] in + self?.dismiss() + }, + onClose: { [weak self] in + self?.dismiss() + }, + onTextChange: { [weak self] newText in + self?.resizeWindow(for: newText) + } + ) + + let hostingController = NSHostingController(rootView: fallbackView) + + let finalSize = calculateOptimalSize(for: text) + + let panel = createFallbackPanel(with: finalSize) + panel.contentView = hostingController.view + + self.fallbackWindow = panel + + panel.alphaValue = 0 + panel.makeKeyAndOrderFront(nil) + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + panel.animator().alphaValue = 1 + } completionHandler: { + DispatchQueue.main.async { + panel.makeFirstResponder(hostingController.view) + } + } + } + + /// Dynamically resizes the window based on new text content + @MainActor + private func resizeWindow(for text: String) { + guard let window = fallbackWindow else { return } + + let newSize = calculateOptimalSize(for: text) + let currentFrame = window.frame + + // Keep the window centered while resizing + let newX = currentFrame.midX - (newSize.width / 2) + let newY = currentFrame.midY - (newSize.height / 2) + + let newFrame = NSRect(x: newX, y: newY, width: newSize.width, height: newSize.height) + + // Animate the resize + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.2 + context.allowsImplicitAnimation = true + window.animator().setFrame(newFrame, display: true) + } + } + + /// Dismisses the fallback window with an animation. + @MainActor + func dismiss() { + guard let window = fallbackWindow else { return } + + fallbackWindow = nil + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.2 + window.animator().alphaValue = 0 + }, completionHandler: { + window.close() + }) + } + + private func calculateOptimalSize(for text: String) -> CGSize { + let minWidth: CGFloat = 280 + let maxWidth: CGFloat = 400 + let minHeight: CGFloat = 80 + let maxHeight: CGFloat = 300 + let horizontalPadding: CGFloat = 48 + let verticalPadding: CGFloat = 56 + + let font = NSFont.systemFont(ofSize: 14, weight: .regular) + let textStorage = NSTextStorage(string: text, attributes: [.font: font]) + let textContainer = NSTextContainer(size: CGSize(width: maxWidth - horizontalPadding, height: .greatestFiniteMagnitude)) + let layoutManager = NSLayoutManager() + + layoutManager.addTextContainer(textContainer) + textStorage.addLayoutManager(layoutManager) + + layoutManager.glyphRange(for: textContainer) + let usedRect = layoutManager.usedRect(for: textContainer) + + let idealWidth = usedRect.width + horizontalPadding + let idealHeight = usedRect.height + verticalPadding + + let finalWidth = min(maxWidth, max(minWidth, idealWidth)) + let finalHeight = min(maxHeight, max(minHeight, idealHeight)) + + return CGSize(width: finalWidth, height: finalHeight) + } + + private func createFallbackPanel(with finalSize: NSSize) -> NSPanel { + let panel = EditablePanel( + contentRect: .zero, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + + panel.isFloatingPanel = true + panel.level = .floating + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = true + panel.isMovable = false + panel.hidesOnDeactivate = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.acceptsMouseMovedEvents = true + panel.worksWhenModal = true + + if let activeScreen = NSScreen.main { + let screenRect = activeScreen.visibleFrame + let xPos = screenRect.midX - (finalSize.width / 2) + let yPos = screenRect.midY - (finalSize.height / 2) + panel.setFrameOrigin(NSPoint(x: xPos, y: yPos)) + } + + panel.setContentSize(finalSize) + + return panel + } +} \ No newline at end of file diff --git a/VoiceInk/Views/Common/TranscriptionFallbackView.swift b/VoiceInk/Views/Common/TranscriptionFallbackView.swift new file mode 100644 index 0000000..2dce94d --- /dev/null +++ b/VoiceInk/Views/Common/TranscriptionFallbackView.swift @@ -0,0 +1,110 @@ +import SwiftUI + +/// A view that provides a fallback UI to display transcribed text when it cannot be pasted automatically. +struct TranscriptionFallbackView: View { + let transcriptionText: String + let onCopy: () -> Void + let onClose: () -> Void + let onTextChange: ((String) -> Void)? + + @State private var editableText: String = "" + @State private var isHoveringTitleBar = false + + var body: some View { + VStack(spacing: 0) { + // Title Bar + HStack { + if isHoveringTitleBar { + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .semibold)) + } + .buttonStyle(TitleBarButtonStyle(color: .red)) + .keyboardShortcut(.cancelAction) + } else { + Spacer().frame(width: 20, height: 20) + } + + Spacer() + + Text("VoiceInk") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundColor(.secondary) + + Spacer() + + if isHoveringTitleBar { + Button(action: { + ClipboardManager.copyToClipboard(editableText) + onCopy() + }) { + Image(systemName: "doc.on.doc") + .font(.system(size: 9, weight: .semibold)) + } + .buttonStyle(TitleBarButtonStyle(color: .blue)) + } else { + Spacer().frame(width: 20, height: 20) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.ultraThinMaterial) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHoveringTitleBar = hovering + } + } + + // Text Editor + TextEditor(text: $editableText) + .font(.system(size: 14, weight: .regular, design: .rounded)) + .scrollContentBackground(.hidden) + .background(Color.clear) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .onAppear { + editableText = transcriptionText + } + .onChange(of: editableText) { newValue in + onTextChange?(newValue) + } + } + .background(.regularMaterial) + .cornerRadius(16) + .background( + Button("", action: onClose) + .keyboardShortcut("w", modifiers: .command) + .hidden() + ) + } +} + +private struct TitleBarButtonStyle: ButtonStyle { + let color: Color + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundColor(.white) + .padding(3) + .background(Circle().fill(color)) + .scaleEffect(configuration.isPressed ? 0.9 : 1.0) + } +} + +#Preview { + VStack { + TranscriptionFallbackView( + transcriptionText: "Short text.", + onCopy: {}, + onClose: {}, + onTextChange: nil + ) + TranscriptionFallbackView( + transcriptionText: "This is a much longer piece of transcription text to demonstrate how the view will adaptively resize to accommodate more content while still respecting the maximum constraints.", + onCopy: {}, + onClose: {}, + onTextChange: nil + ) + } + .padding() +} diff --git a/VoiceInk/Whisper/WhisperState.swift b/VoiceInk/Whisper/WhisperState.swift index ea8434e..dddd913 100644 --- a/VoiceInk/Whisper/WhisperState.swift +++ b/VoiceInk/Whisper/WhisperState.swift @@ -375,7 +375,14 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { SoundManager.shared.playStopSound() DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - CursorPaster.pasteAtCursor(text, shouldPreserveClipboard: !self.isAutoCopyEnabled) + if PasteEligibilityService.isPastePossible() { + CursorPaster.pasteAtCursor(text, shouldPreserveClipboard: !self.isAutoCopyEnabled) + } else { + if self.isAutoCopyEnabled { + ClipboardManager.copyToClipboard(text) + } + TranscriptionFallbackManager.shared.showFallback(for: text) + } } try? FileManager.default.removeItem(at: url) @@ -470,7 +477,14 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { let textToPaste = newTranscription.enhancedText ?? newTranscription.text DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - CursorPaster.pasteAtCursor(textToPaste + " ", shouldPreserveClipboard: !self.isAutoCopyEnabled) + if PasteEligibilityService.isPastePossible() { + CursorPaster.pasteAtCursor(textToPaste + " ", shouldPreserveClipboard: !self.isAutoCopyEnabled) + } else { + if self.isAutoCopyEnabled { + ClipboardManager.copyToClipboard(textToPaste) + } + TranscriptionFallbackManager.shared.showFallback(for: textToPaste) + } } }