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
+4
View File
@@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.6.1] - 2026-08-05
### Added ### Added
- **Personal dictionary → Chinese Pinyin**: adding or deleting terms redeploys an `osg_personal` Rime sidecar (local pinyin from the bundled dict; same-code pin-to-top; Latin names also surface in Chinese mode); English typing and ASR keep using the same dictionary automatically. Takes effect the next time the keyboard opens. / **个性词库 → 中文拼音**:增删词条会重部署 `osg_personal` Rime 旁路词表(复用打包词典本地注音;同码置顶;拉丁专名在中文键盘也可出候选);英文打字与 ASR 继续自动共用同一词库。下次打开键盘生效。 - **Personal dictionary → Chinese Pinyin**: adding or deleting terms redeploys an `osg_personal` Rime sidecar (local pinyin from the bundled dict; same-code pin-to-top; Latin names also surface in Chinese mode); English typing and ASR keep using the same dictionary automatically. Takes effect the next time the keyboard opens. / **个性词库 → 中文拼音**:增删词条会重部署 `osg_personal` Rime 旁路词表(复用打包词典本地注音;同码置顶;拉丁专名在中文键盘也可出候选);英文打字与 ASR 继续自动共用同一词库。下次打开键盘生效。
- **Typing key sound & haptics**: letter / modifier / space / return / delete keys play system click sounds on press-down; Settings → General → Haptics offers Off / Light (default) / Strong role-based feedback. / **打字按键音效与震动**:字母 / 修饰 / 空格 / 回车 / 删除键按下即播系统咔嗒音;设置 → 通用 → 震动提供关 / 轻(默认) / 强 的角色分层触感。 - **Typing key sound & haptics**: letter / modifier / space / return / delete keys play system click sounds on press-down; Settings → General → Haptics offers Off / Light (default) / Strong role-based feedback. / **打字按键音效与震动**:字母 / 修饰 / 空格 / 回车 / 删除键按下即播系统咔嗒音;设置 → 通用 → 震动提供关 / 轻(默认) / 强 的角色分层触感。
- **Typing touch accuracy (Phase 13)**: gap-filling hit regions (no dead seams), grid-level down → move → up tracking with slide-to-reselect, release-to-commit for letters/space/return, press-and-repeat delete, and a light upward touch intent offset. / **打字触控准确率(Phase 1–3)**:热区填缝(无死区)、网格级按下→滑动改选→松手确认、字母/空格/回车松手提交、删除按下连删,以及轻微向上的触点意图偏移。
- **Pinyin next-key bias (Phase 4)**: during full-pinyin composition, ambiguous seam hits prefer legal next letters (capped boost/shrink); clear on-key hits and English / double-pinyin stay unbiased. / **拼音下一键偏心(Phase 4**:全拼组词中,缝隙歧义命中优先合法后续字母(有上下限);明确落在键上的点击以及英文/双拼不受偏置。
### Changed ### Changed
- **Rime personal-dict import**: SharedSupport now imports `osg_personal` into `osg_pinyin` (resource version `2.3.0`). / **Rime 个性词导入**SharedSupport 将 `osg_personal` 导入 `osg_pinyin`(资源版本 `2.3.0`)。 - **Rime personal-dict import**: SharedSupport now imports `osg_personal` into `osg_pinyin` (resource version `2.3.0`). / **Rime 个性词导入**SharedSupport 将 `osg_personal` 导入 `osg_pinyin`(资源版本 `2.3.0`)。
@@ -0,0 +1,270 @@
// TypingKeyTouchPad.swift
// OSGKeyboard · Keyboard Extension
//
// Grid-level UIKit touch tracking for the typing surface:
// Down highlight Move reselect Up commit (letters / space / return),
// delete repeats on down, Shift holds while the gesture owns it.
import SwiftUI
import UIKit
import OSGKeyboardShared
struct TypingKeyTouchPad: UIViewRepresentable {
var layout: TypingKeyLayout
var hapticIntensity: KeyboardHapticIntensity
var onHighlightChange: (String?) -> Void
var onCommit: (TypingKeyHitTarget) -> Void
var onDeleteFire: () -> Void
var onShiftBegan: () -> Void
var onShiftEnded: () -> Void
func makeUIView(context: Context) -> TypingKeyTouchPadUIView {
let view = TypingKeyTouchPadUIView()
view.coordinator = context.coordinator
return view
}
func updateUIView(_ uiView: TypingKeyTouchPadUIView, context: Context) {
context.coordinator.parent = self
uiView.layoutModel = layout
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
@MainActor
final class Coordinator {
var parent: TypingKeyTouchPad
private var activeKeyID: String?
private var gestureOwnsShift = false
private var deleteRepeatTask: Task<Void, Never>?
private var deleteRepeatStartedAt: Date?
private var isDeleteRepeating = false
init(parent: TypingKeyTouchPad) {
self.parent = parent
}
func handleBegan(at point: CGPoint) {
resetDeleteRepeat()
gestureOwnsShift = false
guard let key = hit(at: point) else {
setHighlight(nil)
return
}
activate(key)
}
func handleMoved(at point: CGPoint) {
let key = hit(at: point)
if key?.id == activeKeyID { return }
if activeBehavior == .deleteRepeat {
stopDeleteRepeat()
}
if let key {
activate(key)
} else {
// Outside plane: clear highlight; Shift stays held until ended.
setHighlight(nil)
activeKeyID = nil
}
}
func handleEnded(at point: CGPoint) {
// Outside the key plane cancel (no commit), per accuracy plan.
let key = hit(at: point)
stopDeleteRepeat()
defer {
setHighlight(nil)
activeKeyID = nil
finishShiftIfNeeded()
}
guard let key else { return }
switch key.behavior {
case .commitOnRelease:
parent.onCommit(key)
case .deleteRepeat:
// Already fired on down / while held.
break
case .shiftHold:
// endShiftHold decides tap vs hold-with-type.
break
}
}
func handleCancelled() {
stopDeleteRepeat()
setHighlight(nil)
activeKeyID = nil
finishShiftIfNeeded()
}
// MARK: - Internals
private var activeBehavior: TypingKeyTouchBehavior? {
guard let activeKeyID else { return nil }
return parent.layout.key(id: activeKeyID)?.behavior
}
private func hit(at point: CGPoint) -> TypingKeyHitTarget? {
let layout = parent.layout
return KeyHitTesting.hitTarget(
rawTouch: point,
targets: layout.keys,
keyPlaneBounds: layout.keyPlaneBounds,
horizontalGap: layout.horizontalGap,
verticalGap: layout.verticalGap,
hitWeights: layout.hitWeights
)
}
private func activate(_ key: TypingKeyHitTarget) {
activeKeyID = key.id
setHighlight(key.id)
playFeedback(for: key)
switch key.behavior {
case .commitOnRelease:
break
case .deleteRepeat:
parent.onDeleteFire()
startDeleteRepeat()
case .shiftHold:
if !gestureOwnsShift {
gestureOwnsShift = true
parent.onShiftBegan()
}
}
}
private func setHighlight(_ id: String?) {
parent.onHighlightChange(id)
}
private func playFeedback(for key: TypingKeyHitTarget) {
let role: KeyboardHapticKeyRole
let isDelete: Bool
switch key.behavior {
case .deleteRepeat:
role = .delete
isDelete = true
case .shiftHold:
role = .modifier
isDelete = false
case .commitOnRelease:
isDelete = false
if key.id.hasPrefix("bottom.") {
role = key.id == TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue
? .modifier
: .action
} else if ["123", "#+=", "ABC"].contains(key.label) {
role = .modifier
} else {
role = .character
}
}
TypingKeyFeedback.play(
role: role,
intensity: parent.hapticIntensity,
isDelete: isDelete
)
}
private func startDeleteRepeat() {
stopDeleteRepeat()
isDeleteRepeating = true
deleteRepeatStartedAt = Date()
deleteRepeatTask = Task { @MainActor in
try? await Task.sleep(
nanoseconds: UInt64(RepeatingDeleteTiming.initialDelay * 1_000_000_000)
)
guard !Task.isCancelled, self.isDeleteRepeating else { return }
let anchor = self.deleteRepeatStartedAt ?? Date()
while !Task.isCancelled, self.isDeleteRepeating {
// Match RepeatingPressButton: sound/haptic then action each tick.
TypingKeyFeedback.play(
role: .delete,
intensity: self.parent.hapticIntensity,
isDelete: true
)
self.parent.onDeleteFire()
let elapsed = Date().timeIntervalSince(anchor)
let wait = RepeatingDeleteTiming.interval(for: elapsed)
try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000))
}
}
}
private func stopDeleteRepeat() {
isDeleteRepeating = false
deleteRepeatStartedAt = nil
deleteRepeatTask?.cancel()
deleteRepeatTask = nil
}
private func resetDeleteRepeat() {
stopDeleteRepeat()
}
private func finishShiftIfNeeded() {
guard gestureOwnsShift else { return }
gestureOwnsShift = false
parent.onShiftEnded()
}
}
}
final class TypingKeyTouchPadUIView: UIView {
weak var coordinator: TypingKeyTouchPad.Coordinator?
var layoutModel = TypingKeyLayout(
keys: [],
keyPlaneBounds: .zero,
horizontalGap: 6,
verticalGap: 7,
bottomRowMinY: 0
)
/// Same trick as CursorDragPad: non-zero alpha so SwiftUI hosting does
/// not treat the pad as pass-through.
private static let padTint = UIColor { traits in
UIColor.systemGray4.resolvedColor(with: traits).withAlphaComponent(0.02)
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = Self.padTint
isMultipleTouchEnabled = false
isExclusiveTouch = true
isUserInteractionEnabled = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
coordinator?.handleBegan(at: touch.location(in: self))
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
coordinator?.handleMoved(at: touch.location(in: self))
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
coordinator?.handleEnded(at: touch.location(in: self))
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
coordinator?.handleCancelled()
}
}
+160 -211
View File
@@ -42,6 +42,8 @@ struct TypingRootView: View {
/// After the first expand, keep the panel tree mounted and only toggle /// After the first expand, keep the panel tree mounted and only toggle
/// opacity so subsequent / taps stay cheap. /// opacity so subsequent / taps stay cheap.
@State private var candidatePanelMounted = false @State private var candidatePanelMounted = false
/// Key currently under the finger (grid-level touch pad).
@State private var highlightedKeyID: String?
static let totalHeight: CGFloat = TypingLayoutMetrics.totalHeight static let totalHeight: CGFloat = TypingLayoutMetrics.totalHeight
@@ -58,14 +60,8 @@ struct TypingRootView: View {
// Keep the QWERTY tree mounted; only toggle visibility so / // Keep the QWERTY tree mounted; only toggle visibility so /
// does not destroy GeometryReader key rows every time. // does not destroy GeometryReader key rows every time.
ZStack(alignment: .top) { ZStack(alignment: .top) {
VStack(spacing: 0) { typingKeySurface
keyGrid
.padding(.top, TypingLayoutMetrics.verticalKeySpacing) .padding(.top, TypingLayoutMetrics.verticalKeySpacing)
bottomRow
.frame(height: TypingLayoutMetrics.bottomRowHeight)
.padding(.top, TypingLayoutMetrics.keyRowSpacing)
}
.opacity(expanded ? 0 : 1) .opacity(expanded ? 0 : 1)
.allowsHitTesting(!expanded) .allowsHitTesting(!expanded)
.accessibilityHidden(expanded) .accessibilityHidden(expanded)
@@ -277,149 +273,109 @@ struct TypingRootView: View {
// MARK: - Keys // MARK: - Keys
private var keyGrid: some View { /// Visual keys + grid-level touch pad (Phase 1 gap fill + Phase 2
VStack(spacing: TypingLayoutMetrics.keyRowSpacing) { /// down move up). Geometry is shared via ``TypingKeyLayoutBuilder``.
ForEach(Array(typing.keyRows.enumerated()), id: \.offset) { rowIndex, row in private var typingKeySurface: some View {
GeometryReader { proxy in GeometryReader { proxy in
let inset = rowIndex == 1 ? TypingLayoutMetrics.secondRowInset : 0 let layout = makeTypingKeyLayout(size: proxy.size)
let weights = row.enumerated().map { ZStack(alignment: .topLeading) {
keyWeight(label: $0.element, index: $0.offset, rowIndex: rowIndex) TypingKeyTouchPad(
} layout: layout,
let spacing = TypingLayoutMetrics.keyHorizontalSpacing hapticIntensity: state.keyboardHapticIntensity,
* CGFloat(max(0, row.count - 1)) onHighlightChange: { highlightedKeyID = $0 },
let availableWidth = proxy.size.width - inset * 2 - spacing onCommit: { commitTypingKey($0) },
let unitWidth = availableWidth / max(1, weights.reduce(0, +)) onDeleteFire: { apply(typing.handleKey("")) },
onShiftBegan: { typing.beginShiftHold() },
onShiftEnded: { typing.endShiftHold() }
)
HStack(spacing: TypingLayoutMetrics.keyHorizontalSpacing) { ForEach(layout.keys) { key in
ForEach(Array(row.enumerated()), id: \.offset) { keyIndex, key in visualTypingKey(key)
keyButton(key) .frame(width: key.visualFrame.width, height: key.visualFrame.height)
.frame(width: unitWidth * weights[keyIndex]) .position(
x: key.visualFrame.midX,
y: key.visualFrame.midY
)
// Touches go to the UIKit pad; visuals stay for VoiceOver.
.allowsHitTesting(false)
} }
} }
.padding(.horizontal, inset)
}
.frame(height: TypingLayoutMetrics.keyRowHeight)
} }
} }
private func makeTypingKeyLayout(size: CGSize) -> TypingKeyLayout {
let pageLabel = typing.page == .letters ? "123" : "ABC"
let spaceLabel = typing.language == .chinese ? "空格" : "space"
let returnLabel: String = {
switch state.returnKeyRole {
case .newline: return "return"
case .send: return ExtL10n.string(state.returnKeyRole.titleKey)
}
}()
let layout = TypingKeyLayoutBuilder.build(
size: size,
letterRows: typing.keyRows,
pageSwitchLabel: pageLabel,
spaceLabel: spaceLabel,
returnLabel: returnLabel,
metrics: TypingKeyLayoutBuilder.Metrics(
keyRowHeight: TypingLayoutMetrics.keyRowHeight,
keyRowSpacing: TypingLayoutMetrics.keyRowSpacing,
keyHorizontalSpacing: TypingLayoutMetrics.keyHorizontalSpacing,
secondRowInset: TypingLayoutMetrics.secondRowInset,
bottomRowHeight: TypingLayoutMetrics.bottomRowHeight,
bottomActionSpacing: TypingLayoutMetrics.bottomActionSpacing,
gridToBottomSpacing: TypingLayoutMetrics.keyRowSpacing
),
keyWeight: { label, index, rowIndex in
keyWeight(label: label, index: index, rowIndex: rowIndex)
}
)
let validNext = PinyinNextKeyResolver.validNextKeys(
rawInput: typing.composition.rawInput,
schema: typing.schema,
language: typing.language,
page: typing.page
)
return layout.withHitWeights(
PinyinNextKeyResolver.hitWeights(for: layout.keys, validNext: validNext)
)
} }
@ViewBuilder @ViewBuilder
private func keyButton(_ label: String) -> some View { private func visualTypingKey(_ key: TypingKeyHitTarget) -> some View {
if label == "" { let pressed = highlightedKeyID == key.id
typingDeleteKey() let isBottom = key.id.hasPrefix("bottom.")
} else if label == "" { let isShift = key.label == ""
typingShiftKey() let shiftLit = isShift && typing.isShiftEnabled
} else { let showPressed = pressed || shiftLit
let isSpecial = ["123", "#+=", "ABC"].contains(label)
let role: KeyboardHapticKeyRole = isSpecial ? .modifier : .character
PressDownKeyButton {
TypingKeyFeedback.play(
role: role,
intensity: state.keyboardHapticIntensity
)
apply(typing.handleKey(label))
} label: { isPressed in
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: keyFill,
pressedFill: keyPressedFill,
border: palette.divider,
cornerRadius: TypingLayoutMetrics.keyCornerRadius
) {
keyLabel(label, isSpecial: isSpecial)
}
}
.accessibilityLabel(Text(label))
}
}
/// Hold = continuous uppercase while pressed; tap = one-shot / Caps Lock cycle. Group {
private func typingShiftKey() -> some View { if key.label == "" {
let lit = typing.isShiftEnabled
return keyLabel("", isSpecial: true)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
style: .continuous
)
.fill(lit ? keyPressedFill : keyFill)
)
.overlay(
RoundedRectangle(
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
style: .continuous
)
.stroke(palette.divider, lineWidth: 0.5)
)
.shadow(
color: Color.black.opacity(lit ? 0.04 : 0.13),
radius: lit ? 0.5 : 1,
y: lit ? 0 : 1
)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
if !typing.shiftHeld {
TypingKeyFeedback.play(
role: .modifier,
intensity: state.keyboardHapticIntensity
)
typing.beginShiftHold()
}
}
.onEnded { _ in
typing.endShiftHold()
}
)
.accessibilityLabel(Text("shift"))
.accessibilityAddTraits(.isButton)
}
/// Shares ``RepeatingPressButton`` with the voice toolbar delete key.
private func typingDeleteKey() -> some View {
RepeatingPressButton(hapticIntensity: state.keyboardHapticIntensity) {
apply(typing.handleKey(""))
} label: { isPressed in
Image(systemName: "delete.left") Image(systemName: "delete.left")
.font(.system(size: 20, weight: .medium)) .font(.system(size: 20, weight: .medium))
.foregroundStyle(keyTextColor) .foregroundStyle(keyTextColor)
.frame(maxWidth: .infinity, maxHeight: .infinity) } else if key.label == "" {
.background(
RoundedRectangle(
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
style: .continuous
)
.fill(isPressed ? keyPressedFill : keyFill)
)
.overlay(
RoundedRectangle(
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
style: .continuous
)
.stroke(palette.divider, lineWidth: 0.5)
)
.shadow(
color: Color.black.opacity(isPressed ? 0.04 : 0.13),
radius: isPressed ? 0.5 : 1,
y: isPressed ? 0 : 1
)
.scaleEffect(isPressed ? 0.98 : 1)
.animation(.easeOut(duration: 0.08), value: isPressed)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private func keyLabel(_ label: String, isSpecial: Bool) -> some View {
switch label {
case "":
Image(systemName: typing.isShiftEnabled ? "shift.fill" : "shift") Image(systemName: typing.isShiftEnabled ? "shift.fill" : "shift")
.font(.system(size: 19, weight: .medium)) .font(.system(size: 19, weight: .medium))
.foregroundStyle(keyTextColor) .foregroundStyle(keyTextColor)
default: } else if key.id == TypingKeyLayoutBuilder.BottomKeyID.return.rawValue {
Text(label) returnKeyLabel
.foregroundStyle(returnKeyTextColor)
} else if isBottom {
let isSpecial = key.id == TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue
Text(key.label)
.font(
.system(
size: isSpecial ? 15 : 17,
weight: isSpecial ? .semibold : .regular
)
)
.foregroundStyle(keyTextColor)
} else {
let isSpecial = ["123", "#+=", "ABC"].contains(key.label)
Text(key.label)
.font( .font(
.system( .system(
size: isSpecial ? 15 : 22, size: isSpecial ? 15 : 22,
@@ -429,90 +385,83 @@ struct TypingRootView: View {
.foregroundStyle(keyTextColor) .foregroundStyle(keyTextColor)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
private var bottomRow: some View { .background(
GeometryReader { proxy in RoundedRectangle(
let widths = KeyboardChromeLayout.actionKeyWidths( cornerRadius: TypingLayoutMetrics.keyCornerRadius,
availableWidth: proxy.size.width style: .continuous
) )
.fill(visualKeyFill(for: key, pressed: showPressed))
HStack(spacing: TypingLayoutMetrics.bottomActionSpacing) {
PressDownKeyButton {
TypingKeyFeedback.play(
role: .modifier,
intensity: state.keyboardHapticIntensity
) )
.overlay(
RoundedRectangle(
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
style: .continuous
)
.stroke(visualKeyBorder(for: key), lineWidth: 0.5)
)
.shadow(
color: Color.black.opacity(showPressed ? 0.04 : 0.13),
radius: showPressed ? 0.5 : 1,
y: showPressed ? 0 : 1
)
.scaleEffect(pressed ? 0.98 : 1)
.animation(.easeOut(duration: 0.08), value: pressed)
.accessibilityElement()
.accessibilityAddTraits(.isButton)
.accessibilityLabel(Text(accessibilityLabel(for: key)))
.accessibilityAction {
commitTypingKey(key)
}
}
private func visualKeyFill(for key: TypingKeyHitTarget, pressed: Bool) -> Color {
if key.id == TypingKeyLayoutBuilder.BottomKeyID.return.rawValue {
return pressed ? returnKeyPressedFill : returnKeyFill
}
return pressed ? keyPressedFill : keyFill
}
private func visualKeyBorder(for key: TypingKeyHitTarget) -> Color {
if key.id == TypingKeyLayoutBuilder.BottomKeyID.return.rawValue {
return returnKeyBorder
}
return palette.divider
}
private func accessibilityLabel(for key: TypingKeyHitTarget) -> String {
switch key.id {
case TypingKeyLayoutBuilder.BottomKeyID.return.rawValue:
return returnKeyAccessibilityLabel
case TypingKeyLayoutBuilder.BottomKeyID.space.rawValue:
return key.label
default:
if key.label == "" { return "shift" }
if key.label == "" { return "delete" }
return key.label
}
}
private func commitTypingKey(_ key: TypingKeyHitTarget) {
switch key.id {
case TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue:
typing.setPage(typing.page == .letters ? .numbers : .letters) typing.setPage(typing.page == .letters ? .numbers : .letters)
} label: { isPressed in case TypingKeyLayoutBuilder.BottomKeyID.space.rawValue:
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: keyFill,
pressedFill: keyPressedFill,
border: palette.divider,
cornerRadius: TypingLayoutMetrics.keyCornerRadius
) {
Text(typing.page == .letters ? "123" : "ABC")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(keyTextColor)
}
.frame(
width: widths.side,
height: TypingLayoutMetrics.bottomRowHeight
)
}
.accessibilityLabel(Text(typing.page == .letters ? "123" : "ABC"))
PressDownKeyButton {
TypingKeyFeedback.play(
role: .action,
intensity: state.keyboardHapticIntensity
)
apply(typing.handleSpace()) apply(typing.handleSpace())
} label: { isPressed in case TypingKeyLayoutBuilder.BottomKeyID.return.rawValue:
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: keyFill,
pressedFill: keyPressedFill,
border: palette.divider,
cornerRadius: TypingLayoutMetrics.keyCornerRadius
) {
Text(typing.language == .chinese ? "空格" : "space")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(keyTextColor)
}
.frame(
width: widths.center,
height: TypingLayoutMetrics.bottomRowHeight
)
}
.accessibilityLabel(Text(typing.language == .chinese ? "空格" : "space"))
PressDownKeyButton {
TypingKeyFeedback.play(
role: .action,
intensity: state.keyboardHapticIntensity
)
apply(typing.handleReturn()) apply(typing.handleReturn())
} label: { isPressed in default:
NativeKeyboardKeySurface( switch key.behavior {
isPressed: isPressed, case .commitOnRelease:
fill: returnKeyFill, apply(typing.handleKey(key.label))
pressedFill: returnKeyPressedFill, case .deleteRepeat:
border: returnKeyBorder, apply(typing.handleKey(""))
cornerRadius: TypingLayoutMetrics.keyCornerRadius case .shiftHold:
) { // Mirror a completed Shift tap for VoiceOver / accessibility.
returnKeyLabel typing.beginShiftHold()
.foregroundStyle(returnKeyTextColor) typing.endShiftHold()
}
.frame(
width: widths.side,
height: TypingLayoutMetrics.bottomRowHeight
)
}
.accessibilityLabel(Text(returnKeyAccessibilityLabel))
} }
} }
.frame(height: TypingLayoutMetrics.bottomRowHeight)
} }
@ViewBuilder @ViewBuilder
@@ -0,0 +1,184 @@
// KeyHitTestingTests.swift
// OSGKeyboard · Ext unit tests
//
// Phase 1 / 3: gap fill, nearest-center, intent offset, edge expansion.
import XCTest
@testable import OSGKeyboardShared
final class KeyHitTestingTests: XCTestCase {
private func makePairTargets(
left: CGRect = CGRect(x: 0, y: 0, width: 40, height: 50),
gap: CGFloat = 6
) -> (targets: [TypingKeyHitTarget], plane: CGRect) {
let right = CGRect(
x: left.maxX + gap,
y: left.minY,
width: left.width,
height: left.height
)
let targets = [
TypingKeyHitTarget(
id: "L",
label: "A",
visualFrame: left,
behavior: .commitOnRelease
),
TypingKeyHitTarget(
id: "R",
label: "S",
visualFrame: right,
behavior: .commitOnRelease
)
]
let plane = left.union(right)
return (targets, plane)
}
func testGapMidpointHitsNearestKey() {
let pair = makePairTargets()
let mid = CGPoint(x: 43, y: 25) // center of 6pt gap between 40 and 46
let hit = KeyHitTesting.hitTarget(
at: mid,
targets: pair.targets,
keyPlaneBounds: pair.plane,
horizontalGap: 6,
verticalGap: 7,
edgeExpansion: 0
)
// Midpoint is equidistant; either is acceptable, but must not miss.
XCTAssertNotNil(hit)
}
func testGapCloserToLeftSelectsLeft() {
let pair = makePairTargets()
// Just right of left key visual edge, still in gap, closer to left center.
let point = CGPoint(x: 41, y: 25)
let hit = KeyHitTesting.hitTarget(
at: point,
targets: pair.targets,
keyPlaneBounds: pair.plane,
horizontalGap: 6,
verticalGap: 7,
edgeExpansion: 0
)
XCTAssertEqual(hit?.id, "L")
}
func testGapCloserToRightSelectsRight() {
let pair = makePairTargets()
let point = CGPoint(x: 45, y: 25)
let hit = KeyHitTesting.hitTarget(
at: point,
targets: pair.targets,
keyPlaneBounds: pair.plane,
horizontalGap: 6,
verticalGap: 7,
edgeExpansion: 0
)
XCTAssertEqual(hit?.id, "R")
}
func testOutsidePlaneReturnsNil() {
let pair = makePairTargets()
let hit = KeyHitTesting.hitTarget(
at: CGPoint(x: 200, y: 200),
targets: pair.targets,
keyPlaneBounds: pair.plane,
horizontalGap: 6,
verticalGap: 7,
edgeExpansion: 0
)
XCTAssertNil(hit)
}
func testIntentOffsetShiftsHitUpward() {
// Key occupies y 1060. Raw touch at y=62 is below the key; with a
// 4pt upward intent offset it maps to y=58 and should still hit.
let target = TypingKeyHitTarget(
id: "K",
label: "M",
visualFrame: CGRect(x: 0, y: 10, width: 40, height: 50),
behavior: .commitOnRelease
)
let plane = target.visualFrame
let raw = CGPoint(x: 20, y: 62)
let withoutOffset = KeyHitTesting.hitTarget(
at: raw,
targets: [target],
keyPlaneBounds: plane,
horizontalGap: 0,
verticalGap: 0,
edgeExpansion: 0
)
let withOffset = KeyHitTesting.hitTarget(
rawTouch: raw,
targets: [target],
keyPlaneBounds: plane,
horizontalGap: 0,
verticalGap: 0,
intentOffsetY: 4,
edgeExpansion: 0
)
XCTAssertNil(withoutOffset)
XCTAssertEqual(withOffset?.id, "K")
}
func testEdgeExpansionExtendsOuterHit() {
let target = TypingKeyHitTarget(
id: "Q",
label: "Q",
visualFrame: CGRect(x: 10, y: 0, width: 40, height: 50),
behavior: .commitOnRelease
)
let plane = target.visualFrame
// Point just left of the visual key, inside edge expansion.
let point = CGPoint(x: 7, y: 25)
let hit = KeyHitTesting.hitTarget(
at: point,
targets: [target],
keyPlaneBounds: plane,
horizontalGap: 0,
verticalGap: 0,
edgeExpansion: 5
)
XCTAssertEqual(hit?.id, "Q")
}
func testLayoutBuilderCoversGapsBetweenKeys() {
let layout = TypingKeyLayoutBuilder.build(
size: CGSize(width: 300, height: 200),
letterRows: [
["Q", "W", "E"],
["A", "S", "D"],
["Z", "X", "C"]
],
pageSwitchLabel: "123",
spaceLabel: "space",
returnLabel: "return",
keyWeight: { _, _, _ in 1 }
)
XCTAssertEqual(layout.keys.count, 12) // 9 letters + 3 bottom
// Mid-gap between Q and W on first row should hit something.
let q = layout.keys.first { $0.label == "Q" }!
let w = layout.keys.first { $0.label == "W" }!
let mid = CGPoint(x: (q.visualFrame.maxX + w.visualFrame.minX) / 2, y: q.center.y)
let hit = KeyHitTesting.hitTarget(
at: mid,
targets: layout.keys,
keyPlaneBounds: layout.keyPlaneBounds,
horizontalGap: layout.horizontalGap,
verticalGap: layout.verticalGap
)
XCTAssertNotNil(hit)
XCTAssertTrue(hit?.label == "Q" || hit?.label == "W")
}
func testBehaviorResolver() {
XCTAssertEqual(TypingKeyBehaviorResolver.behavior(for: ""), .deleteRepeat)
XCTAssertEqual(TypingKeyBehaviorResolver.behavior(for: ""), .shiftHold)
XCTAssertEqual(TypingKeyBehaviorResolver.behavior(for: "A"), .commitOnRelease)
XCTAssertEqual(TypingKeyBehaviorResolver.behavior(for: "123"), .commitOnRelease)
}
}
@@ -0,0 +1,181 @@
// PinyinNextKeyResolverTests.swift
// OSGKeyboard · Ext unit tests
//
// Phase 4: legal next-key sets and weighted ambiguous hit resolution.
import XCTest
@testable import OSGKeyboardShared
final class PinyinNextKeyResolverTests: XCTestCase {
func testZhongPrefixAllowsG() {
let keys = PinyinNextKeyResolver.validNextKeys(
rawInput: "zhon",
schema: .fullPinyin,
language: .chinese,
page: .letters
)
XCTAssertEqual(keys, ["g"])
}
func testCompleteZhongAllowsNewSyllableInitials() {
let keys = PinyinNextKeyResolver.validNextKeys(
rawInput: "zhong",
schema: .fullPinyin,
language: .chinese,
page: .letters
)
XCTAssertNotNil(keys)
XCTAssertTrue(keys?.contains("g") == true) // zhongguo
XCTAssertTrue(keys?.contains("w") == true)
}
func testMultiSyllableTrailingGAllowsU() {
let keys = PinyinNextKeyResolver.validNextKeys(
rawInput: "zhongg",
schema: .fullPinyin,
language: .chinese,
page: .letters
)
XCTAssertTrue(keys?.contains("u") == true) // gu / guo
}
func testNiAllowsExtensionAndNewInitial() {
let keys = PinyinNextKeyResolver.validNextKeys(
rawInput: "ni",
schema: .fullPinyin,
language: .chinese,
page: .letters
)
XCTAssertTrue(keys?.contains("a") == true) // nia
XCTAssertTrue(keys?.contains("h") == true) // nihao
}
func testEmptyRawDisablesBias() {
let keys = PinyinNextKeyResolver.validNextKeys(
rawInput: "",
schema: .fullPinyin,
language: .chinese,
page: .letters
)
XCTAssertNil(keys)
}
func testEnglishDisablesBias() {
let keys = PinyinNextKeyResolver.validNextKeys(
rawInput: "ni",
schema: .fullPinyin,
language: .english,
page: .letters
)
XCTAssertNil(keys)
}
func testDoublePinyinDisablesBias() {
let keys = PinyinNextKeyResolver.validNextKeys(
rawInput: "nihk",
schema: .microsoftDoublePinyin,
language: .chinese,
page: .letters
)
XCTAssertNil(keys)
}
func testNumbersPageDisablesBias() {
let keys = PinyinNextKeyResolver.validNextKeys(
rawInput: "ni",
schema: .fullPinyin,
language: .chinese,
page: .numbers
)
XCTAssertNil(keys)
}
func testHitWeightsBoostLegalLetters() {
let keys = [
TypingKeyHitTarget(
id: "grid.0.0",
label: "G",
visualFrame: .zero,
behavior: .commitOnRelease
),
TypingKeyHitTarget(
id: "grid.0.1",
label: "H",
visualFrame: .zero,
behavior: .commitOnRelease
),
TypingKeyHitTarget(
id: "grid.0.2",
label: "",
visualFrame: .zero,
behavior: .deleteRepeat
)
]
let weights = PinyinNextKeyResolver.hitWeights(for: keys, validNext: ["g"])
XCTAssertEqual(weights["grid.0.0"], KeyHitBiasMetrics.legalBoost)
XCTAssertEqual(weights["grid.0.1"], KeyHitBiasMetrics.illegalShrink)
XCTAssertNil(weights["grid.0.2"])
}
func testWeightedNearestPrefersLegalKeyInGap() {
let left = TypingKeyHitTarget(
id: "L",
label: "F",
visualFrame: CGRect(x: 0, y: 0, width: 40, height: 50),
behavior: .commitOnRelease
)
let right = TypingKeyHitTarget(
id: "R",
label: "G",
visualFrame: CGRect(x: 46, y: 0, width: 40, height: 50),
behavior: .commitOnRelease
)
let plane = left.visualFrame.union(right.visualFrame)
// Shared expanded edge (half of 6pt gap) both frames contain x=43.
let point = CGPoint(x: 43, y: 25)
let weighted = KeyHitTesting.hitTarget(
at: point,
targets: [left, right],
keyPlaneBounds: plane,
horizontalGap: 6,
verticalGap: 7,
edgeExpansion: 0,
hitWeights: [
"L": KeyHitBiasMetrics.illegalShrink,
"R": KeyHitBiasMetrics.legalBoost
]
)
XCTAssertEqual(weighted?.id, "R")
}
func testClearSingleHitIgnoresBias() {
// Point clearly inside F must not jump to boosted G.
let left = TypingKeyHitTarget(
id: "L",
label: "F",
visualFrame: CGRect(x: 0, y: 0, width: 40, height: 50),
behavior: .commitOnRelease
)
let right = TypingKeyHitTarget(
id: "R",
label: "G",
visualFrame: CGRect(x: 46, y: 0, width: 40, height: 50),
behavior: .commitOnRelease
)
let plane = left.visualFrame.union(right.visualFrame)
let point = CGPoint(x: 20, y: 25)
let hit = KeyHitTesting.hitTarget(
at: point,
targets: [left, right],
keyPlaneBounds: plane,
horizontalGap: 6,
verticalGap: 7,
edgeExpansion: 0,
hitWeights: [
"L": KeyHitBiasMetrics.illegalShrink,
"R": KeyHitBiasMetrics.legalBoost
]
)
XCTAssertEqual(hit?.id, "L")
}
}
@@ -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 return nil
} }
let preedit = snapshot.preedit let preedit = snapshot.preedit
let raw = bridge?.rawInput() ?? ""
composition = TypingComposition( composition = TypingComposition(
preedit: preedit, preedit: preedit,
rawInput: raw,
candidates: snapshot.candidates.enumerated().map { displayIndex, candidate in candidates: snapshot.candidates.enumerated().map { displayIndex, candidate in
TypingCandidate( TypingCandidate(
id: "\(preedit)|\(displayIndex)|\(candidate.index)|\(candidate.text)", 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. /// Snapshot the UI observes while composing.
public struct TypingComposition: Equatable, Sendable { public struct TypingComposition: Equatable, Sendable {
public var preedit: String 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 var candidates: [TypingCandidate]
public init(preedit: String = "", candidates: [TypingCandidate] = []) { public init(
preedit: String = "",
rawInput: String = "",
candidates: [TypingCandidate] = []
) {
self.preedit = preedit self.preedit = preedit
self.rawInput = rawInput
self.candidates = candidates 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
)
}
}
+4 -1
View File
@@ -1,8 +1,9 @@
# 打字键盘输入准确率提升计划 # 打字键盘输入准确率提升计划
> **文档状态**:产品与工程规划(已讨论对齐,待进入实现 > **文档状态**:产品与工程规划(**实现中**:Phase 1~4 已落地,待真机试打
> **适用范围**iOS 键盘扩展打字面(`OSGKeyboardExt` 打字键网格)+ 共享布局度量(`OSGKeyboardShared` > **适用范围**iOS 键盘扩展打字面(`OSGKeyboardExt` 打字键网格)+ 共享布局度量(`OSGKeyboardShared`
> **关联基线**`docs/TYPING_KEYBOARD_BASELINE.md` > **关联基线**`docs/TYPING_KEYBOARD_BASELINE.md`
> **实现分支**`feat/keyboard-touch-accuracy`
> **创建日期**2026-08-03 > **创建日期**2026-08-03
> **目标版本**:待 Phase 0 基线试打后确定发布节奏 > **目标版本**:待 Phase 0 基线试打后确定发布节奏
@@ -345,3 +346,5 @@ Week 34 Phase 5 气泡等加分项(可选)
| 日期 | 说明 | | 日期 | 说明 |
|------|------| |------|------|
| 2026-08-03 | 初稿:基于系统键盘分层分析与产品讨论冻结 Phase 0~5 | | 2026-08-03 | 初稿:基于系统键盘分层分析与产品讨论冻结 Phase 0~5 |
| 2026-08-05 | 开分支 `feat/keyboard-touch-accuracy`;落地 Phase 1(无死区)+ Phase 2(网格 Down/Move/Up+ Phase 3(触点上偏);纯逻辑见 `KeyHitTesting` / `TypingKeyLayout`,触控见 `TypingKeyTouchPad` |
| 2026-08-05 | Phase 4:全拼合法下一键偏心(`PinyinNextKeyResolver` + `rawInput`);双拼/英文保持中性;歧义最近键加权,单键明确命中不受偏置 |
+2 -2
View File
@@ -51,8 +51,8 @@ settings:
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
STRING_CATALOG_GENERATE_SYMBOLS: YES STRING_CATALOG_GENERATE_SYMBOLS: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17 CLANG_CXX_LANGUAGE_STANDARD: c++17
MARKETING_VERSION: "1.6.0" MARKETING_VERSION: "1.6.1"
CURRENT_PROJECT_VERSION: "44" CURRENT_PROJECT_VERSION: "45"
# 签名配置来自 Signing.local.xcconfiggitignored,不会被覆盖) # 签名配置来自 Signing.local.xcconfiggitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target # 项目级签名 xcconfig,适用于所有 target