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

Show verbatim/correction/completion slots, mmap a 40k-word list, and
use UITextChecker plus supplementary lexicon for conservative autocorrect.
This commit is contained in:
Rocky
2026-08-14 21:49:22 +08:00
parent 2c3a3f80f3
commit 4749a9cbf2
34 changed files with 45539 additions and 3395 deletions
+15 -9
View File
@@ -14,7 +14,8 @@ public struct AIUserSkill: Codable, Equatable, Identifiable, Sendable {
public var summary: String
public var systemImage: String
public var prompt: String
public var shortcutICloudURL: URL
/// Optional iCloud share URL. Nil means the skill only transforms text.
public var shortcutICloudURL: URL?
/// Name used by `shortcuts://run-shortcut?name=`. Independent of `name`.
public var shortcutName: String
/// Per-skill reasoning. Built-in skills are always off; custom defaults off.
@@ -28,8 +29,8 @@ public struct AIUserSkill: Codable, Equatable, Identifiable, Sendable {
summary: String = "",
systemImage: String = AIUserSkillLimits.defaultSystemImage,
prompt: String,
shortcutICloudURL: URL,
shortcutName: String,
shortcutICloudURL: URL? = nil,
shortcutName: String = "",
thinkingEnabled: Bool = false,
createdAt: Date = Date(),
updatedAt: Date? = nil
@@ -49,15 +50,16 @@ public struct AIUserSkill: Codable, Equatable, Identifiable, Sendable {
public var isUserCreated: Bool { id.hasPrefix("user.") }
public func asClipboardSkill() -> AIClipboardSkill {
AIClipboardSkill(
let exportsToShortcut = shortcutICloudURL != nil
return AIClipboardSkill(
id: id,
systemImage: systemImage,
titleKey: "",
cardTitleKey: "",
descriptionKey: "",
kind: .export,
kind: exportsToShortcut ? .export : .transform,
isDefault: false,
shortcutName: shortcutName,
shortcutName: exportsToShortcut ? shortcutName : nil,
shortcutICloudURL: shortcutICloudURL,
customName: name,
customSummary: summary,
@@ -149,9 +151,13 @@ public struct AIUserSkillCatalog: Codable, Equatable, Sendable {
maximum: AIUserSkillLimits.maximumPromptCharacters
)
}
guard !shortcutName.isEmpty else { throw AIUserSkillValidationError.emptyShortcutName }
guard AIShortcutShareLink.isValid(skill.shortcutICloudURL) else {
throw AIUserSkillValidationError.invalidShortcutLink
if let shortcutURL = skill.shortcutICloudURL {
guard !shortcutName.isEmpty else {
throw AIUserSkillValidationError.emptyShortcutName
}
guard AIShortcutShareLink.isValid(shortcutURL) else {
throw AIUserSkillValidationError.invalidShortcutLink
}
}
guard !icon.isEmpty else { throw AIUserSkillValidationError.emptyIcon }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -130,13 +130,26 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
}
public func saveUserSkill(_ skill: AIUserSkill) throws {
let previousURL = userCatalog.skill(id: skill.id)?.shortcutICloudURL
let previousSkill = userCatalog.skill(id: skill.id)
let previousURL = previousSkill?.shortcutICloudURL
let previousLayout = layout.sanitized(catalog: mergedCatalog)
var catalog = userCatalog
try catalog.upsert(skill)
commitUserCatalog(catalog)
if previousURL != nil, previousURL != skill.shortcutICloudURL {
dropShortcutConfirmation(for: skill.id)
guard previousSkill != nil, previousURL != skill.shortcutICloudURL else {
return
}
let keepsKeyboardSlot = skill.shortcutICloudURL == nil
commitLayout(
AIAgentSkillLayout(
enabledIDs: keepsKeyboardSlot
? previousLayout.enabledIDs
: previousLayout.enabledIDs.filter { $0 != skill.id },
confirmedShortcutIDs: previousLayout.confirmedShortcutIDs.filter {
$0 != skill.id
}
)
)
}
public func deleteUserSkill(id: String) {
@@ -152,16 +165,6 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
)
}
private func dropShortcutConfirmation(for id: String) {
let current = layout.sanitized(catalog: mergedCatalog)
commitLayout(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs.filter { $0 != id },
confirmedShortcutIDs: current.confirmedShortcutIDs.filter { $0 != id }
)
)
}
private func commitLayout(_ layout: AIAgentSkillLayout) {
persistLayout(layout)
self.layout = loadLayout()
+424 -145
View File
@@ -2,180 +2,359 @@
// OSGKeyboard · Shared
//
// Offline English word list + bigrams for the typing extension.
// Loaded once, kept compact for the keyboard RSS budget.
// The 40k-word table is a mmap'd binary (`english_lexicon.bin`); dirty heap
// stays near zero until a lookup materializes a handful of result strings.
// TSV files in the repo are the build input, not the runtime format.
import Foundation
public struct EnglishScoredCorrection: Equatable, Sendable {
public var word: String
public var spatialCost: Int
public var frequency: Int
public var isTransposition: Bool
public var isShortening: Bool
public init(
word: String,
spatialCost: Int,
frequency: Int,
isTransposition: Bool,
isShortening: Bool
) {
self.word = word
self.spatialCost = spatialCost
self.frequency = frequency
self.isTransposition = isTransposition
self.isShortening = isShortening
}
}
/// 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 mapped: Data?
private var header: FileHeader?
private var loaded = false
private let lock = NSLock()
public init() {}
/// True after a successful mmap. Tests use this to prove Chinese typing
/// does not pull the English table into the extension.
public var isLoaded: Bool {
lock.lock()
defer { lock.unlock() }
return loaded
}
public func prepare() {
lock.lock()
defer { lock.unlock() }
guard !loaded else { return }
loadLexicon()
loadBigrams()
loaded = true
loadMappedLexicon()
}
/// Release in-memory tables when leaving the typing surface (jetsam recovery).
/// Release the mapped file when leaving English / the typing surface.
public func unload() {
lock.lock()
defer { lock.unlock() }
frequencies.removeAll(keepingCapacity: false)
sortedWords.removeAll(keepingCapacity: false)
bigrams.removeAll(keepingCapacity: false)
mapped = nil
header = nil
loaded = false
}
public var wordCount: Int {
prepareIfNeeded()
return sortedWords.count
lock.lock()
defer { lock.unlock() }
return header?.unigramCount ?? 0
}
public func frequency(of word: String) -> Int {
prepareIfNeeded()
return frequencies[word.lowercased()] ?? 0
withMap { buf, header in
guard let index = lookupIndex(asciiLowered(word), header: header, buf: buf) else {
return 0
}
return frequency(at: index, header: header, buf: buf)
} ?? 0
}
public func contains(_ word: String) -> Bool {
prepareIfNeeded()
return frequencies[word.lowercased()] != nil
withMap { buf, header in
lookupIndex(asciiLowered(word), header: header, buf: buf) != nil
} ?? false
}
/// Highest-frequency unigrams, for next-word fallback when no bigram hits.
public func topWords(limit: Int = 6) -> [String] {
guard limit > 0 else { return [] }
return withMap { buf, header in
let count = min(limit, header.unigramCount)
var words: [String] = []
words.reserveCapacity(count)
for rank in 0..<count {
let index = Int(
readU16(buf, header.freqRankOffset + rank * 2)
)
guard index < header.unigramCount else { continue }
if let word = string(at: index, header: header, buf: buf) {
words.append(word)
}
}
return words
} ?? []
}
/// Prefix completions, highest frequency first.
public func completions(prefix: String, limit: Int = 8) -> [String] {
prepareIfNeeded()
let needle = prefix.lowercased()
let needle = asciiLowered(prefix)
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))
return withMap { buf, header in
var scored: [(Int, Int)] = []
var index = lowerBound(needle, header: header, buf: buf)
while index < header.unigramCount {
guard let bytes = wordBytes(at: index, header: header, buf: buf) else { break }
guard hasPrefix(bytes, needle) else { break }
if !bytesEqual(bytes, needle) {
scored.append((index, frequency(at: index, header: header, buf: buf)))
}
index += 1
// Soft cap scan to keep keystroke path cheap.
if scored.count >= limit * 8 { break }
}
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))
scored.sort { lhs, rhs in
if lhs.1 != rhs.1 { return lhs.1 > rhs.1 }
return lhs.0 < rhs.0
}
return scored.prefix(limit).compactMap { pair in
string(at: pair.0, header: header, buf: buf)
}
} ?? []
}
/// Best edit-distance 2 correction, or nil when the typed word is fine.
/// Uses DamerauLevenshtein so adjacent swaps (teh the) count as 1.
/// Scans only same-initial-letter candidates (not the full frequency table).
public func bestCorrection(for typed: String) -> String? {
prepareIfNeeded()
let needle = typed.lowercased()
guard needle.count >= 2, let first = needle.first else { return nil }
if frequencies[needle] != nil { return nil }
/// Nearby words scored by QWERTY proximity + frequency. Does not decide
/// whether autocorrect should fire the suggestion engine does.
public func scoredCorrections(for typed: String, limit: Int = 6) -> [EnglishScoredCorrection] {
let needle = asciiLowered(typed)
guard needle.count >= 3, let firstByte = needle.first, limit > 0 else { return [] }
let first = Character(UnicodeScalar(firstByte))
var initials = Set(EnglishQWERTYProximity.neighbors(of: first, includingSelf: true))
initials.insert(first)
var best: (word: String, distance: Int, freq: Int)?
var index = lowerBound(String(first))
while index < sortedWords.count {
let word = sortedWords[index]
guard word.first == first else { break }
defer { index += 1 }
guard abs(word.count - needle.count) <= 2 else { continue }
let freq = frequencies[word] ?? 0
let distance = damerauLevenshtein(needle, word, max: 2)
guard distance > 0, distance <= 2 else { continue }
if let current = best {
if distance < current.distance
|| (distance == current.distance && freq > current.freq) {
best = (word, distance, freq)
return withMap { buf, header in
var best: [ScoredIndex] = []
best.reserveCapacity(limit)
for initial in initials {
guard let letter = initial.asciiLetterIndex else { continue }
let rangeOffset = header.initialOffset + letter * 4
let start = Int(readU16(buf, rangeOffset))
let count = Int(readU16(buf, rangeOffset + 2))
guard start >= 0, count >= 0, start + count <= header.unigramCount else { continue }
for index in start..<(start + count) {
guard let bytes = wordBytes(at: index, header: header, buf: buf) else { continue }
let delta = abs(bytes.count - needle.count)
guard delta <= 2, !bytesEqual(bytes, needle) else { continue }
guard let alignment = EnglishQWERTYProximity.align(
typedASCII: needle,
candidateASCII: bytes
) else { continue }
guard alignment.cost > 0 else { continue }
insertBest(
ScoredIndex(
index: index,
spatialCost: alignment.cost,
frequency: frequency(at: index, header: header, buf: buf),
isTransposition: alignment.isTransposition,
isShortening: alignment.isShortening
),
into: &best,
limit: limit
)
}
} 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
return best.compactMap { scored in
guard let word = string(at: scored.index, header: header, buf: buf) else {
return nil
}
return EnglishScoredCorrection(
word: word,
spatialCost: scored.spatialCost,
frequency: scored.frequency,
isTransposition: scored.isTransposition,
isShortening: scored.isShortening
)
}
} ?? []
}
/// Best proximity correction, or nil when the typed word is already known.
public func bestCorrection(for typed: String) -> String? {
if contains(typed) { return nil }
return scoredCorrections(for: typed, limit: 1).first?.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))
guard limit > 0 else { return [] }
let needle = asciiLowered(previous)
return withMap { buf, header in
guard let prevIndex = lookupIndex(needle, header: header, buf: buf) else {
return []
}
guard let group = lookupBigramGroup(prevIndex: prevIndex, header: header, buf: buf) else {
return []
}
let count = min(limit, group.nextCount)
var words: [String] = []
words.reserveCapacity(count)
for offset in 0..<count {
let index = Int(readU16(buf, header.bigramNextOffset + (group.firstNext + offset) * 2))
if let word = string(at: index, header: header, buf: buf) {
words.append(word)
}
}
return words
} ?? []
}
// MARK: - Private
// MARK: - Mapped file
private func prepareIfNeeded() {
if !loaded { prepare() }
private struct FileHeader {
var unigramCount: Int
var bigramGroupCount: Int
var stringPoolOffset: Int
var stringPoolSize: Int
var unigramOffset: Int
var freqRankOffset: Int
var initialOffset: Int
var bigramIndexOffset: Int
var bigramNextOffset: Int
var fileSize: Int
static let magic = "OSGENG01"
static let version = 1
static let headerSize = 64
static let initialCount = 26
static func parse(_ data: Data) -> FileHeader? {
guard data.count >= headerSize else { return nil }
return data.withUnsafeBytes { buf -> FileHeader? in
let magicBytes = UnsafeRawBufferPointer(rebasing: buf[0..<8])
let magic = String(bytes: magicBytes, encoding: .ascii)
guard magic == Self.magic else { return nil }
guard Int(readU32(buf, 8)) == version else { return nil }
let unigramCount = Int(readU32(buf, 12))
let bigramGroupCount = Int(readU32(buf, 16))
let stringPoolOffset = Int(readU32(buf, 20))
let stringPoolSize = Int(readU32(buf, 24))
let unigramOffset = Int(readU32(buf, 28))
let freqRankOffset = Int(readU32(buf, 32))
let initialOffset = Int(readU32(buf, 36))
let bigramIndexOffset = Int(readU32(buf, 40))
let bigramNextOffset = Int(readU32(buf, 44))
let fileSize = data.count
guard unigramCount >= 0, unigramCount <= 200_000 else { return nil }
guard bigramGroupCount >= 0, bigramGroupCount <= 100_000 else { return nil }
guard region(unigramOffset, unigramCount * 8, in: fileSize),
region(freqRankOffset, unigramCount * 2, in: fileSize),
region(initialOffset, initialCount * 4, in: fileSize),
region(bigramIndexOffset, bigramGroupCount * 8, in: fileSize),
region(stringPoolOffset, stringPoolSize, in: fileSize)
else {
return nil
}
return FileHeader(
unigramCount: unigramCount,
bigramGroupCount: bigramGroupCount,
stringPoolOffset: stringPoolOffset,
stringPoolSize: stringPoolSize,
unigramOffset: unigramOffset,
freqRankOffset: freqRankOffset,
initialOffset: initialOffset,
bigramIndexOffset: bigramIndexOffset,
bigramNextOffset: bigramNextOffset,
fileSize: fileSize
)
}
}
private static func region(_ offset: Int, _ size: Int, in fileSize: Int) -> Bool {
offset >= 0 && size >= 0 && offset <= fileSize && size <= fileSize - offset
}
}
private func loadLexicon() {
private struct ScoredIndex {
var index: Int
var spatialCost: Int
var frequency: Int
var isTransposition: Bool
var isShortening: Bool
}
private struct BigramGroup {
var nextCount: Int
var firstNext: Int
}
private func loadMappedLexicon() {
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")
.url(forResource: "english_lexicon", withExtension: "bin")
?? Bundle.main.url(forResource: "english_lexicon", withExtension: "bin")
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")
// `.mappedIfSafe` keeps the 40k table on file-backed pages. Jetsam
// charges dirty heap, not these clean mapped pages.
guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]),
let parsed = FileHeader.parse(data)
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
mapped = data
header = parsed
loaded = true
}
private func lowerBound(_ prefix: String) -> Int {
private func withMap<T>(_ body: (UnsafeRawBufferPointer, FileHeader) -> T) -> T? {
lock.lock()
defer { lock.unlock() }
guard loaded, let data = mapped, let header else { return nil }
return data.withUnsafeBytes { buf in
body(buf, header)
}
}
private func lookupIndex(
_ needle: [UInt8],
header: FileHeader,
buf: UnsafeRawBufferPointer
) -> Int? {
let index = lowerBound(needle, header: header, buf: buf)
guard index < header.unigramCount,
let bytes = wordBytes(at: index, header: header, buf: buf),
bytesEqual(bytes, needle)
else {
return nil
}
return index
}
private func lowerBound(
_ needle: [UInt8],
header: FileHeader,
buf: UnsafeRawBufferPointer
) -> Int {
var low = 0
var high = sortedWords.count
var high = header.unigramCount
while low < high {
let mid = (low + high) / 2
if sortedWords[mid] < prefix {
guard let bytes = wordBytes(at: mid, header: header, buf: buf) else {
high = mid
continue
}
if compare(bytes, needle) < 0 {
low = mid + 1
} else {
high = mid
@@ -184,40 +363,140 @@ public final class EnglishLexicon: @unchecked Sendable {
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)
private func lookupBigramGroup(
prevIndex: Int,
header: FileHeader,
buf: UnsafeRawBufferPointer
) -> BigramGroup? {
var low = 0
var high = header.bigramGroupCount
while low < high {
let mid = (low + high) / 2
let midPrev = Int(readU16(buf, header.bigramIndexOffset + mid * 8))
if midPrev < prevIndex {
low = mid + 1
} else {
high = mid
}
if rowMin > max { return max + 1 }
prevPrev = prev
prev = current
}
return prev[bCount]
guard low < header.bigramGroupCount else { return nil }
let offset = header.bigramIndexOffset + low * 8
guard Int(readU16(buf, offset)) == prevIndex else { return nil }
return BigramGroup(
nextCount: Int(readU16(buf, offset + 2)),
firstNext: Int(readU32(buf, offset + 4))
)
}
private func frequency(at index: Int, header: FileHeader, buf: UnsafeRawBufferPointer) -> Int {
Int(readU16(buf, header.unigramOffset + index * 8 + 6))
}
private func wordBytes(
at index: Int,
header: FileHeader,
buf: UnsafeRawBufferPointer
) -> UnsafeBufferPointer<UInt8>? {
guard index >= 0, index < header.unigramCount else { return nil }
let record = header.unigramOffset + index * 8
let poolOff = Int(readU32(buf, record))
let length = Int(buf[record + 4])
let start = header.stringPoolOffset + poolOff
guard length >= 0,
start >= header.stringPoolOffset,
start + length <= header.stringPoolOffset + header.stringPoolSize,
start + length <= header.fileSize,
let base = buf.baseAddress
else {
return nil
}
return UnsafeBufferPointer(
start: base.advanced(by: start).assumingMemoryBound(to: UInt8.self),
count: length
)
}
private func string(
at index: Int,
header: FileHeader,
buf: UnsafeRawBufferPointer
) -> String? {
guard let bytes = wordBytes(at: index, header: header, buf: buf) else { return nil }
return String(bytes: bytes, encoding: .ascii)
}
private func insertBest(_ scored: ScoredIndex, into best: inout [ScoredIndex], limit: Int) {
if let existing = best.firstIndex(where: { $0.index == scored.index }) {
if isOrderedBefore(scored, best[existing]) {
best[existing] = scored
best.sort(by: isOrderedBefore)
}
return
}
if best.count < limit {
best.append(scored)
best.sort(by: isOrderedBefore)
return
}
if let last = best.last, isOrderedBefore(scored, last) {
best[best.count - 1] = scored
best.sort(by: isOrderedBefore)
}
}
private func isOrderedBefore(_ lhs: ScoredIndex, _ rhs: ScoredIndex) -> Bool {
if lhs.spatialCost != rhs.spatialCost { return lhs.spatialCost < rhs.spatialCost }
if lhs.frequency != rhs.frequency { return lhs.frequency > rhs.frequency }
return lhs.index < rhs.index
}
}
private func readU16(_ buf: UnsafeRawBufferPointer, _ offset: Int) -> UInt16 {
UInt16(littleEndian: buf.loadUnaligned(fromByteOffset: offset, as: UInt16.self))
}
private func readU32(_ buf: UnsafeRawBufferPointer, _ offset: Int) -> UInt32 {
UInt32(littleEndian: buf.loadUnaligned(fromByteOffset: offset, as: UInt32.self))
}
private func asciiLowered(_ string: String) -> [UInt8] {
string.utf8.map { byte in
(byte >= 65 && byte <= 90) ? byte + 32 : byte
}
}
private func compare(_ word: UnsafeBufferPointer<UInt8>, _ needle: [UInt8]) -> Int {
let count = min(word.count, needle.count)
for index in 0..<count {
let left = word[index]
let right = needle[index]
if left < right { return -1 }
if left > right { return 1 }
}
if word.count < needle.count { return -1 }
if word.count > needle.count { return 1 }
return 0
}
private func hasPrefix(_ word: UnsafeBufferPointer<UInt8>, _ prefix: [UInt8]) -> Bool {
guard word.count >= prefix.count else { return false }
for index in prefix.indices where word[index] != prefix[index] {
return false
}
return true
}
private func bytesEqual(_ word: UnsafeBufferPointer<UInt8>, _ needle: [UInt8]) -> Bool {
guard word.count == needle.count else { return false }
for index in needle.indices where word[index] != needle[index] {
return false
}
return true
}
private extension Character {
var asciiLetterIndex: Int? {
guard let value = utf8.first, value >= 97, value <= 122 else { return nil }
return Int(value - 97)
}
}
@@ -0,0 +1,210 @@
// EnglishQWERTYProximity.swift
// OSGKeyboard · Shared
//
// Spatial cost for English autocorrect. Adjacent (including diagonal) keys
// are cheap; far substitutions are expensive. Inspired by AOSP LatinIME's
// proximity weighting formula only, no Android code.
import Foundation
public struct EnglishAlignment: Equatable, Sendable {
/// Weighted edit cost. `0` means identical.
public var cost: Int
public var isTransposition: Bool
public var isShortening: Bool
}
public enum EnglishQWERTYProximity: Sendable {
/// Two adjacent substitutions, or one farther miss, still eligible.
public static let maxAutocorrectCost = 34
public static let adjacentCost = 10
public static let nearCost = 22
public static let farCost = 34
public static let insDelCost = 18
public static let transpositionCost = 10
/// US QWERTY, staggered rows matching the on-screen letter grid.
private static let coordinates: [Character: (x: Double, y: Double)] = {
let rows: [[Character]] = [
Array("qwertyuiop"),
Array("asdfghjkl"),
Array("zxcvbnm")
]
let offsets: [Double] = [0, 0.5, 1.5]
var map: [Character: (x: Double, y: Double)] = [:]
for (rowIndex, row) in rows.enumerated() {
let origin = offsets[rowIndex]
for (column, letter) in row.enumerated() {
map[letter] = (origin + Double(column), Double(rowIndex))
}
}
return map
}()
public static func neighbors(of letter: Character, includingSelf: Bool) -> [Character] {
let needle = Character(letter.lowercased())
guard let origin = coordinates[needle] else {
return includingSelf ? [needle] : []
}
var hits: [Character] = []
for (candidate, point) in coordinates {
let distance = chebyshev(origin, point)
if distance == 0 {
if includingSelf { hits.append(candidate) }
} else if distance <= 1.01 {
hits.append(candidate)
}
}
return hits
}
public static func keyDistance(_ a: Character, _ b: Character) -> Int {
let left = Character(a.lowercased())
let right = Character(b.lowercased())
if left == right { return 0 }
guard let origin = coordinates[left], let other = coordinates[right] else {
return farCost
}
let distance = chebyshev(origin, other)
if distance <= 1.01 { return adjacentCost }
if distance <= 2.01 { return nearCost }
return farCost
}
public static func align(typed: String, candidate: String) -> EnglishAlignment? {
let source = asciiLowered(typed)
let targetBytes = asciiLowered(candidate)
return targetBytes.withUnsafeBufferPointer { pointer in
align(typedASCII: source, candidateASCII: pointer)
}
}
/// Same cost model as `align(typed:candidate:)`, but the candidate stays in
/// a mapped file no Swift `String` per scanned word.
public static func align(
typedASCII: [UInt8],
candidateASCII: UnsafeBufferPointer<UInt8>
) -> EnglishAlignment? {
let source = typedASCII
let target = candidateASCII
let delta = abs(source.count - target.count)
guard delta <= 2 else { return nil }
if delta == 0, bytesEqual(source, target) {
return EnglishAlignment(cost: 0, isTransposition: false, isShortening: false)
}
if source.count == target.count, isAdjacentTransposition(source, target) {
return EnglishAlignment(
cost: transpositionCost,
isTransposition: true,
isShortening: false
)
}
if source.count == target.count {
var cost = 0
for index in source.indices {
cost += keyDistance(source[index], target[index])
if cost > maxAutocorrectCost { return nil }
}
return EnglishAlignment(
cost: cost,
isTransposition: false,
isShortening: false
)
}
let cost = bandedEditCost(source, target)
guard cost <= maxAutocorrectCost else { return nil }
return EnglishAlignment(
cost: cost,
isTransposition: false,
isShortening: target.count < source.count
)
}
private static func keyDistance(_ a: UInt8, _ b: UInt8) -> Int {
if a == b { return 0 }
guard a >= 97, a <= 122, b >= 97, b <= 122 else { return farCost }
return keyDistance(Character(UnicodeScalar(a)), Character(UnicodeScalar(b)))
}
private static func isAdjacentTransposition(
_ source: [UInt8],
_ target: UnsafeBufferPointer<UInt8>
) -> Bool {
guard source.count == target.count, source.count >= 2 else { return false }
var mismatch = -1
for index in source.indices where source[index] != target[index] {
if mismatch == -1 {
mismatch = index
} else if index == mismatch + 1,
source[mismatch] == target[index],
source[index] == target[mismatch] {
for rest in (index + 1)..<source.count where source[rest] != target[rest] {
return false
}
return true
} else {
return false
}
}
return false
}
/// Banded Levenshtein with proximity substitutions and a Damerau swap.
private static func bandedEditCost(
_ source: [UInt8],
_ target: UnsafeBufferPointer<UInt8>
) -> Int {
let aCount = source.count
let bCount = target.count
var previous = Array(0...bCount).map { $0 * insDelCost }
var older = previous
for i in 1...aCount {
var current = [Int](repeating: 0, count: bCount + 1)
current[0] = i * insDelCost
var rowMin = current[0]
for j in 1...bCount {
let substitution = previous[j - 1] + keyDistance(source[i - 1], target[j - 1])
var value = min(
previous[j] + insDelCost,
current[j - 1] + insDelCost,
substitution
)
if i > 1, j > 1,
source[i - 1] == target[j - 2],
source[i - 2] == target[j - 1] {
value = min(value, older[j - 2] + transpositionCost)
}
current[j] = value
rowMin = min(rowMin, value)
}
if rowMin > maxAutocorrectCost { return maxAutocorrectCost + 1 }
older = previous
previous = current
}
return previous[bCount]
}
private static func asciiLowered(_ string: String) -> [UInt8] {
string.utf8.map { byte in
(byte >= 65 && byte <= 90) ? byte + 32 : byte
}
}
private static func bytesEqual(_ source: [UInt8], _ target: UnsafeBufferPointer<UInt8>) -> Bool {
guard source.count == target.count else { return false }
for index in source.indices where source[index] != target[index] {
return false
}
return true
}
private static func chebyshev(
_ a: (x: Double, y: Double),
_ b: (x: Double, y: Double)
) -> Double {
max(abs(a.x - b.x), abs(a.y - b.y))
}
}
@@ -1,8 +1,8 @@
// EnglishSuggestionEngine.swift
// OSGKeyboard · Shared
//
// Builds TypingComposition for English: completions while composing,
// high-confidence corrections on commit, next-word predictions after.
// Builds a 3-slot English QuickType board: verbatim / correction / completion
// (or next-word after commit). Space applies only the correction slot.
import Foundation
@@ -12,19 +12,29 @@ public struct EnglishSuggestionContext: Sendable {
public var personalTerms: [String]
public var learnedBoosts: [String: Int]
public var includeOriginalAfterCorrection: String?
/// Contacts / text replacements from `UILexicon`.
public var systemWords: [String]
public var systemCompletions: [String]
public var systemGuesses: [String]
public init(
currentWord: String = "",
previousWord: String = "",
personalTerms: [String] = [],
learnedBoosts: [String: Int] = [:],
includeOriginalAfterCorrection: String? = nil
includeOriginalAfterCorrection: String? = nil,
systemWords: [String] = [],
systemCompletions: [String] = [],
systemGuesses: [String] = []
) {
self.currentWord = currentWord
self.previousWord = previousWord
self.personalTerms = personalTerms
self.learnedBoosts = learnedBoosts
self.includeOriginalAfterCorrection = includeOriginalAfterCorrection
self.systemWords = systemWords
self.systemCompletions = systemCompletions
self.systemGuesses = systemGuesses
}
}
@@ -47,6 +57,10 @@ public struct EnglishCorrectionDecision: Equatable, Sendable {
/// Pure ranking / candidate builder no UITextDocumentProxy access.
public struct EnglishSuggestionEngine: Sendable {
public static let slotCount = 3
/// In-vocabulary words only yield to a much more common transposition / neighbor.
public static let inVocabularyFrequencyGap = 250
private let lexicon: EnglishLexicon
public init(lexicon: EnglishLexicon = .shared) {
@@ -57,112 +71,238 @@ public struct EnglishSuggestionEngine: Sendable {
lexicon.prepare()
}
/// Suggestions while the user is mid-word.
/// Suggestions only while the user is actively typing an English 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))
guard !prefix.isEmpty else { return .empty }
return makeBoard(context).composition
}
/// Decide whether to autocorrect on space / punctuation.
public func correctionDecision(
for typed: String,
personalTerms: [String],
learnedBoosts: [String: Int]
learnedBoosts: [String: Int],
previousWord: String = "",
systemWords: [String] = [],
systemGuesses: [String] = []
) -> 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)
let context = EnglishSuggestionContext(
currentWord: typed,
previousWord: previousWord,
personalTerms: personalTerms,
learnedBoosts: learnedBoosts,
systemWords: systemWords,
systemGuesses: systemGuesses
)
return makeBoard(context).decision
}
public func nextWordComposition(_ context: EnglishSuggestionContext) -> TypingComposition {
var ranked: [(text: String, score: Int, id: String)] = []
var ranked: [(text: String, score: Int, role: TypingCandidateRole, quoted: Bool)] = []
var seen = Set<String>()
func append(_ raw: String, baseScore: Int, tag: String) {
func append(_ raw: String, baseScore: Int, role: TypingCandidateRole, quoted: Bool = false) {
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)"))
let personalBoost = isPersonal(key, in: context) ? 2_000 : 0
ranked.append((raw, baseScore + boost + personalBoost, role, quoted))
}
if let original = context.includeOriginalAfterCorrection {
append(original, baseScore: 20_000, tag: "original")
append(original, baseScore: 20_000, role: .verbatim, quoted: true)
}
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")
append(word, baseScore: 1_200 - index * 10, role: .nextWord)
}
}
for term in context.personalTerms.prefix(4) {
append(term, baseScore: 500, tag: "personal")
append(term, baseScore: 500, role: .nextWord)
}
if ranked.filter({ $0.role == .nextWord }).isEmpty {
for (index, word) in lexicon.topWords(limit: 6).enumerated() {
append(word, baseScore: 200 - index, role: .nextWord)
}
}
ranked.sort { $0.score > $1.score }
let candidates = ranked.prefix(8).map {
TypingCandidate(id: $0.id, text: $0.text, engineIndex: 0)
let candidates = ranked.prefix(Self.slotCount).map {
TypingCandidate(
id: "\($0.role.rawValue)|\($0.text.lowercased())",
text: $0.text,
role: $0.role,
isQuoted: $0.quoted
)
}
return TypingComposition(preedit: "", candidates: Array(candidates))
}
// MARK: - Helpers
public func isKnownWord(_ word: String, personalTerms: [String], systemWords: [String]) -> Bool {
let lower = word.lowercased()
if lexicon.contains(lower) { return true }
if personalTerms.contains(where: { $0.lowercased() == lower }) { return true }
if systemWords.contains(where: { $0.lowercased() == lower }) { return true }
return false
}
private func shouldSkipAutocorrect(_ typed: String) -> Bool {
if typed.count <= 1 { return true }
// MARK: - Board
private struct Board {
var composition: TypingComposition
var decision: EnglishCorrectionDecision?
}
private func makeBoard(_ context: EnglishSuggestionContext) -> Board {
let typed = context.currentWord
let decision = makeCorrectionDecision(context)
var slots: [TypingCandidate] = []
var seen = Set<String>()
func add(_ text: String, role: TypingCandidateRole, quoted: Bool = false) {
let key = text.lowercased()
guard seen.insert(key).inserted else { return }
slots.append(
TypingCandidate(
id: "\(role.rawValue)|\(key)",
text: text,
role: role,
isQuoted: quoted
)
)
}
let known = isKnownWord(
typed,
personalTerms: context.personalTerms,
systemWords: context.systemWords
)
add(typed, role: .verbatim, quoted: !known)
if let decision {
add(decision.replacement, role: .correction)
}
for term in context.personalTerms where term.lowercased().hasPrefix(typed.lowercased())
&& term.lowercased() != typed.lowercased() {
add(term, role: .completion)
if slots.count >= Self.slotCount { break }
}
for word in context.systemCompletions {
let display = matchCase(of: typed, to: word)
add(display, role: .completion)
if slots.count >= Self.slotCount { break }
}
for word in lexicon.completions(prefix: typed, limit: 8) {
add(matchCase(of: typed, to: word), role: .completion)
if slots.count >= Self.slotCount { break }
}
let composition = TypingComposition(
preedit: typed,
candidates: Array(slots.prefix(Self.slotCount))
)
return Board(composition: composition, decision: decision)
}
private func makeCorrectionDecision(_ context: EnglishSuggestionContext) -> EnglishCorrectionDecision? {
let typed = context.currentWord
guard typed.count >= 3 else { return nil }
let lower = typed.lowercased()
if isProtectedToken(typed) { return nil }
if isPersonal(lower, in: context) { return nil }
if context.systemWords.contains(where: { $0.lowercased() == lower }) { return nil }
if (context.learnedBoosts[lower] ?? 0) >= 5 { return nil }
let inLexicon = lexicon.contains(lower)
let typedFreq = lexicon.frequency(of: lower) + (context.learnedBoosts[lower] ?? 0)
var pool = lexicon.scoredCorrections(for: lower, limit: 8)
for guess in context.systemGuesses {
let word = guess.lowercased()
guard word != lower else { continue }
if pool.contains(where: { $0.word == word }) { continue }
guard let alignment = EnglishQWERTYProximity.align(typed: lower, candidate: word) else { continue }
pool.append(
EnglishScoredCorrection(
word: word,
spatialCost: alignment.cost,
frequency: max(lexicon.frequency(of: word), 1),
isTransposition: alignment.isTransposition,
isShortening: alignment.isShortening
)
)
}
var best: (EnglishScoredCorrection, Int)?
for candidate in pool {
guard allowsAutocorrect(
typed: typed,
replacement: candidate.word,
inLexicon: inLexicon,
typedFreq: typedFreq,
candidate: candidate
) else { continue }
var score = candidate.frequency * 2 - candidate.spatialCost
if isPersonal(candidate.word, in: context) { score += 5_000 }
score += context.learnedBoosts[candidate.word] ?? 0
if lexicon.nextWords(after: context.previousWord).contains(candidate.word) {
score += 80
}
if let current = best {
if score > current.1 { best = (candidate, score) }
} else {
best = (candidate, score)
}
}
guard let best else { return nil }
let keepScore = inLexicon ? typedFreq * 2 : 0
guard best.1 > keepScore + 40 else { return nil }
return EnglishCorrectionDecision(
original: typed,
replacement: matchCase(of: typed, to: best.0.word)
)
}
private func allowsAutocorrect(
typed: String,
replacement: String,
inLexicon: Bool,
typedFreq: Int,
candidate: EnglishScoredCorrection
) -> Bool {
if isTitleCase(typed) {
// Teh The is a same-length transposition. Rocky Rock is not.
guard candidate.isTransposition, !candidate.isShortening else { return false }
}
if inLexicon {
let gap = candidate.frequency - typedFreq
// Web-corpus dumps leak typos (`teh`, `adn`) at the floor of the
// list. Real words like `form` sit much higher and must not yield
// to `from`.
let looksLikeLeakedTypo = typedFreq <= 680
if candidate.isTransposition {
return looksLikeLeakedTypo && gap >= 40
}
if typed.count == replacement.count,
candidate.spatialCost <= EnglishQWERTYProximity.adjacentCost {
return looksLikeLeakedTypo && gap >= Self.inVocabularyFrequencyGap
}
return false
}
return candidate.frequency > 0
}
private func isProtectedToken(_ typed: String) -> Bool {
if typed.count <= 2 { return true }
if typed.allSatisfy(\.isUppercase) { return true }
if typed.contains(where: \.isNumber) { return true }
if typed.contains("@") || typed.contains(".") || typed.contains("/") { return true }
@@ -170,6 +310,16 @@ public struct EnglishSuggestionEngine: Sendable {
return false
}
private func isTitleCase(_ typed: String) -> Bool {
guard let first = typed.first, first.isUppercase else { return false }
let rest = typed.dropFirst()
return !rest.isEmpty && rest.allSatisfy(\.isLowercase)
}
private func isPersonal(_ key: String, in context: EnglishSuggestionContext) -> Bool {
context.personalTerms.contains { $0.lowercased() == key }
}
private func matchCase(of sample: String, to word: String) -> String {
if sample.allSatisfy(\.isUppercase) {
return word.uppercased()
@@ -0,0 +1,67 @@
// EnglishSystemLexicon.swift
// OSGKeyboard · Shared
//
// Apple's sanctioned English sources for a custom keyboard: UITextChecker
// completions / guesses, plus UILexicon names from
// `requestSupplementaryLexicon`. The engine stays pure; the keyboard
// extension fills these fields on each refresh.
import Foundation
#if canImport(UIKit)
import UIKit
#endif
@MainActor
public protocol EnglishSystemLexiconProviding: AnyObject {
func completions(prefix: String, limit: Int) -> [String]
func guesses(for word: String, limit: Int) -> [String]
}
@MainActor
public final class EmptyEnglishSystemLexicon: EnglishSystemLexiconProviding {
public init() {}
public func completions(prefix: String, limit: Int) -> [String] {
[]
}
public func guesses(for word: String, limit: Int) -> [String] {
[]
}
}
#if canImport(UIKit)
/// System spellchecker. Always called from `TypingSessionController` (@MainActor).
@MainActor
public final class UIKitEnglishSystemLexicon: EnglishSystemLexiconProviding {
public var language: String
public init(language: String = "en_US") {
self.language = language
}
public func completions(prefix: String, limit: Int) -> [String] {
guard !prefix.isEmpty, limit > 0 else { return [] }
let checker = UITextChecker()
let range = NSRange(location: 0, length: (prefix as NSString).length)
let hits = checker.completions(forPartialWordRange: range, in: prefix, language: language) ?? []
return Array(hits.prefix(limit))
}
public func guesses(for word: String, limit: Int) -> [String] {
guard word.count >= 3, limit > 0 else { return [] }
let checker = UITextChecker()
let range = NSRange(location: 0, length: (word as NSString).length)
let hits = checker.guesses(forWordRange: range, in: word, language: language) ?? []
return Array(hits.prefix(limit))
}
public static func learnWord(_ word: String) {
let trimmed = word.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
if !UITextChecker.hasLearnedWord(trimmed) {
UITextChecker.learnWord(trimmed)
}
}
}
#endif
@@ -21,6 +21,18 @@ public enum TypingInputLanguage: String, CaseIterable, Identifiable, Sendable {
}
}
/// Role of an English QuickType slot. Chinese candidates stay `.completion`.
public enum TypingCandidateRole: String, Equatable, Sendable {
/// The word currently being typed. Space does not replace it.
case verbatim
/// The unique slot Space will apply when autocorrect is armed.
case correction
/// Prefix completion; tap to accept, Space ignores it.
case completion
/// Next-word prediction after a committed word; tap to insert.
case nextWord
}
/// One candidate row item after composing.
public struct TypingCandidate: Identifiable, Equatable, Sendable {
public let id: String
@@ -28,17 +40,24 @@ public struct TypingCandidate: Identifiable, Equatable, Sendable {
public let annotation: String?
/// Absolute engine index for Chinese selection (may differ from display order).
public let engineIndex: Int
public let role: TypingCandidateRole
/// Unknown verbatim shown in quotes, matching the system / KeyboardKit contract.
public let isQuoted: Bool
public init(
id: String = UUID().uuidString,
text: String,
annotation: String? = nil,
engineIndex: Int = 0
engineIndex: Int = 0,
role: TypingCandidateRole = .completion,
isQuoted: Bool = false
) {
self.id = id
self.text = text
self.annotation = annotation
self.engineIndex = engineIndex
self.role = role
self.isQuoted = isQuoted
}
}
@@ -27,6 +27,10 @@ public final class TypingSessionController: ObservableObject {
/// When true, English suggestions / autocorrect stay off (secure fields).
@Published public var suggestionsEnabled: Bool = true
/// `UITextChecker` completions / guesses. Empty in unit tests.
public var systemLexicon: EnglishSystemLexiconProviding = EmptyEnglishSystemLexicon()
/// Names and text replacements from `requestSupplementaryLexicon`.
public var supplementaryWords: [String] = []
/// Chevron appears only for Chinese composition with at least two candidates.
public var canExpandCandidatePanel: Bool {
@@ -128,9 +132,15 @@ public final class TypingSessionController: ObservableObject {
)
TypingInputConfiguration.shared.reload()
refreshPersonalTerms()
// English lexicon is small; load when entering typing (not at KVC init).
englishEngine.prepare()
OSGDiag.log("typing.enter after englishPrepare \(OSGDiag.memoryTag())", category: "boot")
// mmap the English table only while English is active. Chinese typing
// already has Rime; loading both on appear is what jetsams the extension.
if language == .english {
englishEngine.prepare()
OSGDiag.log("typing.enter after englishPrepare \(OSGDiag.memoryTag())", category: "boot")
} else {
EnglishLexicon.shared.unload()
OSGDiag.log("typing.enter skip englishPrepare lang=\(language.rawValue) \(OSGDiag.memoryTag())", category: "boot")
}
syncAutocapitalization()
if FlowSessionBridge.isHostHeavy() {
OSGDiag.log("typing.enter defer rime hostHeavy=1 — retry scheduled", category: "boot")
@@ -216,6 +226,7 @@ public final class TypingSessionController: ObservableObject {
synchronizeEnglishDocumentContext(caretMoved: true)
} else {
clearEnglishWordState(keepPrevious: false)
EnglishLexicon.shared.unload()
composition = engine.composition
}
return output
@@ -427,6 +438,9 @@ public final class TypingSessionController: ObservableObject {
pendingAutocorrection = nil
englishCurrentWord = pending.original
learningStore.recordDefense(of: pending.original)
#if canImport(UIKit)
UIKitEnglishSystemLexicon.learnWord(pending.original)
#endif
refreshEnglishSuggestions()
return .replace(deleteCount: deleteCount, with: pending.original)
}
@@ -461,15 +475,18 @@ public final class TypingSessionController: ObservableObject {
var decision = englishEngine.correctionDecision(
for: word,
personalTerms: personalTermsCache,
learnedBoosts: learningStore.snapshot()
learnedBoosts: learningStore.snapshot(),
previousWord: englishPreviousWord,
systemWords: supplementaryWords,
systemGuesses: systemLexicon.guesses(for: word, limit: 6)
) {
decision.appliedSuffix = suffix
pendingAutocorrection = decision
englishPreviousWord = decision.replacement
englishCurrentWord = ""
learningStore.recordAcceptance(of: decision.replacement)
// Suggestions stay hidden until the user starts the next word.
composition = .empty
// Machine-applied correction does not count as the user accepting
// the replacement otherwise names train the wrong word.
refreshEnglishSuggestions(afterCommittedWord: decision.replacement)
return .replace(
deleteCount: word.count,
with: decision.replacement + suffix
@@ -479,7 +496,17 @@ public final class TypingSessionController: ObservableObject {
englishPreviousWord = word
englishCurrentWord = ""
pendingAutocorrection = nil
learningStore.recordAcceptance(of: word, amount: 1)
// Learn OOV / names the user actually committed; skip common words.
if !englishEngine.isKnownWord(
word,
personalTerms: personalTermsCache,
systemWords: supplementaryWords
) {
learningStore.recordDefense(of: word, amount: 2)
#if canImport(UIKit)
UIKitEnglishSystemLexicon.learnWord(word)
#endif
}
refreshEnglishSuggestions(afterCommittedWord: word)
return suffix.isEmpty ? .none : .insert(suffix)
}
@@ -487,7 +514,8 @@ public final class TypingSessionController: ObservableObject {
private func selectEnglishCandidate(at index: Int) -> TypingOutput {
guard composition.candidates.indices.contains(index) else { return .none }
guard englishCandidateAnchorMatchesDocument() else { return .none }
let chosen = composition.candidates[index].text
let candidate = composition.candidates[index]
let chosen = candidate.text
// Restoring original after autocorrect (no current word).
if englishCurrentWord.isEmpty,
@@ -498,16 +526,27 @@ public final class TypingSessionController: ObservableObject {
englishPreviousWord = pending.original
englishCurrentWord = ""
learningStore.recordDefense(of: pending.original)
#if canImport(UIKit)
UIKitEnglishSystemLexicon.learnWord(pending.original)
#endif
refreshEnglishSuggestions(afterCommittedWord: pending.original)
return .replace(deleteCount: deleteCount, with: pending.original + " ")
}
if candidate.role == .verbatim {
learningStore.recordDefense(of: chosen)
#if canImport(UIKit)
UIKitEnglishSystemLexicon.learnWord(chosen)
#endif
} else {
learningStore.recordAcceptance(of: chosen)
}
if !englishCurrentWord.isEmpty {
let deleteCount = englishCurrentWord.count
englishPreviousWord = chosen
englishCurrentWord = ""
pendingAutocorrection = nil
learningStore.recordAcceptance(of: chosen)
refreshEnglishSuggestions(afterCommittedWord: chosen)
return .replace(deleteCount: deleteCount, with: chosen + " ")
}
@@ -516,7 +555,6 @@ public final class TypingSessionController: ObservableObject {
englishPreviousWord = chosen
englishCurrentWord = ""
pendingAutocorrection = nil
learningStore.recordAcceptance(of: chosen)
refreshEnglishSuggestions(afterCommittedWord: chosen)
return .insert(chosen + " ")
}
@@ -537,19 +575,23 @@ public final class TypingSessionController: ObservableObject {
composition = .empty
return
}
// Idle / between words: no candidate bar. Completions start after
// the first letter of the current word.
// With no active English word, keep the candidate bar empty. This also
// prevents next-word predictions from appearing between committed words.
guard !englishCurrentWord.isEmpty else {
composition = .empty
return
}
let previous = word ?? englishPreviousWord
let typed = englishCurrentWord
let context = EnglishSuggestionContext(
currentWord: englishCurrentWord,
currentWord: typed,
previousWord: previous,
personalTerms: personalTermsCache,
learnedBoosts: learningStore.snapshot(),
includeOriginalAfterCorrection: nil
includeOriginalAfterCorrection: pendingAutocorrection?.original,
systemWords: supplementaryWords,
systemCompletions: typed.isEmpty ? [] : systemLexicon.completions(prefix: typed, limit: 6),
systemGuesses: typed.count >= 3 ? systemLexicon.guesses(for: typed, limit: 6) : []
)
composition = englishEngine.compositionWhileTyping(context)
}