diff --git a/CHANGELOG.md b/CHANGELOG.md index 34fbc2c..a50b583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`)。 diff --git a/OSGKeyboardExt/Typing/TypingKeyTouchPad.swift b/OSGKeyboardExt/Typing/TypingKeyTouchPad.swift new file mode 100644 index 0000000..976efe0 --- /dev/null +++ b/OSGKeyboardExt/Typing/TypingKeyTouchPad.swift @@ -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? + 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, with event: UIEvent?) { + guard let touch = touches.first else { return } + coordinator?.handleBegan(at: touch.location(in: self)) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + coordinator?.handleMoved(at: touch.location(in: self)) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + coordinator?.handleEnded(at: touch.location(in: self)) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + coordinator?.handleCancelled() + } +} diff --git a/OSGKeyboardExt/Typing/TypingRootView.swift b/OSGKeyboardExt/Typing/TypingRootView.swift index 519ef5c..fd52d33 100644 --- a/OSGKeyboardExt/Typing/TypingRootView.swift +++ b/OSGKeyboardExt/Typing/TypingRootView.swift @@ -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,17 +60,11 @@ 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 - .padding(.top, TypingLayoutMetrics.verticalKeySpacing) - - bottomRow - .frame(height: TypingLayoutMetrics.bottomRowHeight) - .padding(.top, TypingLayoutMetrics.keyRowSpacing) - } - .opacity(expanded ? 0 : 1) - .allowsHitTesting(!expanded) - .accessibilityHidden(expanded) + typingKeySurface + .padding(.top, TypingLayoutMetrics.verticalKeySpacing) + .opacity(expanded ? 0 : 1) + .allowsHitTesting(!expanded) + .accessibilityHidden(expanded) if expanded || candidatePanelMounted { expandedCandidatePanel @@ -277,242 +273,195 @@ struct TypingRootView: View { // MARK: - Keys - private var keyGrid: some View { - VStack(spacing: TypingLayoutMetrics.keyRowSpacing) { - ForEach(Array(typing.keyRows.enumerated()), id: \.offset) { rowIndex, row in - 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, +)) + /// 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 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]) - } - } - .padding(.horizontal, inset) + 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) } - .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) - } + 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 + + Group { + if key.label == "⌫" { + Image(systemName: "delete.left") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(keyTextColor) + } else if key.label == "⇧" { + Image(systemName: typing.isShiftEnabled ? "shift.fill" : "shift") + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(keyTextColor) + } 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, + weight: isSpecial ? .semibold : .regular + ) + ) + .foregroundStyle(keyTextColor) } - .accessibilityLabel(Text(label)) - } - } - - /// 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 - 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 "⇧": - Image(systemName: typing.isShiftEnabled ? "shift.fill" : "shift") - .font(.system(size: 19, weight: .medium)) - .foregroundStyle(keyTextColor) - default: - Text(label) - .font( - .system( - size: isSpecial ? 15 : 22, - weight: isSpecial ? .semibold : .regular - ) - ) - .foregroundStyle(keyTextColor) + .background( + RoundedRectangle( + cornerRadius: TypingLayoutMetrics.keyCornerRadius, + style: .continuous + ) + .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 var bottomRow: some View { - GeometryReader { proxy in - let widths = KeyboardChromeLayout.actionKeyWidths( - availableWidth: proxy.size.width - ) + private func visualKeyFill(for key: TypingKeyHitTarget, pressed: Bool) -> Color { + if key.id == TypingKeyLayoutBuilder.BottomKeyID.return.rawValue { + return pressed ? returnKeyPressedFill : returnKeyFill + } + return pressed ? keyPressedFill : keyFill + } - HStack(spacing: TypingLayoutMetrics.bottomActionSpacing) { - PressDownKeyButton { - TypingKeyFeedback.play( - role: .modifier, - intensity: state.keyboardHapticIntensity - ) - 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")) + private func visualKeyBorder(for key: TypingKeyHitTarget) -> Color { + if key.id == TypingKeyLayoutBuilder.BottomKeyID.return.rawValue { + return returnKeyBorder + } + return palette.divider + } - PressDownKeyButton { - TypingKeyFeedback.play( - role: .action, - intensity: state.keyboardHapticIntensity - ) - 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")) + 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 + } + } - PressDownKeyButton { - TypingKeyFeedback.play( - role: .action, - intensity: state.keyboardHapticIntensity - ) - 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)) + private func commitTypingKey(_ key: TypingKeyHitTarget) { + switch key.id { + case TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue: + typing.setPage(typing.page == .letters ? .numbers : .letters) + case TypingKeyLayoutBuilder.BottomKeyID.space.rawValue: + apply(typing.handleSpace()) + case TypingKeyLayoutBuilder.BottomKeyID.return.rawValue: + apply(typing.handleReturn()) + 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 diff --git a/OSGKeyboardExtTests/KeyHitTestingTests.swift b/OSGKeyboardExtTests/KeyHitTestingTests.swift new file mode 100644 index 0000000..971e480 --- /dev/null +++ b/OSGKeyboardExtTests/KeyHitTestingTests.swift @@ -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) + } +} diff --git a/OSGKeyboardExtTests/PinyinNextKeyResolverTests.swift b/OSGKeyboardExtTests/PinyinNextKeyResolverTests.swift new file mode 100644 index 0000000..16b2a42 --- /dev/null +++ b/OSGKeyboardExtTests/PinyinNextKeyResolverTests.swift @@ -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") + } +} diff --git a/OSGKeyboardShared/Typing/KeyHitTesting.swift b/OSGKeyboardShared/Typing/KeyHitTesting.swift new file mode 100644 index 0000000..37bd4de --- /dev/null +++ b/OSGKeyboardShared/Typing/KeyHitTesting.swift @@ -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 + } + } +} diff --git a/OSGKeyboardShared/Typing/LibrimeEngine.swift b/OSGKeyboardShared/Typing/LibrimeEngine.swift index fa18711..fe93022 100644 --- a/OSGKeyboardShared/Typing/LibrimeEngine.swift +++ b/OSGKeyboardShared/Typing/LibrimeEngine.swift @@ -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)", diff --git a/OSGKeyboardShared/Typing/PinyinNextKeyResolver.swift b/OSGKeyboardShared/Typing/PinyinNextKeyResolver.swift new file mode 100644 index 0000000..a6aa26f --- /dev/null +++ b/OSGKeyboardShared/Typing/PinyinNextKeyResolver.swift @@ -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? { + 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() + 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? + ) -> [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) { + 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) + } + } + } +} diff --git a/OSGKeyboardShared/Typing/PinyinSyllableTable.swift b/OSGKeyboardShared/Typing/PinyinSyllableTable.swift new file mode 100644 index 0000000..b61022b --- /dev/null +++ b/OSGKeyboardShared/Typing/PinyinSyllableTable.swift @@ -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 = [ + "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 "" + } +} diff --git a/OSGKeyboardShared/Typing/RimeEngineBridging.swift b/OSGKeyboardShared/Typing/RimeEngineBridging.swift index 1d72870..3764bfd 100644 --- a/OSGKeyboardShared/Typing/RimeEngineBridging.swift +++ b/OSGKeyboardShared/Typing/RimeEngineBridging.swift @@ -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 } diff --git a/OSGKeyboardShared/Typing/TypingKeyLayout.swift b/OSGKeyboardShared/Typing/TypingKeyLayout.swift new file mode 100644 index 0000000..d771d05 --- /dev/null +++ b/OSGKeyboardShared/Typing/TypingKeyLayout.swift @@ -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 + ) + } +} diff --git a/docs/keyboard-accuracy-plan.md b/docs/keyboard-accuracy-plan.md index 09952cc..fbf3e2f 100644 --- a/docs/keyboard-accuracy-plan.md +++ b/docs/keyboard-accuracy-plan.md @@ -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`);双拼/英文保持中性;歧义最近键加权,单键明确命中不受偏置 | diff --git a/project.yml b/project.yml index af41f38..057edc7 100644 --- a/project.yml +++ b/project.yml @@ -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