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:
@@ -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
|
||||
// portrait→landscape 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
|
||||
// system「允许粘贴」alert 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 {
|
||||
|
||||
@@ -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 (1–0),
|
||||
// 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 (1–0), 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 isn’t usable";
|
||||
"keyboard.clipboard.reject.secureField" = "Clipboard commands aren’t available in password fields";
|
||||
"keyboard.clipboard.reject.noFullAccess" = "Full Access is required for clipboard commands";
|
||||
"keyboard.clipboard.reject.prepareFailed" = "Mic wasn’t 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";
|
||||
|
||||
@@ -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" = "完成编辑指令";
|
||||
|
||||
Reference in New Issue
Block a user