Files
Rocky 2bc8c1b87d fix(ipad): ship iPad P0 layout/globe fixes, edit-last-input, drop clipboard commands
Adapt typing/voice surfaces for iPad width and height, add the system globe
key and last-input editing flow, harden host-only Rime deployment, and remove
clipboard voice commands. Bump build to 61.
2026-08-10 13:50:50 +08:00

210 lines
6.9 KiB
Swift

// 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
/// Optional small number rendered above a letter key (iPad top row,
/// mirroring the iOS system keyboard's number overlay). `nil` elsewhere.
public let displayNumber: String?
public init(
id: String,
label: String,
visualFrame: CGRect,
behavior: TypingKeyTouchBehavior,
displayNumber: String? = nil
) {
self.id = id
self.label = label
self.visualFrame = visualFrame
self.behavior = behavior
self.displayNumber = displayNumber
}
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
}
}
}