b13a1e904b
Erase dialect syllables before abbrev so wom prefers 我们, flush librime on leave, and add a settings action to forget learned Chinese and English order.
186 lines
5.8 KiB
Swift
186 lines
5.8 KiB
Swift
// LibrimeEngine.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Production Chinese IME backed by librime. Runtime access stays on the
|
|
// main actor because UIInputViewController and its text proxy are main-only;
|
|
// expensive schema deployment is performed by the host app beforehand.
|
|
|
|
import Foundation
|
|
|
|
@MainActor
|
|
public final class LibrimeEngine: RimeEngineBridging {
|
|
public private(set) var composition: TypingComposition = .empty
|
|
public private(set) var isReady = false
|
|
public private(set) var schema: TypingInputSchema
|
|
|
|
private var language: TypingInputLanguage = .chinese
|
|
private var bridge: OSGRimeBridge?
|
|
private let configurationProvider: () -> TypingInputConfigurationSnapshot
|
|
private let candidateLimit: Int
|
|
|
|
public init(
|
|
schema: TypingInputSchema = .fullPinyin,
|
|
candidateLimit: Int = 160,
|
|
configurationProvider: @escaping () -> TypingInputConfigurationSnapshot = {
|
|
TypingInputConfiguration.shared.snapshot
|
|
}
|
|
) {
|
|
self.schema = schema
|
|
self.candidateLimit = candidateLimit
|
|
self.configurationProvider = configurationProvider
|
|
}
|
|
|
|
public func prepare() async throws {
|
|
if isReady { return }
|
|
guard RimeResourceInstaller.isReady else {
|
|
throw RimeResourceError.resourcesNotInstalled
|
|
}
|
|
|
|
let paths = try RimeResourcePaths.resolve()
|
|
let runtime = OSGRimeBridge(
|
|
sharedDataDirectory: paths.sharedData.path,
|
|
userDataDirectory: paths.userData.path,
|
|
distributionVersion: RimeResourceInstaller.resourceVersion
|
|
)
|
|
try runtime.start()
|
|
|
|
let configured = configurationProvider()
|
|
schema = configured.schema
|
|
guard runtime.selectSchema(schema.rawValue) else {
|
|
runtime.stopSession()
|
|
throw LibrimeEngineError.schemaUnavailable(schema.rawValue)
|
|
}
|
|
_ = runtime.setASCIIMode(language == .english)
|
|
bridge = runtime
|
|
isReady = true
|
|
_ = refresh()
|
|
}
|
|
|
|
public func teardown() {
|
|
bridge?.clearComposition()
|
|
// Session destroy alone does not flush LevelDB user dictionaries.
|
|
bridge?.finalizeRuntime()
|
|
bridge = nil
|
|
composition = .empty
|
|
isReady = false
|
|
}
|
|
|
|
public func setLanguage(_ language: TypingInputLanguage) {
|
|
self.language = language
|
|
_ = bridge?.setASCIIMode(language == .english)
|
|
if language == .english {
|
|
bridge?.clearComposition()
|
|
composition = .empty
|
|
} else {
|
|
_ = refresh()
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
public func setSchema(_ schema: TypingInputSchema) -> Bool {
|
|
guard let bridge else {
|
|
self.schema = schema
|
|
return false
|
|
}
|
|
bridge.clearComposition()
|
|
guard bridge.selectSchema(schema.rawValue) else { return false }
|
|
self.schema = schema
|
|
composition = .empty
|
|
return true
|
|
}
|
|
|
|
public func processCharacter(_ character: Character) -> String? {
|
|
guard language == .chinese,
|
|
let scalar = character.asciiValue,
|
|
bridge?.processKeyCode(Int32(scalar), modifiers: 0) == true else {
|
|
return nil
|
|
}
|
|
return refresh()
|
|
}
|
|
|
|
public func processBackspace() -> String? {
|
|
guard bridge?.processKeyCode(OSGRimeKeyBackSpace, modifiers: 0) == true else {
|
|
return nil
|
|
}
|
|
return refresh()
|
|
}
|
|
|
|
public func processSpace() -> String? {
|
|
guard bridge?.processKeyCode(32, modifiers: 0) == true else {
|
|
return " "
|
|
}
|
|
return refresh()
|
|
}
|
|
|
|
public func processReturn() -> String? {
|
|
guard bridge?.processKeyCode(OSGRimeKeyReturn, modifiers: 0) == true else {
|
|
return "\n"
|
|
}
|
|
return refresh()
|
|
}
|
|
|
|
public func selectCandidate(at index: Int) -> String {
|
|
guard bridge?.selectCandidate(at: index) == true else { return "" }
|
|
return refresh() ?? ""
|
|
}
|
|
|
|
public func flushPreedit() -> String {
|
|
let raw = bridge?.rawInput() ?? ""
|
|
bridge?.clearComposition()
|
|
composition = .empty
|
|
return raw
|
|
}
|
|
|
|
public func clearComposition() {
|
|
bridge?.clearComposition()
|
|
composition = .empty
|
|
}
|
|
|
|
/// Copies librime-owned memory into Sendable Swift value types and returns
|
|
/// any commit emitted by the preceding key operation.
|
|
@discardableResult
|
|
private func refresh() -> String? {
|
|
guard let snapshot = bridge?.snapshot(withCandidateLimit: candidateLimit) else {
|
|
composition = .empty
|
|
return nil
|
|
}
|
|
let preedit = snapshot.preedit
|
|
let raw = bridge?.rawInput() ?? ""
|
|
composition = TypingComposition(
|
|
preedit: preedit,
|
|
rawInput: raw,
|
|
candidates: snapshot.candidates.enumerated().map { displayIndex, candidate in
|
|
TypingCandidate(
|
|
id: "\(preedit)|\(displayIndex)|\(candidate.index)|\(candidate.text)",
|
|
text: candidate.text,
|
|
annotation: candidate.comment.isEmpty ? nil : candidate.comment,
|
|
engineIndex: Int(candidate.index)
|
|
)
|
|
}
|
|
)
|
|
return snapshot.commitText.isEmpty ? nil : snapshot.commitText
|
|
}
|
|
}
|
|
|
|
public enum LibrimeEngineError: LocalizedError {
|
|
case schemaUnavailable(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .schemaUnavailable(let id):
|
|
return "输入方案不可用:\(id)"
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension Character {
|
|
var asciiValue: UInt8? {
|
|
guard let scalar = unicodeScalars.first,
|
|
unicodeScalars.count == 1,
|
|
scalar.value <= UInt8.max else {
|
|
return nil
|
|
}
|
|
return UInt8(scalar.value)
|
|
}
|
|
}
|