feat: cursor navigation, key sounds, dictionary tooling, key security
Batch of in-progress app work from the working tree. - feat(keyboard): CursorNavigation + CursorDragPad for caret movement; KeyboardSoundFeedback for system key click sounds - feat(dictionary): DictionaryAliasGenerator + PersonalDictionaryEntrySheet; TranscriptPostProcessor quality gate; retire DictionaryLearner - feat(ui): TabBarVisibility handling; drop PageHeaderRow / PageHeaderConfirmButton; refresh views and localizable strings - fix(security): move the hardcoded DeepSeek key out of PreconfiguredKeys.swift into a gitignored PreconfiguredKeys.local.swift (seeded from .example by generate-xcodeproj.sh) - docs(agents): add Conventional Commits versioning + bilingual changelog rules - chore(gitignore): ignore PreconfiguredKeys.local.swift, .cache/, pycache Custom language model / lexicon work stays on feature/custom-language-model-asr. Changelog bullets added under [Unreleased]; no version bump.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
// CursorDragPad.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// SwiftUI layout wrapper for a UIKit pan recognizer. SwiftUI gestures
|
||||
// can be unreliable in keyboard-extension hosting views; keeping the
|
||||
// recognizer in UIKit preserves the existing layout while avoiding that
|
||||
// failure mode.
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import os
|
||||
|
||||
private let cursorDragLog = Logger(subsystem: "com.osgkeyboard.ios", category: "CursorDrag")
|
||||
|
||||
struct CursorDragPad: UIViewRepresentable {
|
||||
let enabled: Bool
|
||||
let onPressingChanged: (Bool) -> Void
|
||||
let moveHorizontal: (Int) -> Void
|
||||
let moveVertical: (Int) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> CursorDragPadUIView {
|
||||
cursorDragLog.info("makeUIView (enabled=\(enabled))")
|
||||
let view = CursorDragPadUIView()
|
||||
view.coordinator = context.coordinator
|
||||
view.isPadEnabled = enabled
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: CursorDragPadUIView, context: Context) {
|
||||
context.coordinator.onPressingChanged = onPressingChanged
|
||||
context.coordinator.moveHorizontal = moveHorizontal
|
||||
context.coordinator.moveVertical = moveVertical
|
||||
uiView.isPadEnabled = enabled
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(
|
||||
onPressingChanged: onPressingChanged,
|
||||
moveHorizontal: moveHorizontal,
|
||||
moveVertical: moveVertical
|
||||
)
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
var onPressingChanged: (Bool) -> Void
|
||||
var moveHorizontal: (Int) -> Void
|
||||
var moveVertical: (Int) -> Void
|
||||
|
||||
init(
|
||||
onPressingChanged: @escaping (Bool) -> Void,
|
||||
moveHorizontal: @escaping (Int) -> Void,
|
||||
moveVertical: @escaping (Int) -> Void
|
||||
) {
|
||||
self.onPressingChanged = onPressingChanged
|
||||
self.moveHorizontal = moveHorizontal
|
||||
self.moveVertical = moveVertical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class CursorDragPadUIView: UIView, UIGestureRecognizerDelegate {
|
||||
weak var coordinator: CursorDragPad.Coordinator?
|
||||
|
||||
var isPadEnabled = true {
|
||||
didSet {
|
||||
isUserInteractionEnabled = isPadEnabled
|
||||
applyIdleTint()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pad tint
|
||||
// MUST stay non-zero. When embedded via `UIViewRepresentable`, a fully
|
||||
// transparent (alpha 0) background makes SwiftUI's host treat the region
|
||||
// as empty pass-through space and the pad stops receiving touches. A tiny
|
||||
// alpha (just above UIKit's 0.01 hit-test threshold) keeps the pad fully
|
||||
// draggable while remaining imperceptible.
|
||||
//
|
||||
// The keyboard surface itself is transparent (system chrome shows
|
||||
// through), so there is no fixed colour to match; `systemGray4` tracks
|
||||
// the system keyboard's grey in both light and dark and, at ~2% alpha,
|
||||
// blends invisibly. `withAlphaComponent` on a dynamic colour can freeze
|
||||
// the current trait, so resolve per-trait to stay appearance-adaptive.
|
||||
private static let padTint = UIColor { traits in
|
||||
UIColor.systemGray4.resolvedColor(with: traits).withAlphaComponent(0.02)
|
||||
}
|
||||
private static var idleTint: UIColor { padTint }
|
||||
private static var activeTint: UIColor { padTint }
|
||||
|
||||
private func applyIdleTint() {
|
||||
backgroundColor = isPadEnabled ? Self.idleTint : .clear
|
||||
}
|
||||
|
||||
private var lastTranslation = CGPoint.zero
|
||||
private var horizontalCarry: CGFloat = 0
|
||||
private var verticalCarry: CGFloat = 0
|
||||
private var didFireBeginHaptic = false
|
||||
/// Once the finger clears the dead zone, lock to one axis so slight
|
||||
/// diagonal jitter does not flip between horizontal and vertical steps.
|
||||
private var lockedAxis: LockedAxis?
|
||||
|
||||
private enum LockedAxis {
|
||||
case horizontal
|
||||
case vertical
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = Self.idleTint
|
||||
isMultipleTouchEnabled = false
|
||||
isUserInteractionEnabled = true
|
||||
|
||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
||||
pan.delegate = self
|
||||
pan.minimumNumberOfTouches = 1
|
||||
pan.maximumNumberOfTouches = 1
|
||||
pan.cancelsTouchesInView = false
|
||||
pan.delaysTouchesBegan = false
|
||||
pan.delaysTouchesEnded = false
|
||||
addGestureRecognizer(pan)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
let size = "\(Int(bounds.width))x\(Int(bounds.height))"
|
||||
cursorDragLog.info("didMoveToWindow size=\(size, privacy: .public) attached=\(self.window != nil)")
|
||||
}
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
let hit = super.hitTest(point, with: event)
|
||||
if hit === self {
|
||||
cursorDragLog.debug("hitTest inside pad")
|
||||
}
|
||||
return hit
|
||||
}
|
||||
|
||||
// Raw touch delivery drives the "drag mode" state so a static hold
|
||||
// (which a pan recognizer ignores until the finger moves) already
|
||||
// switches the keyboard into cursor-drag chrome.
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
guard isPadEnabled else { return }
|
||||
backgroundColor = Self.activeTint
|
||||
coordinator?.onPressingChanged(true)
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesEnded(touches, with: event)
|
||||
applyIdleTint()
|
||||
coordinator?.onPressingChanged(false)
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesCancelled(touches, with: event)
|
||||
applyIdleTint()
|
||||
coordinator?.onPressingChanged(false)
|
||||
}
|
||||
|
||||
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
guard isPadEnabled, let coordinator else { return }
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
resetGestureState()
|
||||
backgroundColor = Self.activeTint
|
||||
coordinator.onPressingChanged(true)
|
||||
cursorDragLog.info("pan began")
|
||||
case .changed:
|
||||
handlePanChanged(gesture, coordinator: coordinator)
|
||||
case .ended, .cancelled, .failed:
|
||||
resetGestureState()
|
||||
applyIdleTint()
|
||||
coordinator.onPressingChanged(false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePanChanged(
|
||||
_ gesture: UIPanGestureRecognizer,
|
||||
coordinator: CursorDragPad.Coordinator
|
||||
) {
|
||||
let translation = gesture.translation(in: self)
|
||||
let delta = CGPoint(
|
||||
x: translation.x - lastTranslation.x,
|
||||
y: translation.y - lastTranslation.y
|
||||
)
|
||||
lastTranslation = translation
|
||||
|
||||
let deadZone: CGFloat = 6
|
||||
guard max(abs(translation.x), abs(translation.y)) > deadZone else { return }
|
||||
|
||||
if !didFireBeginHaptic {
|
||||
didFireBeginHaptic = true
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
|
||||
if lockedAxis == nil {
|
||||
lockedAxis = abs(translation.x) >= abs(translation.y) ? .horizontal : .vertical
|
||||
}
|
||||
|
||||
switch lockedAxis {
|
||||
case .horizontal:
|
||||
horizontalCarry += delta.x
|
||||
let threshold = stepThreshold(for: translation.x)
|
||||
let steps = consumeCarry(&horizontalCarry, threshold: threshold)
|
||||
if steps != 0 {
|
||||
coordinator.moveHorizontal(steps)
|
||||
}
|
||||
case .vertical:
|
||||
verticalCarry += delta.y
|
||||
let threshold = stepThreshold(for: translation.y) * Self.verticalSensitivityDamping
|
||||
let steps = consumeCarry(&verticalCarry, threshold: threshold)
|
||||
if steps != 0 {
|
||||
coordinator.moveVertical(steps)
|
||||
}
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func resetGestureState() {
|
||||
lastTranslation = .zero
|
||||
horizontalCarry = 0
|
||||
verticalCarry = 0
|
||||
didFireBeginHaptic = false
|
||||
lockedAxis = nil
|
||||
}
|
||||
|
||||
/// Vertical steps move in large character chunks, so require more finger
|
||||
/// travel per step than horizontal to keep them from firing too fast.
|
||||
/// Higher = less sensitive.
|
||||
private static let verticalSensitivityDamping: CGFloat = 2.6
|
||||
|
||||
/// Farther drag means a smaller threshold and faster stepping,
|
||||
/// capped so long swipes remain controllable.
|
||||
private func stepThreshold(for totalAxisDistance: CGFloat) -> CGFloat {
|
||||
let deadZone: CGFloat = 6
|
||||
let accelerated = max(0, abs(totalAxisDistance) - deadZone)
|
||||
let progress = min(1, accelerated / 100)
|
||||
return 12 - progress * 7
|
||||
}
|
||||
|
||||
private func consumeCarry(_ carry: inout CGFloat, threshold: CGFloat) -> Int {
|
||||
guard threshold > 0 else { return 0 }
|
||||
let steps = Int(carry / threshold)
|
||||
if steps != 0 {
|
||||
carry -= CGFloat(steps) * threshold
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user