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