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:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user