Files
OSGKeyboard/OSGKeyboardShared/Typing/TypingSessionController.swift
T
Rocky d1e5fed964 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).
2026-08-03 13:14:01 +08:00

192 lines
6.1 KiB
Swift

// 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
}
}
}