fix(ipad): ship iPad P0 layout/globe fixes, edit-last-input, drop clipboard commands

Adapt typing/voice surfaces for iPad width and height, add the system globe
key and last-input editing flow, harden host-only Rime deployment, and remove
clipboard voice commands. Bump build to 61.
This commit is contained in:
Rocky
2026-08-10 13:50:50 +08:00
parent 53abad2050
commit 2bc8c1b87d
85 changed files with 5703 additions and 3997 deletions
+121 -27
View File
@@ -64,13 +64,38 @@ public final class KeyboardViewController: UIInputViewController {
private var textInserter: KeyboardTextInserter!
private var flowCoordinator: KeyboardFlowCoordinator!
private var lastInputEditCoordinator: LastInputEditCoordinator!
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.
private var isIPadLayout: Bool {
KeyboardChromeLayout.usesIPadMetrics(
isPad: UIDevice.current.userInterfaceIdiom == .pad,
hasRegularWidth: traitCollection.horizontalSizeClass == .regular
)
}
/// Width the layout should be sized against. `view.bounds` is empty before
/// the first layout pass, so fall back to the screen the keyboard is on.
private var currentLayoutWidth: CGFloat {
let width = view.bounds.width
guard width > 0 else {
return (view.window?.windowScene?.screen ?? UIScreen.main).bounds.width
}
return width
}
private var targetKeyboardHeight: CGFloat {
KeyboardSurfaceRoot.height(for: state.surface)
KeyboardSurfaceRoot.height(
for: state.surface,
isIPad: state.usesIPadLayoutMetrics,
width: state.layoutWidth
)
}
// MARK: - Init
@@ -96,6 +121,7 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewDidLoad() {
super.viewDidLoad()
state.showsSystemGlobeKey = UIDevice.current.userInterfaceIdiom == .pad
// Voice-first keyboard hide the misleading "English" subtitle in Settings.
primaryLanguage = "mis"
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
@@ -112,6 +138,7 @@ public final class KeyboardViewController: UIInputViewController {
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
// Establish layout dependencies before applying the preferred surface.
// `applySurface` updates height and UIKit may lay out synchronously.
refreshLayoutMode()
installKeyboardHeight()
configureDictationBehavior()
installServices()
@@ -128,7 +155,6 @@ public final class KeyboardViewController: UIInputViewController {
_ = configSync.loadPersistedConfig()
configSync.installDarwinObservers()
flowCoordinator.refreshSessionState()
flowCoordinator.restoreClipboardCommandIfNeeded()
OSGDiag.log(
"KVC.viewDidLoad done surface=\(state.surface.rawValue) "
+ "sessionActive=\(FlowSessionBridge.isSessionActive()) "
@@ -145,18 +171,12 @@ public final class KeyboardViewController: UIInputViewController {
category: "boot"
)
heightPhase = .idle
// Block pasteboard content reads until the next viewDidAppear height lock.
flowCoordinator.setClipboardContentReadsEnabled(false)
flowCoordinator.stopSessionMonitor()
// Remember what the user left on, then pre-position a reused
// extension instance for the next open policy (no first-frame jump).
// Skip snap-to-typing while clipboard paste alert / utterance owns the mic
// otherwise Allow Paste reopens on the typing grid (visible jump).
let preserve = flowCoordinator.preservesLifecycleOnDisappear
|| flowCoordinator.isClipboardCommandActive
|| ClipboardCommandResume.shouldPreferVoice()
TypingInputConfiguration.persistLastSurface(
preserve || ClipboardCommandResume.shouldPreferVoice() ? .voice : state.surface
preserve ? .voice : state.surface
)
if !preserve {
prepareSurfaceForNextPresentation()
@@ -182,16 +202,13 @@ public final class KeyboardViewController: UIInputViewController {
configureDictationBehavior()
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
state.debugHasFullAccess = hasFullAccess
// Do NOT read pasteboard contents here `refreshSessionState` may peek
// changeCount only while content reads stay disabled until height locks.
// Refresh only Flow/config state; edit targets come from verified OSG insertions.
flowCoordinator.refreshSessionState()
flowCoordinator.startSessionMonitor()
configSync.syncOnboardingStateFromAppGroup()
configSync.refreshConfigFromAppGroup()
// Settings may have changed while the extension stayed alive.
applyPreferredSurfaceOnOpen()
// After paste-alert reopen: restore clipboard chrome if sticky + host busy.
flowCoordinator.restoreClipboardCommandIfNeeded()
// Re-warm Taptic after host app switches: SwiftUI `onAppear` often
// skips when the extension process is reused, leaving generators cold.
KeyboardHapticFeedback.prepare()
@@ -233,11 +250,9 @@ public final class KeyboardViewController: UIInputViewController {
heightPhase = .presented
lockPresentedKeyboardHeight()
refreshReturnKeyRole()
// After height is locked: (1) allow pasteboard content reads so the
// paste alert cannot interrupt presentation math; (2) arm PiP handoff.
// Presentation math is locked before arming the PiP handoff.
DispatchQueue.main.async { [weak self] in
guard let self, self.heightPhase == .presented else { return }
self.flowCoordinator.setClipboardContentReadsEnabled(true)
self.flowCoordinator.ensurePiPReadyOnKeyboardOpen()
}
OSGDiag.log(
@@ -249,12 +264,22 @@ public final class KeyboardViewController: UIInputViewController {
public override func textDidChange(_ textInput: UITextInput?) {
super.textDidChange(textInput)
refreshReturnKeyRole()
textInserter?.refreshUndoAvailability()
textInserter?.refreshEditingAvailability()
lastInputEditCoordinator?.refreshContext()
}
public override func selectionDidChange(_ textInput: UITextInput?) {
super.selectionDidChange(textInput)
textInserter?.refreshUndoAvailability()
textInserter?.refreshEditingAvailability()
lastInputEditCoordinator?.refreshContext()
}
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass else {
return
}
refreshLayoutMode()
}
public override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
@@ -282,6 +307,11 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// Rotation does not change `horizontalSizeClass` on iPad (both
// orientations are regular), so this is the only callback that sees a
// portraitlandscape resize. `refreshLayoutMode` no-ops unless the
// layout bucket actually changed, so this cannot loop.
refreshLayoutMode()
cursorDrag?.layoutChrome()
enforcePresentedKeyboardHeightIfNeeded()
}
@@ -294,6 +324,8 @@ public final class KeyboardViewController: UIInputViewController {
insertText: { [weak self] text in self?.textDocumentProxy.insertText(text) },
deleteBackward: { [weak self] in self?.textDocumentProxy.deleteBackward() },
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
fieldContextProvider: { [weak self] in self?.captureFieldContext() },
selectedText: { [weak self] in self?.textDocumentProxy.selectedText },
scheduleAutoClearError: { [weak self] in self?.scheduleAutoClearError() }
)
@@ -302,6 +334,11 @@ public final class KeyboardViewController: UIInputViewController {
persistor: persistor,
onFlowSessionChanged: { [weak self] in
self?.flowCoordinator.refreshSessionState()
},
onConfigChanged: { [weak self] in
// Only an already-live typing session can be showing the setup
// error; never force-create one just to retry.
self?.typingSessionStorage?.retryPrepareAfterResourceDeployment()
}
)
@@ -316,6 +353,29 @@ public final class KeyboardViewController: UIInputViewController {
scheduleAutoClearError: { [weak self] in self?.scheduleAutoClearError() },
refreshConfigFromAppGroup: { [weak self] in self?.configSync.refreshConfigFromAppGroup() }
)
lastInputEditCoordinator = LastInputEditCoordinator(
state: state,
textInserter: textInserter,
beginFlow: { [weak self] reference in
self?.flowCoordinator.beginEditRecording(reference: reference)
?? .rejected(.hostUnavailable)
},
stopFlow: { [weak self] in self?.flowCoordinator.stopEditRecording() },
abortFlow: { [weak self] in self?.flowCoordinator.abortEditRecording() },
acknowledge: { [weak self] outcome in
self?.flowCoordinator.acknowledgeEditResult(outcome)
}
)
flowCoordinator.onEditHostRecordingConfirmed = { [weak self] in
self?.lastInputEditCoordinator.hostRecordingConfirmed()
}
flowCoordinator.onEditResult = { [weak self] result in
self?.lastInputEditCoordinator.receive(result: result)
}
flowCoordinator.onEditFailure = { [weak self] message in
self?.lastInputEditCoordinator.fail(message)
}
_ = textInserter.recoverPendingEditTransactionIfNeeded()
cursorDrag = CursorDragController(
state: state,
@@ -331,13 +391,29 @@ public final class KeyboardViewController: UIInputViewController {
state.beginRecording = { [weak self] in self?.flowCoordinator.pressBegan() }
state.endRecording = { [weak self] in self?.flowCoordinator.pressEnded() }
state.tapMic = { [weak self] in self?.flowCoordinator.toggleRecording() }
state.beginClipboardCommand = { [weak self] in
self?.flowCoordinator.clipboardCommandPressBegan()
state.setMicTouchActive = { [weak self] active in
self?.flowCoordinator.setMicTouchActive(active)
}
state.refreshClipboardEligibility = { [weak self] in
self?.flowCoordinator.refreshClipboardEligibility()
state.cancelVoiceInput = { [weak self] in
self?.flowCoordinator.cancelCurrentDictation()
}
state.beginEditLastInput = { [weak self] in
self?.lastInputEditCoordinator.begin()
}
state.stopEditListening = { [weak self] in
self?.lastInputEditCoordinator.stopListening()
}
state.confirmEditResult = { [weak self] in
self?.lastInputEditCoordinator.confirm()
}
state.closeEditMode = { [weak self] in
self?.lastInputEditCoordinator.close()
}
state.openSettings = { [weak self] in self?.openHostApp() }
state.openInputMethodSetup = { [weak self] in self?.openHostApp(path: "deployrime") }
// The globe UIButton registers this controller's standard
// `handleInputModeList(from:with:)` action for all touch events.
state.inputModeController = self
state.startFlowSession = { [weak self] in self?.flowCoordinator.beginFlowStart() }
state.setMode = { [weak self] m in self?.configSync.persistMode(m) }
state.setLocale = { [weak self] l in self?.configSync.persistLocale(l) }
@@ -349,6 +425,9 @@ public final class KeyboardViewController: UIInputViewController {
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
state.undoLastInsertion = { [weak self] in self?.textInserter.undoLastInsertion() }
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)
}
@@ -410,17 +489,13 @@ public final class KeyboardViewController: UIInputViewController {
private func applyPreferredSurfaceOnOpen() {
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
let sticky = ClipboardCommandResume.shouldPreferVoice()
let resolved = KeyboardOpenSurfacePolicy.resolve(
locksTypingSurface: state.locksTypingSurface,
clipboardCommandActive: flowCoordinator.isClipboardCommandActive,
stickyPreferVoice: sticky,
preferred: preferred
)
OSGDiag.log(
"applyPreferredSurfaceOnOpen preferred=\(preferred.rawValue) "
+ "resolved=\(resolved.rawValue) stickyVoice=\(sticky ? 1 : 0) "
+ "clipboardActive=\(flowCoordinator.isClipboardCommandActive ? 1 : 0) "
+ "resolved=\(resolved.rawValue) "
+ "locksTyping=\(state.locksTypingSurface ? 1 : 0)",
category: "boot"
)
@@ -453,6 +528,25 @@ public final class KeyboardViewController: UIInputViewController {
}
}
private func refreshLayoutMode() {
let usesIPadMetrics = isIPadLayout
let width = currentLayoutWidth
// `state.layoutWidth` tracks the width that last changed the layout
// bucket, not every intermediate width: republishing on each frame of
// a Stage Manager drag would rebuild the SwiftUI grid continuously.
let bucketChanged = KeyboardChromeLayout.usesWideIPadMetrics(
isIPad: usesIPadMetrics,
width: width
) != KeyboardChromeLayout.usesWideIPadMetrics(
isIPad: state.usesIPadLayoutMetrics,
width: state.layoutWidth
)
guard state.usesIPadLayoutMetrics != usesIPadMetrics || bucketChanged else { return }
state.usesIPadLayoutMetrics = usesIPadMetrics
state.layoutWidth = width
refreshKeyboardHeight()
}
private var heightPhaseLog: String {
switch heightPhase {
case .idle: return "idle"
@@ -1,32 +0,0 @@
// ClipboardPasteboardReader.swift
// OSGKeyboard · Keyboard Extension
//
// Pasteboard peeks for clipboard-command UI and long-press snapshot capture.
//
// Idle affordance must use metadata only (`hasStrings` / `changeCount`) so the
// systemalert never appears while the keyboard is merely open.
// Content reads (`string`) happen only on an explicit long-press.
import UIKit
import OSGKeyboardShared
enum ClipboardPasteboardReader {
/// Metadata-only does not trigger the paste permission prompt.
static func changeCount() -> Int {
UIPasteboard.general.changeCount
}
/// Metadata-only whether the pasteboard currently holds string items.
static func hasStrings() -> Bool {
UIPasteboard.general.hasStrings
}
/// Content sample for long-press snapshot. May present the system paste alert.
static func sample() -> (changeCount: Int, text: String?) {
let board = UIPasteboard.general
let changeCount = board.changeCount
// Prefer plain strings; avoid forcing non-text pasteboard items.
let text = board.hasStrings ? board.string : nil
return (changeCount, text)
}
}
@@ -12,6 +12,10 @@ final class KeyboardConfigSync {
private let state: KeyboardState
private let persistor: AppGroupPersistor
private let onFlowSessionChanged: () -> Void
/// Fired on every App Group config change the host posts one after a
/// successful Rime deployment, which is the keyboard's only signal that
/// typing resources just became available.
private let onConfigChanged: () -> Void
/// Grace period after a chip-side translation write during which the
/// 1 Hz App Group poll must not overwrite `translationTargetLocaleId`.
@@ -25,11 +29,13 @@ final class KeyboardConfigSync {
init(
state: KeyboardState,
persistor: AppGroupPersistor,
onFlowSessionChanged: @escaping () -> Void
onFlowSessionChanged: @escaping () -> Void,
onConfigChanged: @escaping () -> Void = {}
) {
self.state = state
self.persistor = persistor
self.onFlowSessionChanged = onFlowSessionChanged
self.onConfigChanged = onConfigChanged
}
func installDarwinObservers() {
@@ -49,7 +55,9 @@ final class KeyboardConfigSync {
configDarwinObserver = FlowSessionDarwinObserver(
notificationName: AppGroupConfigDarwin.notificationName
) { [weak self] in
self?.refreshConfigFromAppGroup()
guard let self else { return }
self.refreshConfigFromAppGroup()
self.onConfigChanged()
}
}
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@
// without re-running LLM polish in the extension. Also tracks the last
// voice insertion so the undo button can roll it back safely.
import UIKit
import OSGKeyboardShared
@MainActor
@@ -13,12 +14,21 @@ final class KeyboardTextInserter {
private let insertText: (String) -> Void
private let deleteBackward: () -> Void
private let contextBeforeInput: () -> String?
private let fieldContextProvider: () -> FlowFieldContext?
private let selectedText: () -> String?
private let scheduleAutoClearError: () -> Void
/// Exact string last inserted by voice (including any word-boundary
/// separator). Cleared after a successful undo or when the caret no
/// longer sits after that text.
private var lastInsertedText: String?
/// Text captured when the last insertion was undone, so redo can
/// re-apply it. Cleared by any new insertion or external edit.
private var redoText: String?
private var redoContextBefore: String?
private let extensionInstanceID = UUID()
private var lastEditUndo: PendingTextEditTransaction?
private var editHintTask: Task<Void, Never>?
/// Suppresses availability refresh while we walk `deleteBackward`
/// for undo, so intermediate contexts don't flicker the button.
private var isUndoing = false
@@ -28,12 +38,16 @@ final class KeyboardTextInserter {
insertText: @escaping (String) -> Void,
deleteBackward: @escaping () -> Void,
contextBeforeInput: @escaping () -> String?,
fieldContextProvider: @escaping () -> FlowFieldContext?,
selectedText: @escaping () -> String?,
scheduleAutoClearError: @escaping () -> Void
) {
self.state = state
self.insertText = insertText
self.deleteBackward = deleteBackward
self.contextBeforeInput = contextBeforeInput
self.fieldContextProvider = fieldContextProvider
self.selectedText = selectedText
self.scheduleAutoClearError = scheduleAutoClearError
}
@@ -66,7 +80,13 @@ final class KeyboardTextInserter {
)
let inserted = separator + trimmed
insertText(inserted)
recordLastInsertion(inserted)
recordLastInsertion(
inserted,
displayText: trimmed,
historyEntryID: delivery.historyEntryID,
historyEntryRevision: delivery.historyEntryRevision,
pendingHistoryMutationID: nil
)
state.lastTranscript = ""
state.level = 0
if let warning = delivery.polishWarning {
@@ -80,6 +100,9 @@ final class KeyboardTextInserter {
/// Roll back the last voice insertion when it is still at the caret.
func undoLastInsertion() {
if undoLastEditIfPossible() {
return
}
guard let text = lastInsertedText, !text.isEmpty else { return }
guard let preceding = contextBeforeInput(), preceding.hasSuffix(text) else {
clearLastInsertion()
@@ -92,35 +115,332 @@ final class KeyboardTextInserter {
for _ in 0..<text.count {
deleteBackward()
}
clearLastInsertion()
// Stash the exact inserted string (incl. separator) for redo before
// dropping the live undo record.
redoText = text
redoContextBefore = contextBeforeInput()
lastInsertedText = nil
state.undoAvailable = false
OSGLog.keyboardExt.info("voice undo length=\(text.count, privacy: .public)")
}
/// Re-evaluate whether the recorded insertion is still undoable.
/// Call from `textDidChange` / `selectionDidChange`.
func refreshUndoAvailability() {
guard !isUndoing else { return }
guard let text = lastInsertedText, !text.isEmpty else {
if state.undoAvailable { state.undoAvailable = false }
/// Re-apply the last undone voice insertion when it is still absent from
/// the caret (i.e. the undo was not overwritten by an external edit).
func redoLastInsertion() {
guard let text = redoText, !text.isEmpty,
contextBeforeInput() == redoContextBefore else {
redoText = nil
redoContextBefore = nil
state.redoAvailable = false
return
}
let available = contextBeforeInput()?.hasSuffix(text) == true
if !available {
// Caret moved or the user edited the insertion drop the record.
lastInsertedText = nil
guard let preceding = contextBeforeInput(), !preceding.hasSuffix(text) else {
redoText = nil
state.redoAvailable = false
return
}
if state.undoAvailable != available {
state.undoAvailable = available
insertText(text)
lastInsertedText = text
redoText = nil
redoContextBefore = nil
state.undoAvailable = true
OSGLog.keyboardExt.info("voice redo length=\(text.count, privacy: .public)")
}
/// Copy the host field's current selection to the pasteboard. Needs Full
/// Access for `selectedText` on some hosts; silently no-ops otherwise.
func copySelection() {
guard let text = selectedText(), !text.isEmpty else { return }
UIPasteboard.general.string = text
OSGLog.keyboardExt.info("copy length=\(text.count, privacy: .public)")
}
/// Copy the host field's current selection, then delete it.
func cutSelection() {
guard let text = selectedText(), !text.isEmpty else { return }
UIPasteboard.general.string = text
// `deleteBackward` removes the active selection in one operation.
deleteBackward()
OSGLog.keyboardExt.info("cut length=\(text.count, privacy: .public)")
}
/// Re-evaluate undo / redo / copy / cut availability. Call from
/// `textDidChange` / `selectionDidChange`.
func refreshEditingAvailability() {
// Undo: only re-check when not mid-undo (avoids flicker while we walk
// `deleteBackward`); the undo method manages availability itself.
if !isUndoing {
if let text = lastInsertedText, !text.isEmpty {
let available = contextBeforeInput()?.hasSuffix(text) == true
if !available {
// Caret moved or the user edited the insertion drop it.
lastInsertedText = nil
state.undoAvailable = false
} else if !state.undoAvailable {
state.undoAvailable = true
}
} else if state.undoAvailable {
state.undoAvailable = false
}
}
// Redo: a stashed insertion that hasn't been overwritten by an edit.
if redoText != nil, contextBeforeInput() != redoContextBefore {
redoText = nil
redoContextBefore = nil
}
let redo = redoText != nil && !(redoText?.isEmpty ?? true)
if state.redoAvailable != redo {
state.redoAvailable = redo
}
// Copy / cut: a non-empty selection exists in the host field.
let hasSelection = (selectedText()?.isEmpty == false)
if state.copyAvailable != hasSelection {
state.copyAvailable = hasSelection
}
if state.cutAvailable != hasSelection {
state.cutAvailable = hasSelection
}
}
private func recordLastInsertion(_ text: String) {
func editableReference() -> EditableInputReference? {
guard var reference = EditableInputReferenceStore.load() else {
return nil
}
if let mutationID = reference.pendingHistoryMutationID,
let receipt = HistoryMutationReceiptStore.receipt(for: mutationID) {
reference = EditableInputReference(
targetID: reference.targetID,
historyEntryID: receipt.entryID,
historyEntryRevision: receipt.revision,
displayText: reference.displayText,
insertedText: reference.insertedText,
postInsertionFingerprint: reference.postInsertionFingerprint,
extensionInstanceID: reference.extensionInstanceID,
observedDocumentRevision: reference.observedDocumentRevision,
createdAt: reference.createdAt
)
EditableInputReferenceStore.save(reference)
}
guard reference.isWithinLengthBudget,
let preceding = contextBeforeInput(),
preceding.hasSuffix(reference.insertedText) else {
return nil
}
guard reference.postInsertionFingerprint == nil
|| reference.postInsertionFingerprint
== fieldContextProvider()?.deliveryFingerprint else {
return nil
}
if reference.extensionInstanceID == extensionInstanceID,
lastInsertedText == reference.insertedText {
return reference
}
return reference.isFullyVerified(
contextBeforeInput: preceding,
fieldFingerprint: fieldContextProvider()?.deliveryFingerprint
) ? reference : nil
}
@discardableResult
func applyEdit(_ review: EditReview, append: Bool) -> Bool {
let source = review.source.reference
let result = review.resultText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !result.isEmpty else { return false }
let mode: PendingTextEditTransaction.DeliveryMode = append ? .append : .replace
if append {
guard let context = fieldContextProvider(),
!context.isSecureEntry,
context.isContextAvailable else {
return false
}
}
if !append {
guard editableReference()?.targetID == source.targetID else { return false }
}
let historyEntryID = append
? UUID()
: (source.historyEntryID ?? UUID())
let historyAction: HistoryMutation.Action = append || source.historyEntryID == nil
? .append
: .update
let mutation = HistoryMutation(
action: historyAction,
entryID: historyEntryID,
expectedRevision: historyAction == .update
? source.historyEntryRevision
: nil,
text: result
)
var transaction = PendingTextEditTransaction(
deliveryMode: mode,
beforeText: source.insertedText,
afterText: result,
expectedFieldFingerprint: fieldContextProvider()?.deliveryFingerprint,
historyMutation: mutation
)
PendingTextEditTransactionStore.save(transaction)
if !append {
for _ in source.insertedText {
deleteBackward()
}
}
let separator = DictationTextComposer.insertionSeparator(
previousContext: contextBeforeInput(),
insertion: result
)
let inserted = separator + result
transaction.appliedInsertedText = inserted
PendingTextEditTransactionStore.save(transaction)
insertText(inserted)
let verificationSuffix = String(inserted.suffix(80))
guard contextBeforeInput()?.hasSuffix(verificationSuffix) == true else {
// Never blindly delete after a partial/opaque host insertion. The
// durable transaction lets a later presentation reconcile safely.
return false
}
transaction.phase = .fieldApplied
PendingTextEditTransactionStore.save(transaction)
HistoryMutationOutbox.enqueue(mutation)
transaction.phase = .committed
PendingTextEditTransactionStore.save(transaction)
lastEditUndo = transaction
recordLastInsertion(
inserted,
displayText: result,
historyEntryID: historyEntryID,
historyEntryRevision: historyAction == .update
? (source.historyEntryRevision ?? 0) + 1
: 0,
pendingHistoryMutationID: mutation.id
)
PendingTextEditTransactionStore.clear()
return true
}
@discardableResult
func recoverPendingEditTransactionIfNeeded() -> Bool {
guard let transaction = PendingTextEditTransactionStore.load(),
let preceding = contextBeforeInput() else {
return false
}
let currentFingerprint = fieldContextProvider()?.deliveryFingerprint
let appliedText = transaction.appliedInsertedText ?? transaction.afterText
if transaction.phase != .prepared,
preceding.hasSuffix(appliedText) {
HistoryMutationOutbox.enqueue(transaction.historyMutation)
PendingTextEditTransactionStore.clear()
return true
}
if transaction.phase == .prepared,
currentFingerprint != transaction.expectedFieldFingerprint,
preceding.hasSuffix(appliedText) {
HistoryMutationOutbox.enqueue(transaction.historyMutation)
PendingTextEditTransactionStore.clear()
return true
}
if transaction.deliveryMode == .replace,
preceding.hasSuffix(transaction.beforeText) {
PendingTextEditTransactionStore.clear()
return false
}
return false
}
private func undoLastEditIfPossible() -> Bool {
guard let transaction = lastEditUndo,
let preceding = contextBeforeInput() else {
return false
}
let insertedAfter = lastInsertedText ?? transaction.afterText
guard preceding.hasSuffix(insertedAfter) else {
lastEditUndo = nil
return false
}
for _ in insertedAfter {
deleteBackward()
}
switch transaction.deliveryMode {
case .replace:
insertText(transaction.beforeText)
let restore = HistoryMutation(
action: transaction.historyMutation.action == .append ? .delete : .restore,
entryID: transaction.historyMutation.entryID,
expectedRevision: transaction.historyMutation.expectedRevision.map { $0 + 1 },
text: transaction.beforeText
.trimmingCharacters(in: .whitespacesAndNewlines)
)
HistoryMutationOutbox.enqueue(restore)
recordLastInsertion(
transaction.beforeText,
displayText: transaction.beforeText
.trimmingCharacters(in: .whitespacesAndNewlines),
historyEntryID: transaction.historyMutation.action == .append
? nil
: transaction.historyMutation.entryID,
historyEntryRevision: transaction.historyMutation.expectedRevision.map { $0 + 2 },
pendingHistoryMutationID: restore.id
)
case .append:
HistoryMutationOutbox.enqueue(
HistoryMutation(
action: .delete,
entryID: transaction.historyMutation.entryID
)
)
clearLastInsertion()
}
lastEditUndo = nil
return true
}
private func recordLastInsertion(
_ text: String,
displayText: String,
historyEntryID: UUID?,
historyEntryRevision: Int64?,
pendingHistoryMutationID: UUID?
) {
lastInsertedText = text
redoText = nil
redoContextBefore = nil
state.undoAvailable = true
EditableInputReferenceStore.save(
EditableInputReference(
historyEntryID: historyEntryID,
historyEntryRevision: historyEntryRevision,
pendingHistoryMutationID: pendingHistoryMutationID,
displayText: displayText,
insertedText: text,
postInsertionFingerprint: fieldContextProvider()?.deliveryFingerprint,
extensionInstanceID: extensionInstanceID
)
)
editHintTask?.cancel()
let hint = ExtL10n.string("keyboard.edit.hint.available")
state.editHint = hint
state.editHintIsPositive = true
editHintTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 10_000_000_000)
guard !Task.isCancelled,
self?.state.editHint == hint,
self?.state.editHintIsPositive == true else {
return
}
self?.state.editHint = nil
self?.state.editHintIsPositive = false
}
}
private func clearLastInsertion() {
lastInsertedText = nil
state.undoAvailable = false
EditableInputReferenceStore.clear()
}
}
@@ -0,0 +1,275 @@
// LastInputEditCoordinator.swift
// OSGKeyboard · Keyboard Extension
//
// Product workflow for explicit editing. Flow transport remains owned by
// KeyboardFlowCoordinator; this coordinator owns only edit state and delivery.
import Foundation
import OSGKeyboardShared
@MainActor
final class LastInputEditCoordinator {
private let state: KeyboardState
private let textInserter: KeyboardTextInserter
private let beginFlow: (EditableInputReference) -> FlowUtteranceStartDisposition
private let stopFlow: () -> Void
private let abortFlow: () -> Void
private let acknowledge: (FlowAck.DeliveryOutcome) -> Void
private var hintTask: Task<Void, Never>?
private var activeUtteranceID: UUID?
private var reviewedUtteranceID: UUID?
private var reviewedRevision: Int64?
init(
state: KeyboardState,
textInserter: KeyboardTextInserter,
beginFlow: @escaping (EditableInputReference) -> FlowUtteranceStartDisposition,
stopFlow: @escaping () -> Void,
abortFlow: @escaping () -> Void,
acknowledge: @escaping (FlowAck.DeliveryOutcome) -> Void
) {
self.state = state
self.textInserter = textInserter
self.beginFlow = beginFlow
self.stopFlow = stopFlow
self.abortFlow = abortFlow
self.acknowledge = acknowledge
}
func begin() {
switch state.editSession {
case .inactive, .failed:
break
default:
return
}
guard !state.micDisabled else {
showHint(ExtL10n.string("keyboard.edit.error.llmUnavailable"))
return
}
switch state.micVoiceAvailability {
case .unavailable(.missingAPIKey):
showHint(ExtL10n.string("keyboard.edit.error.llmUnavailable"))
return
case .unavailable(.noFullAccess):
showHint(ExtL10n.string("keyboard.error.fullAccessRequired"))
return
case .unavailable(.appGroupUnavailable):
showHint(ExtL10n.string("keyboard.error.appGroupCommunication"))
return
case .unavailable(.onboardingIncomplete):
showHint(ExtL10n.string("keyboard.hint.finishSetupInApp"))
return
case .unavailable(.hostNotReady),
.unavailable(.preparingSession),
.ready,
.recording,
.processing:
break
}
guard let reference = textInserter.editableReference() else {
showHint(ExtL10n.string("keyboard.edit.error.noTarget"))
return
}
start(reference)
}
private func start(_ reference: EditableInputReference) {
let source = EditSessionSource(reference: reference)
state.lastTranscript = ""
let disposition = beginFlow(reference)
guard let utteranceID = disposition.utteranceID else {
let message = startFailureMessage(for: disposition)
state.editSession = .failed(source, message: message)
state.phase = .idle
state.lastTranscript = ""
EditUsageMetricsStore.record(.failed)
return
}
activeUtteranceID = utteranceID
reviewedUtteranceID = nil
reviewedRevision = nil
state.editCanReplaceOriginal = true
state.editSession = .preparing(source)
state.phase = .requestingPermissions
EditUsageMetricsStore.record(.entered)
KeyboardHapticFeedback.play(
role: .action,
intensity: state.keyboardHapticIntensity
)
OSGLog.keyboardExt.info(
"edit.start issued utterance=\(self.activeUtteranceID?.uuidString.prefix(8) ?? "nil", privacy: .public)"
)
}
func hostRecordingConfirmed() {
guard case .preparing(let source) = state.editSession else { return }
state.editSession = .listening(source)
state.phase = .recording
KeyboardHapticFeedback.play(
role: .action,
intensity: state.keyboardHapticIntensity
)
OSGLog.keyboardExt.info(
"edit.hostRecording.confirmed utterance=\(self.activeUtteranceID?.uuidString.prefix(8) ?? "nil", privacy: .public)"
)
}
func stopListening() {
guard case .listening(let source) = state.editSession else { return }
state.editSession = .processing(source)
state.phase = .processing
stopFlow()
}
func receive(result: FlowResult) {
guard result.resolvedUtteranceMode == .editLastInput,
result.utteranceId == activeUtteranceID,
case .processing(let source) = state.editSession,
reviewedUtteranceID != result.utteranceId
|| reviewedRevision != result.revision,
let output = result.text else {
return
}
switch EditOutputValidator.validate(
sourceText: source.reference.displayText,
output: output
) {
case .success(let validated):
reviewedUtteranceID = result.utteranceId
reviewedRevision = result.revision
let review = EditReview(
source: source,
resultText: validated,
utteranceID: result.utteranceId
)
state.editCanReplaceOriginal =
textInserter.editableReference()?.targetID == source.reference.targetID
state.editSession = .review(review)
state.phase = .processing
KeyboardHapticFeedback.play(
role: .action,
intensity: state.keyboardHapticIntensity
)
case .failure(.unchanged):
fail(ExtL10n.string("keyboard.edit.error.unchanged"))
acknowledge(.rejected)
case .failure:
fail(ExtL10n.string("keyboard.edit.error.processing"))
acknowledge(.rejected)
}
}
func fail(_ message: String) {
guard let source = state.editSession.source else {
showHint(message)
return
}
state.editSession = .failed(source, message: message)
state.phase = .idle
state.lastTranscript = ""
EditUsageMetricsStore.record(.failed)
activeUtteranceID = nil
reviewedUtteranceID = nil
reviewedRevision = nil
}
func refreshContext() {
guard let source = state.editSession.source else { return }
state.editCanReplaceOriginal =
textInserter.editableReference()?.targetID == source.reference.targetID
}
func confirm() {
guard case .review(let review) = state.editSession else { return }
let shouldAppend = !state.editCanReplaceOriginal
state.editSession = shouldAppend ? .appending(review) : .applying(review)
let applied = textInserter.applyEdit(review, append: shouldAppend)
guard applied else {
state.editSession = .review(review)
state.editCanReplaceOriginal = false
return
}
acknowledge(shouldAppend ? .appended : .replaced)
EditUsageMetricsStore.record(shouldAppend ? .appended : .replaced)
state.editSession = .inactive
state.editCanReplaceOriginal = false
state.phase = .idle
state.lastTranscript = ""
activeUtteranceID = nil
reviewedUtteranceID = nil
reviewedRevision = nil
KeyboardHapticFeedback.play(
role: .action,
intensity: state.keyboardHapticIntensity
)
}
func close() {
if state.editSession.review != nil {
acknowledge(.rejected)
} else {
abortFlow()
}
state.editSession = .inactive
state.editCanReplaceOriginal = false
state.phase = .idle
state.lastTranscript = ""
EditUsageMetricsStore.record(.cancelled)
activeUtteranceID = nil
reviewedUtteranceID = nil
reviewedRevision = nil
}
func showAvailabilityHintAfterDictation() {
guard textInserter.editableReference() != nil else { return }
showHint(
ExtL10n.string("keyboard.edit.hint.available"),
isPositive: true,
durationNanoseconds: 10_000_000_000
)
}
private func showHint(
_ message: String,
isPositive: Bool = false,
durationNanoseconds: UInt64 = 2_500_000_000
) {
hintTask?.cancel()
state.editHint = message
state.editHintIsPositive = isPositive
hintTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: durationNanoseconds)
guard !Task.isCancelled,
self?.state.editHint == message,
self?.state.editHintIsPositive == isPositive else {
return
}
self?.state.editHint = nil
self?.state.editHintIsPositive = false
}
}
private func startFailureMessage(
for disposition: FlowUtteranceStartDisposition
) -> String {
guard case .rejected(let reason) = disposition else {
return ExtL10n.string("keyboard.edit.error.startTimeout")
}
switch reason {
case .missingAPIKey:
return ExtL10n.string("keyboard.edit.error.llmUnavailable")
case .noFullAccess:
return ExtL10n.string("keyboard.error.fullAccessRequired")
case .appGroupUnavailable:
return ExtL10n.string("keyboard.error.appGroupCommunication")
case .onboardingIncomplete:
return ExtL10n.string("keyboard.hint.finishSetupInApp")
case .pipelineBusy:
return ExtL10n.string("keyboard.edit.error.processing")
case .hostUnavailable:
return ExtL10n.string("keyboard.edit.error.startTimeout")
}
}
}
@@ -13,14 +13,16 @@ struct KeyboardSurfaceRoot: View {
var onInsert: (String) -> Void
var onDeleteBackward: () -> Void
static var voiceHeight: CGFloat { KeyboardRootView.totalHeight }
static var typingHeight: CGFloat { TypingRootView.totalHeight }
static func height(for surface: KeyboardState.Surface) -> CGFloat {
switch surface {
case .voice: return voiceHeight
case .typing: return typingHeight
}
/// Height is deliberately independent of the surface: the voice surface
/// adopts the typing surface's content-driven height and parks the surplus
/// above its action cluster, so switching surfaces never resizes the
/// keyboard. Keeping this a single expression is what guarantees it.
static func height(
for surface: KeyboardState.Surface,
isIPad: Bool = false,
width: CGFloat = 0
) -> CGFloat {
TypingSurfaceMetrics.contentHeight(isIPad: isIPad, width: width)
}
var body: some View {
+187 -38
View File
@@ -8,18 +8,13 @@ import SwiftUI
import OSGKeyboardShared
enum TypingLayoutMetrics {
static let outerPaddingTop: CGFloat = 4
static let outerPaddingBottom: CGFloat = 4
static let topRegionHeight: CGFloat = KeyboardTopBarMetrics.height
static let keyRowHeight: CGFloat = 50
static let keyRowSpacing: CGFloat = 7
static let keyHorizontalSpacing: CGFloat = 6
static let bottomRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
static let verticalKeySpacing: CGFloat = 8
static let secondRowInset: CGFloat = 18
// Size decisions live in `TypingSurfaceMetrics` (Shared) so the UIKit
// height constraint and this SwiftUI grid cannot disagree.
static let outerPaddingTop: CGFloat = TypingSurfaceMetrics.outerPaddingTop
static let outerPaddingBottom: CGFloat = TypingSurfaceMetrics.outerPaddingBottom
static let topRegionHeight: CGFloat = TypingSurfaceMetrics.topRegionHeight
static let verticalKeySpacing: CGFloat = TypingSurfaceMetrics.verticalKeySpacing
static let keyCornerRadius: CGFloat = KeyboardChromeLayout.actionKeyCornerRadius
/// Match the voice surface's shared 20 / 60 / 20 bottom-row geometry.
static let bottomActionSpacing: CGFloat = KeyboardChromeLayout.actionKeySpacing
/// Shared top row + three 50 pt key rows + native spacing + bottom row.
static let totalHeight: CGFloat = KeyboardChromeLayout.totalHeight
/// Collapsed candidate strip: keep this small so ScrollView doesn't fight .
@@ -29,6 +24,16 @@ enum TypingLayoutMetrics {
static let expandChevronVisualSize: CGFloat = 34
static let expandGridColumns = 5
static let expandCellHeight: CGFloat = 42
// MARK: - iPad (regular size class) metrics
static func metrics(isIPad: Bool, width: CGFloat) -> TypingKeyLayoutBuilder.Metrics {
TypingSurfaceMetrics.metrics(isIPad: isIPad, width: width)
}
static func contentHeight(isIPad: Bool, width: CGFloat) -> CGFloat {
TypingSurfaceMetrics.contentHeight(isIPad: isIPad, width: width)
}
}
struct TypingRootView: View {
@@ -45,7 +50,9 @@ struct TypingRootView: View {
/// Key currently under the finger (grid-level touch pad).
@State private var highlightedKeyID: String?
static let totalHeight: CGFloat = TypingLayoutMetrics.totalHeight
static func totalHeight(isIPad: Bool = false, width: CGFloat = 0) -> CGFloat {
TypingLayoutMetrics.contentHeight(isIPad: isIPad, width: width)
}
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
@@ -81,9 +88,16 @@ struct TypingRootView: View {
.padding(.top, TypingLayoutMetrics.outerPaddingTop)
.padding(.bottom, TypingLayoutMetrics.outerPaddingBottom)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: KeyboardChromeLayout.contentMaxWidth)
// No content-width cap: a key grid has to span the host width or the
// user's muscle memory for the system keyboard's absolute key
// positions is wrong on every key.
.frame(maxWidth: .infinity)
.frame(height: Self.totalHeight)
.frame(
height: Self.totalHeight(
isIPad: state.usesIPadLayoutMetrics,
width: state.layoutWidth
)
)
.background(Color.clear)
.environment(\.themePalette, palette)
// enterTypingMode is owned by KeyboardViewController.viewWillAppear
@@ -119,12 +133,17 @@ struct TypingRootView: View {
private var idleTopBar: some View {
HStack(spacing: Spacing.xs) {
KeyboardBrandLogo(action: state.openSettings)
// Globe key now lives at the bottom-left of the keyboard (matching
// iOS system layout); see the typingKeySurface ForEach.
if let err = typing.lastError {
Text(err)
.font(.system(size: 11))
.foregroundStyle(palette.danger)
.lineLimit(1)
typingErrorLabel(err)
}
// iOS-style editing cluster (undo / redo / copy / cut) iPad only,
// where the top bar has room to mirror the system shortcut row.
if state.usesIPadLayoutMetrics {
editingToolbar
}
Spacer(minLength: 0)
@@ -139,11 +158,43 @@ struct TypingRootView: View {
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
/// Rime failures that only host-side deployment can fix become a tappable
/// jump into the app; everything else stays a plain read-only notice.
@ViewBuilder
private func typingErrorLabel(_ message: String) -> some View {
if typing.lastErrorNeedsHostDeployment {
Button(action: state.openInputMethodSetup) {
HStack(spacing: 2) {
Text(message)
.font(.system(size: 11))
.lineLimit(1)
Image(systemName: "arrow.up.right")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(palette.danger)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(ExtL10n.text("keyboard.typing.setupA11yHint"))
} else {
Text(message)
.font(.system(size: 11))
.foregroundStyle(palette.danger)
.lineLimit(1)
}
}
// MARK: - Candidates
private var candidateBar: some View {
// HStack (not overlay / safeAreaInset): never paints over candidate text.
// Globe key now lives at the bottom-left of the keyboard (matching
// iOS system layout); see the typingKeySurface ForEach.
HStack(spacing: 0) {
if state.usesIPadLayoutMetrics {
editingToolbar
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Spacing.xs) {
if typing.composition.candidates.isEmpty {
@@ -227,6 +278,58 @@ struct TypingRootView: View {
colorScheme == .dark ? Color(white: 0.30) : .white
}
// MARK: - Editing toolbar (iPad)
/// iOS-style editing cluster: undo / redo / copy / cut. Mirrors the system
/// keyboard's shortcut row; surfaced only on iPad where the top bar fits.
@ViewBuilder
private var editingToolbar: some View {
HStack(spacing: 2) {
editingToolbarButton(
systemName: "arrow.uturn.backward",
label: ExtL10n.string("keyboard.undoA11y"),
enabled: state.undoAvailable
) { state.undoLastInsertion() }
editingToolbarButton(
systemName: "arrow.uturn.forward",
label: ExtL10n.string("keyboard.redoA11y"),
enabled: state.redoAvailable
) { state.redoLastInsertion() }
editingToolbarButton(
systemName: "doc.on.doc",
label: ExtL10n.string("keyboard.copyA11y"),
enabled: state.copyAvailable
) { state.copySelection() }
editingToolbarButton(
systemName: "scissors",
label: ExtL10n.string("keyboard.cutA11y"),
enabled: state.cutAvailable
) { state.cutSelection() }
}
}
private func editingToolbarButton(
systemName: String,
label: String,
enabled: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(enabled ? palette.textSecondary : palette.textTertiary)
.frame(width: 34, height: 34)
.background(enabled ? editingToolbarButtonFill : .clear, in: Circle())
}
.buttonStyle(.plain)
.disabled(!enabled)
.accessibilityLabel(Text(label))
}
private var editingToolbarButtonFill: Color {
colorScheme == .dark ? Color(white: 0.30) : .white
}
/// UIKit-recycled labels no SwiftUI Button per candidate.
private var expandedCandidatePanel: some View {
CandidateExpandGridView(
@@ -292,20 +395,45 @@ struct TypingRootView: View {
)
ForEach(layout.keys) { key in
visualTypingKey(key)
if key.id == TypingKeyLayoutBuilder.BottomKeyID.globe.rawValue {
// Globe key: SystemGlobeKey's UIButton handles its own
// tap (advance) / long-press (system input-mode list),
// so leave hit testing enabled here. The touch pad sits
// underneath but the UIButton intercepts touches in
// this frame, so hit testing against `layout.keys`
// never fires for the globe slot.
SystemGlobeKey(
state: state,
width: key.visualFrame.width,
height: key.visualFrame.height
)
.frame(width: key.visualFrame.width, height: key.visualFrame.height)
.position(
x: key.visualFrame.midX,
y: key.visualFrame.midY
)
// Touches go to the UIKit pad; visuals stay for VoiceOver.
.allowsHitTesting(false)
} else {
visualTypingKey(key)
.frame(width: key.visualFrame.width, height: key.visualFrame.height)
.position(
x: key.visualFrame.midX,
y: key.visualFrame.midY
)
// Touches go to the UIKit pad; visuals stay for VoiceOver.
.allowsHitTesting(false)
}
}
}
}
}
private func makeTypingKeyLayout(size: CGSize) -> TypingKeyLayout {
let isIPad = state.usesIPadLayoutMetrics
// Select metrics from the same width the controller used to size the
// keyboard. Using `size` here would compare the grid's own width to
// its height (always landscape) and could pick a different bucket than
// the height constraint, clipping the bottom row.
let metrics = TypingLayoutMetrics.metrics(isIPad: isIPad, width: state.layoutWidth)
let pageLabel = typing.page == .letters ? "123" : "ABC"
let spaceLabel = typing.language == .chinese ? "空格" : "space"
let returnLabel: String = {
@@ -315,21 +443,26 @@ struct TypingRootView: View {
}
}()
// iPad spends its extra width on comma / period like the system
// keyboard, instead of stretching the space bar across it.
let punctuationKeys: TypingKeyLayoutBuilder.PunctuationKeys? = isIPad
? (typing.language == .chinese
? .init(comma: "", period: "")
: .init(comma: ",", period: "."))
: nil
let layout = TypingKeyLayoutBuilder.build(
size: size,
letterRows: typing.keyRows,
pageSwitchLabel: pageLabel,
spaceLabel: spaceLabel,
returnLabel: returnLabel,
metrics: TypingKeyLayoutBuilder.Metrics(
keyRowHeight: TypingLayoutMetrics.keyRowHeight,
keyRowSpacing: TypingLayoutMetrics.keyRowSpacing,
keyHorizontalSpacing: TypingLayoutMetrics.keyHorizontalSpacing,
secondRowInset: TypingLayoutMetrics.secondRowInset,
bottomRowHeight: TypingLayoutMetrics.bottomRowHeight,
bottomActionSpacing: TypingLayoutMetrics.bottomActionSpacing,
gridToBottomSpacing: TypingLayoutMetrics.keyRowSpacing
),
metrics: metrics,
includeGlobeKey: state.showsSystemGlobeKey,
punctuationKeys: punctuationKeys,
// iPad top letter row carries the small number overlay (10),
// mirroring the iOS system keyboard. iPhone keeps the clean row.
showTopRowNumbers: isIPad,
keyWeight: { label, index, rowIndex in
keyWeight(label: label, index: index, rowIndex: rowIndex)
}
@@ -376,15 +509,26 @@ struct TypingRootView: View {
)
.foregroundStyle(keyTextColor)
} else {
let isSpecial = ["123", "#+=", "ABC"].contains(key.label)
Text(key.label)
.font(
.system(
size: isSpecial ? 15 : 22,
weight: isSpecial ? .semibold : .regular
// Letter / character key. On iPad the top row carries a small
// grey number overlay (10), mirroring the iOS system keyboard;
// `displayNumber` is nil everywhere else, so the layout is a
// single centred letter there.
VStack(spacing: 1) {
if let number = key.displayNumber {
Text(number)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(palette.textSecondary.opacity(0.7))
}
let isSpecial = ["123", "#+=", "ABC"].contains(key.label)
Text(key.label)
.font(
.system(
size: isSpecial ? 15 : 22,
weight: isSpecial ? .semibold : .regular
)
)
)
.foregroundStyle(keyTextColor)
.foregroundStyle(keyTextColor)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -452,6 +596,11 @@ struct TypingRootView: View {
apply(typing.handleSpace())
case TypingKeyLayoutBuilder.BottomKeyID.return.rawValue:
apply(typing.handleReturn())
case TypingKeyLayoutBuilder.BottomKeyID.comma.rawValue,
TypingKeyLayoutBuilder.BottomKeyID.period.rawValue:
// Route through the engine so a pending composition commits first,
// exactly as punctuation typed from the symbols page does.
apply(typing.handleKey(key.label))
default:
switch key.behavior {
case .commitOnRelease:
@@ -0,0 +1,124 @@
// GlobeInputModeButton.swift
// OSGKeyboard · Keyboard Extension
//
// System "next keyboard" (🌐) control. Registering
// `handleInputModeList(from:with:)` for all touch events lets UIKit provide
// native tap-to-advance and long-press input-mode selection without retaining
// a transient `UIEvent`. SwiftUI draws the shared native key chrome while a
// transparent `UIButton` owns touch delivery.
import SwiftUI
import UIKit
import OSGKeyboardShared
struct GlobeInputModeButton: UIViewRepresentable {
@ObservedObject var state: KeyboardState
@Binding var isPressed: Bool
func makeUIView(context: Context) -> GlobeInputModeButtonView {
let view = GlobeInputModeButtonView()
view.onHighlightChanged = { isPressed = $0 }
view.setInputModeController(state.inputModeController)
return view
}
func updateUIView(_ uiView: GlobeInputModeButtonView, context: Context) {
uiView.onHighlightChanged = { isPressed = $0 }
uiView.setInputModeController(state.inputModeController)
}
static func dismantleUIView(_ uiView: GlobeInputModeButtonView, coordinator: Void) {
uiView.onHighlightChanged = nil
uiView.setInputModeController(nil)
}
}
/// SwiftUI wrapper that frames the UIKit globe button and wires it to the
/// shared `KeyboardState` action hooks. Used on both iPad voice and typing
/// surfaces; iPhone relies on the system-provided switch below the keyboard.
/// Default 44×30 remains available for previews; bottom action rows pass an
/// explicit width / height so the key shares their complete geometry.
struct SystemGlobeKey: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette
@ObservedObject var state: KeyboardState
@State private var isPressed = false
var width: CGFloat = 44
var height: CGFloat = 30
var body: some View {
ZStack {
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: NativeKeyboardKeyColors.fill(for: colorScheme),
pressedFill: NativeKeyboardKeyColors.pressedFill(for: colorScheme),
border: palette.divider,
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius
) {
Image(systemName: "globe")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(NativeKeyboardKeyColors.text(for: colorScheme))
.accessibilityHidden(true)
}
GlobeInputModeButton(state: state, isPressed: $isPressed)
.accessibilityLabel(ExtL10n.text("keyboard.nextKeyboardA11y"))
.accessibilityHint(ExtL10n.text("keyboard.nextKeyboardA11yHint"))
}
.frame(width: width, height: height)
}
}
final class GlobeInputModeButtonView: UIButton {
var onHighlightChanged: ((Bool) -> Void)?
/// UIKit controls do not retain action targets, but keeping this explicitly
/// weak documents and enforces the keyboard controller ownership boundary.
private weak var inputModeController: UIInputViewController?
private let inputModeAction = #selector(UIInputViewController.handleInputModeList(from:with:))
override var isHighlighted: Bool {
didSet {
guard oldValue != isHighlighted else { return }
onHighlightChanged?(isHighlighted)
}
}
override init(frame: CGRect) {
super.init(frame: frame)
configure()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func configure() {
// SwiftUI renders the icon, fill, border, shadow, and pressed state.
// This UIKit layer stays transparent and handles only system gestures.
backgroundColor = .clear
isExclusiveTouch = true
accessibilityTraits = .keyboardKey
}
func setInputModeController(_ controller: UIInputViewController?) {
guard inputModeController !== controller else { return }
if let inputModeController {
removeTarget(
inputModeController,
action: inputModeAction,
for: .allTouchEvents
)
}
inputModeController = controller
if let controller {
addTarget(
controller,
action: inputModeAction,
for: .allTouchEvents
)
}
}
}
+166 -136
View File
@@ -35,7 +35,8 @@ private enum KeyboardLayoutMetrics {
/// 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.
static let contentMaxWidth: CGFloat = KeyboardChromeLayout.contentMaxWidth
/// 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
@@ -59,13 +60,16 @@ private enum KeyboardLayoutMetrics {
/// 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.
static var transcriptLineDownwardAdjustment: CGFloat {
/// 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
@@ -76,8 +80,21 @@ private enum KeyboardLayoutMetrics {
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
}
/// 4 + 70 + 24 + 179 + 0 + 4 = 281 pt, matching Chinese / English.
/// 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 {
@@ -101,11 +118,18 @@ public struct KeyboardRootView: 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.
/// 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
@@ -118,30 +142,48 @@ public struct KeyboardRootView: View {
}
public var body: some View {
ZStack {
VStack(spacing: 0) {
headerBand
Group {
if state.editSession.isActive {
LastInputEditView(state: state)
} else {
ZStack {
VStack(spacing: 0) {
headerBand
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterTopGap)
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterTopGap)
micActionRow
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
// 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)
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterBottomGap)
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)
}
}
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
// chrome
.background(Color.clear)
.frame(maxWidth: KeyboardLayoutMetrics.contentMaxWidth)
.frame(maxWidth: .infinity)
.frame(height: Self.totalHeight)
// 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.
@@ -155,13 +197,18 @@ public struct KeyboardRootView: View {
transcript: state.lastTranscript,
micVoiceAvailability: state.micVoiceAvailability,
micDisabledHint: state.micDisabledHint,
clipboardCommandEligible: state.clipboardCommandEligible,
clipboardFailureHint: state.clipboardFailureHint,
editHint: state.editHint,
editHintIsPositive: state.editHintIsPositive,
cursorDragHintActive: state.cursorDragActive,
openSettings: state.openSettings
)
.frame(height: KeyboardLayoutMetrics.transcriptLineHeight)
.offset(y: KeyboardLayoutMetrics.transcriptLineDownwardAdjustment)
.offset(y: KeyboardLayoutMetrics.transcriptLineDownwardAdjustment(
extraSpace: KeyboardLayoutMetrics.extraVerticalSpace(
isIPad: state.usesIPadLayoutMetrics,
width: state.layoutWidth
)
))
}
}
@@ -170,15 +217,25 @@ public struct KeyboardRootView: View {
private var topBar: some View {
HStack(spacing: Spacing.xs) {
KeyboardBrandLogo(action: state.openSettings)
// Globe key now lives at the bottom-left of the keyboard (matching
// iOS system layout); see micActionRow's bottom HStack.
// Engine controls remain available in the host app.
// App context is auto-detected on each mic press no UI.
Spacer(minLength: 0)
KeyboardTopControls(
state: state,
typing: typing,
palette: palette,
onInsert: onInsert
)
if state.canCancelVoiceInput {
KeyboardCancelButton(
action: state.cancelVoiceInput,
accessibilityLabel: ExtL10n.text("keyboard.voice.cancel"),
accessibilityHint: ExtL10n.text("keyboard.voice.cancelHint")
)
} else {
KeyboardTopControls(
state: state,
typing: typing,
palette: palette,
onInsert: onInsert
)
}
}
.padding(.horizontal, KeyboardTopBarMetrics.horizontalInset)
}
@@ -199,19 +256,12 @@ public struct KeyboardRootView: View {
// opacity so the pads' hit area never shifts mid-gesture) and lets
// the cursor-drag chrome take over.
let dragging = state.cursorDragActive
let clipboardRecording = state.clipboardCommandRecording
// Undo hides during drag (like mic) and during clipboard side captions.
let undoVisible = !dragging && !clipboardRecording
let recording = state.phase == .recording
let undoVisible = !dragging && !recording
return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) {
HStack(spacing: 0) {
cursorDragPad(enabled: cursorPadsEnabled)
.overlay {
clipboardSideHint(
ExtL10n.text("keyboard.clipboard.recordingLeft"),
visible: clipboardRecording && !dragging
)
}
.overlay(alignment: .leading) {
// Left-handed: undo shares the outer edge with delete.
if !swapKeys {
@@ -227,12 +277,12 @@ public struct KeyboardRootView: View {
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
isEnabled: micButtonEnabled,
isClipboardCommandRecording: state.clipboardCommandRecording,
onToggle: state.tapMic,
onClipboardLongPressBegan: (state.clipboardCommandEligible
|| state.clipboardCommandUtteranceActive)
&& micButtonEnabled
? state.beginClipboardCommand
onPressingChanged: micButtonEnabled
? state.setMicTouchActive
: { _ in },
onEditLongPressBegan: micButtonEnabled
? state.beginEditLastInput
: nil
)
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
@@ -240,12 +290,6 @@ public struct KeyboardRootView: View {
.opacity(dragging ? 0 : 1)
cursorDragPad(enabled: cursorPadsEnabled)
.overlay {
clipboardSideHint(
ExtL10n.text("keyboard.clipboard.recordingRight"),
visible: clipboardRecording && !dragging
)
}
.overlay(alignment: .trailing) {
// Right-handed: undo mirrors to the outer (delete) side.
if swapKeys {
@@ -257,33 +301,74 @@ public struct KeyboardRootView: View {
}
}
.frame(height: KeyboardLayoutMetrics.micSize)
.animation(.easeInOut(duration: 0.25), value: state.clipboardCommandRecording)
GeometryReader { proxy in
let widths = KeyboardChromeLayout.actionKeyWidths(
availableWidth: proxy.size.width
)
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) {
if swapKeys {
bottomSpaceButton(disabled: editingBlocked)
.frame(width: widths.side)
bottomReturnButton(disabled: editingBlocked)
.frame(width: widths.center)
bottomDeleteButton(disabled: editingBlocked)
.frame(width: widths.side)
} else {
bottomDeleteButton(disabled: editingBlocked)
.frame(width: widths.side)
bottomReturnButton(disabled: editingBlocked)
.frame(width: widths.center)
bottomSpaceButton(disabled: editingBlocked)
.frame(width: widths.side)
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 ? 0 : 1)
.opacity(dragging || recording ? 0 : 1)
.allowsHitTesting(!dragging && !recording)
}
.padding(.horizontal, KeyboardLayoutMetrics.sideActionHorizontalInset)
.frame(maxWidth: .infinity)
@@ -300,23 +385,6 @@ public struct KeyboardRootView: View {
.contentShape(Rectangle())
}
/// Side caption beside the mic during clipboard-command recording.
/// Vertically matches the mic disc (same upward offset); does not steal touches.
private func clipboardSideHint(_ text: Text, visible: Bool) -> some View {
text
// 22pt ~18pt (20%); softer than body so it doesn't compete with the mic.
.font(.system(size: 17.6, weight: .medium))
.foregroundStyle(palette.textSecondary.opacity(0.42))
.multilineTextAlignment(.center)
.lineLimit(3)
.minimumScaleFactor(0.7)
.padding(.horizontal, 2)
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
.opacity(visible ? 1 : 0)
.allowsHitTesting(false)
.accessibilityHidden(!visible)
}
private func bottomDeleteButton(disabled: Bool) -> some View {
RepeatingDeleteButton(
disabled: disabled,
@@ -385,14 +453,6 @@ public struct KeyboardRootView: View {
}
private var buttonPhase: RecordButton.Phase {
switch clipboardMicChrome {
case .preparingCancelable:
return .preparing
case .recordingBlue:
return .recording
case .none:
break
}
switch state.micVoiceAvailability {
case .recording:
return .recording
@@ -405,30 +465,12 @@ public struct KeyboardRootView: View {
}
}
/// Preparing clipboard capture: grey spinner, tap to cancel.
/// Disabled only when the shared voice prerequisites are unavailable.
private var micButtonEnabled: Bool {
if state.micDisabled { return false }
return true
}
private var clipboardMicChrome: ClipboardMicChrome {
let phase: ClipboardPreparingPhase = {
switch state.phase {
case .idle: return .idle
case .denied: return .denied
case .error: return .error
case .requestingPermissions: return .requestingPermissions
case .recording: return .recording
case .processing: return .processing
}
}()
return ClipboardPreparingPolicy.micChrome(
isClipboardUtterance: state.clipboardCommandUtteranceActive,
phase: phase,
awaitingHostConfirm: state.phase == .requestingPermissions
|| (state.phase == .recording && !state.clipboardCommandRecording)
)
}
}
// MARK: - State alias
@@ -477,8 +519,8 @@ private struct TranscriptLine: View {
let transcript: String
let micVoiceAvailability: MicVoiceAvailability
let micDisabledHint: String
let clipboardCommandEligible: Bool
let clipboardFailureHint: String?
let editHint: String?
let editHintIsPositive: Bool
let cursorDragHintActive: Bool
let openSettings: () -> Void
@@ -551,10 +593,10 @@ private struct TranscriptLine: View {
@ViewBuilder
private var idleHint: some View {
if let clipboardFailureHint, !clipboardFailureHint.isEmpty {
Text(clipboardFailureHint)
if let editHint, !editHint.isEmpty {
Text(editHint)
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.foregroundStyle(editHintIsPositive ? palette.accent : palette.warning)
.lineLimit(1)
.truncationMode(.tail)
} else {
@@ -571,25 +613,13 @@ private struct TranscriptLine: View {
Group {
switch micVoiceAvailability {
case .ready:
if clipboardCommandEligible {
ExtL10n.text("keyboard.placeholder.idleClipboard")
} else {
ExtL10n.text("keyboard.placeholder.idle")
}
ExtL10n.text("keyboard.placeholder.idle")
case .unavailable(.missingAPIKey):
Text(micDisabledHint)
case .unavailable(.hostNotReady):
if clipboardCommandEligible {
ExtL10n.text("keyboard.placeholder.idleClipboard")
} else {
ExtL10n.text("keyboard.placeholder.idle")
}
ExtL10n.text("keyboard.placeholder.idle")
case .unavailable(.preparingSession):
if clipboardCommandEligible {
ExtL10n.text("keyboard.placeholder.idleClipboard")
} else {
ExtL10n.text("keyboard.placeholder.idle")
}
ExtL10n.text("keyboard.placeholder.idle")
case .unavailable(.noFullAccess):
ExtL10n.text("keyboard.error.fullAccessRequired")
case .unavailable(.appGroupUnavailable):
+41 -1
View File
@@ -40,6 +40,38 @@ struct KeyboardBrandLogo: View {
}
}
struct KeyboardCancelButton: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette
let action: () -> Void
let accessibilityLabel: Text
let accessibilityHint: Text
var body: some View {
Button(action: action) {
Image(systemName: "xmark")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(palette.textSecondary)
.frame(width: 34, height: 34)
.background(buttonFill, in: Circle())
.overlay(
Circle()
.stroke(palette.divider, lineWidth: 0.5)
)
.frame(width: 44, height: 44)
.contentShape(Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
.accessibilityHint(accessibilityHint)
}
private var buttonFill: Color {
colorScheme == .dark ? Color(white: 0.30) : .white
}
}
private enum KeyboardInputTab: CaseIterable {
case voice
case chinese
@@ -90,7 +122,7 @@ struct KeyboardTopControls: View {
}
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
.disabled(tab != .voice && !state.canEnterTypingSurface)
.opacity(tab != .voice && !state.canEnterTypingSurface ? 0.42 : 1)
.opacity(tabOpacity(tab))
.accessibilityLabel(accessibilityLabel(for: tab))
.accessibilityAddTraits(isSelected(tab) ? .isSelected : [])
}
@@ -113,6 +145,14 @@ struct KeyboardTopControls: View {
colorScheme == .dark ? Color(white: 0.38) : .white
}
private func tabOpacity(_ tab: KeyboardInputTab) -> Double {
guard tab != .voice, !state.canEnterTypingSurface else { return 1 }
if case .recording = state.phase {
return 0
}
return 0.42
}
private var trackFill: Color {
colorScheme == .dark ? Color(white: 0.18) : Color.black.opacity(0.08)
}
@@ -0,0 +1,253 @@
// LastInputEditView.swift
// OSGKeyboard · Keyboard Extension
import SwiftUI
import OSGKeyboardShared
struct LastInputEditView: View {
private enum Layout {
static let primaryButtonHeight: CGFloat = 50
static let primaryButtonWidth: CGFloat = primaryButtonHeight * 3
}
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var state: KeyboardState
@State private var selectedPage: Int? = 0
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
var body: some View {
VStack(spacing: 0) {
topBar.frame(height: KeyboardTopBarMetrics.height)
VStack(spacing: 0) {
ZStack(alignment: .bottom) {
pages
.frame(maxWidth: .infinity, maxHeight: .infinity)
VStack(spacing: 0) {
statusLine.frame(height: 18)
pageIndicator.frame(height: 12)
}
.allowsHitTesting(false)
}
.frame(height: 174)
.contentShape(Rectangle())
.simultaneousGesture(reviewSwipeGesture)
primaryRow.frame(height: 55)
}
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
}
.padding(.vertical, 4)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: .infinity)
.frame(height: KeyboardChromeLayout.totalHeight)
.environment(\.themePalette, palette)
.onChange(of: state.editSession) { _, newValue in
guard newValue.review != nil else {
selectedPage = 0
return
}
if reduceMotion {
selectedPage = 1
} else {
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.86)) {
selectedPage = 1
}
}
}
}
private var topBar: some View {
HStack {
KeyboardBrandLogo(action: state.openSettings)
Spacer(minLength: 0)
KeyboardCancelButton(
action: state.closeEditMode,
accessibilityLabel: ExtL10n.text("keyboard.edit.close"),
accessibilityHint: ExtL10n.text("keyboard.edit.closeHint")
)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
@ViewBuilder
private var pages: some View {
if let source = state.editSession.source {
EditTextPager(
originalTitle: ExtL10n.string("keyboard.edit.page.original"),
originalText: source.reference.displayText,
editedTitle: ExtL10n.string("keyboard.edit.page.edited"),
editedText: state.editSession.review?.resultText,
contentBottomInset: 30,
selectedPage: $selectedPage
)
}
}
private var statusLine: some View {
Text(editTranscript)
.font(TypeStyle.caption)
.foregroundStyle(isFailure ? palette.warning : palette.textPrimary)
.lineLimit(1)
.truncationMode(.head)
.frame(maxWidth: .infinity)
}
private var pageIndicator: some View {
HStack(spacing: 5) {
Circle()
.fill(selectedPage != 1 ? palette.textPrimary : palette.textTertiary.opacity(0.45))
.frame(width: 5, height: 5)
Circle()
.fill(selectedPage == 1 ? palette.textPrimary : palette.textTertiary.opacity(0.45))
.frame(width: 5, height: 5)
}
.opacity(state.editSession.review == nil ? 0 : 1)
.accessibilityHidden(true)
}
private var primaryRow: some View {
HStack(spacing: Spacing.sm) {
helperText(leftHelper)
Button(action: primaryAction) {
ZStack {
Capsule().fill(palette.accent)
if case .listening = state.editSession {
Capsule()
.stroke(Color.white.opacity(0.28), lineWidth: 1.5)
.scaleEffect(1 + min(max(state.level, 0), 1) * 0.08)
.animation(Motion.soft, value: state.level)
}
primaryIcon
}
.frame(
width: Layout.primaryButtonWidth,
height: Layout.primaryButtonHeight
)
.contentShape(Capsule())
}
.buttonStyle(.plain)
.disabled(primaryDisabled)
.accessibilityLabel(Text(primaryAccessibilityLabel))
helperText(rightHelper)
}
}
private func helperText(_ value: String) -> some View {
Text(value)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary.opacity(0.55))
.multilineTextAlignment(.center)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.simultaneousGesture(reviewSwipeGesture)
}
@ViewBuilder
private var primaryIcon: some View {
switch state.editSession {
case .preparing, .processing, .applying, .appending:
ProgressView().tint(.white)
case .review:
Image(systemName: "checkmark")
.font(.system(size: 21, weight: .bold))
.foregroundStyle(.white)
case .listening:
WaveformView(
level: state.level,
barCount: 7,
color: .white,
active: true
)
.frame(width: 35, height: 22)
.clipped()
default:
Image(systemName: "mic.fill")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(.white)
}
}
private var primaryDisabled: Bool {
switch state.editSession {
case .preparing, .processing, .applying, .appending:
return true
default:
return false
}
}
private func primaryAction() {
switch state.editSession {
case .listening:
state.stopEditListening()
case .review:
state.confirmEditResult()
case .failed:
state.beginEditLastInput()
default:
break
}
}
private var editTranscript: String {
if case .failed(_, let message) = state.editSession {
return message
}
return state.lastTranscript
}
private var isFailure: Bool {
if case .failed = state.editSession { return true }
return false
}
private var leftHelper: String {
state.editSession.review == nil
? ExtL10n.string("keyboard.edit.helper.speak")
: ExtL10n.string("keyboard.edit.helper.compare")
}
private var rightHelper: String {
if state.editSession.review != nil {
return state.editCanReplaceOriginal
? ExtL10n.string("keyboard.edit.helper.apply")
: ExtL10n.string("keyboard.edit.helper.append")
}
return ExtL10n.string("keyboard.edit.helper.finish")
}
private var primaryAccessibilityLabel: String {
if state.editSession.review != nil {
return state.editCanReplaceOriginal
? ExtL10n.string("keyboard.edit.apply")
: ExtL10n.string("keyboard.edit.append")
}
return ExtL10n.string("keyboard.edit.stop")
}
private var reviewSwipeGesture: some Gesture {
DragGesture(minimumDistance: 12)
.onEnded { value in
guard state.editSession.review != nil else { return }
let translation = value.predictedEndTranslation
guard abs(translation.width) > abs(translation.height) * 1.2,
abs(translation.width) >= 28 else {
return
}
let targetPage = translation.width < 0 ? 1 : 0
guard selectedPage != targetPage else { return }
if reduceMotion {
selectedPage = targetPage
} else {
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.86)) {
selectedPage = targetPage
}
}
}
}
}
+34 -14
View File
@@ -115,22 +115,8 @@
/* Keyboard (ext) */
"keyboard.placeholder.idle" = "Tap to talk";
"keyboard.placeholder.idleClipboard" = "Tap to talk, long-press for clipboard";
"keyboard.clipboard.recordingLeft" = "Recording command";
"keyboard.clipboard.recordingRight" = "Tap to finish";
"keyboard.placeholder.preparing" = "Preparing";
"keyboard.placeholder.preparingRecording" = "Preparing mic…";
"keyboard.clipboard.reject.pasteDenied" = "Allow Paste to process the clipboard";
"keyboard.clipboard.reject.empty" = "No text on the clipboard to process";
"keyboard.clipboard.reject.phoneOrNumeric" = "Looks like a phone number — not started";
"keyboard.clipboard.reject.emojiOrSymbolOnly" = "No usable text on the clipboard";
"keyboard.clipboard.reject.verificationCode" = "Looks like a code — not started";
"keyboard.clipboard.reject.tooShort" = "Clipboard text is too short";
"keyboard.clipboard.reject.repetitiveSpam" = "Clipboard text isnt usable";
"keyboard.clipboard.reject.secureField" = "Clipboard commands arent available in password fields";
"keyboard.clipboard.reject.noFullAccess" = "Full Access is required for clipboard commands";
"keyboard.clipboard.reject.prepareFailed" = "Mic wasnt ready in time — try again";
"keyboard.clipboard.hint.hostStarting" = "Starting… recording will begin automatically; tap to cancel";
"keyboard.placeholder.processing" = "Processing";
"keyboard.placeholder.error" = "Polishing failed";
"keyboard.placeholder.localBadge" = "On-device";
@@ -145,8 +131,14 @@
"keyboard.deniedHint" = "Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access.";
"keyboard.tapToTalkA11y" = "Tap to talk";
"keyboard.undoA11y" = "Undo last dictation";
"keyboard.redoA11y" = "Redo";
"keyboard.copyA11y" = "Copy";
"keyboard.cutA11y" = "Cut";
"keyboard.cursorDrag.hint" = "Hold and drag to move the cursor";
"keyboard.cursorDrag.centerHint" = "Drag to move the cursor";
"keyboard.nextKeyboardA11y" = "Next keyboard";
"keyboard.nextKeyboardA11yHint" = "Tap to switch to the next keyboard. Touch and hold to see all keyboards.";
"keyboard.typing.setupA11yHint" = "Open OSGKeyboard to finish input method setup.";
/* Flow session (keyboard) */
"keyboard.flow.sessionInactive" = "Voice session off";
@@ -206,6 +198,8 @@
"keyboard.translation.disable" = "Disable translation";
"keyboard.translation.a11y" = "Translation";
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
"keyboard.voice.cancel" = "Cancel voice input";
"keyboard.voice.cancelHint" = "Discard the current recording, recognition, and polish result.";
"keyboard.scenario.a11y" = "Polish scenario";
"keyboard.scenario.a11yHint" = "Choose how dictation is polished.";
@@ -241,3 +235,29 @@
"keyboard.appContext.menu.chat" = "Chat — short, casual, natural tone";
"keyboard.appContext.menu.document" = "Document — long-form, structured";
"keyboard.appContext.menu.unknown" = "General — neutral tone";
/* Long-press editing of the last insertion */
"keyboard.edit.hint.available" = "Hold to edit your last input";
"keyboard.edit.error.noTarget" = "No recent input is available to edit";
"keyboard.edit.error.llmUnavailable" = "Configure an AI service in the main app first";
"keyboard.edit.error.startTimeout" = "Microphone startup timed out. Tap to retry";
"keyboard.edit.error.processing" = "Editing failed. Try again";
"keyboard.edit.error.processingTimeout" = "Editing timed out. Try again";
"keyboard.edit.error.unchanged" = "Nothing changed. Try a different instruction";
"keyboard.edit.status.preparing" = "Starting microphone…";
"keyboard.edit.status.listening" = "Listening for your editing instruction";
"keyboard.edit.status.processing" = "Editing…";
"keyboard.edit.status.review" = "Swipe to compare the original and edited text";
"keyboard.edit.status.applying" = "Applying edit…";
"keyboard.edit.page.original" = "Original";
"keyboard.edit.page.edited" = "Edited";
"keyboard.edit.helper.speak" = "Speak to edit";
"keyboard.edit.helper.finish" = "Tap to finish";
"keyboard.edit.helper.compare" = "Swipe to compare";
"keyboard.edit.helper.apply" = "Tap to apply";
"keyboard.edit.helper.append" = "Insert at cursor";
"keyboard.edit.close" = "Close edit mode";
"keyboard.edit.closeHint" = "Discard this edit and return to voice input.";
"keyboard.edit.apply" = "Apply edit";
"keyboard.edit.append" = "Insert at cursor";
"keyboard.edit.stop" = "Finish editing instruction";
+34 -14
View File
@@ -115,22 +115,8 @@
/* Keyboard (ext) */
"keyboard.placeholder.idle" = "点按说话";
"keyboard.placeholder.idleClipboard" = "点击说话,长按处理剪贴板";
"keyboard.clipboard.recordingLeft" = "指令录制中";
"keyboard.clipboard.recordingRight" = "点按结束处理";
"keyboard.placeholder.preparing" = "准备中…";
"keyboard.placeholder.preparingRecording" = "准备录音…";
"keyboard.clipboard.reject.pasteDenied" = "需要允许粘贴才能处理剪贴板";
"keyboard.clipboard.reject.empty" = "剪贴板里没有可处理的文字";
"keyboard.clipboard.reject.phoneOrNumeric" = "看起来像号码,未开始处理";
"keyboard.clipboard.reject.emojiOrSymbolOnly" = "没有可处理的文字内容";
"keyboard.clipboard.reject.verificationCode" = "看起来像验证码,未开始处理";
"keyboard.clipboard.reject.tooShort" = "内容太短,请复制更完整的文字";
"keyboard.clipboard.reject.repetitiveSpam" = "内容无效,未开始处理";
"keyboard.clipboard.reject.secureField" = "密码框中不能使用剪贴板指令";
"keyboard.clipboard.reject.noFullAccess" = "需要开启完全访问才能处理剪贴板";
"keyboard.clipboard.reject.prepareFailed" = "麦克风未能及时就绪,请再试一次";
"keyboard.clipboard.hint.hostStarting" = "正在启动,准备好后将自动录音;点按可取消";
"keyboard.placeholder.processing" = "处理中…";
"keyboard.placeholder.error" = "润色失败";
"keyboard.placeholder.localBadge" = "本地";
@@ -145,8 +131,14 @@
"keyboard.deniedHint" = "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。";
"keyboard.tapToTalkA11y" = "点按说话";
"keyboard.undoA11y" = "撤销上次听写";
"keyboard.redoA11y" = "重做";
"keyboard.copyA11y" = "拷贝";
"keyboard.cutA11y" = "剪切";
"keyboard.cursorDrag.hint" = "按住并拖动以移动光标";
"keyboard.cursorDrag.centerHint" = "拖动移动光标";
"keyboard.nextKeyboardA11y" = "切换键盘";
"keyboard.nextKeyboardA11yHint" = "轻点切换到下一个键盘;长按查看全部键盘。";
"keyboard.typing.setupA11yHint" = "打开 OSGKeyboard 完成输入法初始化。";
/* Flow session (keyboard) */
"keyboard.flow.sessionInactive" = "语音会话未启动";
@@ -206,6 +198,8 @@
"keyboard.translation.disable" = "关闭翻译";
"keyboard.translation.a11y" = "翻译";
"keyboard.translation.a11yHint" = "切换翻译开关或修改目标语言。";
"keyboard.voice.cancel" = "取消本次语音输入";
"keyboard.voice.cancelHint" = "放弃当前录音、识别和润色结果。";
"keyboard.scenario.a11y" = "润色场景";
"keyboard.scenario.a11yHint" = "选择润色风格或使用场景。";
@@ -241,3 +235,29 @@
"keyboard.appContext.menu.chat" = "聊天 — 简短随意、保留口语";
"keyboard.appContext.menu.document" = "文档 — 长文、结构化";
"keyboard.appContext.menu.unknown" = "通用 — 中性口吻";
/* 长按编辑上一条输入 */
"keyboard.edit.hint.available" = "长按编辑上一条";
"keyboard.edit.error.noTarget" = "没有可编辑的上一条输入";
"keyboard.edit.error.llmUnavailable" = "请先在主 App 配置可用的 AI 服务";
"keyboard.edit.error.startTimeout" = "麦克风启动超时,点击重试";
"keyboard.edit.error.processing" = "编辑失败,请重试";
"keyboard.edit.error.processingTimeout" = "编辑超时,请重试";
"keyboard.edit.error.unchanged" = "内容没有变化,请换一种说法";
"keyboard.edit.status.preparing" = "正在启动麦克风…";
"keyboard.edit.status.listening" = "正在聆听编辑指令";
"keyboard.edit.status.processing" = "正在编辑…";
"keyboard.edit.status.review" = "左右滑动对比原文和结果";
"keyboard.edit.status.applying" = "正在应用编辑…";
"keyboard.edit.page.original" = "原文";
"keyboard.edit.page.edited" = "编辑后";
"keyboard.edit.helper.speak" = "说话编辑文字";
"keyboard.edit.helper.finish" = "点击完成编辑";
"keyboard.edit.helper.compare" = "左右滑动对比";
"keyboard.edit.helper.apply" = "点击应用编辑";
"keyboard.edit.helper.append" = "插入当前位置";
"keyboard.edit.close" = "关闭编辑模式";
"keyboard.edit.closeHint" = "放弃本次编辑并返回语音输入。";
"keyboard.edit.apply" = "应用编辑";
"keyboard.edit.append" = "插入当前位置";
"keyboard.edit.stop" = "完成编辑指令";