feat(keyboard): improve typing, voice flow, and polish reliability
Reduce extension memory pressure and delivery races while adding richer candidates, tactile feedback, and safer two-level creative polishing.
This commit is contained in:
@@ -30,6 +30,16 @@ public final class EnglishLexicon: @unchecked Sendable {
|
||||
loaded = true
|
||||
}
|
||||
|
||||
/// Release in-memory tables when leaving the typing surface (jetsam recovery).
|
||||
public func unload() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
frequencies.removeAll(keepingCapacity: false)
|
||||
sortedWords.removeAll(keepingCapacity: false)
|
||||
bigrams.removeAll(keepingCapacity: false)
|
||||
loaded = false
|
||||
}
|
||||
|
||||
public var wordCount: Int {
|
||||
prepareIfNeeded()
|
||||
return sortedWords.count
|
||||
@@ -72,21 +82,24 @@ public final class EnglishLexicon: @unchecked Sendable {
|
||||
|
||||
/// Best edit-distance ≤ 2 correction, or nil when the typed word is fine.
|
||||
/// Uses Damerau–Levenshtein 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 else { return nil }
|
||||
guard needle.count >= 2, let first = needle.first 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 {
|
||||
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 }
|
||||
if word.first != first, abs(word.count - needle.count) > 1 { continue }
|
||||
let freq = frequencies[word] ?? 0
|
||||
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)
|
||||
|
||||
@@ -101,10 +101,7 @@ public actor RimeResourceInstaller {
|
||||
|
||||
for resource in ["osg_pinyin.dict", "manifest"] {
|
||||
let ext = resource == "manifest" ? "json" : "yaml"
|
||||
guard let source = Bundle(for: RimeResourceBundleToken.self).url(
|
||||
forResource: resource,
|
||||
withExtension: ext
|
||||
) else {
|
||||
guard let source = Self.bundledURL(forResource: resource, withExtension: ext) else {
|
||||
throw RimeResourceError.bundledResourceMissing("\(resource).\(ext)")
|
||||
}
|
||||
try fileManager.copyItem(
|
||||
@@ -140,7 +137,9 @@ public actor RimeResourceInstaller {
|
||||
distributionVersion: Self.resourceVersion
|
||||
)
|
||||
do {
|
||||
try bridge.deploy(withFullCheck: true)
|
||||
// Version bumps already rebuild SharedSupport from scratch; skip the
|
||||
// heavier full integrity pass unless the caller forced a redeploy.
|
||||
try bridge.deploy(withFullCheck: force)
|
||||
} catch {
|
||||
bridge.finalizeRuntime()
|
||||
throw error
|
||||
@@ -166,4 +165,18 @@ public actor RimeResourceInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
extension RimeResourceInstaller {
|
||||
/// Host app owns the dictionary YAML; prefer `Bundle.main`, fall back to
|
||||
/// the Shared token bundle for unit tests that inject fixtures.
|
||||
fileprivate static func bundledURL(forResource name: String, withExtension ext: String) -> URL? {
|
||||
if let url = Bundle.main.url(forResource: name, withExtension: ext) {
|
||||
return url
|
||||
}
|
||||
return Bundle(for: RimeResourceBundleToken.self).url(
|
||||
forResource: name,
|
||||
withExtension: ext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RimeResourceBundleToken: NSObject {}
|
||||
|
||||
@@ -17,36 +17,84 @@ public enum TypingKeyPage: String, CaseIterable, Sendable {
|
||||
/// The Phase 1 SwiftUI keyboard reads this; a future Kit-backed shell
|
||||
/// can feed the same consumer.
|
||||
public protocol TypingLayoutProviding: Sendable {
|
||||
func rows(for page: TypingKeyPage, shiftActive: Bool) -> [[String]]
|
||||
func rows(
|
||||
for page: TypingKeyPage,
|
||||
language: TypingInputLanguage,
|
||||
shiftActive: Bool
|
||||
) -> [[String]]
|
||||
}
|
||||
|
||||
/// Standard phone QWERTY + 123 + light symbols (NanoMouse / system-like).
|
||||
/// Phone QWERTY + 123 / #+= pages aligned with iOS system keyboards
|
||||
/// (English US and Simplified Chinese Pinyin).
|
||||
public struct StandardTypingLayout: TypingLayoutProviding {
|
||||
public init() {}
|
||||
|
||||
public func rows(for page: TypingKeyPage, shiftActive: Bool) -> [[String]] {
|
||||
public func rows(
|
||||
for page: TypingKeyPage,
|
||||
language: TypingInputLanguage,
|
||||
shiftActive: Bool
|
||||
) -> [[String]] {
|
||||
switch page {
|
||||
case .letters:
|
||||
let upper = shiftActive
|
||||
let map: (String) -> String = { upper ? $0.uppercased() : $0 }
|
||||
return [
|
||||
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"].map(map),
|
||||
["a", "s", "d", "f", "g", "h", "j", "k", "l"].map(map),
|
||||
["⇧", "z", "x", "c", "v", "b", "n", "m", "⌫"].map { $0.count == 1 ? map($0) : $0 }
|
||||
]
|
||||
return letterRows(shiftActive: shiftActive)
|
||||
case .numbers:
|
||||
return [
|
||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
|
||||
["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""],
|
||||
["#+=", ".", ",", "?", "!", "'", "⌫"]
|
||||
]
|
||||
return language == .chinese ? chineseNumberRows : englishNumberRows
|
||||
case .symbols:
|
||||
return [
|
||||
["[", "]", "{", "}", "#", "%", "^", "*", "+", "="],
|
||||
["_", "\\", "|", "~", "<", ">", "€", "£", "¥", "·"],
|
||||
["123", ".", ",", "?", "!", "'", "⌫"],
|
||||
[",", "。", "、", "?", "!", ":", ";"]
|
||||
]
|
||||
return language == .chinese ? chineseSymbolRows : englishSymbolRows
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Letters (shared)
|
||||
|
||||
private func letterRows(shiftActive: Bool) -> [[String]] {
|
||||
let upper = shiftActive
|
||||
let map: (String) -> String = { upper ? $0.uppercased() : $0 }
|
||||
return [
|
||||
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"].map(map),
|
||||
["a", "s", "d", "f", "g", "h", "j", "k", "l"].map(map),
|
||||
["⇧", "z", "x", "c", "v", "b", "n", "m", "⌫"].map { $0.count == 1 ? map($0) : $0 }
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - English (iOS US)
|
||||
|
||||
/// iOS English US · Numbers
|
||||
private var englishNumberRows: [[String]] {
|
||||
[
|
||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
|
||||
["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""],
|
||||
["#+=", ".", ",", "?", "!", "'", "⌫"]
|
||||
]
|
||||
}
|
||||
|
||||
/// iOS English US · Symbols
|
||||
private var englishSymbolRows: [[String]] {
|
||||
[
|
||||
["[", "]", "{", "}", "#", "%", "^", "*", "+", "="],
|
||||
["_", "\\", "|", "~", "<", ">", "€", "£", "¥", "·"],
|
||||
["123", ".", ",", "?", "!", "'", "⌫"]
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Chinese (iOS 简体拼音)
|
||||
|
||||
/// iOS Simplified Chinese · Numbers (fullwidth punctuation where system uses it)
|
||||
private var chineseNumberRows: [[String]] {
|
||||
[
|
||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
|
||||
// System 123 page uses curly double quotes “ ” (not corner brackets).
|
||||
["-", "/", ":", ";", "(", ")", "¥", "@", "“", "”"],
|
||||
["#+=", "。", ",", "、", "?", "!", ".", "⌫"]
|
||||
]
|
||||
}
|
||||
|
||||
/// iOS Simplified Chinese · Symbols
|
||||
private var chineseSymbolRows: [[String]] {
|
||||
[
|
||||
// 「」 live on #+= so they remain reachable after numbers use “ ”.
|
||||
["【", "】", "「", "」", "#", "%", "^", "*", "+", "="],
|
||||
["_", "\\", "|", "~", "《", "》", "€", "£", "¥", "·"],
|
||||
["123", "。", ",", "、", "?", "!", ".", "⌫"]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ public final class TypingSessionController: ObservableObject {
|
||||
@Published public private(set) var page: TypingKeyPage = .letters
|
||||
@Published public private(set) var shiftActive: Bool = false
|
||||
@Published public private(set) var capsLock: Bool = false
|
||||
/// Finger is down on Shift (iOS: hold for continuous uppercase; release ends).
|
||||
@Published public private(set) var shiftHeld: Bool = false
|
||||
@Published public private(set) var composition: TypingComposition = .empty
|
||||
@Published public private(set) var engineReady: Bool = false
|
||||
@Published public private(set) var schema: TypingInputSchema
|
||||
@@ -34,32 +36,64 @@ public final class TypingSessionController: ObservableObject {
|
||||
public var autocapitalizationModeProvider: (() -> TypingAutocapitalizationMode)?
|
||||
|
||||
public let layout: TypingLayoutProviding
|
||||
private let engine: RimeEngineBridging
|
||||
private let englishEngine: EnglishSuggestionEngine
|
||||
private let engineFactory: @MainActor () -> RimeEngineBridging
|
||||
private let englishFactory: @MainActor () -> EnglishSuggestionEngine
|
||||
private let learningStore: EnglishLearningStore
|
||||
private var engineStorage: RimeEngineBridging?
|
||||
private var englishStorage: EnglishSuggestionEngine?
|
||||
private var prepared = false
|
||||
private var prepareTask: Task<Void, Never>?
|
||||
|
||||
private var engine: RimeEngineBridging {
|
||||
if let engineStorage { return engineStorage }
|
||||
let created = engineFactory()
|
||||
engineStorage = created
|
||||
schema = created.schema
|
||||
return created
|
||||
}
|
||||
|
||||
private var englishEngine: EnglishSuggestionEngine {
|
||||
if let englishStorage { return englishStorage }
|
||||
let created = englishFactory()
|
||||
englishStorage = created
|
||||
return created
|
||||
}
|
||||
|
||||
// English word-level state (characters are already in the document).
|
||||
private var englishCurrentWord: String = ""
|
||||
private var englishPreviousWord: String = ""
|
||||
private var pendingAutocorrection: EnglishCorrectionDecision?
|
||||
private var personalTermsCache: [String] = []
|
||||
/// User tapped Shift for a one-shot capital; autocap must not overwrite this.
|
||||
private var shiftPrimedByUser = false
|
||||
/// True if any key was typed while the current Shift hold was active.
|
||||
private var typedWhileShiftHeld = false
|
||||
|
||||
public init(
|
||||
engine: RimeEngineBridging = LibrimeEngine(),
|
||||
engine: (@MainActor () -> RimeEngineBridging)? = nil,
|
||||
layout: TypingLayoutProviding = StandardTypingLayout(),
|
||||
englishEngine: EnglishSuggestionEngine = EnglishSuggestionEngine(),
|
||||
englishEngine: (@MainActor () -> EnglishSuggestionEngine)? = nil,
|
||||
learningStore: EnglishLearningStore = EnglishLearningStore()
|
||||
) {
|
||||
self.engine = engine
|
||||
self.engineFactory = engine ?? { LibrimeEngine() }
|
||||
self.layout = layout
|
||||
self.englishEngine = englishEngine
|
||||
self.englishFactory = englishEngine ?? { EnglishSuggestionEngine() }
|
||||
self.learningStore = learningStore
|
||||
schema = engine.schema
|
||||
// Avoid constructing librime until the first Chinese keystroke / prepare.
|
||||
schema = TypingInputConfiguration.shared.schema
|
||||
}
|
||||
|
||||
/// Letter keys and Shift glyph follow any armed / held / Caps Lock state.
|
||||
public var isShiftEnabled: Bool {
|
||||
shiftActive || capsLock || shiftHeld
|
||||
}
|
||||
|
||||
public var keyRows: [[String]] {
|
||||
var rows = layout.rows(for: page, shiftActive: shiftActive || capsLock)
|
||||
var rows = layout.rows(
|
||||
for: page,
|
||||
language: language,
|
||||
shiftActive: isShiftEnabled
|
||||
)
|
||||
if page == .letters,
|
||||
language == .chinese,
|
||||
schema != .fullPinyin,
|
||||
@@ -73,23 +107,57 @@ public final class TypingSessionController: ObservableObject {
|
||||
}
|
||||
|
||||
public func enterTypingMode() {
|
||||
OSGDiag.log(
|
||||
"typing.enter begin lang=\(language.rawValue) schema=\(schema.rawValue) "
|
||||
+ "hostHeavy=\(FlowSessionBridge.isHostHeavy() ? 1 : 0) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
)
|
||||
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")
|
||||
syncAutocapitalization()
|
||||
Task { await prepareIfNeeded() }
|
||||
if FlowSessionBridge.isHostHeavy() {
|
||||
OSGDiag.log("typing.enter defer rime hostHeavy=1 — retry scheduled", category: "boot")
|
||||
prepareTask?.cancel()
|
||||
prepareTask = Task { [weak self] in
|
||||
for _ in 0..<40 {
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
if !FlowSessionBridge.isHostHeavy() {
|
||||
await self.prepareIfNeeded()
|
||||
self.prepareTask = nil
|
||||
return
|
||||
}
|
||||
}
|
||||
self?.prepareTask = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
if prepareTask == nil, !prepared {
|
||||
prepareTask = Task { [weak self] in
|
||||
await self?.prepareIfNeeded()
|
||||
self?.prepareTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func leaveTypingMode() {
|
||||
engine.teardown()
|
||||
OSGDiag.log("typing.leave \(OSGDiag.memoryTag())", category: "boot")
|
||||
prepareTask?.cancel()
|
||||
prepareTask = nil
|
||||
engineStorage?.teardown()
|
||||
prepared = false
|
||||
engineReady = false
|
||||
composition = .empty
|
||||
isCandidatePanelExpanded = false
|
||||
page = .letters
|
||||
shiftActive = false
|
||||
capsLock = false
|
||||
resetShiftState()
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
// Drop English lexicon pages when leaving typing (jetsam recovery).
|
||||
EnglishLexicon.shared.unload()
|
||||
englishStorage = nil
|
||||
}
|
||||
|
||||
public func toggleCandidatePanelExpanded() {
|
||||
@@ -154,24 +222,40 @@ public final class TypingSessionController: ObservableObject {
|
||||
|
||||
public func setPage(_ page: TypingKeyPage) {
|
||||
self.page = page
|
||||
shiftActive = false
|
||||
resetShiftState()
|
||||
if page == .letters {
|
||||
syncAutocapitalization()
|
||||
}
|
||||
}
|
||||
|
||||
/// Touch-down on Shift: hold for continuous uppercase (release ends hold).
|
||||
public func beginShiftHold() {
|
||||
guard page == .letters else { return }
|
||||
shiftHeld = true
|
||||
typedWhileShiftHeld = false
|
||||
}
|
||||
|
||||
/// Touch-up on Shift: end hold; if nothing was typed, treat as a tap.
|
||||
public func endShiftHold() {
|
||||
guard shiftHeld else { return }
|
||||
let typed = typedWhileShiftHeld
|
||||
shiftHeld = false
|
||||
typedWhileShiftHeld = false
|
||||
if typed {
|
||||
if !capsLock && !shiftPrimedByUser {
|
||||
syncAutocapitalization()
|
||||
}
|
||||
return
|
||||
}
|
||||
handleShiftTap()
|
||||
}
|
||||
|
||||
/// Handle a visible key label.
|
||||
public func handleKey(_ label: String) -> TypingOutput {
|
||||
switch label {
|
||||
case "⇧":
|
||||
if shiftActive {
|
||||
capsLock = true
|
||||
shiftActive = false
|
||||
} else if capsLock {
|
||||
capsLock = false
|
||||
} else {
|
||||
shiftActive = true
|
||||
}
|
||||
// Tests / non-gesture callers: same as a completed Shift tap.
|
||||
handleShiftTap()
|
||||
return .none
|
||||
case "⌫":
|
||||
return handleBackspace()
|
||||
@@ -190,7 +274,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
|
||||
if page != .letters {
|
||||
let out = commitEnglishWordIfNeededBeforeNonLetter()
|
||||
if !capsLock { shiftActive = false }
|
||||
clearOneShotShiftIfNeeded()
|
||||
if out.isEmpty {
|
||||
return .insert(label)
|
||||
}
|
||||
@@ -207,7 +291,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
let committed = engine.processCharacter(ch) ?? ""
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
if !capsLock { shiftActive = false }
|
||||
clearOneShotShiftIfNeeded()
|
||||
return committed.isEmpty ? .none : .insert(committed)
|
||||
}
|
||||
|
||||
@@ -252,14 +336,14 @@ public final class TypingSessionController: ObservableObject {
|
||||
pendingAutocorrection = nil
|
||||
if ch.isLetter {
|
||||
englishCurrentWord.append(ch)
|
||||
if !capsLock { shiftActive = false }
|
||||
clearOneShotShiftIfNeeded()
|
||||
refreshEnglishSuggestions()
|
||||
return .insert(String(ch))
|
||||
}
|
||||
|
||||
// Punctuation / digit: commit current word first, then insert.
|
||||
var output = commitEnglishWord(suffix: "")
|
||||
if !capsLock { shiftActive = false }
|
||||
clearOneShotShiftIfNeeded()
|
||||
if output.isEmpty {
|
||||
return .insert(String(ch))
|
||||
}
|
||||
@@ -302,7 +386,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
private func commitEnglishWord(suffix: String) -> TypingOutput {
|
||||
let word = englishCurrentWord
|
||||
defer {
|
||||
if !capsLock { shiftActive = false }
|
||||
clearOneShotShiftIfNeeded()
|
||||
}
|
||||
|
||||
guard suggestionsEnabled, !word.isEmpty else {
|
||||
@@ -405,9 +489,10 @@ public final class TypingSessionController: ObservableObject {
|
||||
}
|
||||
|
||||
/// Arms Shift for sentence / word starts using the host field traits.
|
||||
/// Manual one-shot, hold, and Caps Lock always win over autocapitalization.
|
||||
public func syncAutocapitalization() {
|
||||
guard language == .english, page == .letters else { return }
|
||||
guard !capsLock else { return }
|
||||
guard !capsLock, !shiftHeld, !shiftPrimedByUser else { return }
|
||||
let mode = autocapitalizationModeProvider?() ?? .sentences
|
||||
let preceding = precedingTextProvider?()
|
||||
shiftActive = TypingAutocapitalization.shouldCapitalize(
|
||||
@@ -416,6 +501,43 @@ public final class TypingSessionController: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Shift
|
||||
|
||||
/// Tap cycle: off → one-shot → Caps Lock → off (iOS-style second tap).
|
||||
private func handleShiftTap() {
|
||||
if capsLock {
|
||||
capsLock = false
|
||||
shiftActive = false
|
||||
shiftPrimedByUser = false
|
||||
} else if shiftActive {
|
||||
capsLock = true
|
||||
shiftActive = false
|
||||
shiftPrimedByUser = false
|
||||
} else {
|
||||
shiftActive = true
|
||||
shiftPrimedByUser = true
|
||||
}
|
||||
}
|
||||
|
||||
/// After inserting a character: consume one-shot Shift, keep hold / Caps.
|
||||
private func clearOneShotShiftIfNeeded() {
|
||||
if shiftHeld {
|
||||
typedWhileShiftHeld = true
|
||||
return
|
||||
}
|
||||
guard !capsLock else { return }
|
||||
shiftActive = false
|
||||
shiftPrimedByUser = false
|
||||
}
|
||||
|
||||
private func resetShiftState() {
|
||||
shiftActive = false
|
||||
capsLock = false
|
||||
shiftHeld = false
|
||||
shiftPrimedByUser = false
|
||||
typedWhileShiftHeld = false
|
||||
}
|
||||
|
||||
private func clearEnglishWordState(keepPrevious: Bool) {
|
||||
englishCurrentWord = ""
|
||||
if !keepPrevious { englishPreviousWord = "" }
|
||||
@@ -436,6 +558,14 @@ public final class TypingSessionController: ObservableObject {
|
||||
|
||||
private func prepareIfNeeded() async {
|
||||
guard !prepared else { return }
|
||||
if FlowSessionBridge.isHostHeavy() {
|
||||
OSGDiag.log("rime.prepare deferred hostHeavy=1 \(OSGDiag.memoryTag())", category: "boot")
|
||||
return
|
||||
}
|
||||
OSGDiag.log(
|
||||
"rime.prepare begin ready=\(RimeResourceInstaller.isReady) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
)
|
||||
do {
|
||||
try await engine.prepare()
|
||||
engine.setLanguage(language)
|
||||
@@ -446,9 +576,17 @@ public final class TypingSessionController: ObservableObject {
|
||||
if language == .english {
|
||||
refreshEnglishSuggestions()
|
||||
}
|
||||
OSGDiag.log(
|
||||
"rime.prepare done ready=\(engineReady) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
)
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
engineReady = false
|
||||
OSGDiag.log(
|
||||
"rime.prepare failed error=\(error.localizedDescription) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user