From ac1a85c0569a271f250036ca8c6b0779c0dfa09c Mon Sep 17 00:00:00 2001 From: Beingpax Date: Fri, 28 Nov 2025 20:39:43 +0545 Subject: [PATCH] Improved text formatting during paste operation --- VoiceInk/CursorPaster.swift | 15 +- .../Services/LastTranscriptionService.swift | 8 +- .../Services/TextInsertionFormatter.swift | 166 ++++++++++++++++++ VoiceInk/Views/Settings/SettingsView.swift | 2 +- VoiceInk/Whisper/WhisperState.swift | 5 - 5 files changed, 179 insertions(+), 17 deletions(-) create mode 100644 VoiceInk/Services/TextInsertionFormatter.swift diff --git a/VoiceInk/CursorPaster.swift b/VoiceInk/CursorPaster.swift index 489e4b5..e0cd5c8 100644 --- a/VoiceInk/CursorPaster.swift +++ b/VoiceInk/CursorPaster.swift @@ -2,17 +2,20 @@ import Foundation import AppKit class CursorPaster { - + static func pasteAtCursor(_ text: String) { let pasteboard = NSPasteboard.general let preserveTranscript = UserDefaults.standard.bool(forKey: "preserveTranscriptInClipboard") - + + let context = TextInsertionFormatter.getInsertionContext() + let textToInsert = TextInsertionFormatter.formatTextForInsertion(text, context: context) + var savedContents: [(NSPasteboard.PasteboardType, Data)] = [] - + // Only save clipboard contents if we plan to restore them if !preserveTranscript { let currentItems = pasteboard.pasteboardItems ?? [] - + for item in currentItems { for type in item.types { if let data = item.data(forType: type) { @@ -21,8 +24,8 @@ class CursorPaster { } } } - - ClipboardManager.setClipboard(text, transient: !preserveTranscript) + + ClipboardManager.setClipboard(textToInsert, transient: !preserveTranscript) DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { if UserDefaults.standard.bool(forKey: "UseAppleScriptPaste") { diff --git a/VoiceInk/Services/LastTranscriptionService.swift b/VoiceInk/Services/LastTranscriptionService.swift index e888e8c..91ebae6 100644 --- a/VoiceInk/Services/LastTranscriptionService.swift +++ b/VoiceInk/Services/LastTranscriptionService.swift @@ -67,10 +67,9 @@ class LastTranscriptionService: ObservableObject { } let textToPaste = lastTranscription.text - - // Delay to give the user time to release modifier keys (especially Control) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - CursorPaster.pasteAtCursor(textToPaste + " ") + CursorPaster.pasteAtCursor(textToPaste) } } @@ -94,9 +93,8 @@ class LastTranscriptionService: ObservableObject { } }() - // Delay to allow modifier keys to be released DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - CursorPaster.pasteAtCursor(textToPaste + " ") + CursorPaster.pasteAtCursor(textToPaste) } } diff --git a/VoiceInk/Services/TextInsertionFormatter.swift b/VoiceInk/Services/TextInsertionFormatter.swift new file mode 100644 index 0000000..cf4c11e --- /dev/null +++ b/VoiceInk/Services/TextInsertionFormatter.swift @@ -0,0 +1,166 @@ +import Foundation +import AppKit + +class TextInsertionFormatter { + + struct InsertionContext { + let textBefore: String + let textAfter: String + let charBeforeCursor: Character? + let charAfterCursor: Character? + } + + static func getInsertionContext() -> InsertionContext? { + guard AXIsProcessTrusted() else { + return nil + } + + guard let focusedElement = getFocusedElement() else { + return nil + } + + var selectedRange: CFTypeRef? + let rangeResult = AXUIElementCopyAttributeValue(focusedElement, kAXSelectedTextRangeAttribute as CFString, &selectedRange) + + var textValue: CFTypeRef? + let textResult = AXUIElementCopyAttributeValue(focusedElement, kAXValueAttribute as CFString, &textValue) + + guard rangeResult == .success, + textResult == .success, + let range = selectedRange, + let text = textValue as? String else { + return nil + } + + var rangeValue = CFRange() + guard AXValueGetValue(range as! AXValue, .cfRange, &rangeValue) else { + return nil + } + + let cursorPosition = rangeValue.location + let beforeStart = max(0, cursorPosition - 100) + let textBefore = String(text[text.index(text.startIndex, offsetBy: beforeStart).. 0 ? text[text.index(text.startIndex, offsetBy: cursorPosition - 1)] : nil + let charAfter = cursorPosition < text.count ? text[text.index(text.startIndex, offsetBy: cursorPosition)] : nil + + return InsertionContext( + textBefore: textBefore, + textAfter: textAfter, + charBeforeCursor: charBefore, + charAfterCursor: charAfter + ) + } + + private static func getFocusedElement() -> AXUIElement? { + let systemWideElement = AXUIElementCreateSystemWide() + var focusedApp: CFTypeRef? + + guard AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedApplicationAttribute as CFString, &focusedApp) == .success else { + return nil + } + + var focusedElement: CFTypeRef? + guard AXUIElementCopyAttributeValue(focusedApp as! AXUIElement, kAXFocusedUIElementAttribute as CFString, &focusedElement) == .success else { + return nil + } + + return (focusedElement as! AXUIElement) + } + + static func formatTextForInsertion(_ text: String, context: InsertionContext?) -> String { + guard let context = context else { + return text + " " + } + + var formattedText = text + formattedText = applySmartCapitalization(formattedText, context: context) + formattedText = applySmartSpacing(formattedText, context: context) + + return formattedText + } + + private static func applySmartSpacing(_ text: String, context: InsertionContext) -> String { + var result = text + + if shouldAddSpaceBefore(context: context) { + result = " " + result + } + + if let charAfter = context.charAfterCursor { + if !charAfter.isWhitespace && !charAfter.isPunctuation && !(result.last?.isWhitespace ?? false) { + result = result + " " + } + } else { + result = result + " " + } + + return result + } + + private static func shouldAddSpaceBefore(context: InsertionContext) -> Bool { + guard let charBefore = context.charBeforeCursor else { + return false + } + + if charBefore.isWhitespace { + return false + } + + if charBefore == "." || charBefore == "!" || charBefore == "?" { + return true + } + + if charBefore == "," || charBefore == ";" || charBefore == ":" || charBefore == "-" { + return true + } + + if charBefore.isLetter || charBefore.isNumber { + return true + } + + return false + } + + private static func applySmartCapitalization(_ text: String, context: InsertionContext) -> String { + guard !text.isEmpty else { return text } + + let shouldCapitalize = shouldCapitalizeFirstLetter(context: context) + + if shouldCapitalize { + return text.prefix(1).uppercased() + text.dropFirst() + } else { + let firstWord = text.prefix(while: { !$0.isWhitespace && !$0.isPunctuation }) + let isAcronymOrProper = firstWord.allSatisfy { $0.isUppercase || !$0.isLetter } + + if !isAcronymOrProper { + return text.prefix(1).lowercased() + text.dropFirst() + } + } + + return text + } + + private static func shouldCapitalizeFirstLetter(context: InsertionContext) -> Bool { + if context.textBefore.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return true + } + + let trimmedBefore = context.textBefore.trimmingCharacters(in: .whitespaces) + + if trimmedBefore.isEmpty { + return true + } + + if let lastChar = trimmedBefore.last { + if lastChar == "." || lastChar == "!" || lastChar == "?" || lastChar == "\n" { + return true + } + } + + return false + } +} diff --git a/VoiceInk/Views/Settings/SettingsView.swift b/VoiceInk/Views/Settings/SettingsView.swift index 479e8e1..65634e1 100644 --- a/VoiceInk/Views/Settings/SettingsView.swift +++ b/VoiceInk/Views/Settings/SettingsView.swift @@ -300,7 +300,7 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 8) { Text("Select the method used to paste text. Use AppleScript if you have a non-standard keyboard layout.") .settingsDescription() - + Toggle("Use AppleScript Paste Method", isOn: Binding( get: { UserDefaults.standard.bool(forKey: "UseAppleScriptPaste") }, set: { UserDefaults.standard.set($0, forKey: "UseAppleScriptPaste") } diff --git a/VoiceInk/Whisper/WhisperState.swift b/VoiceInk/Whisper/WhisperState.swift index c43ed95..d8c364c 100644 --- a/VoiceInk/Whisper/WhisperState.swift +++ b/VoiceInk/Whisper/WhisperState.swift @@ -398,11 +398,6 @@ class WhisperState: NSObject, ObservableObject { """ } - let shouldAddSpace = UserDefaults.standard.object(forKey: "AppendTrailingSpace") as? Bool ?? true - if shouldAddSpace { - textToPaste += " " - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { CursorPaster.pasteAtCursor(textToPaste)