Files
OSGKeyboard/OSGKeyboardShared/Typing/TypingSessionController.swift
T
2026-08-25 13:42:00 +08:00

1130 lines
42 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TypingSessionController.swift
// OSGKeyboard · Shared
//
// Owns the typing-surface engine + layout provider. Injected into the
// keyboard extension; torn down when leaving typing mode.
import Combine
import Foundation
@MainActor
public final class TypingSessionController: ObservableObject {
@Published public private(set) var language: TypingInputLanguage = .chinese
@Published public private(set) var page: TypingKeyPage = .letters
@Published public private(set) var shiftActive: Bool = false
@Published public private(set) var capsLock: Bool = false
/// Finger is down on Shift (iOS: hold for continuous uppercase; release ends).
@Published public private(set) var shiftHeld: Bool = false
@Published public private(set) var candidateRevision: UInt64 = 0
@Published public private(set) var composition: TypingComposition = .empty {
didSet {
if oldValue != composition {
candidateRevision &+= 1
}
}
}
@Published public private(set) var engineReady: Bool = false
@Published public private(set) var isPreparingEngine: 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?
/// `true` when `lastError` can only be cleared by deploying resources in
/// the host app — drives the keyboard's tappable setup affordance.
@Published public private(set) var lastErrorNeedsHostDeployment: Bool = false
/// When true, English suggestions / autocorrect stay off (secure fields).
/// Chinese composition is also skipped so passwords never enter Rime userdb.
@Published public var suggestionsEnabled: Bool = true {
didSet {
guard oldValue != suggestionsEnabled else { return }
if !suggestionsEnabled {
clearDocumentScopedState()
}
}
}
/// `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 {
language == .chinese && composition.candidates.count >= 2
}
/// Live document prefix ahead of the caret (from `UITextDocumentProxy`).
public var precedingTextProvider: (() -> String?)?
/// Live document suffix after the caret. A non-empty word suffix means
/// the caret is inside a word, where backward-only replacement is unsafe.
public var followingTextProvider: (() -> String?)?
/// Host field autocapitalization preference.
public var autocapitalizationModeProvider: (() -> TypingAutocapitalizationMode)?
public let layout: TypingLayoutProviding
private let engineFactory: @MainActor () -> RimeEngineBridging
private let englishFactory: @MainActor () -> EnglishSuggestionEngine
private let hostHeavyProvider: @MainActor () -> Bool
private let learningStore: EnglishLearningStore
private let frequentTermStore: FrequentTermStore
private var engineStorage: RimeEngineBridging?
private var englishStorage: EnglishSuggestionEngine?
private var prepared = false
private var prepareTask: Task<Void, Never>?
private var engineEpoch: UInt64 = 0
private var documentPresentationCounter: UInt64 = 0
private var activeDocumentPresentationID: UInt64?
private var engine: RimeEngineBridging {
if let engineStorage { return engineStorage }
let created = engineFactory()
engineStorage = created
schema = created.schema
return created
}
private var englishEngine: EnglishSuggestionEngine {
if let englishStorage { return englishStorage }
let created = englishFactory()
englishStorage = created
return created
}
// English word-level state (characters are already in the document).
private var englishCurrentWord: String = ""
private var englishPreviousWord: String = ""
private var englishFollowingWordSuffix: String = ""
private var pendingAutocorrection: EnglishCorrectionDecision?
private var personalTermsCache: [String] = []
/// User tapped Shift for a one-shot capital; autocap must not overwrite this.
private var shiftPrimedByUser = false
/// True if any key was typed while the current Shift hold was active.
private var typedWhileShiftHeld = false
/// Local caret-prefix mirror so autocap survives stale `documentContextBeforeInput`
/// (common in Notes). Capped; reseeds from the proxy when it looks fresh.
private var precedingShadow = ""
private static let precedingShadowLimit = 400
/// iOS "." Shortcut: second Space shortly after a Space that followed a word.
private var periodShortcutArmed = false
private var lastSpaceAt: Date?
public init(
engine: (@MainActor () -> RimeEngineBridging)? = nil,
layout: TypingLayoutProviding = StandardTypingLayout(),
englishEngine: (@MainActor () -> EnglishSuggestionEngine)? = nil,
learningStore: EnglishLearningStore = EnglishLearningStore(),
frequentTermStore: FrequentTermStore = FrequentTermStore(),
hostHeavyProvider: @escaping @MainActor () -> Bool = {
FlowSessionBridge.isHostHeavy()
}
) {
self.engineFactory = engine ?? { LibrimeEngine() }
self.layout = layout
self.englishFactory = englishEngine ?? { EnglishSuggestionEngine() }
self.hostHeavyProvider = hostHeavyProvider
self.learningStore = learningStore
self.frequentTermStore = frequentTermStore
// Avoid constructing librime until the first Chinese keystroke / prepare.
schema = TypingInputConfiguration.shared.schema
}
/// Letter keys and Shift glyph follow any armed / held / Caps Lock state.
public var isShiftEnabled: Bool {
shiftActive || capsLock || shiftHeld
}
public var keyRows: [[String]] {
var rows = layout.rows(
for: page,
language: language,
shiftActive: isShiftEnabled
)
if page == .letters,
language == .chinese,
schema != .fullPinyin,
rows.indices.contains(2),
rows[2].first == "⇧" {
// Microsoft/Sogou use semicolon for "ing"; Chinese composition
// does not need Shift, so keep the standard row width stable.
rows[2][0] = ";"
}
return rows
}
public func enterTypingMode() {
let hostHeavy = hostHeavyProvider()
KeyboardExtensionMemoryTelemetry.record(
"typing.enter.begin",
details: "language=\(language.rawValue) schema=\(schema.rawValue) "
+ "hostHeavy=\(hostHeavy ? 1 : 0)"
)
OSGDiag.log(
"typing.enter begin lang=\(language.rawValue) schema=\(schema.rawValue) "
+ "hostHeavy=\(hostHeavy ? 1 : 0) \(OSGDiag.memoryTag())",
category: "boot"
)
TypingInputConfiguration.shared.reload()
refreshPersonalTerms()
// 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()
KeyboardExtensionMemoryTelemetry.record(
"typing.englishPrepare.done",
details: "language=\(language.rawValue)"
)
OSGDiag.log("typing.enter after englishPrepare \(OSGDiag.memoryTag())", category: "boot")
} else {
EnglishLexicon.shared.unload()
KeyboardExtensionMemoryTelemetry.record(
"typing.englishPrepare.skipped",
details: "language=\(language.rawValue)"
)
OSGDiag.log("typing.enter skip englishPrepare lang=\(language.rawValue) \(OSGDiag.memoryTag())", category: "boot")
}
syncAutocapitalization()
isPreparingEngine = !prepared
if hostHeavy {
KeyboardExtensionMemoryTelemetry.record(
"typing.rimePrepare.deferred",
details: "hostHeavy=1"
)
OSGDiag.log("typing.enter defer rime hostHeavy=1 — retry scheduled", category: "boot")
prepareTask?.cancel()
engineEpoch &+= 1
let epoch = engineEpoch
prepareTask = Task { [weak self] in
while self?.hostHeavyProvider() == true {
try? await Task.sleep(nanoseconds: 250_000_000)
guard !Task.isCancelled else { return }
}
guard let self, self.engineEpoch == epoch, !Task.isCancelled else { return }
await self.prepareIfNeeded(engineEpoch: epoch)
if self.engineEpoch == epoch {
self.prepareTask = nil
}
}
return
}
if prepareTask == nil, !prepared {
engineEpoch &+= 1
let epoch = engineEpoch
prepareTask = Task { [weak self] in
await self?.prepareIfNeeded(engineEpoch: epoch)
guard let self, self.engineEpoch == epoch else { return }
self.prepareTask = nil
}
}
}
public func leaveTypingMode() {
KeyboardExtensionMemoryTelemetry.record(
"typing.leave.begin",
details: "language=\(language.rawValue)"
)
OSGDiag.log("typing.leave \(OSGDiag.memoryTag())", category: "boot")
engineEpoch &+= 1
prepareTask?.cancel()
prepareTask = nil
endDocumentPresentation()
engineStorage?.teardown()
prepared = false
engineReady = false
isPreparingEngine = false
// Drop English lexicon pages when leaving typing (jetsam recovery).
EnglishLexicon.shared.unload()
englishStorage = nil
KeyboardExtensionMemoryTelemetry.record(
"typing.leave.done",
details: "language=\(language.rawValue)"
)
}
/// Starts a new host-document scope without tearing down dictionaries or
/// the Rime runtime. A keyboard presentation can move between apps while
/// the extension process and controller remain alive.
@discardableResult
public func beginDocumentPresentation() -> UInt64 {
documentPresentationCounter &+= 1
let presentationID = documentPresentationCounter
activeDocumentPresentationID = presentationID
candidateRevision &+= 1
clearDocumentScopedState()
return presentationID
}
/// Invalidates all candidates and input shadows owned by the outgoing
/// document. Heavy engines stay warm until typing mode itself is left.
public func endDocumentPresentation() {
documentPresentationCounter &+= 1
activeDocumentPresentationID = nil
candidateRevision &+= 1
clearDocumentScopedState()
}
public func isCurrentDocumentPresentation(_ presentationID: UInt64) -> Bool {
activeDocumentPresentationID == presentationID
}
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. 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
engineStorage?.setLanguage(newLanguage)
page = .letters
isCandidatePanelExpanded = false
clearPeriodShortcut()
if newLanguage == .english {
clearEnglishWordState(keepPrevious: false)
refreshPersonalTerms()
englishEngine.prepare()
refreshEnglishSuggestions()
syncAutocapitalization()
if activeDocumentPresentationID != nil {
synchronizeEnglishDocumentContext(caretMoved: true)
}
} else {
clearEnglishWordState(keepPrevious: false)
EnglishLexicon.shared.unload()
composition = engine.composition
}
return output
}
/// Reloads host-curated terms after App Group configuration changes.
public func reloadPersonalDictionaryTerms() {
refreshPersonalTerms()
if language == .english {
refreshEnglishSuggestions()
}
}
/// Flushes raw preedit, selects the next built-in scheme, and returns the
/// 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
let next = schemas[(current + 1) % schemas.count]
if engine.setSchema(next) {
schema = next
TypingInputConfiguration.shared.schema = next
}
composition = engine.composition
syncCandidatePanelVisibility()
return raw.isEmpty ? .none : .insert(raw)
}
public func setPage(_ page: TypingKeyPage) {
self.page = page
resetShiftState()
clearPeriodShortcut()
if page == .letters {
syncAutocapitalization()
}
}
/// Touch-down on Shift: hold for continuous uppercase (release ends hold).
public func beginShiftHold() {
guard page == .letters else { return }
shiftHeld = true
typedWhileShiftHeld = false
}
/// Touch-up on Shift: end hold; if nothing was typed, treat as a tap.
public func endShiftHold() {
guard shiftHeld else { return }
let typed = typedWhileShiftHeld
shiftHeld = false
typedWhileShiftHeld = false
if typed {
if !capsLock && !shiftPrimedByUser {
syncAutocapitalization()
}
return
}
handleShiftTap()
}
/// Handle a visible key label.
public func handleKey(_ label: String) -> TypingOutput {
clearPeriodShortcut()
switch label {
case "⇧":
// Tests / non-gesture callers: same as a completed Shift tap.
handleShiftTap()
return .none
case "⌫":
return handleBackspace()
case "123":
setPage(.numbers)
return .none
case "#+=":
setPage(.symbols)
return .none
case "ABC", "abc":
setPage(.letters)
return .none
default:
break
}
if page != .letters {
let out = commitEnglishWordIfNeededBeforeNonLetter()
clearOneShotShiftIfNeeded()
if out.isEmpty {
return .insert(label)
}
return TypingOutput(deleteCount: out.deleteCount, text: out.text + label)
}
guard let ch = label.first else { return .none }
if language == .english {
return handleEnglishCharacter(ch)
}
if !suggestionsEnabled {
clearOneShotShiftIfNeeded()
abandonChineseComposition()
return .insert(String(ch))
}
// Chinese + Shift: insert Latin directly (iOS-style mix-in), leave Rime
// composition untouched. Rime's alphabet is lowercase-only, so uppercase
// keycodes would otherwise be rejected with no output.
if isShiftEnabled, ch.isLetter {
clearOneShotShiftIfNeeded()
return .insert(String(ch))
}
// Chinese letters → compose
let committed = engine.processCharacter(ch) ?? ""
composition = engine.composition
syncCandidatePanelVisibility()
clearOneShotShiftIfNeeded()
recordChineseCommit(committed)
return committed.isEmpty ? .none : .insert(committed)
}
public func handleSpace() -> TypingOutput {
if language == .english {
return handleEnglishSpace()
}
clearPeriodShortcut()
if !suggestionsEnabled {
abandonChineseComposition()
return .insert(" ")
}
let text = engine.processSpace() ?? " "
composition = engine.composition
syncCandidatePanelVisibility()
recordChineseCommit(text)
return .insert(text)
}
public func handleReturn() -> TypingOutput {
clearPeriodShortcut()
if language == .english {
return commitEnglishWord(suffix: "\n")
}
if !suggestionsEnabled {
abandonChineseComposition()
return .insert("\n")
}
let text = engine.processReturn() ?? "\n"
composition = engine.composition
syncCandidatePanelVisibility()
recordChineseCommit(text)
return .insert(text)
}
public func selectCandidate(at index: Int) -> TypingOutput {
clearPeriodShortcut()
if language == .english {
return selectEnglishCandidate(at: index)
}
if !suggestionsEnabled {
return .none
}
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
// Selecting always collapses; follow-up composition may reopen ▼.
isCandidatePanelExpanded = false
syncCandidatePanelVisibility()
recordChineseCommit(text)
return text.isEmpty ? .none : .insert(text)
}
/// Candidate taps originate from a rendered snapshot. Reject the tap when
/// that snapshot has already been replaced by a newer composition.
public func selectCandidate(
id candidateID: String,
candidateRevision expectedRevision: UInt64? = nil
) -> TypingOutput {
if let expectedRevision, expectedRevision != candidateRevision {
return .none
}
guard let index = composition.candidates.firstIndex(where: { $0.id == candidateID }) else {
return .none
}
return selectCandidate(at: index)
}
private func recordChineseCommit(_ text: String) {
guard language == .chinese, suggestionsEnabled, !text.isEmpty else { return }
frequentTermStore.recordCommittedText(text)
}
/// Drop in-flight pinyin so secure fields cannot commit into userdb.
private func abandonChineseComposition() {
engineStorage?.clearComposition()
composition = .empty
isCandidatePanelExpanded = false
}
// MARK: - English
private func handleEnglishSpace() -> TypingOutput {
let preceding = precedingTextForShortcut()
if periodShortcutArmed,
let stamped = lastSpaceAt,
Date().timeIntervalSince(stamped) <= PeriodShortcut.doubleTapInterval,
PeriodShortcut.shouldReplacePreviousSpace(precedingText: preceding) {
clearPeriodShortcut()
let wordOut = commitEnglishWord(suffix: "")
let deleteCount = wordOut.deleteCount + 1
return TypingOutput(deleteCount: deleteCount, text: wordOut.text + ". ")
}
let output = commitEnglishWord(suffix: " ")
if PeriodShortcut.shouldArm(afterSpaceFollowing: preceding) {
periodShortcutArmed = true
lastSpaceAt = Date()
} else {
clearPeriodShortcut()
}
return output
}
private func precedingTextForShortcut() -> String {
if !precedingShadow.isEmpty { return precedingShadow }
return precedingTextProvider?() ?? ""
}
private func clearPeriodShortcut() {
periodShortcutArmed = false
lastSpaceAt = nil
}
private func handleEnglishCharacter(_ ch: Character) -> TypingOutput {
pendingAutocorrection = nil
if ch.isLetter {
englishCurrentWord.append(ch)
clearOneShotShiftIfNeeded()
refreshEnglishSuggestions()
return .insert(String(ch))
}
// Punctuation / digit: commit current word first, then insert.
let output = commitEnglishWord(suffix: "")
clearOneShotShiftIfNeeded()
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)
#if canImport(UIKit)
UIKitEnglishSystemLexicon.learnWord(pending.original)
#endif
refreshEnglishSuggestions()
return .replace(deleteCount: deleteCount, with: pending.original)
}
if !englishCurrentWord.isEmpty {
englishCurrentWord.removeLast()
refreshEnglishSuggestions()
} else {
composition = .empty
}
}
return .backspace
}
private func commitEnglishWord(suffix: String) -> TypingOutput {
let word = englishCurrentWord
defer {
clearOneShotShiftIfNeeded()
}
guard suggestionsEnabled,
englishFollowingWordSuffix.isEmpty,
!word.isEmpty else {
if !word.isEmpty {
englishPreviousWord = word
englishCurrentWord = ""
}
refreshEnglishSuggestions(afterCommittedWord: word.isEmpty ? nil : word)
return suffix.isEmpty ? .none : .insert(suffix)
}
if englishCurrentWordMatchesDocument(),
var decision = englishEngine.correctionDecision(
for: word,
personalTerms: personalTermsCache,
learnedBoosts: learningStore.snapshot(),
previousWord: englishPreviousWord,
systemWords: supplementaryWords,
systemGuesses: systemLexicon.guesses(for: word, limit: 6)
) {
decision.appliedSuffix = suffix
pendingAutocorrection = decision
englishPreviousWord = decision.replacement
englishCurrentWord = ""
// 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
)
}
englishPreviousWord = word
englishCurrentWord = ""
pendingAutocorrection = nil
// 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
}
recordEnglishCommit(word)
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 }
guard englishCandidateAnchorMatchesDocument() else { return .none }
let candidate = composition.candidates[index]
let chosen = candidate.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)
#if canImport(UIKit)
UIKitEnglishSystemLexicon.learnWord(pending.original)
#endif
recordEnglishCommit(pending.original)
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)
}
recordEnglishCommit(chosen)
if !englishCurrentWord.isEmpty {
let deleteCount = englishCurrentWord.count
englishPreviousWord = chosen
englishCurrentWord = ""
pendingAutocorrection = nil
refreshEnglishSuggestions(afterCommittedWord: chosen)
return .replace(deleteCount: deleteCount, with: chosen + " ")
}
// Next-word prediction tap.
englishPreviousWord = chosen
englishCurrentWord = ""
pendingAutocorrection = nil
refreshEnglishSuggestions(afterCommittedWord: chosen)
return .insert(chosen + " ")
}
private func recordEnglishCommit(_ text: String) {
guard language == .english,
suggestionsEnabled,
englishEngine.isPersonalTermCandidate(
text,
personalTerms: personalTermsCache,
systemWords: supplementaryWords
) else {
return
}
frequentTermStore.recordCommittedText(text)
}
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 {
clearEnglishWordState(keepPrevious: false)
composition = .empty
return
}
guard englishFollowingWordSuffix.isEmpty else {
composition = .empty
return
}
// 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: typed,
previousWord: previous,
personalTerms: personalTermsCache,
learnedBoosts: learningStore.snapshot(),
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)
}
/// Arms Shift for sentence / word starts using the host field traits.
/// Manual one-shot, hold, and Caps Lock always win over autocapitalization.
/// - Parameters:
/// - insert: Text just written through the document proxy (may not be
/// reflected in `documentContextBeforeInput` yet).
/// - deleteCount: Characters just deleted before `insert` (replace path).
public func syncAutocapitalization(
accountingForInsert insert: String = "",
deleteCount: Int = 0
) {
guard language == .english, page == .letters else { return }
let preceding = resolvedPrecedingText(
accountingForInsert: insert,
deleteCount: deleteCount
)
if !insert.isEmpty || deleteCount > 0 {
synchronizeEnglishWordState(
precedingText: preceding ?? "",
followingText: followingTextProvider?()
)
}
guard !capsLock, !shiftHeld, !shiftPrimedByUser else { return }
let mode = autocapitalizationModeProvider?() ?? .sentences
shiftActive = TypingAutocapitalization.shouldCapitalize(
precedingText: preceding,
mode: mode
)
}
/// Rebuilds English suggestion state from the real caret context.
/// At the document end, callbacks keep the local shadow when a host
/// briefly reports the immediately preceding edit (common in Notes).
public func synchronizeEnglishDocumentContext(
caretMoved: Bool = false,
presentationID: UInt64? = nil
) {
guard language == .english else { return }
if let presentationID,
activeDocumentPresentationID != presentationID {
return
}
if caretMoved {
clearPeriodShortcut()
}
guard suggestionsEnabled else {
clearEnglishWordState(keepPrevious: false)
composition = .empty
return
}
guard let preceding = precedingTextProvider?() else {
if caretMoved {
clearEnglishWordState(keepPrevious: false)
composition = .empty
}
return
}
if shouldPreserveLocalEnglishContext(over: preceding) {
return
}
applyEnglishDocumentSnapshot(preceding)
}
private func applyEnglishDocumentSnapshot(_ preceding: String) {
if let pending = pendingAutocorrection,
!preceding.hasSuffix(pending.replacement + pending.appliedSuffix) {
pendingAutocorrection = nil
}
_ = storePrecedingShadow(preceding)
synchronizeEnglishWordState(
precedingText: preceding,
followingText: followingTextProvider?()
)
}
/// Prefer a fresh proxy; when the host lags (Notes), merge our just-applied edit.
private func resolvedPrecedingText(
accountingForInsert insert: String,
deleteCount: Int
) -> String? {
// Host-driven refresh (appear / textDidChange): reseed from proxy.
if deleteCount == 0, insert.isEmpty {
if let proxy = precedingTextProvider?() {
return storePrecedingShadow(proxy)
}
return precedingShadow.isEmpty ? nil : precedingShadow
}
let proxy = precedingTextProvider?()
if deleteCount > 0, !insert.isEmpty {
if let proxy {
if proxy.hasSuffix(insert) {
return storePrecedingShadow(proxy)
}
if proxy.count >= deleteCount {
return storePrecedingShadow(String(proxy.dropLast(deleteCount)) + insert)
}
}
trimPrecedingShadow(by: deleteCount)
return storePrecedingShadow(precedingShadow + insert)
}
if deleteCount > 0 {
if let proxy, proxy.count + deleteCount == precedingShadow.count
|| (precedingShadow.count >= deleteCount
&& String(precedingShadow.dropLast(deleteCount)) == proxy) {
return storePrecedingShadow(proxy)
}
if precedingShadow.count >= deleteCount {
return storePrecedingShadow(String(precedingShadow.dropLast(deleteCount)))
}
if let proxy { return storePrecedingShadow(proxy) }
precedingShadow = ""
return ""
}
// insert only
if let proxy {
if proxy.hasSuffix(insert) {
return storePrecedingShadow(proxy)
}
return storePrecedingShadow(proxy + insert)
}
return storePrecedingShadow(precedingShadow + insert)
}
@discardableResult
private func storePrecedingShadow(_ value: String) -> String {
precedingShadow = String(value.suffix(Self.precedingShadowLimit))
return precedingShadow
}
private func trimPrecedingShadow(by count: Int) {
guard count > 0 else { return }
if precedingShadow.count >= count {
precedingShadow.removeLast(count)
} else {
precedingShadow = ""
}
}
// MARK: - Shift
/// Tap cycle: off → one-shot → Caps Lock → off (iOS-style second tap).
private func handleShiftTap() {
if capsLock {
capsLock = false
shiftActive = false
shiftPrimedByUser = false
} else if shiftActive {
capsLock = true
shiftActive = false
shiftPrimedByUser = false
} else {
shiftActive = true
shiftPrimedByUser = true
}
}
/// After inserting a character: consume one-shot Shift, keep hold / Caps.
private func clearOneShotShiftIfNeeded() {
if shiftHeld {
typedWhileShiftHeld = true
return
}
guard !capsLock else { return }
shiftActive = false
shiftPrimedByUser = false
}
private func resetShiftState() {
shiftActive = false
capsLock = false
shiftHeld = false
shiftPrimedByUser = false
typedWhileShiftHeld = false
precedingShadow = ""
}
private func clearEnglishWordState(keepPrevious: Bool) {
englishCurrentWord = ""
if !keepPrevious { englishPreviousWord = "" }
englishFollowingWordSuffix = ""
pendingAutocorrection = nil
}
private func clearDocumentScopedState() {
engineStorage?.clearComposition()
composition = .empty
isCandidatePanelExpanded = false
page = .letters
resetShiftState()
clearEnglishWordState(keepPrevious: false)
clearPeriodShortcut()
}
private func synchronizeEnglishWordState(
precedingText: String,
followingText: String?
) {
let current = Self.englishWordBeforeCaret(in: precedingText)
let textBeforeCurrent = String(precedingText.dropLast(current.count))
englishCurrentWord = current
englishPreviousWord = Self.previousEnglishWord(in: textBeforeCurrent)
englishFollowingWordSuffix = Self.englishWordAfterCaret(in: followingText ?? "")
refreshEnglishSuggestions()
}
private func shouldPreserveLocalEnglishContext(over proxyText: String) -> Bool {
guard !englishCurrentWord.isEmpty,
(followingTextProvider?() ?? "").isEmpty,
!precedingShadow.isEmpty,
proxyText != precedingShadow,
Self.englishWordBeforeCaret(in: precedingShadow) == englishCurrentWord,
Self.englishWordBeforeCaret(in: proxyText) != englishCurrentWord else {
return false
}
return precedingShadow.hasPrefix(proxyText) || proxyText.hasPrefix(precedingShadow)
}
private func englishCandidateAnchorMatchesDocument() -> Bool {
guard englishCurrentWordMatchesDocument() else {
if let preceding = precedingTextProvider?() {
applyEnglishDocumentSnapshot(preceding)
} else {
clearEnglishWordState(keepPrevious: false)
composition = .empty
}
return false
}
return true
}
private func englishCurrentWordMatchesDocument() -> Bool {
guard englishFollowingWordSuffix.isEmpty else { return false }
if let following = followingTextProvider?(),
!Self.englishWordAfterCaret(in: following).isEmpty {
return false
}
guard let preceding = precedingTextProvider?() else {
return true
}
return Self.englishWordBeforeCaret(in: preceding) == englishCurrentWord
}
private static func englishWordBeforeCaret(in text: String) -> String {
var reversed: [Character] = []
for character in text.reversed() {
guard isEnglishWordCharacter(character) else { break }
reversed.append(character)
}
return String(reversed.reversed())
}
private static func englishWordAfterCaret(in text: String) -> String {
String(text.prefix(while: isEnglishWordCharacter))
}
private static func previousEnglishWord(in text: String) -> String {
var remainder = text
while let last = remainder.last, !isEnglishWordCharacter(last) {
remainder.removeLast()
}
return englishWordBeforeCaret(in: remainder)
}
private static func isEnglishWordCharacter(_ character: Character) -> Bool {
if character == "'" || character == "" || character == "-" {
return true
}
guard character.isLetter else { return false }
return character.unicodeScalars.allSatisfy { scalar in
switch scalar.value {
case 0x0041...0x007A,
0x00C0...0x024F,
0x1E00...0x1EFF:
return true
default:
return false
}
}
}
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(engineEpoch expectedEpoch: UInt64) async {
guard !prepared else { return }
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
if hostHeavyProvider() {
KeyboardExtensionMemoryTelemetry.record(
"typing.rimePrepare.deferred",
details: "hostHeavy=1"
)
OSGDiag.log("rime.prepare deferred hostHeavy=1 \(OSGDiag.memoryTag())", category: "boot")
while hostHeavyProvider() {
try? await Task.sleep(nanoseconds: 250_000_000)
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
}
}
KeyboardExtensionMemoryTelemetry.record(
"typing.rimePrepare.begin",
details: "language=\(language.rawValue) "
+ "resourcesReady=\(RimeResourceInstaller.isReady ? 1 : 0)"
)
OSGDiag.log(
"rime.prepare begin ready=\(RimeResourceInstaller.isReady) \(OSGDiag.memoryTag())",
category: "boot"
)
do {
try await engine.prepare()
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
engine.setLanguage(language)
prepared = true
engineReady = engine.isReady
isPreparingEngine = false
schema = engine.schema
lastError = nil
lastErrorNeedsHostDeployment = false
if language == .english {
refreshEnglishSuggestions()
}
KeyboardExtensionMemoryTelemetry.record(
"typing.rimePrepare.done",
details: "language=\(language.rawValue) ready=\(engineReady ? 1 : 0)"
)
OSGDiag.log(
"rime.prepare done ready=\(engineReady) \(OSGDiag.memoryTag())",
category: "boot"
)
} catch {
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
lastError = error.localizedDescription
lastErrorNeedsHostDeployment =
(error as? RimeResourceError)?.isResolvedByHostDeployment ?? false
engineReady = false
isPreparingEngine = false
KeyboardExtensionMemoryTelemetry.record(
"typing.rimePrepare.failed",
details: "language=\(language.rawValue) errorType=\(String(describing: type(of: error)))"
)
OSGDiag.log(
"rime.prepare failed error=\(error.localizedDescription) \(OSGDiag.memoryTag())",
category: "boot"
)
}
}
/// Retries a previously failed prepare once host-side resources land.
/// Driven by the App Group config Darwin notification the host posts after
/// a successful deployment, so a keyboard already showing the setup error
/// recovers without the user switching surfaces.
public func retryPrepareAfterResourceDeployment() {
guard !prepared, lastError != nil else { return }
guard prepareTask == nil else { return }
guard RimeResourceInstaller.isReady else { return }
OSGDiag.log("rime.prepare retry after deployment", category: "boot")
engineEpoch &+= 1
let epoch = engineEpoch
isPreparingEngine = true
prepareTask = Task { [weak self] in
await self?.prepareIfNeeded(engineEpoch: epoch)
guard let self, self.engineEpoch == epoch else { return }
self.prepareTask = nil
}
}
}