fix(keyboard): synchronize adaptive field actions
Enable the correct host action immediately after keyboard edits and advance the shared build number to 75.
This commit is contained in:
@@ -63,6 +63,8 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
/// changes instead of every pass.
|
||||
private var lastLoggedLayoutSnapshot: String?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
/// Coalesces host-document refreshes after mutations issued by this keyboard.
|
||||
private var assistantFieldActionRefreshTask: Task<Void, Never>?
|
||||
|
||||
private var editHintScheduler: EditHintScheduler!
|
||||
private var textInserter: KeyboardTextInserter!
|
||||
@@ -166,6 +168,8 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
assistantFieldActionRefreshTask?.cancel()
|
||||
assistantFieldActionRefreshTask = nil
|
||||
clipboardCapture?.keyboardWillDisappear()
|
||||
// Presentation-scoped hints must never survive a reused extension
|
||||
// controller, including an active Flow handoff.
|
||||
@@ -274,16 +278,16 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state: state,
|
||||
host: WhatsNewDemoDriver.HostHooks(
|
||||
insertText: { [weak self] text in
|
||||
self?.textDocumentProxy.insertText(text)
|
||||
self?.insertTextIntoDocument(text)
|
||||
},
|
||||
deleteBackward: { [weak self] in
|
||||
self?.textDocumentProxy.deleteBackward()
|
||||
self?.deleteBackwardFromDocument()
|
||||
},
|
||||
contextBeforeInput: { [weak self] in
|
||||
self?.textDocumentProxy.documentContextBeforeInput
|
||||
},
|
||||
performReturn: { [weak self] in
|
||||
self?.textDocumentProxy.insertText("\n")
|
||||
self?.performDocumentReturn()
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -304,6 +308,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func selectionDidChange(_ textInput: UITextInput?) {
|
||||
super.selectionDidChange(textInput)
|
||||
refreshReturnKeyRole()
|
||||
typingSession.synchronizeEnglishDocumentContext(caretMoved: true)
|
||||
textInserter?.refreshEditingAvailability()
|
||||
lastInputEditCoordinator?.refreshContext()
|
||||
@@ -357,8 +362,8 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
editHintScheduler = EditHintScheduler(state: state)
|
||||
textInserter = KeyboardTextInserter(
|
||||
state: state,
|
||||
insertText: { [weak self] text in self?.textDocumentProxy.insertText(text) },
|
||||
deleteBackward: { [weak self] in self?.textDocumentProxy.deleteBackward() },
|
||||
insertText: { [weak self] text in self?.insertTextIntoDocument(text) },
|
||||
deleteBackward: { [weak self] in self?.deleteBackwardFromDocument() },
|
||||
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
|
||||
fieldContextProvider: { [weak self] in self?.captureFieldContext() },
|
||||
selectedText: { [weak self] in self?.textDocumentProxy.selectedText },
|
||||
@@ -420,7 +425,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
self?.textInserter.insertAIAnswer(answer) ?? false
|
||||
},
|
||||
performReturn: { [weak self] in
|
||||
self?.textDocumentProxy.insertText("\n")
|
||||
self?.performDocumentReturn()
|
||||
},
|
||||
captureInsertionFingerprint: { [weak self] in
|
||||
self?.captureFieldContext().deliveryFingerprint
|
||||
@@ -508,8 +513,8 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state.discardPendingAIAnswer = { [weak self] in
|
||||
self?.aiKeyboardCoordinator.discardPendingAnswer()
|
||||
}
|
||||
state.sendAssistantAction = { [weak self] in
|
||||
self?.aiKeyboardCoordinator.sendCurrentFieldAction()
|
||||
state.performAssistantFieldAction = { [weak self] in
|
||||
self?.aiKeyboardCoordinator.performCurrentFieldAction()
|
||||
}
|
||||
state.submitAIHint = { [weak self] card in
|
||||
self?.aiKeyboardCoordinator.submitHintCard(card)
|
||||
@@ -564,9 +569,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
self?.configSync.persistTranslationTargetLocaleId(id)
|
||||
self?.aiKeyboardCoordinator.resetConversationForConfigurationChange()
|
||||
}
|
||||
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.insertNewline = { [weak self] in self?.insertTextIntoDocument("\n") }
|
||||
state.insertSpace = { [weak self] in self?.insertTextIntoDocument(" ") }
|
||||
state.deleteBackward = { [weak self] in self?.deleteBackwardFromDocument() }
|
||||
state.undoLastInsertion = { [weak self] in self?.textInserter.undoLastInsertion() }
|
||||
state.redoLastInsertion = { [weak self] in self?.textInserter.redoLastInsertion() }
|
||||
state.copySelection = { [weak self] in self?.textInserter.copySelection() }
|
||||
@@ -710,11 +715,49 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
)
|
||||
}
|
||||
|
||||
private func refreshReturnKeyRole() {
|
||||
state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
|
||||
if !state.returnKeyRole.usesActionFill {
|
||||
state.assistantSendAvailable = false
|
||||
/// Keeps field-action UI current even when a host app does not immediately
|
||||
/// echo this keyboard's own document mutation through `textDidChange`.
|
||||
private func insertTextIntoDocument(_ text: String) {
|
||||
guard !text.isEmpty else { return }
|
||||
textDocumentProxy.insertText(text)
|
||||
// A non-empty insertion makes content actions available immediately.
|
||||
// The next host callback remains the authoritative correction.
|
||||
refreshAssistantFieldAction(hasTextOverride: true)
|
||||
}
|
||||
|
||||
private func deleteBackwardFromDocument() {
|
||||
textDocumentProxy.deleteBackward()
|
||||
refreshAssistantFieldAction()
|
||||
scheduleAssistantFieldActionRefresh()
|
||||
}
|
||||
|
||||
private func performDocumentReturn() {
|
||||
textDocumentProxy.insertText("\n")
|
||||
scheduleAssistantFieldActionRefresh()
|
||||
}
|
||||
|
||||
private func scheduleAssistantFieldActionRefresh() {
|
||||
assistantFieldActionRefreshTask?.cancel()
|
||||
assistantFieldActionRefreshTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.refreshAssistantFieldAction()
|
||||
self.assistantFieldActionRefreshTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAssistantFieldAction(hasTextOverride: Bool? = nil) {
|
||||
let role = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
|
||||
state.returnKeyRole = role
|
||||
state.assistantActionAvailable = role.assistantActionAvailable(
|
||||
hasText: hasTextOverride ?? textDocumentProxy.hasText
|
||||
)
|
||||
}
|
||||
|
||||
private func refreshReturnKeyRole() {
|
||||
assistantFieldActionRefreshTask?.cancel()
|
||||
assistantFieldActionRefreshTask = nil
|
||||
refreshAssistantFieldAction()
|
||||
let isSecure = textDocumentProxy.isSecureTextEntry ?? false
|
||||
state.setSecureTextEntry(isSecure)
|
||||
clipboardCapture?.secureEntryDidChange(isSecure: isSecure)
|
||||
@@ -883,10 +926,10 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state: state,
|
||||
typing: typingSession,
|
||||
onInsert: { [weak self] text in
|
||||
self?.textDocumentProxy.insertText(text)
|
||||
self?.insertTextIntoDocument(text)
|
||||
},
|
||||
onDeleteBackward: { [weak self] in
|
||||
self?.textDocumentProxy.deleteBackward()
|
||||
self?.deleteBackwardFromDocument()
|
||||
}
|
||||
)
|
||||
let host = KeyboardHostingController(rootView: root)
|
||||
|
||||
@@ -186,9 +186,8 @@ final class AIKeyboardCoordinator {
|
||||
requestInsertionFingerprint = nil
|
||||
}
|
||||
|
||||
func sendCurrentFieldAction() {
|
||||
guard state.assistantSendAvailable else { return }
|
||||
state.assistantSendAvailable = false
|
||||
func performCurrentFieldAction() {
|
||||
guard state.assistantActionAvailable else { return }
|
||||
if state.aiSession.canSend {
|
||||
state.aiSession.markAnswerSent()
|
||||
}
|
||||
|
||||
@@ -187,7 +187,6 @@ final class KeyboardTextInserter {
|
||||
lastInsertedText = nil
|
||||
state.undoAvailable = false
|
||||
state.editAvailable = false
|
||||
state.assistantSendAvailable = false
|
||||
EditableInputReferenceStore.clear()
|
||||
OSGLog.keyboardExt.info("undo length=\(text.count, privacy: .public)")
|
||||
}
|
||||
@@ -532,7 +531,6 @@ final class KeyboardTextInserter {
|
||||
)
|
||||
)
|
||||
state.editAvailable = true
|
||||
state.assistantSendAvailable = state.returnKeyRole.usesActionFill
|
||||
state.assistantInsertionSucceeded = true
|
||||
successPulseTask?.cancel()
|
||||
successPulseTask = Task { @MainActor [weak state] in
|
||||
@@ -556,7 +554,6 @@ final class KeyboardTextInserter {
|
||||
lastInsertedText = nil
|
||||
state.undoAvailable = false
|
||||
state.editAvailable = false
|
||||
state.assistantSendAvailable = false
|
||||
EditableInputReferenceStore.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ struct AIKeyboardView: View {
|
||||
static let compactPrimaryHeight: CGFloat = 56
|
||||
static let compactPrimaryWidth: CGFloat = 148
|
||||
static let secondaryHeight: CGFloat = 52
|
||||
static let sendWidth: CGFloat = 132
|
||||
static let compactIPadSendWidth: CGFloat = 112
|
||||
static let fieldActionWidth: CGFloat = 132
|
||||
static let compactIPadFieldActionWidth: CGFloat = 112
|
||||
static let circleSize: CGFloat = 48
|
||||
static let compactIPadCircleSize: CGFloat = 44
|
||||
static let sideButtonEdgeInset: CGFloat = 8
|
||||
@@ -64,7 +64,7 @@ struct AIKeyboardView: View {
|
||||
@State private var debugSkillsDismissed = false
|
||||
@State private var micLongPressConsumed = false
|
||||
@State private var micIsHoldingForAI = false
|
||||
@State private var sendConfirmationVisible = false
|
||||
@State private var fieldActionConfirmationVisible = false
|
||||
|
||||
private var palette: ThemePalette {
|
||||
colorScheme == .dark ? Palette.dark : Palette.light
|
||||
@@ -184,7 +184,7 @@ struct AIKeyboardView: View {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: Layout.sendWidth, height: 44)
|
||||
.frame(width: Layout.fieldActionWidth, height: 44)
|
||||
.background(palette.accent, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -827,7 +827,7 @@ struct AIKeyboardView: View {
|
||||
.allowsHitTesting(sideButtonsVisible)
|
||||
.accessibilityHidden(!sideButtonsVisible)
|
||||
|
||||
sendButton
|
||||
fieldActionButton
|
||||
}
|
||||
.frame(maxWidth: Layout.actionClusterMaxWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -891,49 +891,58 @@ struct AIKeyboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var sendButton: some View {
|
||||
Button(action: performSend) {
|
||||
Image(systemName: sendConfirmationVisible ? "checkmark" : "paperplane.fill")
|
||||
private var fieldActionButton: some View {
|
||||
Button(action: performFieldAction) {
|
||||
Image(systemName: fieldActionSystemImage)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(sendButtonForeground)
|
||||
.foregroundStyle(fieldActionButtonForeground)
|
||||
.frame(
|
||||
width: sendButtonWidth,
|
||||
width: fieldActionButtonWidth,
|
||||
height: KeyboardChromeLayout.assistantActionCapsuleHeight
|
||||
)
|
||||
.background(sendButtonFill, in: Capsule())
|
||||
.background(fieldActionButtonFill, in: Capsule())
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!state.assistantSendAvailable)
|
||||
.accessibilityIdentifier("assistant.send")
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.ai.send"))
|
||||
.accessibilityHint(ExtL10n.text("keyboard.assistant.sendHint"))
|
||||
.disabled(!state.assistantActionAvailable)
|
||||
.accessibilityIdentifier(
|
||||
"assistant.action.\(state.returnKeyRole.assistantActionIdentifier)"
|
||||
)
|
||||
.accessibilityLabel(ExtL10n.text(state.returnKeyRole.titleKey))
|
||||
}
|
||||
|
||||
private var sendButtonFill: Color {
|
||||
state.assistantSendAvailable
|
||||
private var fieldActionSystemImage: String {
|
||||
fieldActionConfirmationVisible
|
||||
? "checkmark"
|
||||
: state.returnKeyRole.assistantActionSystemImage
|
||||
}
|
||||
|
||||
private var fieldActionButtonFill: Color {
|
||||
state.assistantActionAvailable
|
||||
? NativeKeyboardKeyColors.fill(for: colorScheme)
|
||||
: NativeKeyboardKeyColors.pressedFill(for: colorScheme)
|
||||
}
|
||||
|
||||
private var sendButtonForeground: Color {
|
||||
state.assistantSendAvailable
|
||||
private var fieldActionButtonForeground: Color {
|
||||
state.assistantActionAvailable
|
||||
? NativeKeyboardKeyColors.text(for: colorScheme)
|
||||
: NativeKeyboardKeyColors.text(for: colorScheme).opacity(0.58)
|
||||
}
|
||||
|
||||
private func performSend() {
|
||||
guard state.assistantSendAvailable else { return }
|
||||
state.sendAssistantAction()
|
||||
sendConfirmationVisible = true
|
||||
private func performFieldAction() {
|
||||
guard state.assistantActionAvailable else { return }
|
||||
state.performAssistantFieldAction()
|
||||
fieldActionConfirmationVisible = true
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(550))
|
||||
sendConfirmationVisible = false
|
||||
fieldActionConfirmationVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
private var sendButtonWidth: CGFloat {
|
||||
compactIPadLayout ? Layout.compactIPadSendWidth : Layout.sendWidth
|
||||
private var fieldActionButtonWidth: CGFloat {
|
||||
compactIPadLayout
|
||||
? Layout.compactIPadFieldActionWidth
|
||||
: Layout.fieldActionWidth
|
||||
}
|
||||
|
||||
private var lowerCircleSize: CGFloat {
|
||||
|
||||
Reference in New Issue
Block a user