feat(keyboard): add English QuickType bar and system lexicon

Show verbatim/correction/completion slots, mmap a 40k-word list, and
use UITextChecker plus supplementary lexicon for conservative autocorrect.
This commit is contained in:
Rocky
2026-08-14 21:49:22 +08:00
parent 2c3a3f80f3
commit 4749a9cbf2
34 changed files with 45539 additions and 3395 deletions
@@ -27,6 +27,10 @@ public final class TypingSessionController: ObservableObject {
/// When true, English suggestions / autocorrect stay off (secure fields).
@Published public var suggestionsEnabled: Bool = true
/// `UITextChecker` completions / guesses. Empty in unit tests.
public var systemLexicon: EnglishSystemLexiconProviding = EmptyEnglishSystemLexicon()
/// Names and text replacements from `requestSupplementaryLexicon`.
public var supplementaryWords: [String] = []
/// Chevron appears only for Chinese composition with at least two candidates.
public var canExpandCandidatePanel: Bool {
@@ -128,9 +132,15 @@ public final class TypingSessionController: ObservableObject {
)
TypingInputConfiguration.shared.reload()
refreshPersonalTerms()
// English lexicon is small; load when entering typing (not at KVC init).
englishEngine.prepare()
OSGDiag.log("typing.enter after englishPrepare \(OSGDiag.memoryTag())", category: "boot")
// mmap the English table only while English is active. Chinese typing
// already has Rime; loading both on appear is what jetsams the extension.
if language == .english {
englishEngine.prepare()
OSGDiag.log("typing.enter after englishPrepare \(OSGDiag.memoryTag())", category: "boot")
} else {
EnglishLexicon.shared.unload()
OSGDiag.log("typing.enter skip englishPrepare lang=\(language.rawValue) \(OSGDiag.memoryTag())", category: "boot")
}
syncAutocapitalization()
if FlowSessionBridge.isHostHeavy() {
OSGDiag.log("typing.enter defer rime hostHeavy=1 — retry scheduled", category: "boot")
@@ -216,6 +226,7 @@ public final class TypingSessionController: ObservableObject {
synchronizeEnglishDocumentContext(caretMoved: true)
} else {
clearEnglishWordState(keepPrevious: false)
EnglishLexicon.shared.unload()
composition = engine.composition
}
return output
@@ -427,6 +438,9 @@ public final class TypingSessionController: ObservableObject {
pendingAutocorrection = nil
englishCurrentWord = pending.original
learningStore.recordDefense(of: pending.original)
#if canImport(UIKit)
UIKitEnglishSystemLexicon.learnWord(pending.original)
#endif
refreshEnglishSuggestions()
return .replace(deleteCount: deleteCount, with: pending.original)
}
@@ -461,15 +475,18 @@ public final class TypingSessionController: ObservableObject {
var decision = englishEngine.correctionDecision(
for: word,
personalTerms: personalTermsCache,
learnedBoosts: learningStore.snapshot()
learnedBoosts: learningStore.snapshot(),
previousWord: englishPreviousWord,
systemWords: supplementaryWords,
systemGuesses: systemLexicon.guesses(for: word, limit: 6)
) {
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
// Machine-applied correction does not count as the user accepting
// the replacement otherwise names train the wrong word.
refreshEnglishSuggestions(afterCommittedWord: decision.replacement)
return .replace(
deleteCount: word.count,
with: decision.replacement + suffix
@@ -479,7 +496,17 @@ public final class TypingSessionController: ObservableObject {
englishPreviousWord = word
englishCurrentWord = ""
pendingAutocorrection = nil
learningStore.recordAcceptance(of: word, amount: 1)
// Learn OOV / names the user actually committed; skip common words.
if !englishEngine.isKnownWord(
word,
personalTerms: personalTermsCache,
systemWords: supplementaryWords
) {
learningStore.recordDefense(of: word, amount: 2)
#if canImport(UIKit)
UIKitEnglishSystemLexicon.learnWord(word)
#endif
}
refreshEnglishSuggestions(afterCommittedWord: word)
return suffix.isEmpty ? .none : .insert(suffix)
}
@@ -487,7 +514,8 @@ public final class TypingSessionController: ObservableObject {
private func selectEnglishCandidate(at index: Int) -> TypingOutput {
guard composition.candidates.indices.contains(index) else { return .none }
guard englishCandidateAnchorMatchesDocument() else { return .none }
let chosen = composition.candidates[index].text
let candidate = composition.candidates[index]
let chosen = candidate.text
// Restoring original after autocorrect (no current word).
if englishCurrentWord.isEmpty,
@@ -498,16 +526,27 @@ public final class TypingSessionController: ObservableObject {
englishPreviousWord = pending.original
englishCurrentWord = ""
learningStore.recordDefense(of: pending.original)
#if canImport(UIKit)
UIKitEnglishSystemLexicon.learnWord(pending.original)
#endif
refreshEnglishSuggestions(afterCommittedWord: pending.original)
return .replace(deleteCount: deleteCount, with: pending.original + " ")
}
if candidate.role == .verbatim {
learningStore.recordDefense(of: chosen)
#if canImport(UIKit)
UIKitEnglishSystemLexicon.learnWord(chosen)
#endif
} else {
learningStore.recordAcceptance(of: chosen)
}
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 + " ")
}
@@ -516,7 +555,6 @@ public final class TypingSessionController: ObservableObject {
englishPreviousWord = chosen
englishCurrentWord = ""
pendingAutocorrection = nil
learningStore.recordAcceptance(of: chosen)
refreshEnglishSuggestions(afterCommittedWord: chosen)
return .insert(chosen + " ")
}
@@ -537,19 +575,23 @@ public final class TypingSessionController: ObservableObject {
composition = .empty
return
}
// Idle / between words: no candidate bar. Completions start after
// the first letter of the current word.
// With no active English word, keep the candidate bar empty. This also
// prevents next-word predictions from appearing between committed words.
guard !englishCurrentWord.isEmpty else {
composition = .empty
return
}
let previous = word ?? englishPreviousWord
let typed = englishCurrentWord
let context = EnglishSuggestionContext(
currentWord: englishCurrentWord,
currentWord: typed,
previousWord: previous,
personalTerms: personalTermsCache,
learnedBoosts: learningStore.snapshot(),
includeOriginalAfterCorrection: nil
includeOriginalAfterCorrection: pendingAutocorrection?.original,
systemWords: supplementaryWords,
systemCompletions: typed.isEmpty ? [] : systemLexicon.completions(prefix: typed, limit: 6),
systemGuesses: typed.count >= 3 ? systemLexicon.guesses(for: typed, limit: 6) : []
)
composition = englishEngine.compositionWhileTyping(context)
}