9f308fadd2
Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
87 lines
3.8 KiB
Swift
87 lines
3.8 KiB
Swift
// DictationTextComposer.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Merges pre-dictation anchor text with a live cumulative transcript.
|
|
|
|
import Foundation
|
|
|
|
public enum DictationTextComposer {
|
|
/// Combine text that existed before dictation with the current live transcript.
|
|
public static func compose(anchor: String, live: String) -> String {
|
|
let trimmed = live.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return anchor }
|
|
if anchor.isEmpty { return trimmed }
|
|
if let merged = mergeOverlapping(anchor: anchor, live: trimmed) {
|
|
return merged
|
|
}
|
|
if shouldConcatenateWithoutSpace(anchor: anchor, live: trimmed) {
|
|
return anchor + trimmed
|
|
}
|
|
if anchor.last == " " || anchor.last == "\n" { return anchor + trimmed }
|
|
return anchor + " " + trimmed
|
|
}
|
|
|
|
/// Drop duplicated suffix/prefix overlap before falling back to spaced composition.
|
|
/// This catches progressive ASR revisions such as "可用性" + "可用性已经".
|
|
private static func mergeOverlapping(anchor: String, live: String) -> String? {
|
|
let anchorChars = Array(anchor)
|
|
let liveChars = Array(live)
|
|
let maxProbe = min(64, anchorChars.count, liveChars.count)
|
|
if maxProbe > 0 {
|
|
for length in stride(from: maxProbe, through: 2, by: -1) {
|
|
if anchorChars.suffix(length).elementsEqual(liveChars.prefix(length)) {
|
|
return anchor + String(liveChars.dropFirst(length))
|
|
}
|
|
}
|
|
}
|
|
|
|
let normalizedAnchor = TranscriptOverlapUtilities.normalized(anchor)
|
|
let normalizedLive = TranscriptOverlapUtilities.normalized(live)
|
|
let anchorNormChars = Array(normalizedAnchor)
|
|
let liveNormChars = Array(normalizedLive)
|
|
let normProbe = min(64, anchorNormChars.count, liveNormChars.count)
|
|
if normProbe > 0 {
|
|
for length in stride(from: normProbe, through: 3, by: -1) {
|
|
if anchorNormChars.suffix(length).elementsEqual(liveNormChars.prefix(length)) {
|
|
let drop = TranscriptOverlapUtilities.rawDropCount(
|
|
in: live,
|
|
normalizedPrefixLength: length
|
|
)
|
|
return anchor + String(live.dropFirst(drop))
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func shouldConcatenateWithoutSpace(anchor: String, live: String) -> Bool {
|
|
guard let last = anchor.unicodeScalars.last,
|
|
let first = live.unicodeScalars.first else {
|
|
return false
|
|
}
|
|
return HanScript.isIdeograph(last) && HanScript.isIdeograph(first)
|
|
}
|
|
|
|
/// Separator to place between existing document text and an inserted
|
|
/// transcript. Inserting at a cursor that sits right after "Hello" must
|
|
/// produce "Hello world", not "Helloworld" — but CJK, whitespace, and
|
|
/// opening-punctuation boundaries take no space.
|
|
public static func insertionSeparator(previousContext: String?, insertion: String) -> String {
|
|
guard let previousContext,
|
|
let last = previousContext.unicodeScalars.last,
|
|
let first = insertion.unicodeScalars.first else {
|
|
return ""
|
|
}
|
|
if CharacterSet.whitespacesAndNewlines.contains(last) { return "" }
|
|
if HanScript.isIdeograph(last) || HanScript.isIdeograph(first) { return "" }
|
|
// No space after opening brackets/quotes ("(", "[", "「", """…).
|
|
if CharacterSet(charactersIn: "([{\u{201C}\u{2018}\u{300C}\u{300E}\u{3010}\u{FF08}").contains(last) {
|
|
return ""
|
|
}
|
|
// No space before closing/clause punctuation (".", ",", ")", "!"…).
|
|
if CharacterSet.punctuationCharacters.contains(first) { return "" }
|
|
return " "
|
|
}
|
|
}
|