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:
Rocky
2026-08-03 18:30:20 +08:00
parent d1e5fed964
commit 38e5ad570d
35 changed files with 5086 additions and 179 deletions
@@ -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 DamerauLevenshtein 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
}
/// DamerauLevenshtein 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()
}
}
+5 -4
View File
@@ -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