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:
@@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.6.1] - 2026-08-05
|
||||
|
||||
### 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 继续自动共用同一词库。下次打开键盘生效。
|
||||
- **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 1–3)**: 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
|
||||
- **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()
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,8 @@ struct TypingRootView: View {
|
||||
/// After the first expand, keep the panel tree mounted and only toggle
|
||||
/// opacity so subsequent ▼/▲ taps stay cheap.
|
||||
@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
|
||||
|
||||
@@ -58,14 +60,8 @@ struct TypingRootView: View {
|
||||
// Keep the QWERTY tree mounted; only toggle visibility so ▼/▲
|
||||
// does not destroy GeometryReader key rows every time.
|
||||
ZStack(alignment: .top) {
|
||||
VStack(spacing: 0) {
|
||||
keyGrid
|
||||
typingKeySurface
|
||||
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
|
||||
|
||||
bottomRow
|
||||
.frame(height: TypingLayoutMetrics.bottomRowHeight)
|
||||
.padding(.top, TypingLayoutMetrics.keyRowSpacing)
|
||||
}
|
||||
.opacity(expanded ? 0 : 1)
|
||||
.allowsHitTesting(!expanded)
|
||||
.accessibilityHidden(expanded)
|
||||
@@ -277,149 +273,109 @@ struct TypingRootView: View {
|
||||
|
||||
// MARK: - Keys
|
||||
|
||||
private var keyGrid: some View {
|
||||
VStack(spacing: TypingLayoutMetrics.keyRowSpacing) {
|
||||
ForEach(Array(typing.keyRows.enumerated()), id: \.offset) { rowIndex, row in
|
||||
/// Visual keys + grid-level touch pad (Phase 1 gap fill + Phase 2
|
||||
/// down → move → up). Geometry is shared via ``TypingKeyLayoutBuilder``.
|
||||
private var typingKeySurface: some View {
|
||||
GeometryReader { proxy in
|
||||
let inset = rowIndex == 1 ? TypingLayoutMetrics.secondRowInset : 0
|
||||
let weights = row.enumerated().map {
|
||||
keyWeight(label: $0.element, index: $0.offset, rowIndex: rowIndex)
|
||||
}
|
||||
let spacing = TypingLayoutMetrics.keyHorizontalSpacing
|
||||
* CGFloat(max(0, row.count - 1))
|
||||
let availableWidth = proxy.size.width - inset * 2 - spacing
|
||||
let unitWidth = availableWidth / max(1, weights.reduce(0, +))
|
||||
let layout = makeTypingKeyLayout(size: proxy.size)
|
||||
ZStack(alignment: .topLeading) {
|
||||
TypingKeyTouchPad(
|
||||
layout: layout,
|
||||
hapticIntensity: state.keyboardHapticIntensity,
|
||||
onHighlightChange: { highlightedKeyID = $0 },
|
||||
onCommit: { commitTypingKey($0) },
|
||||
onDeleteFire: { apply(typing.handleKey("⌫")) },
|
||||
onShiftBegan: { typing.beginShiftHold() },
|
||||
onShiftEnded: { typing.endShiftHold() }
|
||||
)
|
||||
|
||||
HStack(spacing: TypingLayoutMetrics.keyHorizontalSpacing) {
|
||||
ForEach(Array(row.enumerated()), id: \.offset) { keyIndex, key in
|
||||
keyButton(key)
|
||||
.frame(width: unitWidth * weights[keyIndex])
|
||||
ForEach(layout.keys) { key in
|
||||
visualTypingKey(key)
|
||||
.frame(width: key.visualFrame.width, height: key.visualFrame.height)
|
||||
.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
|
||||
private func keyButton(_ label: String) -> some View {
|
||||
if label == "⌫" {
|
||||
typingDeleteKey()
|
||||
} else if label == "⇧" {
|
||||
typingShiftKey()
|
||||
} else {
|
||||
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))
|
||||
}
|
||||
}
|
||||
private func visualTypingKey(_ key: TypingKeyHitTarget) -> some View {
|
||||
let pressed = highlightedKeyID == key.id
|
||||
let isBottom = key.id.hasPrefix("bottom.")
|
||||
let isShift = key.label == "⇧"
|
||||
let shiftLit = isShift && typing.isShiftEnabled
|
||||
let showPressed = pressed || shiftLit
|
||||
|
||||
/// Hold = continuous uppercase while pressed; tap = one-shot / Caps Lock cycle.
|
||||
private func typingShiftKey() -> some View {
|
||||
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
|
||||
Group {
|
||||
if key.label == "⌫" {
|
||||
Image(systemName: "delete.left")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(keyTextColor)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.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 "⇧":
|
||||
} else if key.label == "⇧" {
|
||||
Image(systemName: typing.isShiftEnabled ? "shift.fill" : "shift")
|
||||
.font(.system(size: 19, weight: .medium))
|
||||
.foregroundStyle(keyTextColor)
|
||||
default:
|
||||
Text(label)
|
||||
} else if key.id == TypingKeyLayoutBuilder.BottomKeyID.return.rawValue {
|
||||
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(
|
||||
.system(
|
||||
size: isSpecial ? 15 : 22,
|
||||
@@ -429,90 +385,83 @@ struct TypingRootView: View {
|
||||
.foregroundStyle(keyTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomRow: some View {
|
||||
GeometryReader { proxy in
|
||||
let widths = KeyboardChromeLayout.actionKeyWidths(
|
||||
availableWidth: proxy.size.width
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
|
||||
HStack(spacing: TypingLayoutMetrics.bottomActionSpacing) {
|
||||
PressDownKeyButton {
|
||||
TypingKeyFeedback.play(
|
||||
role: .modifier,
|
||||
intensity: state.keyboardHapticIntensity
|
||||
.fill(visualKeyFill(for: key, pressed: showPressed))
|
||||
)
|
||||
.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)
|
||||
} label: { isPressed in
|
||||
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
|
||||
)
|
||||
case TypingKeyLayoutBuilder.BottomKeyID.space.rawValue:
|
||||
apply(typing.handleSpace())
|
||||
} label: { isPressed in
|
||||
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
|
||||
)
|
||||
case TypingKeyLayoutBuilder.BottomKeyID.return.rawValue:
|
||||
apply(typing.handleReturn())
|
||||
} label: { isPressed in
|
||||
NativeKeyboardKeySurface(
|
||||
isPressed: isPressed,
|
||||
fill: returnKeyFill,
|
||||
pressedFill: returnKeyPressedFill,
|
||||
border: returnKeyBorder,
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius
|
||||
) {
|
||||
returnKeyLabel
|
||||
.foregroundStyle(returnKeyTextColor)
|
||||
}
|
||||
.frame(
|
||||
width: widths.side,
|
||||
height: TypingLayoutMetrics.bottomRowHeight
|
||||
)
|
||||
}
|
||||
.accessibilityLabel(Text(returnKeyAccessibilityLabel))
|
||||
default:
|
||||
switch key.behavior {
|
||||
case .commitOnRelease:
|
||||
apply(typing.handleKey(key.label))
|
||||
case .deleteRepeat:
|
||||
apply(typing.handleKey("⌫"))
|
||||
case .shiftHold:
|
||||
// Mirror a completed Shift tap for VoiceOver / accessibility.
|
||||
typing.beginShiftHold()
|
||||
typing.endShiftHold()
|
||||
}
|
||||
}
|
||||
.frame(height: TypingLayoutMetrics.bottomRowHeight)
|
||||
}
|
||||
|
||||
@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 10…60. 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
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
# 打字键盘输入准确率提升计划
|
||||
|
||||
> **文档状态**:产品与工程规划(已讨论对齐,待进入实现)
|
||||
> **文档状态**:产品与工程规划(**实现中**:Phase 1~4 已落地,待真机试打)
|
||||
> **适用范围**:iOS 键盘扩展打字面(`OSGKeyboardExt` 打字键网格)+ 共享布局度量(`OSGKeyboardShared`)
|
||||
> **关联基线**:`docs/TYPING_KEYBOARD_BASELINE.md`
|
||||
> **实现分支**:`feat/keyboard-touch-accuracy`
|
||||
> **创建日期**:2026-08-03
|
||||
> **目标版本**:待 Phase 0 基线试打后确定发布节奏
|
||||
|
||||
@@ -345,3 +346,5 @@ Week 3~4 Phase 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
@@ -51,8 +51,8 @@ settings:
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
|
||||
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||
MARKETING_VERSION: "1.6.0"
|
||||
CURRENT_PROJECT_VERSION: "44"
|
||||
MARKETING_VERSION: "1.6.1"
|
||||
CURRENT_PROJECT_VERSION: "45"
|
||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||
|
||||
# 项目级签名 xcconfig,适用于所有 target
|
||||
|
||||
Reference in New Issue
Block a user