feat(typing): add English autocomplete and Chinese candidate expand panel
Offline English lexicon suggestions with autocapitalization, plus a same-height Chinese more-candidates grid over the key area.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
// EnglishLearningStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Lightweight per-word boost counts for English typing. Lives in the App
|
||||
// Group so the extension can read/write without touching PersonalDictionary.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Records accepted suggestions / defended originals for ranking.
|
||||
public final class EnglishLearningStore: @unchecked Sendable {
|
||||
public static let defaultsKey = "englishTyping.learnedBoosts.v1"
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = AppGroupStore().defaults) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
public func boost(for word: String) -> Int {
|
||||
let key = word.lowercased()
|
||||
guard !key.isEmpty else { return 0 }
|
||||
return snapshot()[key] ?? 0
|
||||
}
|
||||
|
||||
public func recordAcceptance(of word: String, amount: Int = 3) {
|
||||
mutate(word: word, delta: amount)
|
||||
}
|
||||
|
||||
public func recordDefense(of word: String, amount: Int = 5) {
|
||||
// User rejected autocorrect / insisted on original spelling.
|
||||
mutate(word: word, delta: amount)
|
||||
}
|
||||
|
||||
public func snapshot() -> [String: Int] {
|
||||
(defaults.dictionary(forKey: Self.defaultsKey) as? [String: Int]) ?? [:]
|
||||
}
|
||||
|
||||
private func mutate(word: String, delta: Int) {
|
||||
let key = word.lowercased()
|
||||
guard !key.isEmpty else { return }
|
||||
var map = snapshot()
|
||||
map[key] = min(10_000, (map[key] ?? 0) + delta)
|
||||
defaults.set(map, forKey: Self.defaultsKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// EnglishLexicon.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Offline English word list + bigrams for the typing extension.
|
||||
// Loaded once, kept compact for the keyboard RSS budget.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Ranked English lexicon used by autocomplete / autocorrect / next-word.
|
||||
public final class EnglishLexicon: @unchecked Sendable {
|
||||
public static let shared = EnglishLexicon()
|
||||
|
||||
/// Lowercased word → relative frequency (higher is more common).
|
||||
private var frequencies: [String: Int] = [:]
|
||||
/// Sorted lowercased words for prefix binary search.
|
||||
private var sortedWords: [String] = []
|
||||
/// previous(lower) → next-word candidates (lower).
|
||||
private var bigrams: [String: [String]] = [:]
|
||||
private var loaded = false
|
||||
private let lock = NSLock()
|
||||
|
||||
public init() {}
|
||||
|
||||
public func prepare() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !loaded else { return }
|
||||
loadLexicon()
|
||||
loadBigrams()
|
||||
loaded = true
|
||||
}
|
||||
|
||||
public var wordCount: Int {
|
||||
prepareIfNeeded()
|
||||
return sortedWords.count
|
||||
}
|
||||
|
||||
public func frequency(of word: String) -> Int {
|
||||
prepareIfNeeded()
|
||||
return frequencies[word.lowercased()] ?? 0
|
||||
}
|
||||
|
||||
public func contains(_ word: String) -> Bool {
|
||||
prepareIfNeeded()
|
||||
return frequencies[word.lowercased()] != nil
|
||||
}
|
||||
|
||||
/// Prefix completions, highest frequency first.
|
||||
public func completions(prefix: String, limit: Int = 8) -> [String] {
|
||||
prepareIfNeeded()
|
||||
let needle = prefix.lowercased()
|
||||
guard !needle.isEmpty, limit > 0 else { return [] }
|
||||
|
||||
var results: [(String, Int)] = []
|
||||
var index = lowerBound(needle)
|
||||
while index < sortedWords.count {
|
||||
let word = sortedWords[index]
|
||||
guard word.hasPrefix(needle) else { break }
|
||||
if word != needle {
|
||||
results.append((word, frequencies[word] ?? 0))
|
||||
}
|
||||
index += 1
|
||||
// Soft cap scan to keep keystroke path cheap.
|
||||
if results.count >= limit * 8 { break }
|
||||
}
|
||||
results.sort { lhs, rhs in
|
||||
if lhs.1 != rhs.1 { return lhs.1 > rhs.1 }
|
||||
return lhs.0 < rhs.0
|
||||
}
|
||||
return Array(results.prefix(limit).map(\.0))
|
||||
}
|
||||
|
||||
/// Best edit-distance ≤ 2 correction, or nil when the typed word is fine.
|
||||
/// Uses Damerau–Levenshtein so adjacent swaps (teh → the) count as 1.
|
||||
public func bestCorrection(for typed: String) -> String? {
|
||||
prepareIfNeeded()
|
||||
let needle = typed.lowercased()
|
||||
guard needle.count >= 2 else { return nil }
|
||||
if frequencies[needle] != nil { return nil }
|
||||
|
||||
var best: (word: String, distance: Int, freq: Int)?
|
||||
let first = needle.first
|
||||
for (word, freq) in frequencies {
|
||||
guard abs(word.count - needle.count) <= 2 else { continue }
|
||||
if word.first != first, abs(word.count - needle.count) > 1 { continue }
|
||||
let distance = damerauLevenshtein(needle, word, max: 2)
|
||||
guard distance > 0, distance <= 2 else { continue }
|
||||
if let current = best {
|
||||
// Prefer closer edits; at equal distance prefer higher frequency.
|
||||
if distance < current.distance
|
||||
|| (distance == current.distance && freq > current.freq) {
|
||||
best = (word, distance, freq)
|
||||
}
|
||||
} else {
|
||||
best = (word, distance, freq)
|
||||
}
|
||||
}
|
||||
guard let best else { return nil }
|
||||
// Distance-2 corrections need a common word so rare near-misses don't win.
|
||||
if best.distance == 2, best.freq < 200 { return nil }
|
||||
return best.word
|
||||
}
|
||||
|
||||
public func nextWords(after previous: String, limit: Int = 6) -> [String] {
|
||||
prepareIfNeeded()
|
||||
let key = previous.lowercased()
|
||||
guard let list = bigrams[key] else { return [] }
|
||||
return Array(list.prefix(limit))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func prepareIfNeeded() {
|
||||
if !loaded { prepare() }
|
||||
}
|
||||
|
||||
private func loadLexicon() {
|
||||
guard let url = Bundle(for: EnglishLexicon.self)
|
||||
.url(forResource: "english_lexicon", withExtension: "tsv", subdirectory: nil)
|
||||
?? Bundle(for: EnglishLexicon.self)
|
||||
.url(forResource: "english_lexicon", withExtension: "tsv")
|
||||
?? Bundle.main.url(forResource: "english_lexicon", withExtension: "tsv")
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard let data = try? String(contentsOf: url, encoding: .utf8) else { return }
|
||||
var map: [String: Int] = [:]
|
||||
for line in data.split(whereSeparator: \.isNewline) {
|
||||
let parts = line.split(separator: "\t", maxSplits: 1)
|
||||
guard parts.count == 2,
|
||||
let freq = Int(parts[1]) else { continue }
|
||||
let word = String(parts[0]).lowercased()
|
||||
guard !word.isEmpty else { continue }
|
||||
map[word] = freq
|
||||
}
|
||||
frequencies = map
|
||||
sortedWords = map.keys.sorted()
|
||||
}
|
||||
|
||||
private func loadBigrams() {
|
||||
guard let url = Bundle(for: EnglishLexicon.self)
|
||||
.url(forResource: "english_bigrams", withExtension: "tsv")
|
||||
?? Bundle.main.url(forResource: "english_bigrams", withExtension: "tsv")
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard let data = try? String(contentsOf: url, encoding: .utf8) else { return }
|
||||
var map: [String: [String]] = [:]
|
||||
for line in data.split(whereSeparator: \.isNewline) {
|
||||
let parts = line.split(separator: "\t", maxSplits: 1)
|
||||
guard parts.count == 2 else { continue }
|
||||
let prev = String(parts[0]).lowercased()
|
||||
let nexts = parts[1].split(whereSeparator: \.isWhitespace).map { String($0).lowercased() }
|
||||
guard !prev.isEmpty, !nexts.isEmpty else { continue }
|
||||
map[prev] = nexts
|
||||
}
|
||||
bigrams = map
|
||||
}
|
||||
|
||||
private func lowerBound(_ prefix: String) -> Int {
|
||||
var low = 0
|
||||
var high = sortedWords.count
|
||||
while low < high {
|
||||
let mid = (low + high) / 2
|
||||
if sortedWords[mid] < prefix {
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid
|
||||
}
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
/// Damerau–Levenshtein with early exit when distance would exceed `max`.
|
||||
private func damerauLevenshtein(_ a: String, _ b: String, max: Int) -> Int {
|
||||
let aChars = Array(a)
|
||||
let bChars = Array(b)
|
||||
let aCount = aChars.count
|
||||
let bCount = bChars.count
|
||||
if abs(aCount - bCount) > max { return max + 1 }
|
||||
|
||||
var prevPrev = [Int](repeating: 0, count: bCount + 1)
|
||||
var prev = Array(0...bCount)
|
||||
for i in 1...aCount {
|
||||
var current = [Int](repeating: 0, count: bCount + 1)
|
||||
current[0] = i
|
||||
var rowMin = current[0]
|
||||
for j in 1...bCount {
|
||||
let cost = aChars[i - 1] == bChars[j - 1] ? 0 : 1
|
||||
var value = min(
|
||||
prev[j] + 1,
|
||||
current[j - 1] + 1,
|
||||
prev[j - 1] + cost
|
||||
)
|
||||
// Adjacent transposition
|
||||
if i > 1, j > 1,
|
||||
aChars[i - 1] == bChars[j - 2],
|
||||
aChars[i - 2] == bChars[j - 1] {
|
||||
value = min(value, prevPrev[j - 2] + 1)
|
||||
}
|
||||
current[j] = value
|
||||
rowMin = min(rowMin, value)
|
||||
}
|
||||
if rowMin > max { return max + 1 }
|
||||
prevPrev = prev
|
||||
prev = current
|
||||
}
|
||||
return prev[bCount]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// EnglishSuggestionEngine.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Builds TypingComposition for English: completions while composing,
|
||||
// high-confidence corrections on commit, next-word predictions after.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct EnglishSuggestionContext: Sendable {
|
||||
public var currentWord: String
|
||||
public var previousWord: String
|
||||
public var personalTerms: [String]
|
||||
public var learnedBoosts: [String: Int]
|
||||
public var includeOriginalAfterCorrection: String?
|
||||
|
||||
public init(
|
||||
currentWord: String = "",
|
||||
previousWord: String = "",
|
||||
personalTerms: [String] = [],
|
||||
learnedBoosts: [String: Int] = [:],
|
||||
includeOriginalAfterCorrection: String? = nil
|
||||
) {
|
||||
self.currentWord = currentWord
|
||||
self.previousWord = previousWord
|
||||
self.personalTerms = personalTerms
|
||||
self.learnedBoosts = learnedBoosts
|
||||
self.includeOriginalAfterCorrection = includeOriginalAfterCorrection
|
||||
}
|
||||
}
|
||||
|
||||
public struct EnglishCorrectionDecision: Equatable, Sendable {
|
||||
public var original: String
|
||||
public var replacement: String
|
||||
/// Trailing characters inserted with the replacement (`" "`, `"\n"`, punct).
|
||||
public var appliedSuffix: String
|
||||
|
||||
public init(original: String, replacement: String, appliedSuffix: String = "") {
|
||||
self.original = original
|
||||
self.replacement = replacement
|
||||
self.appliedSuffix = appliedSuffix
|
||||
}
|
||||
|
||||
public var undoDeleteCount: Int {
|
||||
replacement.count + appliedSuffix.count
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure ranking / candidate builder — no UITextDocumentProxy access.
|
||||
public struct EnglishSuggestionEngine: Sendable {
|
||||
private let lexicon: EnglishLexicon
|
||||
|
||||
public init(lexicon: EnglishLexicon = .shared) {
|
||||
self.lexicon = lexicon
|
||||
}
|
||||
|
||||
public func prepare() {
|
||||
lexicon.prepare()
|
||||
}
|
||||
|
||||
/// Suggestions while the user is mid-word.
|
||||
public func compositionWhileTyping(_ context: EnglishSuggestionContext) -> TypingComposition {
|
||||
let prefix = context.currentWord
|
||||
guard !prefix.isEmpty else {
|
||||
return nextWordComposition(context)
|
||||
}
|
||||
|
||||
var ranked: [(text: String, score: Int, id: String)] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
func append(_ raw: String, baseScore: Int, tag: String, preserveCase: Bool = false) {
|
||||
let display = preserveCase ? raw : matchCase(of: prefix, to: raw)
|
||||
let key = display.lowercased()
|
||||
guard seen.insert(key).inserted else { return }
|
||||
let boost = context.learnedBoosts[key] ?? 0
|
||||
let personalBoost = context.personalTerms.contains { $0.lowercased() == key } ? 5_000 : 0
|
||||
ranked.append((display, baseScore + boost + personalBoost, "\(tag)|\(key)"))
|
||||
}
|
||||
|
||||
for term in context.personalTerms where term.lowercased().hasPrefix(prefix.lowercased())
|
||||
&& term.lowercased() != prefix.lowercased() {
|
||||
append(term, baseScore: 8_000 + term.count, tag: "personal", preserveCase: true)
|
||||
}
|
||||
|
||||
for word in lexicon.completions(prefix: prefix, limit: 12) {
|
||||
append(word, baseScore: lexicon.frequency(of: word), tag: "complete")
|
||||
}
|
||||
|
||||
ranked.sort { lhs, rhs in
|
||||
if lhs.score != rhs.score { return lhs.score > rhs.score }
|
||||
return lhs.text.count < rhs.text.count
|
||||
}
|
||||
|
||||
let candidates = ranked.prefix(8).map {
|
||||
TypingCandidate(id: $0.id, text: $0.text, engineIndex: 0)
|
||||
}
|
||||
return TypingComposition(preedit: prefix, candidates: Array(candidates))
|
||||
}
|
||||
|
||||
/// Decide whether to autocorrect on space / punctuation.
|
||||
public func correctionDecision(
|
||||
for typed: String,
|
||||
personalTerms: [String],
|
||||
learnedBoosts: [String: Int]
|
||||
) -> EnglishCorrectionDecision? {
|
||||
let trimmed = typed
|
||||
guard trimmed.count >= 2 else { return nil }
|
||||
let lower = trimmed.lowercased()
|
||||
|
||||
if personalTerms.contains(where: { $0.lowercased() == lower }) { return nil }
|
||||
if (learnedBoosts[lower] ?? 0) >= 5 { return nil }
|
||||
if shouldSkipAutocorrect(trimmed) { return nil }
|
||||
if lexicon.contains(lower) { return nil }
|
||||
|
||||
guard let correction = lexicon.bestCorrection(for: lower) else { return nil }
|
||||
// Personal dictionary wins over lexicon corrections.
|
||||
if personalTerms.contains(where: { $0.lowercased() == correction }) {
|
||||
return EnglishCorrectionDecision(original: trimmed, replacement: matchCase(of: trimmed, to: correction))
|
||||
}
|
||||
let typedBoost = learnedBoosts[lower] ?? 0
|
||||
let correctionFreq = lexicon.frequency(of: correction) + (learnedBoosts[correction] ?? 0)
|
||||
// High-confidence gate: correction must clearly beat defending the typo.
|
||||
guard correctionFreq >= 80, correctionFreq > typedBoost + 40 else { return nil }
|
||||
return EnglishCorrectionDecision(
|
||||
original: trimmed,
|
||||
replacement: matchCase(of: trimmed, to: correction)
|
||||
)
|
||||
}
|
||||
|
||||
public func nextWordComposition(_ context: EnglishSuggestionContext) -> TypingComposition {
|
||||
var ranked: [(text: String, score: Int, id: String)] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
func append(_ raw: String, baseScore: Int, tag: String) {
|
||||
let key = raw.lowercased()
|
||||
guard seen.insert(key).inserted else { return }
|
||||
let boost = context.learnedBoosts[key] ?? 0
|
||||
let personalBoost = context.personalTerms.contains { $0.lowercased() == key } ? 2_000 : 0
|
||||
ranked.append((raw, baseScore + boost + personalBoost, "\(tag)|\(key)"))
|
||||
}
|
||||
|
||||
if let original = context.includeOriginalAfterCorrection {
|
||||
append(original, baseScore: 20_000, tag: "original")
|
||||
}
|
||||
|
||||
if !context.previousWord.isEmpty {
|
||||
for (index, word) in lexicon.nextWords(after: context.previousWord, limit: 8).enumerated() {
|
||||
append(word, baseScore: 1_000 - index * 10, tag: "next")
|
||||
}
|
||||
}
|
||||
|
||||
for term in context.personalTerms.prefix(4) {
|
||||
append(term, baseScore: 500, tag: "personal")
|
||||
}
|
||||
|
||||
ranked.sort { $0.score > $1.score }
|
||||
let candidates = ranked.prefix(8).map {
|
||||
TypingCandidate(id: $0.id, text: $0.text, engineIndex: 0)
|
||||
}
|
||||
return TypingComposition(preedit: "", candidates: Array(candidates))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func shouldSkipAutocorrect(_ typed: String) -> Bool {
|
||||
if typed.count <= 1 { return true }
|
||||
if typed.allSatisfy(\.isUppercase) { return true }
|
||||
if typed.contains(where: \.isNumber) { return true }
|
||||
if typed.contains("@") || typed.contains(".") || typed.contains("/") { return true }
|
||||
if typed.contains("-") || typed.contains("_") { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func matchCase(of sample: String, to word: String) -> String {
|
||||
if sample.allSatisfy(\.isUppercase) {
|
||||
return word.uppercased()
|
||||
}
|
||||
if let first = sample.first, first.isUppercase {
|
||||
return word.prefix(1).uppercased() + word.dropFirst().lowercased()
|
||||
}
|
||||
return word.lowercased()
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ public final class LibrimeEngine: RimeEngineBridging {
|
||||
|
||||
public init(
|
||||
schema: TypingInputSchema = .fullPinyin,
|
||||
candidateLimit: Int = 50,
|
||||
candidateLimit: Int = 160,
|
||||
configurationProvider: @escaping () -> TypingInputConfigurationSnapshot = {
|
||||
TypingInputConfiguration.shared.snapshot
|
||||
}
|
||||
@@ -146,11 +146,12 @@ public final class LibrimeEngine: RimeEngineBridging {
|
||||
let preedit = snapshot.preedit
|
||||
composition = TypingComposition(
|
||||
preedit: preedit,
|
||||
candidates: snapshot.candidates.enumerated().map { index, candidate in
|
||||
candidates: snapshot.candidates.enumerated().map { displayIndex, candidate in
|
||||
TypingCandidate(
|
||||
id: "\(preedit)|\(index)|\(candidate.text)",
|
||||
id: "\(preedit)|\(displayIndex)|\(candidate.index)|\(candidate.text)",
|
||||
text: candidate.text,
|
||||
annotation: candidate.comment.isEmpty ? nil : candidate.comment
|
||||
annotation: candidate.comment.isEmpty ? nil : candidate.comment,
|
||||
engineIndex: Int(candidate.index)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -26,11 +26,19 @@ public struct TypingCandidate: Identifiable, Equatable, Sendable {
|
||||
public let id: String
|
||||
public let text: String
|
||||
public let annotation: String?
|
||||
/// Absolute engine index for Chinese selection (may differ from display order).
|
||||
public let engineIndex: Int
|
||||
|
||||
public init(id: String = UUID().uuidString, text: String, annotation: String? = nil) {
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
text: String,
|
||||
annotation: String? = nil,
|
||||
engineIndex: Int = 0
|
||||
) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
self.annotation = annotation
|
||||
self.engineIndex = engineIndex
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ public struct RimeResourcePaths: Sendable {
|
||||
|
||||
public actor RimeResourceInstaller {
|
||||
public static let shared = RimeResourceInstaller()
|
||||
public static let resourceVersion = "2.0.0"
|
||||
public static let resourceVersion = "2.2.0"
|
||||
|
||||
public init() {}
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ public enum RimeSchemaGenerator {
|
||||
caption: 输入方案
|
||||
hotkeys: []
|
||||
menu:
|
||||
page_size: 9
|
||||
# Large page so librime prepares enough candidates for the expand panel.
|
||||
# The bridge also iterates beyond the current page via candidate_list_*.
|
||||
page_size: 100
|
||||
ascii_composer:
|
||||
good_old_caps_lock: true
|
||||
switch_key:
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// TypingAutocapitalization.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Decides when the English letter page should arm Shift once for
|
||||
// automatic capitalization (mirrors UITextAutocapitalizationType).
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum TypingAutocapitalizationMode: String, Sendable, Equatable {
|
||||
case none
|
||||
case words
|
||||
case sentences
|
||||
case allCharacters
|
||||
}
|
||||
|
||||
public enum TypingAutocapitalization: Sendable {
|
||||
/// Whether Shift should be armed for the next Latin letter.
|
||||
public static func shouldCapitalize(
|
||||
precedingText: String?,
|
||||
mode: TypingAutocapitalizationMode
|
||||
) -> Bool {
|
||||
switch mode {
|
||||
case .none:
|
||||
return false
|
||||
case .allCharacters:
|
||||
return true
|
||||
case .words:
|
||||
return needsWordCapitalization(precedingText)
|
||||
case .sentences:
|
||||
return needsSentenceCapitalization(precedingText)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func needsWordCapitalization(_ preceding: String?) -> Bool {
|
||||
guard let preceding, !preceding.isEmpty else { return true }
|
||||
guard let last = preceding.last else { return true }
|
||||
return last.isWhitespace || last.isNewline
|
||||
}
|
||||
|
||||
private static func needsSentenceCapitalization(_ preceding: String?) -> Bool {
|
||||
guard let preceding, !preceding.isEmpty else { return true }
|
||||
|
||||
// Walk backward past trailing whitespace / newlines; capitalize when
|
||||
// the field is empty or the previous visible character ends a sentence.
|
||||
var index = preceding.endIndex
|
||||
var sawContent = false
|
||||
while index > preceding.startIndex {
|
||||
index = preceding.index(before: index)
|
||||
let character = preceding[index]
|
||||
if character.isWhitespace || character.isNewline {
|
||||
continue
|
||||
}
|
||||
sawContent = true
|
||||
return isSentenceTerminator(character)
|
||||
}
|
||||
return !sawContent
|
||||
}
|
||||
|
||||
private static func isSentenceTerminator(_ character: Character) -> Bool {
|
||||
switch character {
|
||||
case ".", "!", "?", "…", "。", "!", "?":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// TypingOutput.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Unified mutation the typing UI applies to UITextDocumentProxy.
|
||||
// English autocorrect / completion need multi-delete + insert; Chinese
|
||||
// mostly inserts or issues a single backspace sentinel.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Text mutation produced by one typing action.
|
||||
public struct TypingOutput: Equatable, Sendable {
|
||||
/// How many `deleteBackward` calls to issue before inserting.
|
||||
public var deleteCount: Int
|
||||
/// Text to insert after deletions (may be empty).
|
||||
public var text: String
|
||||
|
||||
public init(deleteCount: Int = 0, text: String = "") {
|
||||
self.deleteCount = deleteCount
|
||||
self.text = text
|
||||
}
|
||||
|
||||
public static let none = TypingOutput()
|
||||
|
||||
public static func insert(_ text: String) -> TypingOutput {
|
||||
TypingOutput(deleteCount: 0, text: text)
|
||||
}
|
||||
|
||||
public static let backspace = TypingOutput(deleteCount: 1, text: "")
|
||||
|
||||
public static func replace(deleteCount: Int, with text: String) -> TypingOutput {
|
||||
TypingOutput(deleteCount: deleteCount, text: text)
|
||||
}
|
||||
|
||||
public var isEmpty: Bool {
|
||||
deleteCount == 0 && text.isEmpty
|
||||
}
|
||||
}
|
||||
@@ -16,18 +16,45 @@ public final class TypingSessionController: ObservableObject {
|
||||
@Published public private(set) var composition: TypingComposition = .empty
|
||||
@Published public private(set) var engineReady: Bool = false
|
||||
@Published public private(set) var schema: TypingInputSchema
|
||||
/// Chinese-only: key grid replaced by a same-height candidate grid.
|
||||
@Published public private(set) var isCandidatePanelExpanded: Bool = false
|
||||
@Published public var lastError: String?
|
||||
|
||||
/// When true, English suggestions / autocorrect stay off (secure fields).
|
||||
@Published public var suggestionsEnabled: Bool = true
|
||||
|
||||
/// Chevron appears only for Chinese composition with at least two candidates.
|
||||
public var canExpandCandidatePanel: Bool {
|
||||
language == .chinese && composition.candidates.count >= 2
|
||||
}
|
||||
|
||||
/// Live document prefix ahead of the caret (from `UITextDocumentProxy`).
|
||||
public var precedingTextProvider: (() -> String?)?
|
||||
/// Host field autocapitalization preference.
|
||||
public var autocapitalizationModeProvider: (() -> TypingAutocapitalizationMode)?
|
||||
|
||||
public let layout: TypingLayoutProviding
|
||||
private let engine: RimeEngineBridging
|
||||
private let englishEngine: EnglishSuggestionEngine
|
||||
private let learningStore: EnglishLearningStore
|
||||
private var prepared = false
|
||||
|
||||
// English word-level state (characters are already in the document).
|
||||
private var englishCurrentWord: String = ""
|
||||
private var englishPreviousWord: String = ""
|
||||
private var pendingAutocorrection: EnglishCorrectionDecision?
|
||||
private var personalTermsCache: [String] = []
|
||||
|
||||
public init(
|
||||
engine: RimeEngineBridging = LibrimeEngine(),
|
||||
layout: TypingLayoutProviding = StandardTypingLayout()
|
||||
layout: TypingLayoutProviding = StandardTypingLayout(),
|
||||
englishEngine: EnglishSuggestionEngine = EnglishSuggestionEngine(),
|
||||
learningStore: EnglishLearningStore = EnglishLearningStore()
|
||||
) {
|
||||
self.engine = engine
|
||||
self.layout = layout
|
||||
self.englishEngine = englishEngine
|
||||
self.learningStore = learningStore
|
||||
schema = engine.schema
|
||||
}
|
||||
|
||||
@@ -47,6 +74,9 @@ public final class TypingSessionController: ObservableObject {
|
||||
|
||||
public func enterTypingMode() {
|
||||
TypingInputConfiguration.shared.reload()
|
||||
refreshPersonalTerms()
|
||||
englishEngine.prepare()
|
||||
syncAutocapitalization()
|
||||
Task { await prepareIfNeeded() }
|
||||
}
|
||||
|
||||
@@ -55,32 +85,60 @@ public final class TypingSessionController: ObservableObject {
|
||||
prepared = false
|
||||
engineReady = false
|
||||
composition = .empty
|
||||
isCandidatePanelExpanded = false
|
||||
page = .letters
|
||||
shiftActive = false
|
||||
capsLock = false
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
}
|
||||
|
||||
public func toggleLanguage() -> String {
|
||||
public func toggleCandidatePanelExpanded() {
|
||||
guard canExpandCandidatePanel else {
|
||||
isCandidatePanelExpanded = false
|
||||
return
|
||||
}
|
||||
isCandidatePanelExpanded.toggle()
|
||||
}
|
||||
|
||||
public func collapseCandidatePanel() {
|
||||
isCandidatePanelExpanded = false
|
||||
}
|
||||
|
||||
public func toggleLanguage() -> TypingOutput {
|
||||
let next: TypingInputLanguage = language == .chinese ? .english : .chinese
|
||||
return setLanguage(next)
|
||||
}
|
||||
|
||||
/// Selects a specific language for the shared voice / Chinese / English
|
||||
/// capsule. Any active preedit is returned so callers can commit it
|
||||
/// before switching modes.
|
||||
public func setLanguage(_ newLanguage: TypingInputLanguage) -> String {
|
||||
guard language != newLanguage else { return "" }
|
||||
let raw = composition.preedit.isEmpty ? "" : engine.flushPreedit()
|
||||
/// capsule. Chinese preedit is flushed; English half-words are already
|
||||
/// in the document so we only clear suggestion state.
|
||||
public func setLanguage(_ newLanguage: TypingInputLanguage) -> TypingOutput {
|
||||
guard language != newLanguage else { return .none }
|
||||
var output = TypingOutput.none
|
||||
if language == .chinese, !composition.preedit.isEmpty {
|
||||
let raw = engine.flushPreedit()
|
||||
if !raw.isEmpty { output = .insert(raw) }
|
||||
}
|
||||
language = newLanguage
|
||||
engine.setLanguage(newLanguage)
|
||||
composition = engine.composition
|
||||
page = .letters
|
||||
return raw
|
||||
isCandidatePanelExpanded = false
|
||||
if newLanguage == .english {
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
refreshPersonalTerms()
|
||||
englishEngine.prepare()
|
||||
refreshEnglishSuggestions()
|
||||
syncAutocapitalization()
|
||||
} else {
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
composition = engine.composition
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
/// Flushes raw preedit, selects the next built-in scheme, and returns the
|
||||
/// raw text that the caller should insert before switching.
|
||||
public func cycleSchema() -> String {
|
||||
/// text that the caller should insert before switching.
|
||||
public func cycleSchema() -> TypingOutput {
|
||||
let raw = composition.preedit.isEmpty ? "" : engine.flushPreedit()
|
||||
let schemas = TypingInputSchema.allCases
|
||||
let current = schemas.firstIndex(of: schema) ?? 0
|
||||
@@ -90,17 +148,20 @@ public final class TypingSessionController: ObservableObject {
|
||||
TypingInputConfiguration.shared.schema = next
|
||||
}
|
||||
composition = engine.composition
|
||||
return raw
|
||||
syncCandidatePanelVisibility()
|
||||
return raw.isEmpty ? .none : .insert(raw)
|
||||
}
|
||||
|
||||
public func setPage(_ page: TypingKeyPage) {
|
||||
self.page = page
|
||||
shiftActive = false
|
||||
if page == .letters {
|
||||
syncAutocapitalization()
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a visible key label. Returns text the proxy should insert now
|
||||
/// (may be empty when composing Chinese).
|
||||
public func handleKey(_ label: String) -> String {
|
||||
/// Handle a visible key label.
|
||||
public func handleKey(_ label: String) -> TypingOutput {
|
||||
switch label {
|
||||
case "⇧":
|
||||
if shiftActive {
|
||||
@@ -111,67 +172,266 @@ public final class TypingSessionController: ObservableObject {
|
||||
} else {
|
||||
shiftActive = true
|
||||
}
|
||||
return ""
|
||||
return .none
|
||||
case "⌫":
|
||||
if language == .chinese, !engine.composition.preedit.isEmpty {
|
||||
let committed = engine.processBackspace() ?? ""
|
||||
composition = engine.composition
|
||||
return committed
|
||||
}
|
||||
return "\u{8}" // sentinel: caller deletes backward
|
||||
return handleBackspace()
|
||||
case "123":
|
||||
setPage(.numbers)
|
||||
return ""
|
||||
return .none
|
||||
case "#+=":
|
||||
setPage(.symbols)
|
||||
return ""
|
||||
return .none
|
||||
case "ABC", "abc":
|
||||
setPage(.letters)
|
||||
return ""
|
||||
return .none
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if page != .letters {
|
||||
// Number/symbol: insert directly
|
||||
let out = label
|
||||
let out = commitEnglishWordIfNeededBeforeNonLetter()
|
||||
if !capsLock { shiftActive = false }
|
||||
return out
|
||||
if out.isEmpty {
|
||||
return .insert(label)
|
||||
}
|
||||
return TypingOutput(deleteCount: out.deleteCount, text: out.text + label)
|
||||
}
|
||||
|
||||
guard let ch = label.first else { return "" }
|
||||
guard let ch = label.first else { return .none }
|
||||
|
||||
if language == .english {
|
||||
let out = String(ch)
|
||||
if !capsLock { shiftActive = false }
|
||||
return out
|
||||
return handleEnglishCharacter(ch)
|
||||
}
|
||||
|
||||
// Chinese letters → compose
|
||||
let committed = engine.processCharacter(ch) ?? ""
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
if !capsLock { shiftActive = false }
|
||||
return committed
|
||||
return committed.isEmpty ? .none : .insert(committed)
|
||||
}
|
||||
|
||||
public func handleSpace() -> String {
|
||||
if language == .english { return " " }
|
||||
public func handleSpace() -> TypingOutput {
|
||||
if language == .english {
|
||||
return commitEnglishWord(suffix: " ")
|
||||
}
|
||||
let text = engine.processSpace() ?? " "
|
||||
composition = engine.composition
|
||||
return text
|
||||
syncCandidatePanelVisibility()
|
||||
return .insert(text)
|
||||
}
|
||||
|
||||
public func handleReturn() -> String {
|
||||
if language == .english { return "\n" }
|
||||
public func handleReturn() -> TypingOutput {
|
||||
if language == .english {
|
||||
return commitEnglishWord(suffix: "\n")
|
||||
}
|
||||
let text = engine.processReturn() ?? "\n"
|
||||
composition = engine.composition
|
||||
return text
|
||||
syncCandidatePanelVisibility()
|
||||
return .insert(text)
|
||||
}
|
||||
|
||||
public func selectCandidate(at index: Int) -> String {
|
||||
let text = engine.selectCandidate(at: index)
|
||||
public func selectCandidate(at index: Int) -> TypingOutput {
|
||||
if language == .english {
|
||||
return selectEnglishCandidate(at: index)
|
||||
}
|
||||
guard composition.candidates.indices.contains(index) else { return .none }
|
||||
// Display order may put phrases before first-syllable chars; select by engine index.
|
||||
let engineIndex = composition.candidates[index].engineIndex
|
||||
let text = engine.selectCandidate(at: engineIndex)
|
||||
composition = engine.composition
|
||||
return text
|
||||
// Selecting always collapses; follow-up composition may reopen ▼.
|
||||
isCandidatePanelExpanded = false
|
||||
syncCandidatePanelVisibility()
|
||||
return text.isEmpty ? .none : .insert(text)
|
||||
}
|
||||
|
||||
// MARK: - English
|
||||
|
||||
private func handleEnglishCharacter(_ ch: Character) -> TypingOutput {
|
||||
pendingAutocorrection = nil
|
||||
if ch.isLetter {
|
||||
englishCurrentWord.append(ch)
|
||||
if !capsLock { shiftActive = false }
|
||||
refreshEnglishSuggestions()
|
||||
return .insert(String(ch))
|
||||
}
|
||||
|
||||
// Punctuation / digit: commit current word first, then insert.
|
||||
var output = commitEnglishWord(suffix: "")
|
||||
if !capsLock { shiftActive = false }
|
||||
if output.isEmpty {
|
||||
return .insert(String(ch))
|
||||
}
|
||||
return TypingOutput(deleteCount: output.deleteCount, text: output.text + String(ch))
|
||||
}
|
||||
|
||||
private func handleBackspace() -> TypingOutput {
|
||||
if language == .chinese, !engine.composition.preedit.isEmpty {
|
||||
let committed = engine.processBackspace() ?? ""
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
return committed.isEmpty ? .none : .insert(committed)
|
||||
}
|
||||
|
||||
if language == .english {
|
||||
if let pending = pendingAutocorrection {
|
||||
// Undo last autocorrection: delete replacement+suffix, restore original.
|
||||
let deleteCount = pending.undoDeleteCount
|
||||
pendingAutocorrection = nil
|
||||
englishCurrentWord = pending.original
|
||||
learningStore.recordDefense(of: pending.original)
|
||||
refreshEnglishSuggestions()
|
||||
return .replace(deleteCount: deleteCount, with: pending.original)
|
||||
}
|
||||
if !englishCurrentWord.isEmpty {
|
||||
englishCurrentWord.removeLast()
|
||||
refreshEnglishSuggestions()
|
||||
} else if !englishPreviousWord.isEmpty {
|
||||
// Stepping back into the previous word.
|
||||
englishCurrentWord = englishPreviousWord
|
||||
englishPreviousWord = ""
|
||||
refreshEnglishSuggestions()
|
||||
} else {
|
||||
composition = .empty
|
||||
}
|
||||
}
|
||||
return .backspace
|
||||
}
|
||||
|
||||
private func commitEnglishWord(suffix: String) -> TypingOutput {
|
||||
let word = englishCurrentWord
|
||||
defer {
|
||||
if !capsLock { shiftActive = false }
|
||||
}
|
||||
|
||||
guard suggestionsEnabled, !word.isEmpty else {
|
||||
if !word.isEmpty {
|
||||
englishPreviousWord = word
|
||||
englishCurrentWord = ""
|
||||
}
|
||||
refreshEnglishSuggestions(afterCommittedWord: word.isEmpty ? nil : word)
|
||||
return suffix.isEmpty ? .none : .insert(suffix)
|
||||
}
|
||||
|
||||
if var decision = englishEngine.correctionDecision(
|
||||
for: word,
|
||||
personalTerms: personalTermsCache,
|
||||
learnedBoosts: learningStore.snapshot()
|
||||
) {
|
||||
decision.appliedSuffix = suffix
|
||||
pendingAutocorrection = decision
|
||||
englishPreviousWord = decision.replacement
|
||||
englishCurrentWord = ""
|
||||
learningStore.recordAcceptance(of: decision.replacement)
|
||||
// Suggestions stay hidden until the user starts the next word.
|
||||
composition = .empty
|
||||
return .replace(
|
||||
deleteCount: word.count,
|
||||
with: decision.replacement + suffix
|
||||
)
|
||||
}
|
||||
|
||||
englishPreviousWord = word
|
||||
englishCurrentWord = ""
|
||||
pendingAutocorrection = nil
|
||||
learningStore.recordAcceptance(of: word, amount: 1)
|
||||
refreshEnglishSuggestions(afterCommittedWord: word)
|
||||
return suffix.isEmpty ? .none : .insert(suffix)
|
||||
}
|
||||
|
||||
private func selectEnglishCandidate(at index: Int) -> TypingOutput {
|
||||
guard composition.candidates.indices.contains(index) else { return .none }
|
||||
let chosen = composition.candidates[index].text
|
||||
|
||||
// Restoring original after autocorrect (no current word).
|
||||
if englishCurrentWord.isEmpty,
|
||||
let pending = pendingAutocorrection,
|
||||
chosen.compare(pending.original, options: [.caseInsensitive]) == .orderedSame {
|
||||
let deleteCount = pending.undoDeleteCount
|
||||
pendingAutocorrection = nil
|
||||
englishPreviousWord = pending.original
|
||||
englishCurrentWord = ""
|
||||
learningStore.recordDefense(of: pending.original)
|
||||
refreshEnglishSuggestions(afterCommittedWord: pending.original)
|
||||
return .replace(deleteCount: deleteCount, with: pending.original + " ")
|
||||
}
|
||||
|
||||
if !englishCurrentWord.isEmpty {
|
||||
let deleteCount = englishCurrentWord.count
|
||||
englishPreviousWord = chosen
|
||||
englishCurrentWord = ""
|
||||
pendingAutocorrection = nil
|
||||
learningStore.recordAcceptance(of: chosen)
|
||||
refreshEnglishSuggestions(afterCommittedWord: chosen)
|
||||
return .replace(deleteCount: deleteCount, with: chosen + " ")
|
||||
}
|
||||
|
||||
// Next-word prediction tap.
|
||||
englishPreviousWord = chosen
|
||||
englishCurrentWord = ""
|
||||
pendingAutocorrection = nil
|
||||
learningStore.recordAcceptance(of: chosen)
|
||||
refreshEnglishSuggestions(afterCommittedWord: chosen)
|
||||
return .insert(chosen + " ")
|
||||
}
|
||||
|
||||
private func commitEnglishWordIfNeededBeforeNonLetter() -> TypingOutput {
|
||||
guard language == .english, !englishCurrentWord.isEmpty else { return .none }
|
||||
return commitEnglishWord(suffix: "")
|
||||
}
|
||||
|
||||
private func refreshEnglishSuggestions(afterCommittedWord word: String? = nil) {
|
||||
guard language == .english else { return }
|
||||
guard suggestionsEnabled else {
|
||||
composition = .empty
|
||||
return
|
||||
}
|
||||
// Idle / between words: no candidate bar. Completions start after
|
||||
// the first letter of the current word.
|
||||
guard !englishCurrentWord.isEmpty else {
|
||||
composition = .empty
|
||||
return
|
||||
}
|
||||
let previous = word ?? englishPreviousWord
|
||||
let context = EnglishSuggestionContext(
|
||||
currentWord: englishCurrentWord,
|
||||
previousWord: previous,
|
||||
personalTerms: personalTermsCache,
|
||||
learnedBoosts: learningStore.snapshot(),
|
||||
includeOriginalAfterCorrection: nil
|
||||
)
|
||||
composition = englishEngine.compositionWhileTyping(context)
|
||||
}
|
||||
|
||||
/// Arms Shift for sentence / word starts using the host field traits.
|
||||
public func syncAutocapitalization() {
|
||||
guard language == .english, page == .letters else { return }
|
||||
guard !capsLock else { return }
|
||||
let mode = autocapitalizationModeProvider?() ?? .sentences
|
||||
let preceding = precedingTextProvider?()
|
||||
shiftActive = TypingAutocapitalization.shouldCapitalize(
|
||||
precedingText: preceding,
|
||||
mode: mode
|
||||
)
|
||||
}
|
||||
|
||||
private func clearEnglishWordState(keepPrevious: Bool) {
|
||||
englishCurrentWord = ""
|
||||
if !keepPrevious { englishPreviousWord = "" }
|
||||
pendingAutocorrection = nil
|
||||
}
|
||||
|
||||
private func refreshPersonalTerms() {
|
||||
// English keyboard only accepts Latin hotwords; Chinese terms stay for ASR/polish.
|
||||
personalTermsCache = AppGroupStore().personalDictionary.englishTypingHotwords()
|
||||
}
|
||||
|
||||
/// Collapse the expand panel when Chinese no longer has enough candidates.
|
||||
private func syncCandidatePanelVisibility() {
|
||||
if !canExpandCandidatePanel {
|
||||
isCandidatePanelExpanded = false
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareIfNeeded() async {
|
||||
@@ -183,6 +443,9 @@ public final class TypingSessionController: ObservableObject {
|
||||
engineReady = engine.isReady
|
||||
schema = engine.schema
|
||||
lastError = nil
|
||||
if language == .english {
|
||||
refreshEnglishSuggestions()
|
||||
}
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
engineReady = false
|
||||
|
||||
Reference in New Issue
Block a user