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:
Rocky
2026-07-05 18:27:23 +08:00
parent 074e24d87f
commit 05e005e9ce
60 changed files with 3247 additions and 1388 deletions
+208 -15
View File
@@ -20,6 +20,26 @@
import UIKit
import SwiftUI
import OSGKeyboardShared
import os
/// Unified log for the keyboard extension. Visible in Console.app when
/// filtered by `subsystem: com.osgkeyboard.ios`. Note: plain `print`
/// from an extension process does NOT reliably reach Xcode's console
/// (the debugger is usually attached to the host app, not the
/// extension), which is why extension-side diagnostics must go through
/// `os.Logger` to be observable.
private let keyboardExtLog = Logger(subsystem: "com.osgkeyboard.ios", category: "KeyboardExt")
private final class KeyboardHostingController: UIHostingController<KeyboardRootView> {
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
[.left, .right]
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
}
}
@objc(KeyboardViewController)
@MainActor
@@ -59,7 +79,9 @@ public final class KeyboardViewController: UIInputViewController {
private let polisher = PolishingService()
private let persistor = AppGroupPersistor()
private var hosting: UIHostingController<KeyboardRootView>!
private var hosting: UIHostingController<KeyboardRootView>?
/// Centred "" hint, stacked above the SwiftUI tree.
private var cursorDragHintLabel: UILabel?
/// Legacy one-shot handoff (`osgkeyboard://dictate`).
private var awaitingDictationResult = false
private var dictationRequestStartedAt: TimeInterval = 0
@@ -75,6 +97,14 @@ public final class KeyboardViewController: UIInputViewController {
private var flowSessionMonitorTask: Task<Void, Never>?
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
private var configDarwinObserver: FlowSessionDarwinObserver?
/// Serializes caret moves so `textDocumentProxy` keeps up with drag events.
private var pendingHorizontalCursorSteps = 0
private var pendingVerticalCursorSteps = 0
private var cursorMoveFlushScheduled = false
/// Fires once per vertical chunk step during a cursor drag.
private let cursorLineHaptic = UIImpactFeedbackGenerator(style: .light)
/// Characters moved per vertical drag step (up = back, down = forward).
private static let cursorVerticalChunkSize = 20
/// Grace period after a chip-side translation write during which the
/// 1 Hz App Group poll must not overwrite `translationTargetLocaleId`.
private var translationConfigProtectedUntil: Date?
@@ -94,6 +124,8 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewDidLoad() {
super.viewDidLoad()
keyboardExtLog.info("viewDidLoad — extension booted (build marker: cursor-drag diag)")
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
installKeyboardHeight()
configureDictationBehavior()
installStateActions()
@@ -120,6 +152,7 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
configureDictationBehavior()
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
consumePendingDictationResultIfNeeded()
@@ -131,6 +164,7 @@ public final class KeyboardViewController: UIInputViewController {
// from Settings.app or the host app, and the App Group is the
// only thing both processes see consistently.
syncOnboardingStateFromAppGroup()
refreshConfigFromAppGroup()
// Auto-advance past step 3 ("Enable Keyboard") if the user has
// enabled the keyboard in Settings.app while we were away.
// This is the "automatic return from jump" feature: no manual
@@ -145,10 +179,19 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
disableSystemGestureDelays()
// Presentation finished lock to the true content-driven height.
keyboardHeightConstraint?.constant = targetKeyboardHeight
}
public override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
[.left, .right]
}
public override var childForScreenEdgesDeferringSystemGestures: UIViewController? {
hosting
}
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
cancelPipeline()
@@ -168,6 +211,37 @@ public final class KeyboardViewController: UIInputViewController {
hasDictationKey = true
}
/// Keyboard extensions can lose or delay touches near the screen
/// edges because system edge-pan recognizers get first refusal.
/// Deferring edges above is the intent; this sweep removes delay
/// flags from recognizers already attached to the host hierarchy.
private func disableSystemGestureDelays() {
disableGestureDelays(in: view)
var parent = view.superview
while let current = parent {
disableGestureDelays(in: current)
parent = current.superview
}
if let window = view.window {
disableGestureDelays(in: window)
if let rootView = window.rootViewController?.view {
disableGestureDelays(in: rootView)
}
}
}
private func disableGestureDelays(in targetView: UIView) {
targetView.gestureRecognizers?.forEach { recognizer in
recognizer.delaysTouchesBegan = false
recognizer.delaysTouchesEnded = false
recognizer.cancelsTouchesInView = false
if recognizer is UIScreenEdgePanGestureRecognizer {
recognizer.isEnabled = false
}
}
targetView.subviews.forEach(disableGestureDelays)
}
// MARK: - Wiring
private func installStateActions() {
@@ -194,6 +268,93 @@ public final class KeyboardViewController: UIInputViewController {
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
state.moveCursorHorizontal = { [weak self] steps in
self?.moveCursorHorizontally(by: steps)
}
state.moveCursorVertical = { [weak self] steps in
self?.moveCursorVertically(by: steps)
}
state.setCursorDragActive = { [weak self] active in
self?.setCursorDragActive(active)
}
}
private func setCursorDragActive(_ active: Bool) {
state.cursorDragActive = active
updateCursorDragWash(active: active)
}
private func updateCursorDragWash(active: Bool) {
if active {
cursorLineHaptic.prepare()
}
layoutCursorDragChrome()
// Gradient wash intentionally not shown only the centred hint.
guard let hint = cursorDragHintLabel else { return }
if active {
hint.isHidden = false
UIView.animate(withDuration: 0.12) { hint.alpha = 1 }
} else {
UIView.animate(withDuration: 0.12, animations: { hint.alpha = 0 }) { [weak self] _ in
guard let self, !self.state.cursorDragActive else { return }
hint.isHidden = true
}
}
}
private func moveCursorHorizontally(by steps: Int) {
guard steps != 0 else { return }
pendingHorizontalCursorSteps += steps
scheduleCursorMoveFlush()
}
private func moveCursorVertically(by steps: Int) {
guard steps != 0 else { return }
pendingVerticalCursorSteps += steps
scheduleCursorMoveFlush()
}
private func scheduleCursorMoveFlush() {
guard !cursorMoveFlushScheduled else { return }
cursorMoveFlushScheduled = true
// Give the text-document proxy one run-loop turn between drag
// samples so caret updates are not dropped. Runs on the main
// queue (not a Task) to avoid `unsafeForcedSync` proxy access.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.012) { [weak self] in
guard let self else { return }
self.cursorMoveFlushScheduled = false
let horizontal = self.pendingHorizontalCursorSteps
let vertical = self.pendingVerticalCursorSteps
self.pendingHorizontalCursorSteps = 0
self.pendingVerticalCursorSteps = 0
if horizontal != 0 {
keyboardExtLog.info("adjustTextPosition h=\(horizontal)")
self.textDocumentProxy.adjustTextPosition(byCharacterOffset: horizontal)
}
if vertical != 0 {
self.applyVerticalCursorSteps(vertical)
}
if self.pendingHorizontalCursorSteps != 0 || self.pendingVerticalCursorSteps != 0 {
self.scheduleCursorMoveFlush()
}
}
}
private func applyVerticalCursorSteps(_ steps: Int) {
let direction = steps > 0 ? 1 : -1
var remaining = abs(steps)
let chunk = Self.cursorVerticalChunkSize
while remaining > 0 {
textDocumentProxy.adjustTextPosition(byCharacterOffset: direction * chunk)
cursorLineHaptic.impactOccurred()
cursorLineHaptic.prepare()
remaining -= 1
}
}
// MARK: - Onboarding persistence (v0.3.0)
@@ -271,7 +432,7 @@ public final class KeyboardViewController: UIInputViewController {
private func installSwiftUI() {
let root = KeyboardRootView(state: state)
let host = UIHostingController(rootView: root)
let host = KeyboardHostingController(rootView: root)
host.view.backgroundColor = .clear
host.view.translatesAutoresizingMaskIntoConstraints = false
host.view.clipsToBounds = false
@@ -289,12 +450,42 @@ public final class KeyboardViewController: UIInputViewController {
])
host.didMove(toParent: self)
self.hosting = host
// Cursor-drag chrome: only a centred hint label above the SwiftUI
// tree (non-interactive so the pads underneath still receive
// touches). The green gradient wash was removed it was too hard to
// align cleanly with the system keyboard's rounded top edge.
let hint = UILabel()
hint.text = ExtL10n.string("keyboard.cursorDrag.centerHint")
hint.font = .systemFont(ofSize: 22, weight: .medium)
hint.textColor = UIColor.label.withAlphaComponent(0.10)
hint.textAlignment = .center
hint.numberOfLines = 1
hint.adjustsFontSizeToFitWidth = true
hint.minimumScaleFactor = 0.7
hint.isUserInteractionEnabled = false
hint.isHidden = true
hint.alpha = 0
view.addSubview(hint)
self.cursorDragHintLabel = hint
}
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
layoutCursorDragChrome()
}
private func layoutCursorDragChrome() {
cursorDragHintLabel?.frame = view.bounds
}
private func loadPersistedConfig() {
switch persistor.load(into: state) {
case .loaded:
break
keyboardExtLog.info(
"config loaded — cursorDragNavigationEnabled=\(self.state.cursorDragNavigationEnabled)"
)
case .unavailable:
state.phase = .error(
.appGroupUnavailable,
@@ -457,6 +648,7 @@ public final class KeyboardViewController: UIInputViewController {
default:
return
}
guard !state.micDisabled else { return }
guard hasFullAccess else {
let msg = ExtL10n.string("keyboard.error.fullAccessRequired")
state.phase = .error(.unknown(msg), message: msg)
@@ -731,11 +923,18 @@ public final class KeyboardViewController: UIInputViewController {
guard let self else { return }
let polishMode = runtimeStore.polishModeForPipeline
let overrideProviderId = runtimeStore.polishProviderIdOverride
let preceding = self.textDocumentProxy.documentContextBeforeInput
let polishContext = PolishContext(
appContext: runtimeStore.detectedAppContext?.context ?? .unknown,
intensity: runtimeStore.polishIntensity,
precedingText: preceding
)
do {
let polished = try await self.polisher.polish(
trimmed,
mode: polishMode,
providerIdOverride: overrideProviderId
providerIdOverride: overrideProviderId,
context: polishContext
)
self.textDocumentProxy.insertText(polished)
self.state.lastTranscript = ""
@@ -773,16 +972,12 @@ public final class KeyboardViewController: UIInputViewController {
self.scheduleAutoClearError()
}
} catch let polishError as PolishingService.PolishError where polishError == .missingAPIKey {
// v0.2.0: local engine + cloud polish toggle on, but the
// user hasn't entered an API key. Insert the raw transcript
// (so the user doesn't lose what they said) and surface the
// same "fill in your key" hint we use in the cloud path.
self.textDocumentProxy.insertText(trimmed)
self.state.lastTranscript = ""
self.state.phase = .error(
.llm(.noAPIKey),
message: ExtL10n.string("keyboard.error.llm.noApiKey")
)
let message = runtimeStore.engineMode == "local"
? ExtL10n.string("keyboard.error.llm.localPolishUnavailable")
: ExtL10n.string("keyboard.error.llm.noApiKey")
self.state.phase = .error(.llm(.noAPIKey), message: message)
self.scheduleAutoClearError()
} catch {
// Network / timeout / decoding fall back to the raw
@@ -981,8 +1176,6 @@ public final class KeyboardViewController: UIInputViewController {
}
private func debug(_ message: String) {
#if DEBUG
print("🎙️[KeyboardVC] \(message)")
#endif
keyboardExtLog.info("\(message, privacy: .public)")
}
}
@@ -41,7 +41,12 @@ public struct AppGroupPersistor {
// the keyboard stays open.
state.translationTargetLocaleId = store.translationTargetLocaleId
state.handednessPreference = store.handednessPreference
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
: ""
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
// into the State flags so downstream consumers see the same
// shape they did when the previous Qwen3 stack reported "ready".
@@ -95,6 +100,11 @@ public struct AppGroupPersistor {
state.translationTargetLocaleId = store.translationTargetLocaleId
}
state.handednessPreference = store.handednessPreference
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
: ""
// v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these
// toggles here so the keyboard UI doesn't flicker if the host
// app briefly clears them while refactoring.
@@ -0,0 +1,41 @@
// KeyboardSoundFeedback.swift
// OSGKeyboard · Keyboard Extension
//
// Plays the built-in iOS keyboard click sounds so the custom bottom-row
// keys (space / return / delete) sound identical to the stock keyboard.
import UIKit
import AudioToolbox
/// `UIDevice.playInputClick()`
/// true
/// `UIInputView`
extension UIInputView: @retroactive UIInputViewAudioFeedback {
public var enableInputClicksWhenVisible: Bool { true }
}
/// / /
///
/// / `playInputClick()`
/// / `playInputClick()`
/// `1155`
/// 访
enum KeyboardSoundFeedback {
///
private static let deleteSoundID: SystemSoundID = 1155
///
@MainActor
static func keyClick() {
UIDevice.current.playInputClick()
}
///
static func deleteClick() {
// 访
// 线
DispatchQueue.global(qos: .userInteractive).async {
AudioServicesPlaySystemSound(deleteSoundID)
}
}
}
+263
View File
@@ -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
}
}
+104 -29
View File
@@ -25,8 +25,12 @@ private enum KeyboardLayoutMetrics {
static let bottomActionRowHeight: CGFloat = 48
static let bottomActionFixedWidth: CGFloat = 86
static let bottomActionSpacing: CGFloat = Spacing.xs
/// Gap between the top chip row and the transcript / hint line (4 pt 8 pt, +100%).
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs
/// Gap between the top chip row and the transcript / hint line.
/// Tightened (8 4) so the "" line hugs the chip row. The
/// space reclaimed here and from `actionClusterTopGap` is added back
/// into `actionClusterBottomGap`, keeping `totalHeight` constant while
/// nudging the mic up toward the vertical centre.
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs / 2
/// Outer inset for the bottom action row from screen edges (8 pt 24 pt, +200%).
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
@@ -37,16 +41,18 @@ private enum KeyboardLayoutMetrics {
static let transcriptLineHeight: CGFloat = 22
/// mic (121) + gap (8) + bottom row (48) = 177 pt
static let actionClusterHeight: CGFloat = micSize + micToButtonGap + bottomActionRowHeight
/// Gap between transcript line and mic (30% from former 16 pt).
static let actionClusterTopGap: CGFloat = Spacing.md * 0.7
/// Minimal gap below the bottom action row.
static let actionClusterBottomGap: CGFloat = Spacing.xs / 2
/// Gap between transcript line and mic. Tightened (11.2 4) to pull
/// the mic up; the reclaimed space moves to `actionClusterBottomGap`.
static let actionClusterTopGap: CGFloat = Spacing.xs / 2
/// Gap below the bottom action row.
static let actionClusterBottomGap: CGFloat = 6
static var headerBandHeight: CGFloat {
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
}
/// 2 + 68 + 11.2 + 177 + 4 + 1 = 263.2 pt
/// 2 + 64 + 4 + 177 + 15.2 + 1 = 263.2 pt (unchanged; the mic cluster
/// just sits higher now that the top gaps moved to the bottom gap).
static var totalHeight: CGFloat {
outerPaddingTop
+ headerBandHeight
@@ -70,6 +76,17 @@ public struct KeyboardRootView: View {
/// in `KeyboardViewController` (see `KeyboardLayoutMetrics.totalHeight`).
static let totalHeight: CGFloat = KeyboardLayoutMetrics.totalHeight
// MARK: - Cursor-drag pad geometry
/// Mic disc side length.
static let micSize: CGFloat = KeyboardLayoutMetrics.micSize
/// Vertical offset from the keyboard's top edge to the mic disc.
static let micTopOffset: CGFloat = KeyboardLayoutMetrics.outerPaddingTop
+ KeyboardLayoutMetrics.headerBandHeight
+ KeyboardLayoutMetrics.actionClusterTopGap
/// Horizontal inset the side pads should respect.
static let sideInset: CGFloat = KeyboardLayoutMetrics.sideActionHorizontalInset
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
@@ -109,6 +126,7 @@ public struct KeyboardRootView: View {
}
}
.animation(.easeInOut(duration: 0.18), value: state.hasCompletedOnboarding)
.animation(.easeInOut(duration: 0.12), value: state.cursorDragActive)
}
/// Top chip row + transcript / hint line.
@@ -121,9 +139,12 @@ public struct KeyboardRootView: View {
phase: state.phase,
transcript: state.lastTranscript,
flowSessionActive: state.flowSessionActive,
micDisabled: state.micDisabled,
micDisabledHint: state.micDisabledHint,
isLocalEngine: state.isLocalEngine,
localModelsReady: state.localModelsReady,
localModelsLoaded: state.localModelsLoaded,
cursorDragHintActive: state.cursorDragActive,
openSettings: state.openSettings,
startFlowSession: state.startFlowSession
)
@@ -142,7 +163,14 @@ public struct KeyboardRootView: View {
}
// App context is auto-detected on each mic press no UI.
if state.isTranslationChipVisible {
TranslationChip(state: state)
TranslationChip(
palette: palette,
targetLocaleId: state.translationTargetLocaleId,
onSelect: state.setTranslationTargetLocaleId
)
// Decouple the open picker from the keyboard's 1 Hz App
// Group poll so scrolling doesn't reset / dismiss it.
.equatable()
}
Spacer(minLength: 0)
Button(action: state.openSettings) {
@@ -162,18 +190,37 @@ public struct KeyboardRootView: View {
// MARK: - Action cluster
/// Mic centred above a bottom row: delete · space · return (or swapped).
/// The side cursor-drag pads are SwiftUI layout wrappers around UIKit
/// pan recognizers, avoiding SwiftUI gesture delivery issues in
/// keyboard extensions.
private var micActionRow: some View {
let editingBlocked = voiceInputBlocksEditing
let swapKeys = state.handednessPreference.swapsActionKeys
let micDisabled = state.micDisabled
let cursorPadsEnabled = state.cursorDragNavigationEnabled && !editingBlocked
// Dragging hides the mic + bottom keys (kept in the layout via
// opacity so the pads' hit area never shifts mid-gesture) and lets
// the cursor-drag chrome take over.
let dragging = state.cursorDragActive
return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) {
RecordButton(
phase: buttonPhase,
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
onToggle: state.tapMic
)
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
HStack(spacing: 0) {
cursorDragPad(enabled: cursorPadsEnabled)
RecordButton(
phase: buttonPhase,
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
isEnabled: !micDisabled,
onToggle: state.tapMic
)
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
.opacity(dragging ? 0 : 1)
cursorDragPad(enabled: cursorPadsEnabled)
}
.frame(height: KeyboardLayoutMetrics.micSize)
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
if swapKeys {
@@ -186,11 +233,23 @@ public struct KeyboardRootView: View {
bottomReturnButton(disabled: editingBlocked)
}
}
.opacity(dragging ? 0 : 1)
}
.padding(.horizontal, KeyboardLayoutMetrics.sideActionHorizontalInset)
.frame(maxWidth: .infinity)
}
private func cursorDragPad(enabled: Bool) -> some View {
CursorDragPad(
enabled: enabled,
onPressingChanged: state.setCursorDragActive,
moveHorizontal: state.moveCursorHorizontal,
moveVertical: state.moveCursorVertical
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
private func bottomDeleteButton(disabled: Bool) -> some View {
RepeatingDeleteButton(disabled: disabled) {
state.deleteBackward()
@@ -276,17 +335,39 @@ private struct TranscriptLine: View {
let phase: KeyboardViewController.State.Phase
let transcript: String
let flowSessionActive: Bool
let micDisabled: Bool
let micDisabledHint: String
let isLocalEngine: Bool
let localModelsReady: Bool
let localModelsLoaded: Bool
let cursorDragHintActive: Bool
let openSettings: () -> Void
let startFlowSession: () -> Void
var body: some View {
ZStack {
switch phase {
case .idle:
if isLocalEngine, !localModelsReady {
// While dragging the caret, the whole mic cluster + transcript
// line give way to the cursor-drag overlay, so hide this line's
// "" / status text entirely.
if !cursorDragHintActive {
phaseContent
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Spacing.md)
}
@ViewBuilder
private var phaseContent: some View {
switch phase {
case .idle:
if micDisabled {
Text(micDisabledHint)
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.lineLimit(1)
.truncationMode(.tail)
} else if isLocalEngine, !localModelsReady {
Button(action: openSettings) {
HStack(spacing: 4) {
Text(ExtL10n.string("keyboard.models.notDownloaded"))
@@ -335,14 +416,11 @@ private struct TranscriptLine: View {
.truncationMode(.head)
.frame(maxWidth: .infinity)
case .processing:
HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.accent)
Text(transcript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") : transcript)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
}
Text(transcript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") : transcript)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
case .error(_, let msg):
Text(msg ?? "")
.font(TypeStyle.caption)
@@ -365,10 +443,7 @@ private struct TranscriptLine: View {
}
.buttonStyle(.plain)
.accessibilityHint(ExtL10n.text("keyboard.deniedHint"))
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Spacing.md)
}
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
+10 -2
View File
@@ -21,6 +21,7 @@ struct RecordButton: View {
let level: Double // 0...1
/// Seconds left in the current utterance; shown only while recording.
let remainingSeconds: Int?
let isEnabled: Bool
let onToggle: () -> Void
@State private var breath: Bool = false
@@ -29,11 +30,13 @@ struct RecordButton: View {
phase: Phase,
level: Double,
remainingSeconds: Int? = nil,
isEnabled: Bool = true,
onToggle: @escaping () -> Void
) {
self.phase = phase
self.level = level
self.remainingSeconds = remainingSeconds
self.isEnabled = isEnabled
self.onToggle = onToggle
}
@@ -103,6 +106,8 @@ struct RecordButton: View {
.foregroundStyle(.white)
.monospacedDigit()
.contentTransition(.numericText())
//
.offset(y: 3)
}
WaveformView(
level: level,
@@ -110,13 +115,15 @@ struct RecordButton: View {
active: true
)
.frame(width: 73, height: 32)
.opacity(0.4)
.scaleEffect(0.96)
}
.transition(.opacity)
case .processing:
ProgressView()
.progressViewStyle(.circular)
.tint(palette.textPrimary)
.scaleEffect(2.5)
.scaleEffect(1.25)
case .error:
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 32, weight: .medium))
@@ -129,8 +136,9 @@ struct RecordButton: View {
.animation(Motion.soft, value: remainingSeconds)
}
.contentShape(Circle())
.opacity(isEnabled ? 1 : 0.45)
.onTapGesture {
guard phase != .processing else { return }
guard isEnabled, phase != .processing else { return }
onToggle()
}
.onAppear { breath = (phase == .recording) }
+29 -57
View File
@@ -4,7 +4,6 @@
// Bottom-row action keys: repeating delete, space, and return.
import SwiftUI
import UIKit
import OSGKeyboardShared
// MARK: - Layout metrics
@@ -17,36 +16,8 @@ private enum ToolbarButtonMetrics {
static let pressOverlayOpacity: CGFloat = 0.18
}
// MARK: - Haptics
private enum ToolbarHaptics {
@MainActor
static func tap() {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
}
// MARK: - Press styling
private struct ToolbarKeyPressStyle: ButtonStyle {
let cornerRadius: CGFloat
func makeBody(configuration: Configuration) -> some View {
configuration.label
.overlay {
if configuration.isPressed {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(Color.black.opacity(ToolbarButtonMetrics.pressOverlayOpacity))
}
}
.scaleEffect(configuration.isPressed ? ToolbarButtonMetrics.pressScale : 1)
.animation(.easeOut(duration: 0.1), value: configuration.isPressed)
.sensoryFeedback(.impact(weight: .light), trigger: configuration.isPressed) { _, pressed in
pressed
}
}
}
private struct ToolbarKeySurface<Content: View>: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette
@@ -120,7 +91,7 @@ struct RepeatingDeleteButton: View {
guard !disabled, !isPressing else { return }
isPressing = true
repeatStartedAt = Date()
ToolbarHaptics.tap()
KeyboardSoundFeedback.deleteClick()
action()
startRepeating()
}
@@ -143,6 +114,7 @@ struct RepeatingDeleteButton: View {
guard !Task.isCancelled, isPressing else { return }
let anchor = repeatStartedAt ?? Date()
while !Task.isCancelled, isPressing {
KeyboardSoundFeedback.deleteClick()
action()
let elapsed = Date().timeIntervalSince(anchor)
let wait = interval(for: elapsed)
@@ -186,39 +158,39 @@ struct RectangularToolbarButton: View {
self.action = action
}
@State private var isPressing = false
var body: some View {
Button(action: action) {
Group {
if spaceStyle {
Capsule()
.fill(palette.textPrimary)
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
}
ToolbarKeySurface(isPressed: isPressing, cornerRadius: ToolbarButtonMetrics.cornerRadius) {
if spaceStyle {
Capsule()
.fill(palette.textPrimary)
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(keyBackground)
.overlay(
RoundedRectangle(cornerRadius: ToolbarButtonMetrics.cornerRadius, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
}
.buttonStyle(ToolbarKeyPressStyle(cornerRadius: ToolbarButtonMetrics.cornerRadius))
.disabled(disabled)
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text(label))
.accessibilityAddTraits(.isButton)
}
@Environment(\.colorScheme) private var colorScheme
private var keyBackground: some View {
let fill = colorScheme == .dark
? Color(red: 0.20, green: 0.20, blue: 0.22)
: palette.surfaceElevated
return RoundedRectangle(cornerRadius: ToolbarButtonMetrics.cornerRadius, style: .continuous)
.fill(fill)
// Button
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !disabled, !isPressing else { return }
isPressing = true
KeyboardSoundFeedback.keyClick()
action()
}
.onEnded { _ in
isPressing = false
}
}
}
+24 -13
View File
@@ -29,10 +29,27 @@
import SwiftUI
import OSGKeyboardShared
struct TranslationChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
struct TranslationChip: View, Equatable {
/// Passed in as a value (not read from `@Environment`) so the chip can
/// be wrapped in `.equatable()` at the call site: `EquatableView`
/// suppresses environment-driven refreshes, so injecting the palette
/// here keeps colours correct across dark/light switches.
let palette: ThemePalette
/// The active target-locale id (`offLocaleId` == translation off).
let targetLocaleId: String
/// Writes the picked locale id wired to `state.setTranslationTargetLocaleId`.
let onSelect: (String) -> Void
@ObservedObject var state: KeyboardViewController.State
/// Only `palette` and `targetLocaleId` drive the visuals; the
/// `onSelect` closure is deliberately excluded from equality. Because
/// the keyboard polls the App Group at 1 Hz (each poll re-publishes the
/// `KeyboardState`), the parent view re-renders every second. Without
/// this, SwiftUI would rebuild the `Menu` on every poll dismissing an
/// open picker or snapping its scroll position back to the top. With
/// `.equatable()` the picker is rebuilt only on a real state change.
nonisolated static func == (lhs: TranslationChip, rhs: TranslationChip) -> Bool {
lhs.palette == rhs.palette && lhs.targetLocaleId == rhs.targetLocaleId
}
var body: some View {
Menu {
@@ -43,9 +60,9 @@ struct TranslationChip: View {
// derived from it.
ForEach(TranslationLanguageCatalog.all) { language in
Button {
state.setTranslationTargetLocaleId(language.id)
onSelect(language.id)
} label: {
if language.id == currentSelectionId {
if language.id == targetLocaleId {
Label(displayLabel(for: language), systemImage: "checkmark")
} else {
Text(displayLabel(for: language))
@@ -62,8 +79,8 @@ struct TranslationChip: View {
@ViewBuilder
private var label: some View {
let target = TranslationLanguageCatalog.resolve(state.translationTargetLocaleId)
let enabled = state.translationEnabled
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
let enabled = targetLocaleId != TranslationLanguageCatalog.offLocaleId
HStack(spacing: 4) {
Image(systemName: enabled ? "character.bubble" : "character.bubble.fill")
@@ -80,12 +97,6 @@ struct TranslationChip: View {
.overlay(Capsule().stroke(stroke(enabled: enabled), lineWidth: 0.5))
}
/// Active selection id the chip derives "on" from a non-off
/// locale id, so reading `translationTargetLocaleId` is enough.
private var currentSelectionId: String {
state.translationTargetLocaleId
}
private func displayLabel(for language: TranslationLanguage) -> String {
if language.id == TranslationLanguageCatalog.offLocaleId {
return ExtL10n.string("keyboard.translation.offMenu")
+5 -1
View File
@@ -128,6 +128,8 @@
"keyboard.openSettingsA11y" = "Open OSGKeyboard settings";
"keyboard.deniedHint" = "Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access.";
"keyboard.tapToTalkA11y" = "Tap to talk";
"keyboard.cursorDrag.hint" = "Hold and drag to move the cursor";
"keyboard.cursorDrag.centerHint" = "Drag to move the cursor";
/* Flow session (keyboard) */
"keyboard.flow.sessionInactive" = "Voice session off";
@@ -155,6 +157,8 @@
"keyboard.error.manualOpenDictateLocal" = "System blocked the jump. Open OSGKeyboard for on-device dictation, then return.";
"keyboard.error.manualOpenDictate" = "System blocked the jump. Open OSGKeyboard to record, then return.";
"keyboard.error.llm.noApiKey" = "API key missing · configure it in the main app";
"keyboard.mic.disabled.missingApiKey" = "Fill in API key in Settings first";
"keyboard.error.llm.localPolishUnavailable" = "Built-in polish unavailable · inserted raw text";
"keyboard.error.llm.unauthorized" = "Invalid API key (401) · check main app settings";
"keyboard.error.llm.rateLimited" = "Rate limited (429) · try again later";
@@ -234,6 +238,6 @@
"keyboard.appContext.chip.unknown" = "General";
"keyboard.appContext.menu.code" = "Code — preserve identifiers, no natural-language wrap";
"keyboard.appContext.menu.email" = "Email — polite, professional, paragraph-broken";
"keyboard.appContext.menu.chat" = "Chat — short, casual, emoji-friendly";
"keyboard.appContext.menu.chat" = "Chat — short, casual, natural tone";
"keyboard.appContext.menu.document" = "Document — long-form, structured";
"keyboard.appContext.menu.unknown" = "General — neutral tone";
@@ -128,6 +128,8 @@
"keyboard.openSettingsA11y" = "打开 OSGKeyboard 设置";
"keyboard.deniedHint" = "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。";
"keyboard.tapToTalkA11y" = "点按说话";
"keyboard.cursorDrag.hint" = "按住并拖动以移动光标";
"keyboard.cursorDrag.centerHint" = "拖动移动光标";
/* Flow session (keyboard) */
"keyboard.flow.sessionInactive" = "语音会话未启动";
@@ -155,6 +157,8 @@
"keyboard.error.manualOpenDictateLocal" = "系统拒绝了跳转,请手动打开 OSGKeyboard 完成本地转写";
"keyboard.error.manualOpenDictate" = "系统拒绝了跳转,请手动打开 OSGKeyboard 录音";
"keyboard.error.llm.noApiKey" = "未配置 API Key · 请在主 App 设置中填写";
"keyboard.mic.disabled.missingApiKey" = "请先在设置中填写 API Key";
"keyboard.error.llm.localPolishUnavailable" = "内置润色不可用 · 已插入原始文本";
"keyboard.error.llm.unauthorized" = "API Key 无效 (401) · 请检查主 App 设置";
"keyboard.error.llm.rateLimited" = "API 限流 (429) · 请稍后再试";
@@ -234,6 +238,6 @@
"keyboard.appContext.chip.unknown" = "通用";
"keyboard.appContext.menu.code" = "代码 — 保留标识符、不做自然语言化";
"keyboard.appContext.menu.email" = "邮件 — 礼貌专业、合理分段";
"keyboard.appContext.menu.chat" = "聊天 — 简短随意、可带 emoji";
"keyboard.appContext.menu.chat" = "聊天 — 简短随意、保留口语";
"keyboard.appContext.menu.document" = "文档 — 长文、结构化";
"keyboard.appContext.menu.unknown" = "通用 — 中性口吻";