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
@@ -9,5 +9,7 @@ public enum KeyboardChromeLayout {
public static let totalHeight: CGFloat = 281
public static let actionKeyHeight: CGFloat = 50
public static let actionKeyCornerRadius: CGFloat = 10
/// Fixed width for the two side keys in every three-key bottom row.
public static let sideActionKeyWidth: CGFloat = 86
public static let horizontalInset: CGFloat = 8
}
@@ -237,6 +237,45 @@ extension PersonalDictionary {
return merged
}
/// Personal hotwords safe for the English typing keyboard.
/// Drops CJK terms; keeps Latin product names / acronyms (and English aliases).
public func englishTypingHotwords() -> [String] {
var seen = Set<String>()
var words: [String] = []
for entry in effectiveEntries {
for raw in ([entry.term] + entry.aliases) {
let term = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard Self.isEnglishTypingHotword(term) else { continue }
let key = term.lowercased()
guard seen.insert(key).inserted else { continue }
words.append(term)
}
}
return words
}
/// Latin-script hotword with no CJK ideographs (allows digits / hyphen / apostrophe).
public static func isEnglishTypingHotword(_ term: String) -> Bool {
guard !term.isEmpty else { return false }
var hasLatinLetter = false
for scalar in term.unicodeScalars {
if isCJKIdeograph(scalar) { return false }
if scalar.isASCII, CharacterSet.letters.contains(scalar) {
hasLatinLetter = true
}
}
return hasLatinLetter
}
private static func isCJKIdeograph(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
case 0x3400...0x4DBF, 0x4E00...0x9FFF, 0xF900...0xFAFF:
return true
default:
return false
}
}
/// Case-insensitive lookup by canonical term.
public func entry(matchingTerm term: String) -> Entry? {
let key = term.lowercased()
@@ -0,0 +1,57 @@
the of and to in is for that with on a
of the a this my our their these those course them
to the be a do make see get go have my
and the I a then also other more so we you
a lot few new good great little bit way time person
in the a my this order fact front time case addition
is a the not to that this it one more also
for the a example me you us this that now sure
on the a my this top time earth it our your
that is the I you we it this are was would
with the a my you me us this that him her
I am have will would can think know want need was
you can are will have know want need should may would
we are have can will need want should would were do
it is was will can would has does seems might could
this is was will can time way one year week day
have a the to been been not an some any more
will be not have you we I the also help make
can be you I we not help see make get do
would be like not have you I we rather love prefer
from the a my this that our their here there home
at the a least home work school night least all once
as a the well soon if much long far usual expected
be a the able sure happy ready there here more good
or the a not so even maybe perhaps other something anything
but the I we you it also not then still now
not a the be to only sure really yet just going
are a the you we not there here going also more
was a the not in to very just also there born
my name friend family life work time house car phone job
your name email phone help time life work family friend order
our team company family friends time work goal plan product service
if you I we the it this that possible needed so
when you I we the it this that people they he
how to are is do can much many long about come
what is are do you I we about if the happened
there is are was were will have can a the no
here is are was were you we to for at comes
thank you god goodness
thanks for so a again
please let help send check review confirm see note find make
looking forward for at into up to
let me us you the it
make sure a the it sense up out
need to a the help more some
want to a the you more
going to on out home back
able to to
due to date
according to
based on
instead of
because of I you we it the they
out of the to there here
up to the and for with
into the a this that account
about the a this that it you to
1 the of and to in is for that with on a
2 of the a this my our their these those course them
3 to the be a do make see get go have my
4 and the I a then also other more so we you
5 a lot few new good great little bit way time person
6 in the a my this order fact front time case addition
7 is a the not to that this it one more also
8 for the a example me you us this that now sure
9 on the a my this top time earth it our your
10 that is the I you we it this are was would
11 with the a my you me us this that him her
12 I am have will would can think know want need was
13 you can are will have know want need should may would
14 we are have can will need want should would were do
15 it is was will can would has does seems might could
16 this is was will can time way one year week day
17 have a the to been been not an some any more
18 will be not have you we I the also help make
19 can be you I we not help see make get do
20 would be like not have you I we rather love prefer
21 from the a my this that our their here there home
22 at the a least home work school night least all once
23 as a the well soon if much long far usual expected
24 be a the able sure happy ready there here more good
25 or the a not so even maybe perhaps other something anything
26 but the I we you it also not then still now
27 not a the be to only sure really yet just going
28 are a the you we not there here going also more
29 was a the not in to very just also there born
30 my name friend family life work time house car phone job
31 your name email phone help time life work family friend order
32 our team company family friends time work goal plan product service
33 if you I we the it this that possible needed so
34 when you I we the it this that people they he
35 how to are is do can much many long about come
36 what is are do you I we about if the happened
37 there is are was were will have can a the no
38 here is are was were you we to for at comes
39 thank you god goodness
40 thanks for so a again
41 please let help send check review confirm see note find make
42 looking forward for at into up to
43 let me us you the it
44 make sure a the it sense up out
45 need to a the help more some
46 want to a the you more
47 going to on out home back
48 able to to
49 due to date
50 according to
51 based on
52 instead of
53 because of I you we it the they
54 out of the to there here
55 up to the and for with
56 into the a this that account
57 about the a this that it you to
File diff suppressed because it is too large Load Diff
@@ -27,3 +27,9 @@ rime-ice, rime-double-pinyin, rime-luna-pinyin, rime-essay and KeyboardKit Pro.
The Apache-2.0 and MIT license texts are available from the source links above;
all required copyright and permission notices must remain with distributions.
English typing lexicon
----------------------
english_lexicon.tsv and english_bigrams.tsv are OSG-curated word lists with
synthetic relative frequency ranks for offline autocomplete / autocorrect /
next-word ranking. They are not derived from GPL/LGPL dictionaries.
@@ -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