|
|
@ -75,6 +75,18 @@ public final class InputBoxManager { |
|
|
public func streamDelta(text: String) { |
|
|
public func streamDelta(text: String) { |
|
|
guard !text.isEmpty else { return } |
|
|
guard !text.isEmpty else { return } |
|
|
|
|
|
|
|
|
|
|
|
// Suppress trailing end triggers ("and", "تمام", "end") from live typing |
|
|
|
|
|
let triggerWords = text.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } |
|
|
|
|
|
if let last = triggerWords.last?.replacingOccurrences(of: #"[.,!?:;،؛؟"'()\[\]{}«»–—\-]"#, with: "", options: .regularExpression).lowercased(), |
|
|
|
|
|
last == "and" || last == "تمام" || last == "end" { |
|
|
|
|
|
if triggerWords.count <= 1 { |
|
|
|
|
|
return // Drop completely if it was just the end word |
|
|
|
|
|
} |
|
|
|
|
|
let textWithoutEnd = triggerWords.dropLast().joined(separator: " ") + " " |
|
|
|
|
|
streamDelta(text: textWithoutEnd) |
|
|
|
|
|
return |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
lock.lock() |
|
|
lock.lock() |
|
|
if isPrefixResolved { |
|
|
if isPrefixResolved { |
|
|
var textToPaste = text |
|
|
var textToPaste = text |
|
|
@ -219,9 +231,22 @@ public final class InputBoxManager { |
|
|
pasteQueue.async { [weak self] in |
|
|
pasteQueue.async { [weak self] in |
|
|
guard let self = self else { return } |
|
|
guard let self = self else { return } |
|
|
|
|
|
|
|
|
|
|
|
// Helper to restore full text onto clipboard for the user after injection completes |
|
|
|
|
|
let restoreClipboardFullText = { |
|
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { |
|
|
|
|
|
let pb = NSPasteboard.general |
|
|
|
|
|
pb.clearContents() |
|
|
|
|
|
pb.setString(textWithTrailing, forType: .string) |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
// Case 1: Nothing was streamed live -> Paste entire text |
|
|
// Case 1: Nothing was streamed live -> Paste entire text |
|
|
if cleanStreamed.isEmpty { |
|
|
if cleanStreamed.isEmpty { |
|
|
|
|
|
let pb = NSPasteboard.general |
|
|
|
|
|
pb.clearContents() |
|
|
|
|
|
pb.setString(textWithTrailing, forType: .string) |
|
|
self.postCleanCmdV() |
|
|
self.postCleanCmdV() |
|
|
|
|
|
restoreClipboardFullText() |
|
|
return |
|
|
return |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -233,6 +258,7 @@ public final class InputBoxManager { |
|
|
pb.setString(" ", forType: .string) |
|
|
pb.setString(" ", forType: .string) |
|
|
self.postCleanCmdV() |
|
|
self.postCleanCmdV() |
|
|
} |
|
|
} |
|
|
|
|
|
restoreClipboardFullText() |
|
|
return |
|
|
return |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -244,40 +270,124 @@ public final class InputBoxManager { |
|
|
pb.clearContents() |
|
|
pb.clearContents() |
|
|
pb.setString(suffixToPaste, forType: .string) |
|
|
pb.setString(suffixToPaste, forType: .string) |
|
|
self.postCleanCmdV() |
|
|
self.postCleanCmdV() |
|
|
|
|
|
restoreClipboardFullText() |
|
|
return |
|
|
return |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// Case 4: Word-level token overlap matching (robust to punctuation, ZWNJ, and acoustic model variations) |
|
|
|
|
|
|
|
|
// Case 4: Intelligent Word-level Tail-Anchor Matching (robust to domain vocabulary, number formatting, punctuation, ZWNJ) |
|
|
let streamedWords = cleanStreamed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } |
|
|
let streamedWords = cleanStreamed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } |
|
|
let finalWords = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } |
|
|
let finalWords = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } |
|
|
|
|
|
|
|
|
|
|
|
guard !finalWords.isEmpty else { |
|
|
|
|
|
restoreClipboardFullText() |
|
|
|
|
|
return |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
let cleanWord: (String) -> String = { w in |
|
|
let cleanWord: (String) -> String = { w in |
|
|
w.replacingOccurrences(of: "[.,!?:;،؛؟\\\"'()\\[\\]{}«»–—\\-]", with: "", options: .regularExpression).lowercased() |
|
|
|
|
|
|
|
|
w.replacingOccurrences(of: "\u{200C}", with: "") |
|
|
|
|
|
.replacingOccurrences(of: "\u{200D}", with: "") |
|
|
|
|
|
.replacingOccurrences(of: "[.,!?:;،؛؟\\\"'()\\[\\]{}«»–—\\-]", with: "", options: .regularExpression) |
|
|
|
|
|
.lowercased() |
|
|
|
|
|
} |
|
|
|
|
|
let normStreamed = streamedWords.map(cleanWord).filter { !$0.isEmpty } |
|
|
|
|
|
let normFinal = finalWords.map(cleanWord).filter { !$0.isEmpty } |
|
|
|
|
|
|
|
|
|
|
|
var unstreamedStartIndex: Int? = nil |
|
|
|
|
|
|
|
|
|
|
|
// Strategy 4A: Search for 3-word tail anchor from streamed text in final words |
|
|
|
|
|
if normStreamed.count >= 3 { |
|
|
|
|
|
let a1 = normStreamed[normStreamed.count - 3] |
|
|
|
|
|
let a2 = normStreamed[normStreamed.count - 2] |
|
|
|
|
|
let a3 = normStreamed[normStreamed.count - 1] |
|
|
|
|
|
for j in stride(from: normFinal.count - 1, through: 2, by: -1) { |
|
|
|
|
|
if normFinal[j] == a3 && normFinal[j - 1] == a2 && normFinal[j - 2] == a1 { |
|
|
|
|
|
unstreamedStartIndex = j + 1 |
|
|
|
|
|
break |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
let normStreamed = streamedWords.map(cleanWord) |
|
|
|
|
|
let normFinal = finalWords.map(cleanWord) |
|
|
|
|
|
|
|
|
|
|
|
var matchedWordCount = 0 |
|
|
|
|
|
|
|
|
// Strategy 4B: Search for 2-word tail anchor |
|
|
|
|
|
if unstreamedStartIndex == nil && normStreamed.count >= 2 { |
|
|
|
|
|
let a1 = normStreamed[normStreamed.count - 2] |
|
|
|
|
|
let a2 = normStreamed[normStreamed.count - 1] |
|
|
|
|
|
for j in stride(from: normFinal.count - 1, through: 1, by: -1) { |
|
|
|
|
|
if normFinal[j] == a2 && normFinal[j - 1] == a1 { |
|
|
|
|
|
unstreamedStartIndex = j + 1 |
|
|
|
|
|
break |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Strategy 4C: Search for 1-word tail anchor in recent final words |
|
|
|
|
|
if unstreamedStartIndex == nil && !normStreamed.isEmpty { |
|
|
|
|
|
let a1 = normStreamed[normStreamed.count - 1] |
|
|
|
|
|
let searchStart = max(0, normFinal.count - max(15, normStreamed.count)) |
|
|
|
|
|
for j in stride(from: normFinal.count - 1, through: searchStart, by: -1) { |
|
|
|
|
|
if normFinal[j] == a1 { |
|
|
|
|
|
unstreamedStartIndex = j + 1 |
|
|
|
|
|
break |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Strategy 4D: Forward prefix match fallback |
|
|
|
|
|
if unstreamedStartIndex == nil { |
|
|
|
|
|
var prefixMatchCount = 0 |
|
|
for i in 0..<min(normStreamed.count, normFinal.count) { |
|
|
for i in 0..<min(normStreamed.count, normFinal.count) { |
|
|
if normStreamed[i] == normFinal[i] { |
|
|
if normStreamed[i] == normFinal[i] { |
|
|
matchedWordCount = i + 1 |
|
|
|
|
|
|
|
|
prefixMatchCount = i + 1 |
|
|
} else { |
|
|
} else { |
|
|
break |
|
|
break |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
if prefixMatchCount > 0 { |
|
|
|
|
|
unstreamedStartIndex = prefixMatchCount |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
if matchedWordCount > 0 && finalWords.count > matchedWordCount { |
|
|
|
|
|
let remainingWords = Array(finalWords.dropFirst(matchedWordCount)) |
|
|
|
|
|
|
|
|
// Suffix Evaluation: |
|
|
|
|
|
if let startIndex = unstreamedStartIndex { |
|
|
|
|
|
if startIndex < finalWords.count { |
|
|
|
|
|
// Only paste unstreamed trailing words |
|
|
|
|
|
let remainingWords = Array(finalWords.dropFirst(startIndex)) |
|
|
let suffix = remainingWords.joined(separator: " ") |
|
|
let suffix = remainingWords.joined(separator: " ") |
|
|
let suffixToPaste = addTrailingSpace ? (" " + suffix + " ") : (" " + suffix) |
|
|
let suffixToPaste = addTrailingSpace ? (" " + suffix + " ") : (" " + suffix) |
|
|
let pb = NSPasteboard.general |
|
|
let pb = NSPasteboard.general |
|
|
pb.clearContents() |
|
|
pb.clearContents() |
|
|
pb.setString(suffixToPaste, forType: .string) |
|
|
pb.setString(suffixToPaste, forType: .string) |
|
|
self.postCleanCmdV() |
|
|
self.postCleanCmdV() |
|
|
|
|
|
restoreClipboardFullText() |
|
|
return |
|
|
return |
|
|
|
|
|
} else { |
|
|
|
|
|
// startIndex >= finalWords.count: All words were ALREADY streamed! |
|
|
|
|
|
if addTrailingSpace { |
|
|
|
|
|
let pb = NSPasteboard.general |
|
|
|
|
|
pb.clearContents() |
|
|
|
|
|
pb.setString(" ", forType: .string) |
|
|
|
|
|
self.postCleanCmdV() |
|
|
|
|
|
} |
|
|
|
|
|
restoreClipboardFullText() |
|
|
|
|
|
return |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// Case 5: Approximate fallback when streamed words were few and final words are many |
|
|
|
|
|
|
|
|
// Case 5: Anti-Duplication Safety Guard |
|
|
|
|
|
// If more than half the words were streamed, the utterance is already in the input box! |
|
|
|
|
|
// NEVER paste the full text, as that guarantees 100% duplication! |
|
|
|
|
|
if streamedWords.count >= (finalWords.count / 2) { |
|
|
|
|
|
print("InputBoxManager: Most words already streamed (\(streamedWords.count) vs \(finalWords.count)). Suppressing full paste to prevent duplication.") |
|
|
|
|
|
if addTrailingSpace { |
|
|
|
|
|
let pb = NSPasteboard.general |
|
|
|
|
|
pb.clearContents() |
|
|
|
|
|
pb.setString(" ", forType: .string) |
|
|
|
|
|
self.postCleanCmdV() |
|
|
|
|
|
} |
|
|
|
|
|
restoreClipboardFullText() |
|
|
|
|
|
return |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Case 6: Fallback when only very few words were streamed (<50%) and no anchor was found |
|
|
if finalWords.count > streamedWords.count { |
|
|
if finalWords.count > streamedWords.count { |
|
|
let remainingWords = Array(finalWords.dropFirst(streamedWords.count)) |
|
|
let remainingWords = Array(finalWords.dropFirst(streamedWords.count)) |
|
|
let suffix = remainingWords.joined(separator: " ") |
|
|
let suffix = remainingWords.joined(separator: " ") |
|
|
@ -286,16 +396,11 @@ public final class InputBoxManager { |
|
|
pb.clearContents() |
|
|
pb.clearContents() |
|
|
pb.setString(suffixToPaste, forType: .string) |
|
|
pb.setString(suffixToPaste, forType: .string) |
|
|
self.postCleanCmdV() |
|
|
self.postCleanCmdV() |
|
|
return |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// Case 6: Fallback - paste the full text so speech is NEVER discarded |
|
|
|
|
|
self.postCleanCmdV() |
|
|
|
|
|
|
|
|
restoreClipboardFullText() |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/// Posts Cmd+V using privateState event source to cgAnnotatedSessionEventTap |
|
|
|
|
|
/// This strips physical Option ⌥ / Shift / Control flags and pastes cleanly across all apps |
|
|
|
|
|
private func postCleanCmdV() { |
|
|
private func postCleanCmdV() { |
|
|
let src = CGEventSource(stateID: .privateState) |
|
|
let src = CGEventSource(stateID: .privateState) |
|
|
let vKeyCode: CGKeyCode = 0x09 // 'v' |
|
|
let vKeyCode: CGKeyCode = 0x09 // 'v' |
|
|
|