feat(keyboard): unify assistant voice and AI workflows
Merge dictation and AI controls into one assistant surface, preserve safe insertion and clipboard actions, and align settings and tests with the new flow.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Principal class for the Custom Keyboard Extension. Hosts a single
|
||||
// SwiftUI tree (`KeyboardRootView`) and wires Flow voice input:
|
||||
// SwiftUI tree (`KeyboardSurfaceRoot`) and wires Flow voice / AI input:
|
||||
//
|
||||
// host app Flow session ──► App Group transcript ──► insertText
|
||||
//
|
||||
@@ -71,10 +71,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
private var aiKeyboardCoordinator: AIKeyboardCoordinator!
|
||||
private var clipboardCapture: ClipboardCaptureCoordinator!
|
||||
private var configSync: KeyboardConfigSync!
|
||||
/// UIKit may synchronously lay out the view during `viewDidLoad`.
|
||||
/// Keep this optional so an early layout pass is harmless.
|
||||
private var cursorDrag: CursorDragController?
|
||||
|
||||
/// iPad-scale keys require both an iPad host and regular horizontal space.
|
||||
/// This keeps compact iPad multitasking on phone metrics and prevents wide
|
||||
/// iPhones from being mistaken for iPads.
|
||||
@@ -191,10 +187,8 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
if state.surface == .typing {
|
||||
TypingInputConfiguration.persistLastTypingLanguage(typingSession.language)
|
||||
}
|
||||
if state.surface == .ai {
|
||||
// AI context never survives a keyboard presentation, but the
|
||||
// selected surface itself is restored on the next open.
|
||||
TypingInputConfiguration.persistLastSurface(.ai)
|
||||
if state.aiSession.isActive {
|
||||
// Unified assistant conversations never survive a keyboard presentation.
|
||||
aiKeyboardCoordinator.leave()
|
||||
}
|
||||
if !preserve {
|
||||
@@ -353,7 +347,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
// portrait→landscape resize. `refreshLayoutMode` no-ops unless the
|
||||
// layout bucket actually changed, so this cannot loop.
|
||||
refreshLayoutMode()
|
||||
cursorDrag?.layoutChrome()
|
||||
enforcePresentedKeyboardHeightIfNeeded()
|
||||
logLayoutSnapshotIfChanged()
|
||||
}
|
||||
@@ -428,6 +421,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
},
|
||||
performReturn: { [weak self] in
|
||||
self?.textDocumentProxy.insertText("\n")
|
||||
},
|
||||
captureInsertionFingerprint: { [weak self] in
|
||||
self?.captureFieldContext().deliveryFingerprint
|
||||
}
|
||||
)
|
||||
clipboardCapture = ClipboardCaptureCoordinator(state: state)
|
||||
@@ -474,13 +470,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
self?.state.aiSession.cancelCurrentWork()
|
||||
}
|
||||
_ = textInserter.recoverPendingEditTransactionIfNeeded()
|
||||
|
||||
cursorDrag = CursorDragController(
|
||||
state: state,
|
||||
adjustTextPosition: { [weak self] offset in
|
||||
self?.textDocumentProxy.adjustTextPosition(byCharacterOffset: offset)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Wiring
|
||||
@@ -513,8 +502,14 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state.cancelAIInput = { [weak self] in
|
||||
self?.aiKeyboardCoordinator.cancel()
|
||||
}
|
||||
state.sendAIAnswer = { [weak self] in
|
||||
self?.aiKeyboardCoordinator.sendLatestAnswer()
|
||||
state.confirmPendingAIAnswer = { [weak self] in
|
||||
self?.aiKeyboardCoordinator.confirmPendingAnswer()
|
||||
}
|
||||
state.discardPendingAIAnswer = { [weak self] in
|
||||
self?.aiKeyboardCoordinator.discardPendingAnswer()
|
||||
}
|
||||
state.sendAssistantAction = { [weak self] in
|
||||
self?.aiKeyboardCoordinator.sendCurrentFieldAction()
|
||||
}
|
||||
state.submitAIHint = { [weak self] card in
|
||||
self?.aiKeyboardCoordinator.submitHintCard(card)
|
||||
@@ -567,6 +562,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state.setEngineMode = { [weak self] m in self?.configSync.persistEngineMode(m) }
|
||||
state.setTranslationTargetLocaleId = { [weak self] id in
|
||||
self?.configSync.persistTranslationTargetLocaleId(id)
|
||||
self?.aiKeyboardCoordinator.resetConversationForConfigurationChange()
|
||||
}
|
||||
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
|
||||
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
|
||||
@@ -575,15 +571,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state.redoLastInsertion = { [weak self] in self?.textInserter.redoLastInsertion() }
|
||||
state.copySelection = { [weak self] in self?.textInserter.copySelection() }
|
||||
state.cutSelection = { [weak self] in self?.textInserter.cutSelection() }
|
||||
state.moveCursorHorizontal = { [weak self] steps in
|
||||
self?.cursorDrag?.moveCursorHorizontally(by: steps)
|
||||
}
|
||||
state.moveCursorVertical = { [weak self] steps in
|
||||
self?.cursorDrag?.moveCursorVertically(by: steps)
|
||||
}
|
||||
state.setCursorDragActive = { [weak self] active in
|
||||
self?.cursorDrag?.setCursorDragActive(active)
|
||||
}
|
||||
state.setSurface = { [weak self] surface in
|
||||
self?.applySurface(surface)
|
||||
}
|
||||
@@ -608,7 +595,10 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func applySurface(_ surface: State.Surface) {
|
||||
private func applySurface(_ requestedSurface: State.Surface) {
|
||||
// `.ai` is retained only to decode preferences written by older builds.
|
||||
// The product now has one unified assistant surface.
|
||||
let surface: State.Surface = requestedSurface == .ai ? .voice : requestedSurface
|
||||
if surface == .typing, state.locksTypingSurface {
|
||||
OSGDiag.log("applySurface blocked typing (locksTypingSurface)", category: "boot")
|
||||
return
|
||||
@@ -618,9 +608,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
return
|
||||
}
|
||||
guard state.surface != surface else {
|
||||
if surface == .ai {
|
||||
aiKeyboardCoordinator.enterIfNeeded()
|
||||
}
|
||||
refreshKeyboardHeight()
|
||||
return
|
||||
}
|
||||
@@ -628,10 +615,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
"applySurface \(state.surface.rawValue) → \(surface.rawValue) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
)
|
||||
let previousSurface = state.surface
|
||||
if previousSurface == .ai, surface != .ai {
|
||||
aiKeyboardCoordinator.leave()
|
||||
}
|
||||
state.surface = surface
|
||||
if surface == .typing {
|
||||
typingSession.enterTypingMode()
|
||||
@@ -639,9 +622,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
} else {
|
||||
typingSession.leaveTypingMode()
|
||||
}
|
||||
if surface == .ai {
|
||||
aiKeyboardCoordinator.enterIfNeeded()
|
||||
}
|
||||
refreshKeyboardHeight()
|
||||
}
|
||||
|
||||
@@ -662,9 +642,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
if resolved == .typing, let language = preference.typingLanguage {
|
||||
_ = typingSession.setLanguage(language)
|
||||
}
|
||||
if resolved == .ai {
|
||||
aiKeyboardCoordinator.beginNewPresentation()
|
||||
}
|
||||
}
|
||||
|
||||
/// When not remembering, snap to the static open preference while hidden
|
||||
@@ -735,6 +712,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
private func refreshReturnKeyRole() {
|
||||
state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
|
||||
if !state.returnKeyRole.usesActionFill {
|
||||
state.assistantSendAvailable = false
|
||||
}
|
||||
let isSecure = textDocumentProxy.isSecureTextEntry ?? false
|
||||
state.setSecureTextEntry(isSecure)
|
||||
clipboardCapture?.secureEntryDidChange(isSecure: isSecure)
|
||||
@@ -925,7 +905,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
])
|
||||
host.didMove(toParent: self)
|
||||
hosting = host
|
||||
cursorDrag?.install(on: view)
|
||||
}
|
||||
|
||||
// MARK: - App context
|
||||
|
||||
@@ -14,22 +14,31 @@ final class AIKeyboardCoordinator {
|
||||
private let flow: KeyboardFlowCoordinator
|
||||
private let insertAnswer: (AIAnswer) -> Bool
|
||||
private let performReturn: () -> Void
|
||||
private let captureInsertionFingerprint: () -> String?
|
||||
private var requestInsertionFingerprint: String?
|
||||
private var conversationInsertionFingerprint: String?
|
||||
private var hasConversationInsertionTarget = false
|
||||
|
||||
init(
|
||||
state: KeyboardState,
|
||||
flow: KeyboardFlowCoordinator,
|
||||
insertAnswer: @escaping (AIAnswer) -> Bool,
|
||||
performReturn: @escaping () -> Void
|
||||
performReturn: @escaping () -> Void,
|
||||
captureInsertionFingerprint: @escaping () -> String?
|
||||
) {
|
||||
self.state = state
|
||||
self.flow = flow
|
||||
self.insertAnswer = insertAnswer
|
||||
self.performReturn = performReturn
|
||||
self.captureInsertionFingerprint = captureInsertionFingerprint
|
||||
}
|
||||
|
||||
func beginNewPresentation() {
|
||||
endConversationIfNeeded()
|
||||
state.aiSession.enter()
|
||||
requestInsertionFingerprint = nil
|
||||
conversationInsertionFingerprint = nil
|
||||
hasConversationInsertionTarget = false
|
||||
}
|
||||
|
||||
func enterIfNeeded() {
|
||||
@@ -43,6 +52,9 @@ final class AIKeyboardCoordinator {
|
||||
}
|
||||
endConversationIfNeeded()
|
||||
state.aiSession.leave()
|
||||
requestInsertionFingerprint = nil
|
||||
conversationInsertionFingerprint = nil
|
||||
hasConversationInsertionTarget = false
|
||||
}
|
||||
|
||||
func toggleMicrophone() {
|
||||
@@ -51,17 +63,17 @@ final class AIKeyboardCoordinator {
|
||||
guard let utteranceID = state.aiSession.activeUtteranceID else { return }
|
||||
state.aiSession.beginRecognizing(utteranceID: utteranceID)
|
||||
flow.stopAIRecording()
|
||||
case .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
|
||||
enterIfNeeded()
|
||||
case .idle, .awaitingSend, .inserted, .sent, .failed:
|
||||
prepareConversationForRequest()
|
||||
guard let conversationID = state.aiSession.conversationID else { return }
|
||||
let disposition = flow.beginAIRecording(conversationID: conversationID)
|
||||
if case .rejected(let rejection) = disposition {
|
||||
state.aiSession.fail(message(for: rejection), utteranceID: nil)
|
||||
}
|
||||
case .inactive:
|
||||
enterIfNeeded()
|
||||
prepareConversationForRequest()
|
||||
toggleMicrophone()
|
||||
case .preparing, .recognizing, .generating:
|
||||
case .preparing, .recognizing, .generating, .ready:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -69,7 +81,7 @@ final class AIKeyboardCoordinator {
|
||||
/// Tap a clipboard skill chip: same fail-closed material path as hint cards.
|
||||
func submitClipboardSkill(_ skill: AIClipboardSkill) {
|
||||
guard canAcceptIdleSubmit else { return }
|
||||
enterIfNeeded()
|
||||
prepareConversationForRequest()
|
||||
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||
if skill.kind == .export {
|
||||
state.pendingClipboardSkillID = skill.id
|
||||
@@ -77,11 +89,14 @@ final class AIKeyboardCoordinator {
|
||||
} else {
|
||||
clearPendingExportSkill()
|
||||
}
|
||||
let instruction = AIClipboardSkillCatalog.instruction(
|
||||
var instruction = AIClipboardSkillCatalog.instruction(
|
||||
for: skill,
|
||||
locale: AIHintLocaleResolver.packLocale(),
|
||||
translationTargetLocaleId: state.translationTargetLocaleId
|
||||
)
|
||||
if skill.kind == .export {
|
||||
instruction += "\nPreserve the source language, addresses, names, and proper nouns."
|
||||
}
|
||||
AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)")
|
||||
if let material {
|
||||
AIAgentShortcutRun.traceBody("keyboard.clipboard", material)
|
||||
@@ -101,7 +116,7 @@ final class AIKeyboardCoordinator {
|
||||
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
|
||||
func submitHintCard(_ card: AIHintCard) {
|
||||
guard canAcceptIdleSubmit else { return }
|
||||
enterIfNeeded()
|
||||
prepareConversationForRequest()
|
||||
let resolution = AIHintPool.resolvePrompt(
|
||||
for: card,
|
||||
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||
@@ -111,10 +126,10 @@ final class AIKeyboardCoordinator {
|
||||
|
||||
private var canAcceptIdleSubmit: Bool {
|
||||
switch state.aiSession.phase {
|
||||
case .inactive, .idle, .failed:
|
||||
case .inactive, .idle, .awaitingSend, .inserted, .sent, .failed:
|
||||
return true
|
||||
case .preparing, .listening, .recognizing, .generating,
|
||||
.ready, .awaitingSend, .inserted, .sent:
|
||||
.ready:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -150,23 +165,37 @@ final class AIKeyboardCoordinator {
|
||||
func cancel() {
|
||||
guard state.aiSession.isBusy else { return }
|
||||
clearPendingExportSkill()
|
||||
requestInsertionFingerprint = nil
|
||||
flow.cancelAIRecording()
|
||||
state.aiSession.cancelCurrentWork()
|
||||
}
|
||||
|
||||
func sendLatestAnswer() {
|
||||
func confirmPendingAnswer() {
|
||||
if state.aiSession.canInsert, let answer = state.aiSession.answer {
|
||||
guard insertAnswer(answer) else { return }
|
||||
state.aiSession.markAnswerInserted(
|
||||
offersSend: state.returnKeyRole.usesActionFill
|
||||
)
|
||||
} else if state.aiSession.canSend {
|
||||
conversationInsertionFingerprint = captureInsertionFingerprint()
|
||||
hasConversationInsertionTarget = true
|
||||
}
|
||||
}
|
||||
|
||||
func discardPendingAnswer() {
|
||||
state.aiSession.discardReadyAnswer()
|
||||
requestInsertionFingerprint = nil
|
||||
}
|
||||
|
||||
func sendCurrentFieldAction() {
|
||||
guard state.assistantSendAvailable else { return }
|
||||
state.assistantSendAvailable = false
|
||||
if state.aiSession.canSend {
|
||||
state.aiSession.markAnswerSent()
|
||||
// Let the host consume the inserted answer before issuing Return.
|
||||
Task { @MainActor [weak self] in
|
||||
await Task.yield()
|
||||
self?.performReturn()
|
||||
}
|
||||
}
|
||||
// Let the host consume the inserted answer before issuing Return.
|
||||
Task { @MainActor [weak self] in
|
||||
await Task.yield()
|
||||
self?.performReturn()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,13 +259,36 @@ final class AIKeyboardCoordinator {
|
||||
return
|
||||
}
|
||||
state.aiSession.receiveAnswer(answer, utteranceID: result.utteranceId)
|
||||
defer { requestInsertionFingerprint = nil }
|
||||
guard state.aiSession.canInsert,
|
||||
let expected = requestInsertionFingerprint,
|
||||
captureInsertionFingerprint() == expected,
|
||||
let answer = state.aiSession.answer,
|
||||
insertAnswer(answer) else {
|
||||
// Keep `.ready`: the unified UI presents an explicit Insert / Discard
|
||||
// fallback when the field or caret changed during generation.
|
||||
return
|
||||
}
|
||||
state.aiSession.markAnswerInserted(
|
||||
offersSend: state.returnKeyRole.usesActionFill
|
||||
)
|
||||
conversationInsertionFingerprint = captureInsertionFingerprint()
|
||||
hasConversationInsertionTarget = true
|
||||
}
|
||||
|
||||
func fail(_ message: String, utteranceID: UUID?) {
|
||||
clearPendingExportSkill()
|
||||
requestInsertionFingerprint = nil
|
||||
state.aiSession.fail(message, utteranceID: utteranceID)
|
||||
}
|
||||
|
||||
/// A global output-language change starts a clean conversation so retained
|
||||
/// turns cannot override the newly selected language policy.
|
||||
func resetConversationForConfigurationChange() {
|
||||
guard state.aiSession.isActive else { return }
|
||||
beginNewPresentation()
|
||||
}
|
||||
|
||||
private func endConversationIfNeeded() {
|
||||
clearPendingExportSkill()
|
||||
guard let conversationID = state.aiSession.conversationID else { return }
|
||||
@@ -248,6 +300,20 @@ final class AIKeyboardCoordinator {
|
||||
state.pendingClipboardSkillSource = nil
|
||||
}
|
||||
|
||||
private func prepareConversationForRequest() {
|
||||
let currentFingerprint = captureInsertionFingerprint()
|
||||
if state.aiSession.isActive,
|
||||
hasConversationInsertionTarget,
|
||||
conversationInsertionFingerprint != currentFingerprint {
|
||||
beginNewPresentation()
|
||||
} else {
|
||||
enterIfNeeded()
|
||||
}
|
||||
conversationInsertionFingerprint = currentFingerprint
|
||||
hasConversationInsertionTarget = true
|
||||
requestInsertionFingerprint = currentFingerprint
|
||||
}
|
||||
|
||||
private var isPendingExportSkill: Bool {
|
||||
guard let id = state.pendingClipboardSkillID else { return false }
|
||||
return resolvedSkill(id: id)?.kind == .export
|
||||
|
||||
@@ -40,7 +40,6 @@ public struct AppGroupPersistor {
|
||||
// the keyboard stays open.
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
state.handednessPreference = store.handednessPreference
|
||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||
@@ -96,7 +95,6 @@ public struct AppGroupPersistor {
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
}
|
||||
state.handednessPreference = store.handednessPreference
|
||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
// CursorDragController.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Cursor-drag hint chrome and batched caret moves via textDocumentProxy.
|
||||
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
@MainActor
|
||||
final class CursorDragController {
|
||||
private let state: KeyboardState
|
||||
private let adjustTextPosition: (Int) -> Void
|
||||
private weak var parentView: UIView?
|
||||
|
||||
private var cursorDragHintLabel: UILabel?
|
||||
private var pendingHorizontalCursorSteps = 0
|
||||
private var pendingVerticalCursorSteps = 0
|
||||
private var cursorMoveFlushScheduled = false
|
||||
private let cursorLineHaptic = UIImpactFeedbackGenerator(style: .light)
|
||||
|
||||
private static let cursorVerticalChunkSize = 20
|
||||
|
||||
init(
|
||||
state: KeyboardState,
|
||||
adjustTextPosition: @escaping (Int) -> Void
|
||||
) {
|
||||
self.state = state
|
||||
self.adjustTextPosition = adjustTextPosition
|
||||
}
|
||||
|
||||
func install(on view: UIView) {
|
||||
parentView = view
|
||||
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)
|
||||
cursorDragHintLabel = hint
|
||||
layoutChrome()
|
||||
}
|
||||
|
||||
func layoutChrome() {
|
||||
guard let view = parentView else { return }
|
||||
cursorDragHintLabel?.frame = view.bounds
|
||||
}
|
||||
|
||||
func setCursorDragActive(_ active: Bool) {
|
||||
state.cursorDragActive = active
|
||||
updateCursorDragWash(active: active)
|
||||
}
|
||||
|
||||
func moveCursorHorizontally(by steps: Int) {
|
||||
guard steps != 0 else { return }
|
||||
pendingHorizontalCursorSteps += steps
|
||||
scheduleCursorMoveFlush()
|
||||
}
|
||||
|
||||
func moveCursorVertically(by steps: Int) {
|
||||
guard steps != 0 else { return }
|
||||
pendingVerticalCursorSteps += steps
|
||||
scheduleCursorMoveFlush()
|
||||
}
|
||||
|
||||
private func updateCursorDragWash(active: Bool) {
|
||||
if active {
|
||||
cursorLineHaptic.prepare()
|
||||
}
|
||||
layoutChrome()
|
||||
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 scheduleCursorMoveFlush() {
|
||||
guard !cursorMoveFlushScheduled else { return }
|
||||
cursorMoveFlushScheduled = true
|
||||
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 {
|
||||
OSGLog.keyboardExt.info("adjustTextPosition h=\(horizontal)")
|
||||
self.adjustTextPosition(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 {
|
||||
adjustTextPosition(direction * chunk)
|
||||
cursorLineHaptic.impactOccurred()
|
||||
cursorLineHaptic.prepare()
|
||||
remaining -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,9 +64,7 @@ final class KeyboardConfigSync {
|
||||
func loadPersistedConfig() -> AppGroupLoadResult {
|
||||
switch persistor.load(into: state) {
|
||||
case .loaded:
|
||||
OSGLog.keyboardExt.info(
|
||||
"config loaded — cursorDragNavigationEnabled=\(self.state.cursorDragNavigationEnabled)"
|
||||
)
|
||||
OSGLog.keyboardExt.info("config loaded")
|
||||
syncOnboardingStateFromAppGroup()
|
||||
return .loaded
|
||||
case .unavailable:
|
||||
|
||||
@@ -37,6 +37,7 @@ final class KeyboardTextInserter {
|
||||
/// Suppresses availability refresh while we walk `deleteBackward`
|
||||
/// for undo, so intermediate contexts don't flicker the button.
|
||||
private var isUndoing = false
|
||||
private var successPulseTask: Task<Void, Never>?
|
||||
|
||||
init(
|
||||
state: KeyboardState,
|
||||
@@ -155,6 +156,7 @@ final class KeyboardTextInserter {
|
||||
// The paste pushed any previous input away from the caret, so the
|
||||
// "editable last input" hint no longer applies.
|
||||
clearEditHintIfPositive()
|
||||
state.editAvailable = false
|
||||
OSGLog.keyboardExt.info("clipboard insert length=\(text.count, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -184,6 +186,9 @@ final class KeyboardTextInserter {
|
||||
redoContextBefore = contextBeforeInput()
|
||||
lastInsertedText = nil
|
||||
state.undoAvailable = false
|
||||
state.editAvailable = false
|
||||
state.assistantSendAvailable = false
|
||||
EditableInputReferenceStore.clear()
|
||||
OSGLog.keyboardExt.info("undo length=\(text.count, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -262,6 +267,10 @@ final class KeyboardTextInserter {
|
||||
if state.cutAvailable != hasSelection {
|
||||
state.cutAvailable = hasSelection
|
||||
}
|
||||
let editAvailable = editableReference() != nil
|
||||
if state.editAvailable != editAvailable {
|
||||
state.editAvailable = editAvailable
|
||||
}
|
||||
}
|
||||
|
||||
func editableReference() -> EditableInputReference? {
|
||||
@@ -288,15 +297,25 @@ final class KeyboardTextInserter {
|
||||
preceding.hasSuffix(reference.insertedText) else {
|
||||
return nil
|
||||
}
|
||||
// The current extension instance owns an exact in-memory insertion
|
||||
// record. Prefer it before the cross-process field fingerprint:
|
||||
// UITextDocumentProxy can publish its updated surrounding context one
|
||||
// callback after `insertText`, which otherwise makes a freshly shown
|
||||
// Edit button disappear or reject its first tap.
|
||||
if reference.matchesLiveInsertion(
|
||||
extensionInstanceID: extensionInstanceID,
|
||||
lastInsertedText: lastInsertedText,
|
||||
contextBeforeInput: preceding
|
||||
) {
|
||||
return reference
|
||||
}
|
||||
guard reference.postInsertionFingerprint == nil
|
||||
|| reference.postInsertionFingerprint
|
||||
== fieldContextProvider()?.deliveryFingerprint else {
|
||||
return nil
|
||||
}
|
||||
if reference.extensionInstanceID == extensionInstanceID,
|
||||
lastInsertedText == reference.insertedText {
|
||||
return reference
|
||||
}
|
||||
// Rebuilt extension instances have no trusted in-memory insertion
|
||||
// record, so they continue to require the complete field fingerprint.
|
||||
return reference.isFullyVerified(
|
||||
contextBeforeInput: preceding,
|
||||
fieldFingerprint: fieldContextProvider()?.deliveryFingerprint
|
||||
@@ -512,6 +531,15 @@ final class KeyboardTextInserter {
|
||||
extensionInstanceID: extensionInstanceID
|
||||
)
|
||||
)
|
||||
state.editAvailable = true
|
||||
state.assistantSendAvailable = state.returnKeyRole.usesActionFill
|
||||
state.assistantInsertionSucceeded = true
|
||||
successPulseTask?.cancel()
|
||||
successPulseTask = Task { @MainActor [weak state] in
|
||||
try? await Task.sleep(for: .milliseconds(450))
|
||||
guard !Task.isCancelled else { return }
|
||||
state?.assistantInsertionSucceeded = false
|
||||
}
|
||||
let hint = ExtL10n.string("keyboard.edit.hint.available")
|
||||
editHintScheduler.show(
|
||||
message: hint,
|
||||
@@ -527,6 +555,8 @@ final class KeyboardTextInserter {
|
||||
private func clearLastInsertion() {
|
||||
lastInsertedText = nil
|
||||
state.undoAvailable = false
|
||||
state.editAvailable = false
|
||||
state.assistantSendAvailable = false
|
||||
EditableInputReferenceStore.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ struct KeyboardSurfaceRoot: View {
|
||||
Group {
|
||||
switch state.surface {
|
||||
case .voice:
|
||||
KeyboardRootView(
|
||||
AIKeyboardView(
|
||||
state: state,
|
||||
typing: typing,
|
||||
onInsert: wrappedInsert
|
||||
@@ -50,6 +50,8 @@ struct KeyboardSurfaceRoot: View {
|
||||
onDeleteBackward: wrappedDeleteBackward
|
||||
)
|
||||
case .ai:
|
||||
// Legacy persisted surface; controller canonicalizes it to
|
||||
// `.voice`, but render the unified assistant defensively.
|
||||
AIKeyboardView(
|
||||
state: state,
|
||||
typing: typing,
|
||||
|
||||
@@ -174,8 +174,7 @@ final class TypingKeyTouchPadUIView: UIView {
|
||||
bottomRowMinY: 0
|
||||
)
|
||||
|
||||
/// Same trick as CursorDragPad: non-zero alpha so SwiftUI hosting does
|
||||
/// not treat the pad as pass-through.
|
||||
/// Non-zero alpha keeps SwiftUI hosting from treating the pad as pass-through.
|
||||
private static let padTint = UIColor { traits in
|
||||
UIColor.systemGray4.resolvedColor(with: traits).withAlphaComponent(0.02)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -324,7 +324,7 @@ private struct ClipboardHistoryRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Top clipboard button (replaces translation chip slot)
|
||||
// MARK: - Top clipboard button (right of the translation chip)
|
||||
|
||||
struct KeyboardClipboardMenuButton: View, Equatable {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@@ -359,6 +359,7 @@ struct KeyboardClipboardMenuButton: View, Equatable {
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("assistant.clipboard")
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.clipboard.a11y"))
|
||||
.accessibilityHint(ExtL10n.text("keyboard.clipboard.a11yHint"))
|
||||
}
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,709 +0,0 @@
|
||||
// KeyboardRootView.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Typeless-inspired keyboard surface. The keyboard is laid out in three
|
||||
// vertical bands, but the entire height is reserved for us — we set
|
||||
// `KeyboardViewController` drives height on `view` (priority 999) and mirrors
|
||||
// `KeyboardLayoutMetrics.totalHeight` in SwiftUI — the input view is bottom-
|
||||
// anchored so a transient over-tall system container cannot float the chrome.
|
||||
//
|
||||
// ┌───────────────────────────────────────────┐
|
||||
// │ [OSG] 语音 中文 EN 译 │ ← header band (top)
|
||||
// │ (transcript preview) │
|
||||
// │ ┊ │
|
||||
// │ ⟲ ◯ mic (centred) │ ← action cluster:
|
||||
// │ [delete] [ return ] [space] │ mic + undo + bottom row
|
||||
// │ ┊ │
|
||||
// └───────────────────────────────────────────┘
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
private enum KeyboardLayoutMetrics {
|
||||
static let micSize: CGFloat = 121
|
||||
static let micToButtonGap: CGFloat = 8
|
||||
/// Circular undo / translation keys beside the mic (outer edge).
|
||||
static let undoButtonSize: CGFloat = 52
|
||||
static let bottomActionRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
|
||||
static let bottomActionSpacing: CGFloat = KeyboardChromeLayout.actionKeySpacing
|
||||
/// Gap between the top control row and the transcript / hint line.
|
||||
/// Four points keeps the "点按说话" line visually attached to the controls.
|
||||
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs / 2
|
||||
/// Match the typing key grid's outer edge.
|
||||
static let sideActionHorizontalInset: CGFloat = KeyboardChromeLayout.horizontalInset
|
||||
/// iPad: cap the content column. A full-width (~1180 pt) keyboard would
|
||||
/// park delete/return at the far screen edges and turn each cursor-drag
|
||||
/// pad into a ~450 pt runway — capping keeps the reach ergonomics of the
|
||||
/// phone layout. iPhone widths are all below this, so it is a no-op there.
|
||||
/// The typing surface deliberately does not share this cap.
|
||||
static let contentMaxWidth: CGFloat = KeyboardChromeLayout.voiceContentMaxWidth
|
||||
|
||||
// MARK: - Content-driven keyboard height (single source of truth)
|
||||
static let outerPaddingTop: CGFloat = 4
|
||||
static let outerPaddingBottom: CGFloat = 4
|
||||
static let topBarHeight: CGFloat = KeyboardTopBarMetrics.height
|
||||
static let transcriptLineHeight: CGFloat = 22
|
||||
/// mic (121) + gap (8) + bottom row (50) = 179 pt
|
||||
static let actionClusterHeight: CGFloat = micSize + micToButtonGap + bottomActionRowHeight
|
||||
/// Moves the action cluster down so its keys share the typing row's baseline.
|
||||
static let actionClusterTopGap: CGFloat = Spacing.xl
|
||||
/// Centres the mic between the transcript hint and bottom action row.
|
||||
static let micUpwardAdjustment: CGFloat = (actionClusterTopGap - micToButtonGap) / 2
|
||||
/// The shared 4 pt outer padding is the complete bottom inset.
|
||||
static let actionClusterBottomGap: CGFloat = 0
|
||||
|
||||
/// `RecordButton` draws its outer ring at 106 pt inside the 121 pt touch
|
||||
/// frame, so the disc's *visible* top edge sits below the frame's top.
|
||||
static let micRingInset: CGFloat = (micSize - 106) / 2
|
||||
/// 语音/中文/EN capsule: 30 pt buttons + 2 pt padding, centred in the top bar.
|
||||
static let topBarTabCapsuleHeight: CGFloat = 34
|
||||
/// Pushes the transcript / hint line down to the vertical centre of the gap
|
||||
/// between the tab capsule's bottom edge and the mic's visible top edge.
|
||||
/// Applied as an offset so the band heights — and therefore `totalHeight`
|
||||
/// and `micUpwardAdjustment` — stay untouched. `extraSpace` is the slack a
|
||||
/// taller iPad keyboard adds above the action cluster, which moves the mic
|
||||
/// down and so must move this line with it.
|
||||
static func transcriptLineDownwardAdjustment(extraSpace: CGFloat) -> CGFloat {
|
||||
let capsuleBottom = (topBarHeight + topBarTabCapsuleHeight) / 2
|
||||
let micVisibleTop = topBarHeight
|
||||
+ topBarToTranscriptSpacing
|
||||
+ transcriptLineHeight
|
||||
+ actionClusterTopGap
|
||||
+ extraSpace
|
||||
- micUpwardAdjustment
|
||||
+ micRingInset
|
||||
let currentCentre = topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight / 2
|
||||
return (capsuleBottom + micVisibleTop) / 2 - currentCentre
|
||||
}
|
||||
|
||||
static var headerBandHeight: CGFloat {
|
||||
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
|
||||
}
|
||||
|
||||
/// 4 + 70 + 24 + 179 + 0 + 4 = 281 pt on phones.
|
||||
static let totalHeight: CGFloat = KeyboardChromeLayout.totalHeight
|
||||
|
||||
/// Voice and typing must resolve to the same height or switching surfaces
|
||||
/// visibly resizes the keyboard — 113 pt on an iPad in landscape. The
|
||||
/// typing surface is content-driven, so voice adopts its height and parks
|
||||
/// the surplus above the action cluster (keeping the bottom row on the
|
||||
/// same baseline as the typing bottom row).
|
||||
static func totalHeight(isIPad: Bool, width: CGFloat) -> CGFloat {
|
||||
TypingSurfaceMetrics.contentHeight(isIPad: isIPad, width: width)
|
||||
}
|
||||
|
||||
static func extraVerticalSpace(isIPad: Bool, width: CGFloat) -> CGFloat {
|
||||
max(0, totalHeight(isIPad: isIPad, width: width) - totalHeight)
|
||||
}
|
||||
}
|
||||
|
||||
public struct KeyboardRootView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@ObservedObject var state: State
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
let onInsert: (String) -> Void
|
||||
|
||||
public init(
|
||||
state: KeyboardViewController.State,
|
||||
typing: TypingSessionController,
|
||||
onInsert: @escaping (String) -> Void = { _ in }
|
||||
) {
|
||||
self.state = state
|
||||
self.typing = typing
|
||||
self.onInsert = onInsert
|
||||
}
|
||||
|
||||
/// Content-driven keyboard height; mirrored on `UIInputViewController.view`
|
||||
/// in `KeyboardViewController` (see `KeyboardLayoutMetrics.totalHeight`).
|
||||
static let totalHeight: CGFloat = KeyboardLayoutMetrics.totalHeight
|
||||
|
||||
/// Matches the typing surface so switching surfaces never resizes the
|
||||
/// keyboard. Surplus height is parked above the action cluster.
|
||||
static func totalHeight(isIPad: Bool, width: CGFloat) -> CGFloat {
|
||||
KeyboardLayoutMetrics.totalHeight(isIPad: isIPad, width: width)
|
||||
}
|
||||
|
||||
// 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. iPad adds
|
||||
/// `KeyboardLayoutMetrics.extraVerticalSpace` on top of this.
|
||||
static let micTopOffset: CGFloat = KeyboardLayoutMetrics.outerPaddingTop
|
||||
+ KeyboardLayoutMetrics.headerBandHeight
|
||||
+ KeyboardLayoutMetrics.actionClusterTopGap
|
||||
- KeyboardLayoutMetrics.micUpwardAdjustment
|
||||
/// Horizontal inset the side pads should respect.
|
||||
static let sideInset: CGFloat = KeyboardLayoutMetrics.sideActionHorizontalInset
|
||||
|
||||
private var palette: ThemePalette {
|
||||
colorScheme == .dark ? Palette.dark : Palette.light
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Group {
|
||||
if state.editSession.isActive {
|
||||
LastInputEditView(state: state)
|
||||
} else {
|
||||
ZStack {
|
||||
VStack(spacing: 0) {
|
||||
headerBand
|
||||
|
||||
Color.clear
|
||||
.frame(height: KeyboardLayoutMetrics.actionClusterTopGap)
|
||||
|
||||
// Absorbs the surplus of a taller iPad keyboard here so
|
||||
// the action cluster stays pinned to the bottom and its
|
||||
// keys share the typing surface's bottom-row baseline.
|
||||
Spacer(minLength: 0)
|
||||
|
||||
micActionRow
|
||||
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
|
||||
|
||||
Color.clear
|
||||
.frame(height: KeyboardLayoutMetrics.actionClusterBottomGap)
|
||||
}
|
||||
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
|
||||
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
|
||||
// 透明背景:让系统键盘 chrome 透出,不自行铺色(深浅模式一致)。
|
||||
.background(Color.clear)
|
||||
// No content-width cap: the surface fills the host width so
|
||||
// switching between voice and typing never changes width.
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(
|
||||
height: Self.totalHeight(
|
||||
isIPad: state.usesIPadLayoutMetrics,
|
||||
width: state.layoutWidth
|
||||
)
|
||||
)
|
||||
// Feed the resolved palette to all nested chips/buttons.
|
||||
.environment(\.themePalette, palette)
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.12), value: state.cursorDragActive)
|
||||
.animation(.easeInOut(duration: 0.12), value: state.editSession.isActive)
|
||||
}
|
||||
|
||||
/// Top brand / mode row + transcript / hint line.
|
||||
private var headerBand: some View {
|
||||
VStack(spacing: KeyboardLayoutMetrics.topBarToTranscriptSpacing) {
|
||||
topBar
|
||||
.frame(height: KeyboardLayoutMetrics.topBarHeight)
|
||||
|
||||
TranscriptLine(
|
||||
phase: state.phase,
|
||||
transcript: state.lastTranscript,
|
||||
micVoiceAvailability: state.micVoiceAvailability,
|
||||
micDisabledHint: state.micDisabledHint,
|
||||
editHint: state.editHint,
|
||||
editHintIsPositive: state.editHintIsPositive,
|
||||
cursorDragHintActive: state.cursorDragActive,
|
||||
openSettings: state.openSettings
|
||||
)
|
||||
.frame(height: KeyboardLayoutMetrics.transcriptLineHeight)
|
||||
.offset(y: KeyboardLayoutMetrics.transcriptLineDownwardAdjustment(
|
||||
extraSpace: KeyboardLayoutMetrics.extraVerticalSpace(
|
||||
isIPad: state.usesIPadLayoutMetrics,
|
||||
width: state.layoutWidth
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Top bar
|
||||
|
||||
private var topBar: some View {
|
||||
Group {
|
||||
if state.canCancelVoiceInput {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
Spacer(minLength: 0)
|
||||
KeyboardCancelButton(
|
||||
action: state.cancelVoiceInput,
|
||||
accessibilityLabel: ExtL10n.text("keyboard.voice.cancel"),
|
||||
accessibilityHint: ExtL10n.text("keyboard.voice.cancelHint")
|
||||
)
|
||||
}
|
||||
} else if shouldShowClipboardSuggestion {
|
||||
// Occupies the logo + capsule-tab slot until dismissed.
|
||||
ClipboardSuggestionBar(
|
||||
text: state.clipboardSuggestionText ?? "",
|
||||
onInsert: {
|
||||
if let text = state.clipboardSuggestionText {
|
||||
state.insertClipboardText(text)
|
||||
}
|
||||
},
|
||||
onDismiss: state.dismissClipboardSuggestion
|
||||
)
|
||||
} else {
|
||||
ZStack {
|
||||
KeyboardTopControls(
|
||||
state: state,
|
||||
typing: typing,
|
||||
palette: palette,
|
||||
onInsert: onInsert
|
||||
)
|
||||
HStack {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
// Globe key now lives at the bottom-left of the keyboard (matching
|
||||
// iOS system layout); see micActionRow's bottom HStack.
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.horizontalInset)
|
||||
}
|
||||
|
||||
// MARK: - Action cluster
|
||||
|
||||
/// Mic centred above a bottom row: delete · smart return · space (or swapped).
|
||||
/// The side cursor-drag pads are SwiftUI layout wrappers around UIKit
|
||||
/// pan recognizers, avoiding SwiftUI gesture delivery issues in
|
||||
/// keyboard extensions. A square undo key sits on the outer pad,
|
||||
/// vertically centred with the mic and mirrored with handedness.
|
||||
private var micActionRow: some View {
|
||||
let editingBlocked = voiceInputBlocksEditing
|
||||
let swapKeys = state.handednessPreference.swapsActionKeys
|
||||
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
|
||||
let recording = state.phase == .recording
|
||||
let undoVisible = !dragging && !recording
|
||||
|
||||
return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) {
|
||||
HStack(spacing: 0) {
|
||||
cursorDragPad(enabled: cursorPadsEnabled)
|
||||
.overlay(alignment: .leading) {
|
||||
// Left-handed: undo shares the outer edge with delete.
|
||||
if !swapKeys {
|
||||
undoButton(
|
||||
disabled: editingBlocked || !state.undoAvailable,
|
||||
visible: undoVisible
|
||||
)
|
||||
} else {
|
||||
// Right-handed undo is on the trailing pad; put
|
||||
// translation on the leading (mic-left) side.
|
||||
translationButton(visible: undoVisible)
|
||||
}
|
||||
}
|
||||
|
||||
RecordButton(
|
||||
phase: buttonPhase,
|
||||
level: state.level,
|
||||
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
|
||||
isEnabled: micButtonEnabled,
|
||||
usesLiquidGlass: true,
|
||||
onToggle: state.tapMic,
|
||||
onPressingChanged: micButtonEnabled
|
||||
? state.setMicTouchActive
|
||||
: { _ in },
|
||||
onEditLongPressBegan: micButtonEnabled
|
||||
? state.beginEditLastInput
|
||||
: nil
|
||||
)
|
||||
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
|
||||
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
|
||||
.opacity(dragging ? 0 : 1)
|
||||
|
||||
cursorDragPad(enabled: cursorPadsEnabled)
|
||||
.overlay(alignment: .trailing) {
|
||||
// Right-handed: undo mirrors to the outer (delete) side.
|
||||
if swapKeys {
|
||||
undoButton(
|
||||
disabled: editingBlocked || !state.undoAvailable,
|
||||
visible: undoVisible
|
||||
)
|
||||
} else {
|
||||
// Default: translation sits on the mic's right,
|
||||
// symmetric with undo on the left.
|
||||
translationButton(visible: undoVisible)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: KeyboardLayoutMetrics.micSize)
|
||||
|
||||
GeometryReader { proxy in
|
||||
if state.showsSystemGlobeKey {
|
||||
// iPad uses a flatter split: at full width the phone's 50%
|
||||
// centre fraction would hand return ~577 pt.
|
||||
let widths = state.usesIPadLayoutMetrics
|
||||
? KeyboardChromeLayout.iPadVoiceActionKeyWidths(
|
||||
availableWidth: proxy.size.width
|
||||
)
|
||||
: KeyboardChromeLayout.actionKeyWidths(
|
||||
availableWidth: proxy.size.width
|
||||
)
|
||||
|
||||
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
|
||||
// Globe key pins to the far-left of the iPad action row.
|
||||
// Tap advances; long-press presents the system list.
|
||||
SystemGlobeKey(
|
||||
state: state,
|
||||
width: widths.globe,
|
||||
height: KeyboardLayoutMetrics.bottomActionRowHeight
|
||||
)
|
||||
.frame(
|
||||
width: widths.globe,
|
||||
height: KeyboardLayoutMetrics.bottomActionRowHeight
|
||||
)
|
||||
if swapKeys {
|
||||
bottomSpaceButton(disabled: editingBlocked)
|
||||
.frame(width: widths.side)
|
||||
bottomReturnButton(disabled: editingBlocked)
|
||||
.frame(width: widths.center)
|
||||
bottomDeleteButton(disabled: editingBlocked)
|
||||
.frame(width: widths.side2)
|
||||
} else {
|
||||
bottomDeleteButton(disabled: editingBlocked)
|
||||
.frame(width: widths.side)
|
||||
bottomReturnButton(disabled: editingBlocked)
|
||||
.frame(width: widths.center)
|
||||
bottomSpaceButton(disabled: editingBlocked)
|
||||
.frame(width: widths.side2)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let widths = KeyboardChromeLayout.actionKeyWidthsWithoutGlobe(
|
||||
availableWidth: proxy.size.width
|
||||
)
|
||||
|
||||
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
|
||||
if swapKeys {
|
||||
bottomSpaceButton(disabled: editingBlocked)
|
||||
.frame(width: widths.side)
|
||||
bottomReturnButton(disabled: editingBlocked)
|
||||
.frame(width: widths.center)
|
||||
bottomDeleteButton(disabled: editingBlocked)
|
||||
.frame(width: widths.side2)
|
||||
} else {
|
||||
bottomDeleteButton(disabled: editingBlocked)
|
||||
.frame(width: widths.side)
|
||||
bottomReturnButton(disabled: editingBlocked)
|
||||
.frame(width: widths.center)
|
||||
bottomSpaceButton(disabled: editingBlocked)
|
||||
.frame(width: widths.side2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
||||
.opacity(dragging || recording ? 0 : 1)
|
||||
.allowsHitTesting(!dragging && !recording)
|
||||
}
|
||||
.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,
|
||||
hapticIntensity: state.keyboardHapticIntensity
|
||||
) {
|
||||
state.deleteBackward()
|
||||
}
|
||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
||||
}
|
||||
|
||||
/// Circular undo key on the outer drag pad — same chrome / haptic / click
|
||||
/// as space & return. Vertically matches the mic disc.
|
||||
private func undoButton(disabled: Bool, visible: Bool) -> some View {
|
||||
RectangularToolbarButton(
|
||||
systemName: "arrow.uturn.backward",
|
||||
label: ExtL10n.string("keyboard.undoA11y"),
|
||||
disabled: disabled,
|
||||
usesLiquidGlass: true,
|
||||
usesCircleGlass: true,
|
||||
hapticIntensity: state.keyboardHapticIntensity
|
||||
) {
|
||||
state.undoLastInsertion()
|
||||
}
|
||||
.frame(
|
||||
width: KeyboardLayoutMetrics.undoButtonSize,
|
||||
height: KeyboardLayoutMetrics.undoButtonSize
|
||||
)
|
||||
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
|
||||
.opacity(visible ? 1 : 0)
|
||||
.allowsHitTesting(visible && !disabled)
|
||||
.accessibilityHidden(!visible)
|
||||
}
|
||||
|
||||
/// Translation chip relocated from the top bar — mirrors undo across the mic.
|
||||
private func translationButton(visible: Bool) -> some View {
|
||||
KeyboardTranslationMenuButton(
|
||||
palette: palette,
|
||||
targetLocaleId: state.translationTargetLocaleId,
|
||||
onSelect: state.setTranslationTargetLocaleId
|
||||
)
|
||||
.equatable()
|
||||
.frame(
|
||||
width: KeyboardLayoutMetrics.undoButtonSize,
|
||||
height: KeyboardLayoutMetrics.undoButtonSize
|
||||
)
|
||||
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
|
||||
.opacity(visible ? 1 : 0)
|
||||
.allowsHitTesting(visible)
|
||||
.accessibilityHidden(!visible)
|
||||
}
|
||||
|
||||
private var shouldShowClipboardSuggestion: Bool {
|
||||
guard state.canShowClipboardEntry else { return false }
|
||||
guard let text = state.clipboardSuggestionText, !text.isEmpty else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private func bottomSpaceButton(disabled: Bool) -> some View {
|
||||
RectangularToolbarButton(
|
||||
spaceStyle: true,
|
||||
label: "space",
|
||||
disabled: disabled,
|
||||
hapticIntensity: state.keyboardHapticIntensity
|
||||
) {
|
||||
state.insertSpace()
|
||||
}
|
||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
||||
}
|
||||
|
||||
private func bottomReturnButton(disabled: Bool) -> some View {
|
||||
let title = ExtL10n.string(state.returnKeyRole.titleKey)
|
||||
return RectangularToolbarButton(
|
||||
title: title,
|
||||
label: title,
|
||||
disabled: disabled,
|
||||
isSend: state.returnKeyRole.usesActionFill,
|
||||
hapticIntensity: state.keyboardHapticIntensity
|
||||
) {
|
||||
state.insertNewline()
|
||||
}
|
||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
||||
}
|
||||
|
||||
/// Option C: block typing keys during the full voice-input pipeline.
|
||||
private var voiceInputBlocksEditing: Bool {
|
||||
switch state.phase {
|
||||
case .requestingPermissions, .recording, .processing:
|
||||
return true
|
||||
case .idle, .error, .denied:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var buttonPhase: RecordButton.Phase {
|
||||
switch state.micVoiceAvailability {
|
||||
case .recording:
|
||||
return .recording
|
||||
case .processing:
|
||||
return .processing
|
||||
case .ready, .unavailable:
|
||||
// Host/PiP readiness is handled by an automatic app handoff. Keep
|
||||
// the idle mic visually green instead of exposing startup state.
|
||||
return .idleReady
|
||||
}
|
||||
}
|
||||
|
||||
/// Disabled only when the shared voice prerequisites are unavailable.
|
||||
private var micButtonEnabled: Bool {
|
||||
if state.micDisabled { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - State alias
|
||||
|
||||
extension KeyboardRootView {
|
||||
typealias State = KeyboardViewController.State
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI Preview
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Keyboard · Idle") {
|
||||
KeyboardRootView(
|
||||
state: KeyboardViewController.State.previewIdle,
|
||||
typing: TypingSessionController()
|
||||
)
|
||||
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview("Keyboard · Recording") {
|
||||
KeyboardRootView(
|
||||
state: KeyboardViewController.State.previewRecording,
|
||||
typing: TypingSessionController()
|
||||
)
|
||||
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview("Keyboard · Processing") {
|
||||
KeyboardRootView(
|
||||
state: KeyboardViewController.State.previewProcessing,
|
||||
typing: TypingSessionController()
|
||||
)
|
||||
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Transcript line
|
||||
|
||||
private struct TranscriptLine: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let phase: KeyboardViewController.State.Phase
|
||||
let transcript: String
|
||||
let micVoiceAvailability: MicVoiceAvailability
|
||||
let micDisabledHint: String
|
||||
let editHint: String?
|
||||
let editHintIsPositive: Bool
|
||||
let cursorDragHintActive: Bool
|
||||
let openSettings: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 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:
|
||||
idleHint
|
||||
case .requestingPermissions:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.mini).tint(palette.textSecondary)
|
||||
Text(
|
||||
transcript.isEmpty
|
||||
? ExtL10n.string("keyboard.placeholder.preparing")
|
||||
: transcript
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
case .recording:
|
||||
Text(transcript.isEmpty ? " " : transcript)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
.frame(maxWidth: .infinity)
|
||||
case .processing:
|
||||
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)
|
||||
.foregroundStyle(palette.warning)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .denied(let reason):
|
||||
Button(action: openSettings) {
|
||||
HStack(spacing: 4) {
|
||||
Text(deniedMessage(for: reason))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.warning)
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(ExtL10n.text("keyboard.deniedHint"))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var idleHint: some View {
|
||||
if let editHint, !editHint.isEmpty {
|
||||
Text(editHint)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(editHintIsPositive ? palette.accent : palette.warning)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else {
|
||||
let isWarning: Bool = {
|
||||
switch micVoiceAvailability {
|
||||
case .unavailable(.hostNotReady), .unavailable(.preparingSession):
|
||||
return false
|
||||
case .unavailable:
|
||||
return true
|
||||
case .ready, .recording, .processing:
|
||||
return false
|
||||
}
|
||||
}()
|
||||
Group {
|
||||
switch micVoiceAvailability {
|
||||
case .ready:
|
||||
// Soft tip when polish key is missing but local ASR can still run.
|
||||
if !micDisabledHint.isEmpty {
|
||||
Text(micDisabledHint)
|
||||
} else {
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
}
|
||||
case .unavailable(.missingAPIKey):
|
||||
Text(micDisabledHint.isEmpty
|
||||
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
|
||||
: micDisabledHint)
|
||||
case .unavailable(.hostNotReady):
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
case .unavailable(.preparingSession):
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
case .unavailable(.noFullAccess):
|
||||
ExtL10n.text("keyboard.error.fullAccessRequired")
|
||||
case .unavailable(.appGroupUnavailable):
|
||||
ExtL10n.text("keyboard.error.appGroupCommunication")
|
||||
case .unavailable(.onboardingIncomplete):
|
||||
ExtL10n.text("keyboard.hint.finishSetupInApp")
|
||||
case .recording, .processing:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(
|
||||
(isWarning || (micVoiceAvailability.isReady && !micDisabledHint.isEmpty))
|
||||
? palette.warning
|
||||
: palette.textTertiary
|
||||
)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
|
||||
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
|
||||
switch reason {
|
||||
case .mic: return ExtL10n.string("keyboard.denied.mic")
|
||||
case .speech: return ExtL10n.string("keyboard.denied.speech")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,8 @@ enum KeyboardTopBarMetrics {
|
||||
static let nestedHorizontalInset: CGFloat = horizontalInset - KeyboardChromeLayout.horizontalInset
|
||||
static let logoHeight: CGFloat = 16
|
||||
static let logoWidth: CGFloat = logoHeight * 952 / 291
|
||||
/// Equal hit width for AI / Voice / Chinese / English input tabs.
|
||||
static let inputTabWidth: CGFloat = 42
|
||||
/// Equal hit width for Assistant / Chinese / English input tabs.
|
||||
static let inputTabWidth: CGFloat = 48
|
||||
/// Shared footprint for top-trailing chips (clipboard, cancel/X, translation).
|
||||
static let trailingChipSize: CGFloat = 34
|
||||
static let trailingChipIconSize: CGFloat = 15
|
||||
@@ -63,6 +63,7 @@ struct KeyboardCancelButton: View {
|
||||
let action: () -> Void
|
||||
let accessibilityLabel: Text
|
||||
let accessibilityHint: Text
|
||||
var accessibilityIdentifier = "keyboard.cancel"
|
||||
|
||||
var body: some View {
|
||||
// Same 34×34 chip as KeyboardClipboardMenuButton / translation.
|
||||
@@ -82,21 +83,20 @@ struct KeyboardCancelButton: View {
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier(accessibilityIdentifier)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
.accessibilityHint(accessibilityHint)
|
||||
}
|
||||
}
|
||||
|
||||
private enum KeyboardInputTab: CaseIterable {
|
||||
case ai
|
||||
case voice
|
||||
case assistant
|
||||
case chinese
|
||||
case english
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .ai: return ExtL10n.string("keyboard.tab.ai")
|
||||
case .voice: return ExtL10n.string("keyboard.tab.voice")
|
||||
case .assistant: return ExtL10n.string("keyboard.tab.ai")
|
||||
case .chinese: return ExtL10n.string("keyboard.tab.chinese")
|
||||
case .english: return ExtL10n.string("keyboard.tab.english")
|
||||
}
|
||||
@@ -117,9 +117,17 @@ struct KeyboardTopControls: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
inputTabSwitcher
|
||||
if state.canShowClipboardEntry {
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: 6) {
|
||||
Spacer(minLength: 0)
|
||||
if state.surface != .typing {
|
||||
KeyboardTranslationMenuButton(
|
||||
palette: palette,
|
||||
targetLocaleId: state.translationTargetLocaleId,
|
||||
onSelect: state.setTranslationTargetLocaleId
|
||||
)
|
||||
.equatable()
|
||||
}
|
||||
if state.canShowClipboardEntry {
|
||||
KeyboardClipboardMenuButton(
|
||||
palette: palette,
|
||||
action: state.openClipboardPanel
|
||||
@@ -156,8 +164,9 @@ struct KeyboardTopControls: View {
|
||||
tabLabel(tab, selected: selected)
|
||||
}
|
||||
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
|
||||
.disabled(tab != .voice && !state.canEnterTypingSurface)
|
||||
.disabled(tab != .assistant && !state.canEnterTypingSurface)
|
||||
.opacity(tabOpacity(tab))
|
||||
.accessibilityIdentifier("assistant.tab.\(tabIdentifier(tab))")
|
||||
.accessibilityLabel(accessibilityLabel(for: tab))
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
@@ -187,11 +196,8 @@ struct KeyboardTopControls: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func tabContent(_ tab: KeyboardInputTab, selected: Bool) -> some View {
|
||||
if tab == .ai {
|
||||
Image(systemName: "sparkle")
|
||||
.font(.system(size: 15, weight: selected ? .semibold : .medium))
|
||||
} else if tab == .voice {
|
||||
Image(systemName: "waveform.mid")
|
||||
if tab == .assistant {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 15, weight: selected ? .semibold : .medium))
|
||||
} else {
|
||||
Text(tab.title)
|
||||
@@ -200,13 +206,21 @@ struct KeyboardTopControls: View {
|
||||
}
|
||||
|
||||
private func tabOpacity(_ tab: KeyboardInputTab) -> Double {
|
||||
guard tab != .voice, !state.canEnterTypingSurface else { return 1 }
|
||||
guard tab != .assistant, !state.canEnterTypingSurface else { return 1 }
|
||||
if case .recording = state.phase {
|
||||
return 0
|
||||
}
|
||||
return 0.42
|
||||
}
|
||||
|
||||
private func tabIdentifier(_ tab: KeyboardInputTab) -> String {
|
||||
switch tab {
|
||||
case .assistant: return "assistant"
|
||||
case .chinese: return "chinese"
|
||||
case .english: return "english"
|
||||
}
|
||||
}
|
||||
|
||||
private var pressedFill: Color {
|
||||
colorScheme == .dark ? Color(white: 0.22) : Color(white: 0.84)
|
||||
}
|
||||
@@ -219,10 +233,8 @@ struct KeyboardTopControls: View {
|
||||
|
||||
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
|
||||
switch tab {
|
||||
case .ai:
|
||||
return state.surface == .ai
|
||||
case .voice:
|
||||
return state.surface == .voice
|
||||
case .assistant:
|
||||
return state.surface == .voice || state.surface == .ai
|
||||
case .chinese:
|
||||
return state.surface == .typing && typing.language == .chinese
|
||||
case .english:
|
||||
@@ -232,9 +244,7 @@ struct KeyboardTopControls: View {
|
||||
|
||||
private func select(_ tab: KeyboardInputTab) {
|
||||
switch tab {
|
||||
case .ai:
|
||||
state.setSurface(.ai)
|
||||
case .voice:
|
||||
case .assistant:
|
||||
state.setSurface(.voice)
|
||||
case .chinese:
|
||||
applyTypingOutput(typing.setLanguage(.chinese))
|
||||
@@ -262,8 +272,7 @@ struct KeyboardTopControls: View {
|
||||
|
||||
private func accessibilityLabel(for tab: KeyboardInputTab) -> String {
|
||||
switch tab {
|
||||
case .ai: return ExtL10n.string("keyboard.tab.ai.a11y")
|
||||
case .voice: return ExtL10n.string("keyboard.tab.voice.a11y")
|
||||
case .assistant: return ExtL10n.string("keyboard.tab.ai.a11y")
|
||||
case .chinese: return ExtL10n.string("keyboard.tab.chinese.a11y")
|
||||
case .english: return ExtL10n.string("keyboard.tab.english.a11y")
|
||||
}
|
||||
@@ -271,6 +280,8 @@ struct KeyboardTopControls: View {
|
||||
}
|
||||
|
||||
struct KeyboardTranslationMenuButton: View, Equatable {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let palette: ThemePalette
|
||||
let targetLocaleId: String
|
||||
let onSelect: (String) -> Void
|
||||
@@ -310,10 +321,16 @@ struct KeyboardTranslationMenuButton: View, Equatable {
|
||||
: palette.textSecondary
|
||||
)
|
||||
}
|
||||
.frame(
|
||||
width: KeyboardTopBarMetrics.trailingChipSize,
|
||||
height: KeyboardTopBarMetrics.trailingChipSize
|
||||
)
|
||||
.background(NativeKeyboardKeyColors.fill(for: colorScheme), in: Circle())
|
||||
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||||
.contentShape(Circle())
|
||||
.glassEffect(.regular.interactive(), in: Circle())
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.accessibilityIdentifier("assistant.translation")
|
||||
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
|
||||
.accessibilityHint(Text(SharedL10n.string("keyboard.translation.a11yHint")))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import OSGKeyboardShared
|
||||
|
||||
struct LastInputEditView: View {
|
||||
private enum Layout {
|
||||
static let primaryButtonHeight: CGFloat = 50
|
||||
static let primaryButtonHeight = KeyboardChromeLayout.assistantActionCapsuleHeight
|
||||
static let primaryButtonWidth: CGFloat = primaryButtonHeight * 3
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ struct LastInputEditView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(primaryDisabled)
|
||||
.accessibilityIdentifier(primaryAccessibilityIdentifier)
|
||||
.accessibilityLabel(Text(primaryAccessibilityLabel))
|
||||
helperText(rightHelper)
|
||||
}
|
||||
@@ -239,6 +240,17 @@ struct LastInputEditView: View {
|
||||
return ExtL10n.string("keyboard.edit.stop")
|
||||
}
|
||||
|
||||
private var primaryAccessibilityIdentifier: String {
|
||||
switch state.editSession {
|
||||
case .listening:
|
||||
return "assistant.edit.stop"
|
||||
case .review:
|
||||
return "assistant.edit.confirm"
|
||||
default:
|
||||
return "assistant.edit.primary"
|
||||
}
|
||||
}
|
||||
|
||||
private var reviewSwipeGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 12)
|
||||
.onEnded { value in
|
||||
|
||||
@@ -206,6 +206,7 @@ struct RectangularToolbarButton: View {
|
||||
let usesCircleGlass: Bool
|
||||
/// Settings → General → Haptics; space / return use `.action` role.
|
||||
var hapticIntensity: KeyboardHapticIntensity = .off
|
||||
var onPressingChanged: (Bool) -> Void = { _ in }
|
||||
let action: () -> Void
|
||||
|
||||
init(
|
||||
@@ -215,6 +216,7 @@ struct RectangularToolbarButton: View {
|
||||
usesLiquidGlass: Bool = false,
|
||||
usesCircleGlass: Bool = false,
|
||||
hapticIntensity: KeyboardHapticIntensity = .off,
|
||||
onPressingChanged: @escaping (Bool) -> Void = { _ in },
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.systemName = systemName
|
||||
@@ -226,6 +228,7 @@ struct RectangularToolbarButton: View {
|
||||
self.usesLiquidGlass = usesLiquidGlass
|
||||
self.usesCircleGlass = usesCircleGlass
|
||||
self.hapticIntensity = hapticIntensity
|
||||
self.onPressingChanged = onPressingChanged
|
||||
self.action = action
|
||||
}
|
||||
|
||||
@@ -236,6 +239,7 @@ struct RectangularToolbarButton: View {
|
||||
isSend: Bool = false,
|
||||
usesLiquidGlass: Bool = false,
|
||||
hapticIntensity: KeyboardHapticIntensity = .off,
|
||||
onPressingChanged: @escaping (Bool) -> Void = { _ in },
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.systemName = nil
|
||||
@@ -246,6 +250,7 @@ struct RectangularToolbarButton: View {
|
||||
self.usesLiquidGlass = usesLiquidGlass
|
||||
self.usesCircleGlass = false
|
||||
self.hapticIntensity = hapticIntensity
|
||||
self.onPressingChanged = onPressingChanged
|
||||
self.action = action
|
||||
self.title = title
|
||||
}
|
||||
@@ -256,6 +261,7 @@ struct RectangularToolbarButton: View {
|
||||
disabled: Bool = false,
|
||||
usesLiquidGlass: Bool = false,
|
||||
hapticIntensity: KeyboardHapticIntensity = .off,
|
||||
onPressingChanged: @escaping (Bool) -> Void = { _ in },
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.systemName = nil
|
||||
@@ -267,6 +273,7 @@ struct RectangularToolbarButton: View {
|
||||
self.usesLiquidGlass = usesLiquidGlass
|
||||
self.usesCircleGlass = false
|
||||
self.hapticIntensity = hapticIntensity
|
||||
self.onPressingChanged = onPressingChanged
|
||||
self.action = action
|
||||
}
|
||||
|
||||
@@ -280,6 +287,11 @@ struct RectangularToolbarButton: View {
|
||||
.allowsHitTesting(!disabled)
|
||||
.accessibilityLabel(Text(label))
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.onDisappear {
|
||||
guard isPressing else { return }
|
||||
isPressing = false
|
||||
onPressingChanged(false)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -337,12 +349,14 @@ struct RectangularToolbarButton: View {
|
||||
.onChanged { _ in
|
||||
guard !disabled, !isPressing else { return }
|
||||
isPressing = true
|
||||
onPressingChanged(true)
|
||||
KeyboardSoundFeedback.keyClick()
|
||||
KeyboardHapticFeedback.play(role: .action, intensity: hapticIntensity)
|
||||
action()
|
||||
}
|
||||
.onEnded { _ in
|
||||
isPressing = false
|
||||
onPressingChanged(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +296,19 @@
|
||||
"keyboard.ai.sendA11y" = "Tap once to insert the AI answer, then tap again to send in supported fields";
|
||||
"keyboard.ai.startA11y" = "Start asking AI";
|
||||
"keyboard.ai.stopA11y" = "Finish the question and send it to AI";
|
||||
"keyboard.assistant.longPressCoach" = "Tap to dictate · Hold to ask AI";
|
||||
"keyboard.assistant.micA11y" = "Voice and AI";
|
||||
"keyboard.assistant.micHint" = "Tap to dictate. Hold to ask AI.";
|
||||
"keyboard.assistant.dictationAction" = "Start voice dictation";
|
||||
"keyboard.assistant.aiAction" = "Ask AI";
|
||||
"keyboard.assistant.space" = "Space";
|
||||
"keyboard.assistant.delete" = "Delete";
|
||||
"keyboard.assistant.sendHint" = "Performs the current field's Send, Search, Done, or Go action.";
|
||||
"keyboard.assistant.insertPending" = "Insert retained AI answer";
|
||||
"keyboard.assistant.discardPending" = "Discard AI answer";
|
||||
"keyboard.assistant.discardPendingHint" = "Discard the answer retained because the cursor or input field changed.";
|
||||
"keyboard.assistant.dismissClipboard" = "Dismiss clipboard suggestions";
|
||||
"keyboard.assistant.dismissClipboardHint" = "Hide the current clipboard summary and skills.";
|
||||
"keyboard.ai.error.missingAPIKey" = "Configure an AI service in the main app first";
|
||||
"keyboard.ai.error.pipelineBusy" = "Voice input is busy. Try again shortly";
|
||||
"keyboard.ai.error.startTimeout" = "Microphone startup timed out. Try again";
|
||||
|
||||
@@ -296,6 +296,19 @@
|
||||
"keyboard.ai.sendA11y" = "首次点击插入 AI 回答;在支持发送的输入框中再次点击发送";
|
||||
"keyboard.ai.startA11y" = "开始向 AI 提问";
|
||||
"keyboard.ai.stopA11y" = "结束提问并发送给 AI";
|
||||
"keyboard.assistant.longPressCoach" = "轻点听写 · 长按问 AI";
|
||||
"keyboard.assistant.micA11y" = "语音与 AI";
|
||||
"keyboard.assistant.micHint" = "轻点开始语音听写,长按向 AI 提问。";
|
||||
"keyboard.assistant.dictationAction" = "开始语音听写";
|
||||
"keyboard.assistant.aiAction" = "向 AI 提问";
|
||||
"keyboard.assistant.space" = "空格";
|
||||
"keyboard.assistant.delete" = "删除";
|
||||
"keyboard.assistant.sendHint" = "执行当前输入框的发送、搜索、完成或前往操作。";
|
||||
"keyboard.assistant.insertPending" = "插入保留的 AI 回答";
|
||||
"keyboard.assistant.discardPending" = "丢弃 AI 回答";
|
||||
"keyboard.assistant.discardPendingHint" = "丢弃因光标或输入框变化而保留的回答。";
|
||||
"keyboard.assistant.dismissClipboard" = "关闭剪贴板建议";
|
||||
"keyboard.assistant.dismissClipboardHint" = "隐藏当前剪贴板摘要和技能。";
|
||||
"keyboard.ai.error.missingAPIKey" = "请先在主 App 配置可用的 AI 服务";
|
||||
"keyboard.ai.error.pipelineBusy" = "语音服务正忙,请稍后重试";
|
||||
"keyboard.ai.error.startTimeout" = "麦克风启动超时,请重试";
|
||||
|
||||
Reference in New Issue
Block a user