e5a83843db
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
1014 lines
38 KiB
Swift
1014 lines
38 KiB
Swift
// 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 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?
|
||
/// `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 }
|
||
abandonChineseComposition()
|
||
}
|
||
}
|
||
/// `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 learningStore: EnglishLearningStore
|
||
private let rimeFrequentTermStore: RimeFrequentTermStore
|
||
private var engineStorage: RimeEngineBridging?
|
||
private var englishStorage: EnglishSuggestionEngine?
|
||
private var prepared = false
|
||
private var prepareTask: Task<Void, Never>?
|
||
|
||
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(),
|
||
rimeFrequentTermStore: RimeFrequentTermStore = RimeFrequentTermStore()
|
||
) {
|
||
self.engineFactory = engine ?? { LibrimeEngine() }
|
||
self.layout = layout
|
||
self.englishFactory = englishEngine ?? { EnglishSuggestionEngine() }
|
||
self.learningStore = learningStore
|
||
self.rimeFrequentTermStore = rimeFrequentTermStore
|
||
// 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 = FlowSessionBridge.isHostHeavy()
|
||
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()
|
||
if hostHeavy {
|
||
KeyboardExtensionMemoryTelemetry.record(
|
||
"typing.rimePrepare.deferred",
|
||
details: "hostHeavy=1"
|
||
)
|
||
OSGDiag.log("typing.enter defer rime hostHeavy=1 — retry scheduled", category: "boot")
|
||
prepareTask?.cancel()
|
||
prepareTask = Task { [weak self] in
|
||
for _ in 0..<40 {
|
||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||
guard let self, !Task.isCancelled else { return }
|
||
if !FlowSessionBridge.isHostHeavy() {
|
||
await self.prepareIfNeeded()
|
||
self.prepareTask = nil
|
||
return
|
||
}
|
||
}
|
||
self?.prepareTask = nil
|
||
}
|
||
return
|
||
}
|
||
if prepareTask == nil, !prepared {
|
||
prepareTask = Task { [weak self] in
|
||
await self?.prepareIfNeeded()
|
||
self?.prepareTask = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
public func leaveTypingMode() {
|
||
KeyboardExtensionMemoryTelemetry.record(
|
||
"typing.leave.begin",
|
||
details: "language=\(language.rawValue)"
|
||
)
|
||
OSGDiag.log("typing.leave \(OSGDiag.memoryTag())", category: "boot")
|
||
prepareTask?.cancel()
|
||
prepareTask = nil
|
||
engineStorage?.teardown()
|
||
prepared = false
|
||
engineReady = false
|
||
composition = .empty
|
||
isCandidatePanelExpanded = false
|
||
page = .letters
|
||
resetShiftState()
|
||
clearEnglishWordState(keepPrevious: false)
|
||
clearPeriodShortcut()
|
||
// Drop English lexicon pages when leaving typing (jetsam recovery).
|
||
EnglishLexicon.shared.unload()
|
||
englishStorage = nil
|
||
KeyboardExtensionMemoryTelemetry.record(
|
||
"typing.leave.done",
|
||
details: "language=\(language.rawValue)"
|
||
)
|
||
}
|
||
|
||
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
|
||
engine.setLanguage(newLanguage)
|
||
page = .letters
|
||
isCandidatePanelExpanded = false
|
||
clearPeriodShortcut()
|
||
if newLanguage == .english {
|
||
clearEnglishWordState(keepPrevious: false)
|
||
refreshPersonalTerms()
|
||
englishEngine.prepare()
|
||
refreshEnglishSuggestions()
|
||
syncAutocapitalization()
|
||
synchronizeEnglishDocumentContext(caretMoved: true)
|
||
} else {
|
||
clearEnglishWordState(keepPrevious: false)
|
||
EnglishLexicon.shared.unload()
|
||
composition = engine.composition
|
||
}
|
||
return output
|
||
}
|
||
|
||
/// 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()
|
||
recordRimeCommit(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()
|
||
recordRimeCommit(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()
|
||
recordRimeCommit(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()
|
||
recordRimeCommit(text)
|
||
return text.isEmpty ? .none : .insert(text)
|
||
}
|
||
|
||
private func recordRimeCommit(_ text: String) {
|
||
guard language == .chinese, suggestionsEnabled, !text.isEmpty else { return }
|
||
rimeFrequentTermStore.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
|
||
}
|
||
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
|
||
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
|
||
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 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) {
|
||
guard language == .english else { 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 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() async {
|
||
guard !prepared else { return }
|
||
if FlowSessionBridge.isHostHeavy() {
|
||
KeyboardExtensionMemoryTelemetry.record(
|
||
"typing.rimePrepare.deferred",
|
||
details: "hostHeavy=1"
|
||
)
|
||
OSGDiag.log("rime.prepare deferred hostHeavy=1 \(OSGDiag.memoryTag())", category: "boot")
|
||
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()
|
||
engine.setLanguage(language)
|
||
prepared = true
|
||
engineReady = engine.isReady
|
||
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 {
|
||
lastError = error.localizedDescription
|
||
lastErrorNeedsHostDeployment =
|
||
(error as? RimeResourceError)?.isResolvedByHostDeployment ?? false
|
||
engineReady = 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")
|
||
prepareTask = Task { [weak self] in
|
||
await self?.prepareIfNeeded()
|
||
self?.prepareTask = nil
|
||
}
|
||
}
|
||
}
|