feat(typing): improve key hit accuracy and bump to 1.6.1

Add gap-filling hit regions, release-to-commit with slide-to-reselect,
touch intent offset, and full-pinyin next-key bias; cut release 1.6.1 (build 45).
This commit is contained in:
Rocky
2026-08-05 22:47:20 +08:00
parent 717902716a
commit 2ed16e1fbf
13 changed files with 1395 additions and 233 deletions
@@ -0,0 +1,204 @@
// KeyHitTesting.swift
// OSGKeyboard · Shared
//
// Pure geometry for typing-key hit testing: invisible gap fill, edge
// expansion, and a light upward intent offset (Phase 1 + Phase 3).
import CoreGraphics
import Foundation
/// How a key should respond once the finger is tracked onto it.
public enum TypingKeyTouchBehavior: Equatable, Sendable {
/// Highlight on down / move; commit on finger-up (letters, space, return).
case commitOnRelease
/// Fire on down and repeat while held (delete).
case deleteRepeat
/// Hold-to-shift while the gesture owns Shift ().
case shiftHold
}
/// One hittable key in surface coordinates.
public struct TypingKeyHitTarget: Equatable, Identifiable, Sendable {
public let id: String
public let label: String
public let visualFrame: CGRect
public let behavior: TypingKeyTouchBehavior
public init(
id: String,
label: String,
visualFrame: CGRect,
behavior: TypingKeyTouchBehavior
) {
self.id = id
self.label = label
self.visualFrame = visualFrame
self.behavior = behavior
}
public var center: CGPoint {
CGPoint(x: visualFrame.midX, y: visualFrame.midY)
}
}
/// Tunables for gap-filling hit regions and finger intent correction.
public enum KeyHitTestingMetrics: Sendable {
/// Shift the reported touch slightly upward thumbs contact below the
/// visual aim point.
public static let intentOffsetY: CGFloat = 4
/// Extra expansion on the outer edges of the key plane (Q / P / ).
public static let edgeExpansion: CGFloat = 5
}
public enum KeyHitTesting {
/// Map a raw touch into an intent point (Phase 3).
public static func intentPoint(
from point: CGPoint,
offsetY: CGFloat = KeyHitTestingMetrics.intentOffsetY
) -> CGPoint {
CGPoint(x: point.x, y: point.y - offsetY)
}
/// Expand a visual key frame so neighboring keys meet at the mid-gap
/// (no dead zone). Outer keys grow further past the plane edge.
public static func expandedHitFrame(
for visualFrame: CGRect,
keyPlaneBounds: CGRect,
horizontalGap: CGFloat,
verticalGap: CGFloat,
edgeExpansion: CGFloat = KeyHitTestingMetrics.edgeExpansion
) -> CGRect {
var frame = visualFrame.insetBy(
dx: -horizontalGap / 2,
dy: -verticalGap / 2
)
let epsilon: CGFloat = 0.5
if visualFrame.minX <= keyPlaneBounds.minX + epsilon {
frame.origin.x -= edgeExpansion
frame.size.width += edgeExpansion
}
if visualFrame.maxX >= keyPlaneBounds.maxX - epsilon {
frame.size.width += edgeExpansion
}
if visualFrame.minY <= keyPlaneBounds.minY + epsilon {
frame.origin.y -= edgeExpansion
frame.size.height += edgeExpansion
}
if visualFrame.maxY >= keyPlaneBounds.maxY - epsilon {
frame.size.height += edgeExpansion
}
return frame
}
/// Resolve which key owns `point` (already intent-corrected, or raw).
///
/// - Returns `nil` when the point is outside the key plane (cancel).
/// - Inside the plane: prefer expanded frames; fall back to nearest center
/// so mid-gap touches never miss.
public static func hitTarget(
at point: CGPoint,
targets: [TypingKeyHitTarget],
keyPlaneBounds: CGRect,
horizontalGap: CGFloat,
verticalGap: CGFloat,
edgeExpansion: CGFloat = KeyHitTestingMetrics.edgeExpansion,
hitWeights: [String: CGFloat] = [:]
) -> TypingKeyHitTarget? {
guard !targets.isEmpty else { return nil }
let activePlane = keyPlaneBounds.insetBy(
dx: -edgeExpansion,
dy: -edgeExpansion
)
guard activePlane.contains(point) else { return nil }
let expanded = targets.map { target in
(
target,
expandedHitFrame(
for: target.visualFrame,
keyPlaneBounds: keyPlaneBounds,
horizontalGap: horizontalGap,
verticalGap: verticalGap,
edgeExpansion: edgeExpansion
)
)
}
let containing = expanded.compactMap { target, frame -> TypingKeyHitTarget? in
frame.contains(point) ? target : nil
}
// Clear single-key hit always wins bias only breaks ties / nearest.
if containing.count == 1 {
return containing[0]
}
if containing.count > 1 {
return nearest(to: point, among: containing, hitWeights: hitWeights)
}
return nearest(to: point, among: targets, hitWeights: hitWeights)
}
/// Convenience: apply intent offset then hit-test.
public static func hitTarget(
rawTouch point: CGPoint,
targets: [TypingKeyHitTarget],
keyPlaneBounds: CGRect,
horizontalGap: CGFloat,
verticalGap: CGFloat,
intentOffsetY: CGFloat = KeyHitTestingMetrics.intentOffsetY,
edgeExpansion: CGFloat = KeyHitTestingMetrics.edgeExpansion,
hitWeights: [String: CGFloat] = [:]
) -> TypingKeyHitTarget? {
hitTarget(
at: intentPoint(from: point, offsetY: intentOffsetY),
targets: targets,
keyPlaneBounds: keyPlaneBounds,
horizontalGap: horizontalGap,
verticalGap: verticalGap,
edgeExpansion: edgeExpansion,
hitWeights: hitWeights
)
}
private static func nearest(
to point: CGPoint,
among targets: [TypingKeyHitTarget],
hitWeights: [String: CGFloat]
) -> TypingKeyHitTarget? {
targets.min { lhs, rhs in
weightedDistanceSquared(point, lhs, hitWeights)
< weightedDistanceSquared(point, rhs, hitWeights)
}
}
private static func weightedDistanceSquared(
_ point: CGPoint,
_ target: TypingKeyHitTarget,
_ hitWeights: [String: CGFloat]
) -> CGFloat {
let weight = max(0.01, hitWeights[target.id] ?? 1.0)
return distanceSquared(point, target.center) / weight
}
private static func distanceSquared(_ a: CGPoint, _ b: CGPoint) -> CGFloat {
let dx = a.x - b.x
let dy = a.y - b.y
return dx * dx + dy * dy
}
}
/// Resolve touch behavior from a visible key label.
public enum TypingKeyBehaviorResolver {
public static func behavior(for label: String) -> TypingKeyTouchBehavior {
switch label {
case "":
return .deleteRepeat
case "":
return .shiftHold
default:
return .commitOnRelease
}
}
}
@@ -144,8 +144,10 @@ public final class LibrimeEngine: RimeEngineBridging {
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)",
@@ -0,0 +1,102 @@
// 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)
}
}
}
}
@@ -0,0 +1,77 @@
// PinyinSyllableTable.swift
// OSGKeyboard · Shared
//
// Mandarin pinyin syllables (no tones) for Phase 4 next-key bias.
// Uses ASCII `v` for ü so it matches keyboard / Rime raw input.
import Foundation
public enum PinyinSyllableTable {
/// Complete syllables accepted by full-pinyin next-key logic.
public static let syllables: Set<String> = [
"a", "ai", "an", "ang", "ao",
"ba", "bai", "ban", "bang", "bao", "bei", "ben", "beng", "bi", "bian",
"biao", "bie", "bin", "bing", "bo", "bu",
"ca", "cai", "can", "cang", "cao", "ce", "cen", "ceng", "cha", "chai",
"chan", "chang", "chao", "che", "chen", "cheng", "chi", "chong", "chou",
"chu", "chua", "chuai", "chuan", "chuang", "chui", "chun", "chuo",
"ci", "cong", "cou", "cu", "cuan", "cui", "cun", "cuo",
"da", "dai", "dan", "dang", "dao", "de", "dei", "den", "deng", "di",
"dia", "dian", "diao", "die", "ding", "diu", "dong", "dou", "du",
"duan", "dui", "dun", "duo",
"e", "ei", "en", "eng", "er",
"fa", "fan", "fang", "fei", "fen", "feng", "fiao", "fo", "fou", "fu",
"ga", "gai", "gan", "gang", "gao", "ge", "gei", "gen", "geng", "gong",
"gou", "gu", "gua", "guai", "guan", "guang", "gui", "gun", "guo",
"ha", "hai", "han", "hang", "hao", "he", "hei", "hen", "heng", "hong",
"hou", "hu", "hua", "huai", "huan", "huang", "hui", "hun", "huo",
"ji", "jia", "jian", "jiang", "jiao", "jie", "jin", "jing", "jiong",
"jiu", "ju", "juan", "jue", "jun",
"ka", "kai", "kan", "kang", "kao", "ke", "ken", "keng", "kong", "kou",
"ku", "kua", "kuai", "kuan", "kuang", "kui", "kun", "kuo",
"la", "lai", "lan", "lang", "lao", "le", "lei", "leng", "li", "lia",
"lian", "liang", "liao", "lie", "lin", "ling", "liu", "lo", "long",
"lou", "lu", "luan", "lue", "lun", "luo", "lv", "lve",
"ma", "mai", "man", "mang", "mao", "me", "mei", "men", "meng", "mi",
"mian", "miao", "mie", "min", "ming", "miu", "mo", "mou", "mu",
"na", "nai", "nan", "nang", "nao", "ne", "nei", "nen", "neng", "ni",
"nian", "niang", "niao", "nie", "nin", "ning", "niu", "nong", "nou",
"nu", "nuan", "nue", "nun", "nuo", "nv", "nve",
"o", "ou",
"pa", "pai", "pan", "pang", "pao", "pei", "pen", "peng", "pi", "pian",
"piao", "pie", "pin", "ping", "po", "pou", "pu",
"qi", "qia", "qian", "qiang", "qiao", "qie", "qin", "qing", "qiong",
"qiu", "qu", "quan", "que", "qun",
"ran", "rang", "rao", "re", "ren", "reng", "ri", "rong", "rou", "ru",
"ruan", "rui", "run", "ruo",
"sa", "sai", "san", "sang", "sao", "se", "sen", "seng", "sha", "shai",
"shan", "shang", "shao", "she", "shei", "shen", "sheng", "shi", "shou",
"shu", "shua", "shuai", "shuan", "shuang", "shui", "shun", "shuo",
"si", "song", "sou", "su", "suan", "sui", "sun", "suo",
"ta", "tai", "tan", "tang", "tao", "te", "tei", "ten", "teng", "ti",
"tian", "tiao", "tie", "ting", "tong", "tou", "tu", "tuan", "tui",
"tun", "tuo",
"wa", "wai", "wan", "wang", "wei", "wen", "weng", "wo", "wu",
"xi", "xia", "xian", "xiang", "xiao", "xie", "xin", "xing", "xiong",
"xiu", "xu", "xuan", "xue", "xun",
"ya", "yan", "yang", "yao", "ye", "yi", "yin", "ying", "yo", "yong",
"you", "yu", "yuan", "yue", "yun",
"za", "zai", "zan", "zang", "zao", "ze", "zei", "zen", "zeng", "zha",
"zhai", "zhan", "zhang", "zhao", "zhe", "zhei", "zhen", "zheng",
"zhi", "zhong", "zhou", "zhu", "zhua", "zhuai", "zhuan", "zhuang",
"zhui", "zhun", "zhuo", "zi", "zong", "zou", "zu", "zuan", "zui",
"zun", "zuo"
]
public static func longestSyllablePrefix(of input: String) -> String {
let maxLen = min(6, input.count)
guard maxLen > 0 else { return "" }
for len in stride(from: maxLen, through: 1, by: -1) {
let prefix = String(input.prefix(len))
if syllables.contains(prefix) {
return prefix
}
}
return ""
}
}
@@ -45,10 +45,18 @@ public struct TypingCandidate: Identifiable, Equatable, Sendable {
/// Snapshot the UI observes while composing.
public struct TypingComposition: Equatable, Sendable {
public var preedit: String
/// Raw key sequence from the engine (ASCII). Prefer this over `preedit`
/// for spelling / next-key logic preedit may include separators.
public var rawInput: String
public var candidates: [TypingCandidate]
public init(preedit: String = "", candidates: [TypingCandidate] = []) {
public init(
preedit: String = "",
rawInput: String = "",
candidates: [TypingCandidate] = []
) {
self.preedit = preedit
self.rawInput = rawInput
self.candidates = candidates
}
@@ -0,0 +1,178 @@
// TypingKeyLayout.swift
// OSGKeyboard · Shared
//
// Builds visual frames for the typing grid + bottom action row so hit
// testing and rendering share one geometry source.
import CoreGraphics
import Foundation
public struct TypingKeyLayout: Equatable, Sendable {
public let keys: [TypingKeyHitTarget]
/// Union of all visual key frames (letter grid + bottom row).
public let keyPlaneBounds: CGRect
public let horizontalGap: CGFloat
public let verticalGap: CGFloat
public let bottomRowMinY: CGFloat
/// Phase 4: per-key hit weights for ambiguous nearest resolution.
/// Empty / missing id neutral (`1.0`).
public let hitWeights: [String: CGFloat]
public init(
keys: [TypingKeyHitTarget],
keyPlaneBounds: CGRect,
horizontalGap: CGFloat,
verticalGap: CGFloat,
bottomRowMinY: CGFloat,
hitWeights: [String: CGFloat] = [:]
) {
self.keys = keys
self.keyPlaneBounds = keyPlaneBounds
self.horizontalGap = horizontalGap
self.verticalGap = verticalGap
self.bottomRowMinY = bottomRowMinY
self.hitWeights = hitWeights
}
public func key(id: String) -> TypingKeyHitTarget? {
keys.first { $0.id == id }
}
public func withHitWeights(_ weights: [String: CGFloat]) -> TypingKeyLayout {
TypingKeyLayout(
keys: keys,
keyPlaneBounds: keyPlaneBounds,
horizontalGap: horizontalGap,
verticalGap: verticalGap,
bottomRowMinY: bottomRowMinY,
hitWeights: weights
)
}
}
public enum TypingKeyLayoutBuilder {
public struct Metrics: Equatable, Sendable {
public var keyRowHeight: CGFloat
public var keyRowSpacing: CGFloat
public var keyHorizontalSpacing: CGFloat
public var secondRowInset: CGFloat
public var bottomRowHeight: CGFloat
public var bottomActionSpacing: CGFloat
/// Gap between the last letter row and the bottom action row.
public var gridToBottomSpacing: CGFloat
public init(
keyRowHeight: CGFloat = 50,
keyRowSpacing: CGFloat = 7,
keyHorizontalSpacing: CGFloat = 6,
secondRowInset: CGFloat = 18,
bottomRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight,
bottomActionSpacing: CGFloat = KeyboardChromeLayout.actionKeySpacing,
gridToBottomSpacing: CGFloat = 7
) {
self.keyRowHeight = keyRowHeight
self.keyRowSpacing = keyRowSpacing
self.keyHorizontalSpacing = keyHorizontalSpacing
self.secondRowInset = secondRowInset
self.bottomRowHeight = bottomRowHeight
self.bottomActionSpacing = bottomActionSpacing
self.gridToBottomSpacing = gridToBottomSpacing
}
}
/// Bottom-row semantic labels used by the touch pad (not always the glyph).
public enum BottomKeyID: String, Sendable {
case pageSwitch = "bottom.page"
case space = "bottom.space"
case `return` = "bottom.return"
}
public static func build(
size: CGSize,
letterRows: [[String]],
pageSwitchLabel: String,
spaceLabel: String,
returnLabel: String,
metrics: Metrics = Metrics(),
keyWeight: (_ label: String, _ index: Int, _ rowIndex: Int) -> CGFloat
) -> TypingKeyLayout {
var keys: [TypingKeyHitTarget] = []
var cursorY: CGFloat = 0
for (rowIndex, row) in letterRows.enumerated() {
let inset = rowIndex == 1 ? metrics.secondRowInset : 0
let weights = row.enumerated().map { keyWeight($0.element, $0.offset, rowIndex) }
let spacingTotal = metrics.keyHorizontalSpacing * CGFloat(max(0, row.count - 1))
let availableWidth = size.width - inset * 2 - spacingTotal
let unitWidth = availableWidth / max(1, weights.reduce(0, +))
var x = inset
for (keyIndex, label) in row.enumerated() {
let width = unitWidth * weights[keyIndex]
let frame = CGRect(
x: x,
y: cursorY,
width: width,
height: metrics.keyRowHeight
)
keys.append(
TypingKeyHitTarget(
id: "grid.\(rowIndex).\(keyIndex)",
label: label,
visualFrame: frame,
behavior: TypingKeyBehaviorResolver.behavior(for: label)
)
)
x += width + metrics.keyHorizontalSpacing
}
cursorY += metrics.keyRowHeight
if rowIndex < letterRows.count - 1 {
cursorY += metrics.keyRowSpacing
}
}
cursorY += metrics.gridToBottomSpacing
let bottomY = cursorY
let widths = KeyboardChromeLayout.actionKeyWidths(availableWidth: size.width)
let bottomFrames: [(String, String, CGFloat)] = [
(BottomKeyID.pageSwitch.rawValue, pageSwitchLabel, widths.side),
(BottomKeyID.space.rawValue, spaceLabel, widths.center),
(BottomKeyID.return.rawValue, returnLabel, widths.side)
]
var bottomX: CGFloat = 0
for (index, item) in bottomFrames.enumerated() {
let frame = CGRect(
x: bottomX,
y: bottomY,
width: item.2,
height: metrics.bottomRowHeight
)
keys.append(
TypingKeyHitTarget(
id: item.0,
label: item.1,
visualFrame: frame,
behavior: .commitOnRelease
)
)
bottomX += item.2
if index < bottomFrames.count - 1 {
bottomX += metrics.bottomActionSpacing
}
}
let plane = keys.reduce(CGRect.null) { $0.union($1.visualFrame) }
return TypingKeyLayout(
keys: keys,
keyPlaneBounds: plane.isNull ? .zero : plane,
horizontalGap: metrics.keyHorizontalSpacing,
// Between letter rows use keyRowSpacing; between grid and bottom
// use gridToBottomSpacing. Hit-test uses the larger gap so the
// mid-gap is always covered.
verticalGap: max(metrics.keyRowSpacing, metrics.gridToBottomSpacing),
bottomRowMinY: bottomY
)
}
}