chore(typing): snapshot local pinyin WIP before syncing cloud branch
Preserve the in-progress local typing/pinyin implementation so feat/pinyin can safely reset to origin/feat/pinyin (cloud English + Chinese typing).
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
// 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 = 50,
|
||||
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()
|
||||
bridge?.stopSession()
|
||||
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
|
||||
composition = TypingComposition(
|
||||
preedit: preedit,
|
||||
candidates: snapshot.candidates.enumerated().map { index, candidate in
|
||||
TypingCandidate(
|
||||
id: "\(preedit)|\(index)|\(candidate.text)",
|
||||
text: candidate.text,
|
||||
annotation: candidate.comment.isEmpty ? nil : candidate.comment
|
||||
)
|
||||
}
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// RimeEngineBridging.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Engine façade for the typing surface. `LibrimeEngine` is the production
|
||||
// implementation; the protocol keeps SwiftUI independent of the C runtime.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Input language for the typing surface (not ASR locale).
|
||||
public enum TypingInputLanguage: String, CaseIterable, Identifiable, Sendable {
|
||||
case chinese
|
||||
case english
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var shortLabel: String {
|
||||
switch self {
|
||||
case .chinese: return "中"
|
||||
case .english: return "英"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One candidate row item after composing.
|
||||
public struct TypingCandidate: Identifiable, Equatable, Sendable {
|
||||
public let id: String
|
||||
public let text: String
|
||||
public let annotation: String?
|
||||
|
||||
public init(id: String = UUID().uuidString, text: String, annotation: String? = nil) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
self.annotation = annotation
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the UI observes while composing.
|
||||
public struct TypingComposition: Equatable, Sendable {
|
||||
public var preedit: String
|
||||
public var candidates: [TypingCandidate]
|
||||
|
||||
public init(preedit: String = "", candidates: [TypingCandidate] = []) {
|
||||
self.preedit = preedit
|
||||
self.candidates = candidates
|
||||
}
|
||||
|
||||
public static let empty = TypingComposition()
|
||||
}
|
||||
|
||||
/// Bridge between key events and IME state. Keep this small so Phase 2
|
||||
/// can swap KeyboardKit-style shells or librime without UI rewrites.
|
||||
@MainActor
|
||||
public protocol RimeEngineBridging: AnyObject {
|
||||
var composition: TypingComposition { get }
|
||||
var isReady: Bool { get }
|
||||
var schema: TypingInputSchema { get }
|
||||
|
||||
/// Load dictionaries / open session. Safe to call repeatedly.
|
||||
func prepare() async throws
|
||||
/// Drop heavy caches (leave typing mode / memory warning).
|
||||
func teardown()
|
||||
|
||||
func setLanguage(_ language: TypingInputLanguage)
|
||||
@discardableResult
|
||||
func setSchema(_ schema: TypingInputSchema) -> Bool
|
||||
/// Process one key and return newly committed text, if any.
|
||||
func processCharacter(_ character: Character) -> String?
|
||||
func processBackspace() -> String?
|
||||
func processSpace() -> String?
|
||||
func processReturn() -> String?
|
||||
/// Commit candidate at index; returns text to insert (empty if invalid).
|
||||
func selectCandidate(at index: Int) -> String
|
||||
/// Force-commit current preedit as raw latin (or empty).
|
||||
func flushPreedit() -> String
|
||||
func clearComposition()
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// RimeResourceInstaller.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// The host app owns Rime deployment. The keyboard extension only opens
|
||||
// an already-built session, keeping expensive maintenance work out of
|
||||
// the extension's constrained lifecycle.
|
||||
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
public enum RimeResourceError: LocalizedError {
|
||||
case appGroupUnavailable
|
||||
case bundledResourceMissing(String)
|
||||
case lockUnavailable
|
||||
case deploymentFailed
|
||||
case resourcesNotInstalled
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .appGroupUnavailable:
|
||||
return "App Group 不可用"
|
||||
case .bundledResourceMissing(let name):
|
||||
return "缺少输入法资源:\(name)"
|
||||
case .lockUnavailable:
|
||||
return "输入法资源正在被其他进程更新"
|
||||
case .deploymentFailed:
|
||||
return "输入法资源部署失败"
|
||||
case .resourcesNotInstalled:
|
||||
return "请先打开 OSGKeyboard 完成输入法初始化"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct RimeResourcePaths: Sendable {
|
||||
public let root: URL
|
||||
public let sharedData: URL
|
||||
public let userData: URL
|
||||
public let lockFile: URL
|
||||
|
||||
public static func resolve() throws -> RimeResourcePaths {
|
||||
guard let container = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: AppGroup.identifier
|
||||
) else {
|
||||
throw RimeResourceError.appGroupUnavailable
|
||||
}
|
||||
let root = container.appendingPathComponent("Rime", isDirectory: true)
|
||||
return RimeResourcePaths(
|
||||
root: root,
|
||||
sharedData: root.appendingPathComponent("SharedSupport", isDirectory: true),
|
||||
userData: root.appendingPathComponent("UserData", isDirectory: true),
|
||||
lockFile: root.appendingPathComponent(".deployment.lock")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public actor RimeResourceInstaller {
|
||||
public static let shared = RimeResourceInstaller()
|
||||
public static let resourceVersion = "2.0.0"
|
||||
|
||||
public init() {}
|
||||
|
||||
public static var isReady: Bool {
|
||||
guard TypingInputConfiguration.installedResourceVersion() == resourceVersion,
|
||||
let paths = try? RimeResourcePaths.resolve() else {
|
||||
return false
|
||||
}
|
||||
return FileManager.default.fileExists(
|
||||
atPath: paths.userData.appendingPathComponent("build").path
|
||||
)
|
||||
}
|
||||
|
||||
/// Installs source data and asks librime to prebuild schemas. Call only
|
||||
/// from the host app, never from the keyboard extension.
|
||||
public func installIfNeeded(
|
||||
configuration: TypingInputConfigurationSnapshot,
|
||||
force: Bool = false
|
||||
) throws {
|
||||
if !force, Self.isReady { return }
|
||||
|
||||
let paths = try RimeResourcePaths.resolve()
|
||||
let fileManager = FileManager.default
|
||||
try fileManager.createDirectory(at: paths.root, withIntermediateDirectories: true)
|
||||
try fileManager.createDirectory(at: paths.userData, withIntermediateDirectories: true)
|
||||
|
||||
let descriptor = open(paths.lockFile.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR)
|
||||
guard descriptor >= 0 else { throw RimeResourceError.lockUnavailable }
|
||||
defer {
|
||||
flock(descriptor, LOCK_UN)
|
||||
close(descriptor)
|
||||
}
|
||||
guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else {
|
||||
throw RimeResourceError.lockUnavailable
|
||||
}
|
||||
|
||||
let staging = paths.root.appendingPathComponent(
|
||||
"SharedSupport.staging-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? fileManager.removeItem(at: staging) }
|
||||
try fileManager.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||
|
||||
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 {
|
||||
throw RimeResourceError.bundledResourceMissing("\(resource).\(ext)")
|
||||
}
|
||||
try fileManager.copyItem(
|
||||
at: source,
|
||||
to: staging.appendingPathComponent("\(resource).\(ext)")
|
||||
)
|
||||
}
|
||||
|
||||
try RimeSchemaGenerator.defaultConfiguration().write(
|
||||
to: staging.appendingPathComponent("default.yaml"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
for schema in TypingInputSchema.allCases {
|
||||
try RimeSchemaGenerator.schema(
|
||||
for: schema,
|
||||
fuzzyPairs: configuration.fuzzyPairs
|
||||
).write(
|
||||
to: staging.appendingPathComponent("\(schema.rawValue).schema.yaml"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
}
|
||||
|
||||
if fileManager.fileExists(atPath: paths.sharedData.path) {
|
||||
try fileManager.removeItem(at: paths.sharedData)
|
||||
}
|
||||
try fileManager.moveItem(at: staging, to: paths.sharedData)
|
||||
|
||||
let bridge = OSGRimeBridge(
|
||||
sharedDataDirectory: paths.sharedData.path,
|
||||
userDataDirectory: paths.userData.path,
|
||||
distributionVersion: Self.resourceVersion
|
||||
)
|
||||
do {
|
||||
try bridge.deploy(withFullCheck: true)
|
||||
} catch {
|
||||
bridge.finalizeRuntime()
|
||||
throw error
|
||||
}
|
||||
bridge.finalizeRuntime()
|
||||
|
||||
TypingInputConfiguration.setInstalledResourceVersion(Self.resourceVersion)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func syncUserData() throws {
|
||||
let paths = try RimeResourcePaths.resolve()
|
||||
let bridge = OSGRimeBridge(
|
||||
sharedDataDirectory: paths.sharedData.path,
|
||||
userDataDirectory: paths.userData.path,
|
||||
distributionVersion: Self.resourceVersion
|
||||
)
|
||||
try bridge.start()
|
||||
// Destroying the session and finalizing librime flushes LevelDB
|
||||
// user dictionaries. `sync_user_data` is for external Rime sync
|
||||
// deployments and is intentionally not needed here.
|
||||
bridge.finalizeRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
private final class RimeResourceBundleToken: NSObject {}
|
||||
@@ -0,0 +1,187 @@
|
||||
// RimeSchemaGenerator.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Generates OSG-owned schemas from public Microsoft/Sogou key maps.
|
||||
// No GPL Rime schema files are copied or distributed.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum RimeSchemaGenerator {
|
||||
public static func defaultConfiguration() -> String {
|
||||
"""
|
||||
# Generated by OSGKeyboard.
|
||||
config_version: "1.0"
|
||||
schema_list:
|
||||
- schema: \(TypingInputSchema.fullPinyin.rawValue)
|
||||
- schema: \(TypingInputSchema.microsoftDoublePinyin.rawValue)
|
||||
- schema: \(TypingInputSchema.sogouDoublePinyin.rawValue)
|
||||
switcher:
|
||||
caption: 输入方案
|
||||
hotkeys: []
|
||||
menu:
|
||||
page_size: 9
|
||||
ascii_composer:
|
||||
good_old_caps_lock: true
|
||||
switch_key:
|
||||
Shift_L: noop
|
||||
Shift_R: noop
|
||||
Control_L: noop
|
||||
Control_R: noop
|
||||
key_binder:
|
||||
bindings: []
|
||||
recognizer:
|
||||
patterns: {}
|
||||
"""
|
||||
}
|
||||
|
||||
public static func schema(
|
||||
for inputSchema: TypingInputSchema,
|
||||
fuzzyPairs: Set<PinyinFuzzyPair>
|
||||
) -> String {
|
||||
let alphabet = inputSchema == .fullPinyin
|
||||
? "zyxwvutsrqponmlkjihgfedcba"
|
||||
: "zyxwvutsrqponmlkjihgfedcba;"
|
||||
let algebra = fuzzyRules(fuzzyPairs) + algebraRules(for: inputSchema)
|
||||
let algebraYAML = algebra.map { " - '\($0)'" }.joined(separator: "\n")
|
||||
|
||||
return """
|
||||
# Generated by OSGKeyboard. Do not edit; change settings in the host app.
|
||||
schema:
|
||||
schema_id: \(inputSchema.rawValue)
|
||||
name: \(inputSchema.displayName)
|
||||
version: "1.0"
|
||||
author:
|
||||
- OSGKeyboard contributors
|
||||
description: |
|
||||
Commercially permissive OSG schema backed by osg_pinyin.
|
||||
|
||||
switches:
|
||||
- name: ascii_mode
|
||||
reset: 0
|
||||
states: [中, 英]
|
||||
|
||||
engine:
|
||||
processors:
|
||||
- ascii_composer
|
||||
- recognizer
|
||||
- key_binder
|
||||
- speller
|
||||
- punctuator
|
||||
- selector
|
||||
- navigator
|
||||
- express_editor
|
||||
segmentors:
|
||||
- ascii_segmentor
|
||||
- matcher
|
||||
- abc_segmentor
|
||||
- punct_segmentor
|
||||
- fallback_segmentor
|
||||
translators:
|
||||
- punct_translator
|
||||
- script_translator
|
||||
|
||||
speller:
|
||||
alphabet: "\(alphabet)"
|
||||
initials: "\(alphabet.replacingOccurrences(of: ";", with: ""))"
|
||||
delimiter: " '"
|
||||
algebra:
|
||||
\(algebraYAML)
|
||||
|
||||
translator:
|
||||
dictionary: osg_pinyin
|
||||
prism: \(inputSchema.rawValue)
|
||||
enable_sentence: true
|
||||
enable_completion: true
|
||||
enable_user_dict: true
|
||||
initial_quality: 1.2
|
||||
|
||||
punctuator:
|
||||
half_shape:
|
||||
",": ","
|
||||
".": "。"
|
||||
"?": "?"
|
||||
"!": "!"
|
||||
|
||||
key_binder:
|
||||
import_preset: default
|
||||
|
||||
recognizer:
|
||||
import_preset: default
|
||||
"""
|
||||
}
|
||||
|
||||
/// Rules run against full-pinyin dictionary codes before double-pinyin
|
||||
/// transforms, so fuzzy pairs work consistently in all three schemas.
|
||||
public static func fuzzyRules(_ enabled: Set<PinyinFuzzyPair>) -> [String] {
|
||||
var rules: [String] = []
|
||||
for pair in PinyinFuzzyPair.allCases where enabled.contains(pair) {
|
||||
switch pair {
|
||||
case .zhZ:
|
||||
rules += ["derive/^zh/z/", "derive/^z([^h])/zh$1/"]
|
||||
case .chC:
|
||||
rules += ["derive/^ch/c/", "derive/^c([^h])/ch$1/"]
|
||||
case .shS:
|
||||
rules += ["derive/^sh/s/", "derive/^s([^h])/sh$1/"]
|
||||
case .nL:
|
||||
rules += ["derive/^n/l/", "derive/^l/n/"]
|
||||
case .fH:
|
||||
rules += ["derive/^f/h/", "derive/^h/f/"]
|
||||
case .anAng:
|
||||
rules += ["derive/ang$/an/", "derive/an$/ang/"]
|
||||
case .enEng:
|
||||
rules += ["derive/eng$/en/", "derive/en$/eng/"]
|
||||
case .inIng:
|
||||
rules += ["derive/ing$/in/", "derive/in$/ing/"]
|
||||
}
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
private static func algebraRules(for schema: TypingInputSchema) -> [String] {
|
||||
switch schema {
|
||||
case .fullPinyin:
|
||||
return [
|
||||
"derive/^([jqxy])u$/$1v/",
|
||||
"abbrev/^([a-z]).+$/$1/"
|
||||
]
|
||||
|
||||
case .microsoftDoublePinyin, .sogouDoublePinyin:
|
||||
// Microsoft and Sogou's commonly shipped layouts are key-compatible.
|
||||
// Uppercase markers prevent transforms from matching each other.
|
||||
return [
|
||||
"erase/^xx$/",
|
||||
"derive/^([jqxy])u$/$1v/",
|
||||
"derive/^([aoe].*)$/o$1/",
|
||||
"xform/^([ae])(.*)$/$1$1$2/",
|
||||
"xform/iu$/Q/",
|
||||
"xform/[iu]a$/W/",
|
||||
"xform/er$|[uv]an$/R/",
|
||||
"xform/[uv]e$/T/",
|
||||
"xform/v$|uai$/Y/",
|
||||
"xform/^sh/U/",
|
||||
"xform/^ch/I/",
|
||||
"xform/^zh/V/",
|
||||
"xform/uo$/O/",
|
||||
"xform/[uv]n$/P/",
|
||||
"xform/(.)i?ong$/$1S/",
|
||||
"xform/[iu]ang$/D/",
|
||||
"xform/(.)en$/$1F/",
|
||||
"xform/(.)eng$/$1G/",
|
||||
"xform/(.)ang$/$1H/",
|
||||
"xform/ian$/M/",
|
||||
"xform/(.)an$/$1J/",
|
||||
"xform/iao$/C/",
|
||||
"xform/(.)ao$/$1K/",
|
||||
"xform/(.)ai$/$1L/",
|
||||
"xform/(.)ei$/$1Z/",
|
||||
"xform/ie$/X/",
|
||||
"xform/ui$/V/",
|
||||
"derive/T$/V/",
|
||||
"xform/(.)ou$/$1B/",
|
||||
"xform/in$/N/",
|
||||
"xform/ing$/;/",
|
||||
"xlit/QWRTYUIOPSDFGHMJCKLZXVBN/qwrtyuiopsdfghmjcklzxvbn/"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// TypingLayoutProviding.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Phase 2 escape hatch: replace the in-repo SwiftUI key shell with a
|
||||
// KeyboardKit-based (or other) layout without changing the engine bridge.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Page shown by the typing key shell.
|
||||
public enum TypingKeyPage: String, CaseIterable, Sendable {
|
||||
case letters
|
||||
case numbers
|
||||
case symbols
|
||||
}
|
||||
|
||||
/// Abstraction over “which characters the current page shows”.
|
||||
/// 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]]
|
||||
}
|
||||
|
||||
/// Standard phone QWERTY + 123 + light symbols (NanoMouse / system-like).
|
||||
public struct StandardTypingLayout: TypingLayoutProviding {
|
||||
public init() {}
|
||||
|
||||
public func rows(for page: TypingKeyPage, 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 }
|
||||
]
|
||||
case .numbers:
|
||||
return [
|
||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
|
||||
["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""],
|
||||
["#+=", ".", ",", "?", "!", "'", "⌫"]
|
||||
]
|
||||
case .symbols:
|
||||
return [
|
||||
["[", "]", "{", "}", "#", "%", "^", "*", "+", "="],
|
||||
["_", "\\", "|", "~", "<", ">", "€", "£", "¥", "·"],
|
||||
["123", ".", ",", "?", "!", "'", "⌫"],
|
||||
[",", "。", "、", "?", "!", ":", ";"]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// TypingSessionController.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Owns the typing-surface engine + layout provider. Injected into the
|
||||
// keyboard extension; torn down when leaving typing mode.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
public final class TypingSessionController: ObservableObject {
|
||||
@Published public private(set) var language: TypingInputLanguage = .chinese
|
||||
@Published public private(set) var page: TypingKeyPage = .letters
|
||||
@Published public private(set) var shiftActive: Bool = false
|
||||
@Published public private(set) var capsLock: 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
|
||||
@Published public var lastError: String?
|
||||
|
||||
public let layout: TypingLayoutProviding
|
||||
private let engine: RimeEngineBridging
|
||||
private var prepared = false
|
||||
|
||||
public init(
|
||||
engine: RimeEngineBridging = LibrimeEngine(),
|
||||
layout: TypingLayoutProviding = StandardTypingLayout()
|
||||
) {
|
||||
self.engine = engine
|
||||
self.layout = layout
|
||||
schema = engine.schema
|
||||
}
|
||||
|
||||
public var keyRows: [[String]] {
|
||||
var rows = layout.rows(for: page, shiftActive: shiftActive || capsLock)
|
||||
if page == .letters,
|
||||
language == .chinese,
|
||||
schema != .fullPinyin,
|
||||
rows.indices.contains(2),
|
||||
rows[2].first == "⇧" {
|
||||
// Microsoft/Sogou use semicolon for "ing"; Chinese composition
|
||||
// does not need Shift, so keep the standard row width stable.
|
||||
rows[2][0] = ";"
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
public func enterTypingMode() {
|
||||
TypingInputConfiguration.shared.reload()
|
||||
Task { await prepareIfNeeded() }
|
||||
}
|
||||
|
||||
public func leaveTypingMode() {
|
||||
engine.teardown()
|
||||
prepared = false
|
||||
engineReady = false
|
||||
composition = .empty
|
||||
page = .letters
|
||||
shiftActive = false
|
||||
capsLock = false
|
||||
}
|
||||
|
||||
public func toggleLanguage() -> String {
|
||||
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()
|
||||
language = newLanguage
|
||||
engine.setLanguage(newLanguage)
|
||||
composition = engine.composition
|
||||
page = .letters
|
||||
return raw
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let raw = composition.preedit.isEmpty ? "" : engine.flushPreedit()
|
||||
let schemas = TypingInputSchema.allCases
|
||||
let current = schemas.firstIndex(of: schema) ?? 0
|
||||
let next = schemas[(current + 1) % schemas.count]
|
||||
if engine.setSchema(next) {
|
||||
schema = next
|
||||
TypingInputConfiguration.shared.schema = next
|
||||
}
|
||||
composition = engine.composition
|
||||
return raw
|
||||
}
|
||||
|
||||
public func setPage(_ page: TypingKeyPage) {
|
||||
self.page = page
|
||||
shiftActive = false
|
||||
}
|
||||
|
||||
/// Handle a visible key label. Returns text the proxy should insert now
|
||||
/// (may be empty when composing Chinese).
|
||||
public func handleKey(_ label: String) -> String {
|
||||
switch label {
|
||||
case "⇧":
|
||||
if shiftActive {
|
||||
capsLock = true
|
||||
shiftActive = false
|
||||
} else if capsLock {
|
||||
capsLock = false
|
||||
} else {
|
||||
shiftActive = true
|
||||
}
|
||||
return ""
|
||||
case "⌫":
|
||||
if language == .chinese, !engine.composition.preedit.isEmpty {
|
||||
let committed = engine.processBackspace() ?? ""
|
||||
composition = engine.composition
|
||||
return committed
|
||||
}
|
||||
return "\u{8}" // sentinel: caller deletes backward
|
||||
case "123":
|
||||
setPage(.numbers)
|
||||
return ""
|
||||
case "#+=":
|
||||
setPage(.symbols)
|
||||
return ""
|
||||
case "ABC", "abc":
|
||||
setPage(.letters)
|
||||
return ""
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if page != .letters {
|
||||
// Number/symbol: insert directly
|
||||
let out = label
|
||||
if !capsLock { shiftActive = false }
|
||||
return out
|
||||
}
|
||||
|
||||
guard let ch = label.first else { return "" }
|
||||
|
||||
if language == .english {
|
||||
let out = String(ch)
|
||||
if !capsLock { shiftActive = false }
|
||||
return out
|
||||
}
|
||||
|
||||
// Chinese letters → compose
|
||||
let committed = engine.processCharacter(ch) ?? ""
|
||||
composition = engine.composition
|
||||
if !capsLock { shiftActive = false }
|
||||
return committed
|
||||
}
|
||||
|
||||
public func handleSpace() -> String {
|
||||
if language == .english { return " " }
|
||||
let text = engine.processSpace() ?? " "
|
||||
composition = engine.composition
|
||||
return text
|
||||
}
|
||||
|
||||
public func handleReturn() -> String {
|
||||
if language == .english { return "\n" }
|
||||
let text = engine.processReturn() ?? "\n"
|
||||
composition = engine.composition
|
||||
return text
|
||||
}
|
||||
|
||||
public func selectCandidate(at index: Int) -> String {
|
||||
let text = engine.selectCandidate(at: index)
|
||||
composition = engine.composition
|
||||
return text
|
||||
}
|
||||
|
||||
private func prepareIfNeeded() async {
|
||||
guard !prepared else { return }
|
||||
do {
|
||||
try await engine.prepare()
|
||||
engine.setLanguage(language)
|
||||
prepared = true
|
||||
engineReady = engine.isReady
|
||||
schema = engine.schema
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
engineReady = false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user