Files
Rocky 498f407585 feat(account): add managed credits and cloud gateway
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
2026-08-20 11:43:21 +08:00

102 lines
3.8 KiB
Swift

// PinyinNextKeyResolver.swift
// OSGKeyboard · Shared
//
// Phase 4: legal next letters during full-pinyin composition.
// Double-pinyin schemas return nil (no bias) until a dedicated FSM exists.
import CoreGraphics
import Foundation
public enum KeyHitBiasMetrics: Sendable {
/// Legal next letters are slightly “sticky” in ambiguous hit tests.
public static let legalBoost: CGFloat = 1.20
/// Illegal letters shrink a little but remain reachable.
public static let illegalShrink: CGFloat = 0.90
public static let neutral: CGFloat = 1.0
}
public enum PinyinNextKeyResolver {
/// Returns legal next key characters, or `nil` when bias should be off.
public static func validNextKeys(
rawInput: String,
schema: TypingInputSchema,
language: TypingInputLanguage,
page: TypingKeyPage
) -> Set<Character>? {
guard language == .chinese, page == .letters else { return nil }
// Phase 4a: full pinyin only. Double-pinyin stays neutral.
guard schema == .fullPinyin else { return nil }
let normalized = normalize(rawInput)
guard !normalized.isEmpty else { return nil }
let segment = lastSpellingSegment(normalized)
var result = Set<Character>()
collectNext(prefix: segment, into: &result)
return result.isEmpty ? nil : result
}
/// Maps layout keys → hit weights for ambiguous nearest-key resolution.
public static func hitWeights(
for keys: [TypingKeyHitTarget],
validNext: Set<Character>?
) -> [String: CGFloat] {
guard let validNext, !validNext.isEmpty else { return [:] }
var weights: [String: CGFloat] = [:]
for key in keys {
guard key.id.hasPrefix("grid.") else { continue }
guard key.behavior == .commitOnRelease else { continue }
let label = key.label.lowercased()
guard label.count == 1, let char = label.first, char.isLetter else {
continue
}
weights[key.id] = validNext.contains(char)
? KeyHitBiasMetrics.legalBoost
: KeyHitBiasMetrics.illegalShrink
}
return weights
}
// MARK: - Internals
private static func normalize(_ raw: String) -> String {
raw.lowercased().replacingOccurrences(of: "ü", with: "v")
}
private static func lastSpellingSegment(_ input: String) -> String {
let lettersAndDelim = input.filter { $0.isLetter || $0 == "'" || $0 == " " }
let parts = lettersAndDelim.split { $0 == "'" || $0 == " " }
return parts.last.map(String.init) ?? ""
}
private static func collectNext(prefix: String, into result: inout Set<Character>) {
var canExtend = false
for syllable in PinyinSyllableTable.syllables
where syllable.hasPrefix(prefix) && syllable.count > prefix.count {
let index = syllable.index(syllable.startIndex, offsetBy: prefix.count)
result.insert(syllable[index])
canExtend = true
}
// Complete syllable (or empty) → also allow starting a new syllable.
if prefix.isEmpty || PinyinSyllableTable.syllables.contains(prefix) {
for syllable in PinyinSyllableTable.syllables {
if let first = syllable.first {
result.insert(first)
}
}
}
// Multi-syllable undelimited input (e.g. "zhongg" → "zhong" + "g").
// Only peel when the whole prefix cannot extend as one syllable.
if !canExtend, !prefix.isEmpty {
let longest = PinyinSyllableTable.longestSyllablePrefix(of: prefix)
if !longest.isEmpty, longest.count < prefix.count {
let remainder = String(prefix.dropFirst(longest.count))
collectNext(prefix: remainder, into: &result)
}
}
}
}