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,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()
}
}