feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish

Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
Rocky
2026-08-13 01:00:51 +08:00
parent fd6e0d3e7e
commit 9f308fadd2
202 changed files with 10897 additions and 5962 deletions
+106 -52
View File
@@ -49,19 +49,21 @@ public final class KeyboardViewController: UIInputViewController {
private var hosting: UIHostingController<KeyboardSurfaceRoot>?
private var keyboardHeightConstraint: NSLayoutConstraint?
private var systemEncapsulatedHeight: CGFloat = 228
/// Presentation height priming is only valid during the slide-in. After
/// `viewDidAppear` we must keep the constraint at `target` re-applying
/// the offset (or letting a paste alert interrupt the appear sequence)
/// makes the slot land at `target + encapsulated` and floats the chrome.
/// The system owns the input view's height during the slide-in via a
/// required `UIView-Encapsulated-Layout-Height` constraint, which it walks
/// from the full screen height down to the keyboard slot. Our own
/// constraint only has to hold `target` and stay out of that transition.
private enum HeightPresentationPhase {
case idle
case priming
case presented
}
private var heightPhase: HeightPresentationPhase = .idle
/// Last logged layout snapshot, so `viewDidLayoutSubviews` only reports
/// changes instead of every pass.
private var lastLoggedLayoutSnapshot: String?
private var cancellables = Set<AnyCancellable>()
private var editHintScheduler: EditHintScheduler!
private var textInserter: KeyboardTextInserter!
private var flowCoordinator: KeyboardFlowCoordinator!
private var lastInputEditCoordinator: LastInputEditCoordinator!
@@ -168,6 +170,9 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
clipboardCapture?.keyboardWillDisappear()
// Presentation-scoped hints must never survive a reused extension
// controller, including an active Flow handoff.
editHintScheduler?.invalidate()
OSGDiag.log(
"KVC.viewWillDisappear surface=\(state.surface.rawValue) "
+ "preserve=\(flowCoordinator.preservesLifecycleOnDisappear) \(OSGDiag.memoryTag())",
@@ -181,6 +186,10 @@ public final class KeyboardViewController: UIInputViewController {
TypingInputConfiguration.persistLastSurface(
preserve ? .voice : state.surface
)
// Remember pinyin/English with the surface so "remember last" restores both.
if state.surface == .typing {
TypingInputConfiguration.persistLastTypingLanguage(typingSession.language)
}
if state.surface == .ai {
// AI context never survives a keyboard presentation, but the
// selected surface itself is restored on the next open.
@@ -210,7 +219,6 @@ public final class KeyboardViewController: UIInputViewController {
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
configureDictationBehavior()
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
state.debugHasFullAccess = hasFullAccess
// Refresh only Flow/config state; edit targets come from verified OSG insertions.
flowCoordinator.refreshSessionState()
flowCoordinator.startSessionMonitor()
@@ -235,20 +243,18 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
if heightPhase == .presented {
// Spurious re-appear while already on screen (e.g. system alert
// lifecycle noise) never re-run the offset trick.
lockPresentedKeyboardHeight()
} else {
heightPhase = .priming
applyPresentationHeightOffset()
}
// One constant, every time: the previous "prime at target system
// encapsulated height" trick read the pre-presentation full-screen
// height (874 pt on an iPhone), clamped to 0, and could not win against
// the system's required constraint anyway.
lockPresentedKeyboardHeight()
OSGDiag.log(
"KVC.viewIsAppearing phase=\(heightPhaseLog) "
+ "height=\(keyboardHeightConstraint?.constant ?? -1) "
+ "\(OSGDiag.memoryTag())",
category: "boot"
)
logHeightConstraints(tag: "viewIsAppearing")
}
public override func viewDidAppear(_ animated: Bool) {
@@ -266,6 +272,26 @@ public final class KeyboardViewController: UIInputViewController {
guard let self, self.heightPhase == .presented else { return }
self.flowCoordinator.ensurePiPReadyOnKeyboardOpen()
}
#if DEBUG
WhatsNewDemoDriver.resetForNewPresentation()
WhatsNewDemoDriver.startIfNeeded(
state: state,
host: WhatsNewDemoDriver.HostHooks(
insertText: { [weak self] text in
self?.textDocumentProxy.insertText(text)
},
deleteBackward: { [weak self] in
self?.textDocumentProxy.deleteBackward()
},
contextBeforeInput: { [weak self] in
self?.textDocumentProxy.documentContextBeforeInput
},
performReturn: { [weak self] in
self?.textDocumentProxy.insertText("\n")
}
)
)
#endif
OSGDiag.log(
"KVC.viewDidAppear done height=\(targetKeyboardHeight) \(OSGDiag.memoryTag())",
category: "boot"
@@ -275,12 +301,14 @@ public final class KeyboardViewController: UIInputViewController {
public override func textDidChange(_ textInput: UITextInput?) {
super.textDidChange(textInput)
refreshReturnKeyRole()
typingSession.synchronizeEnglishDocumentContext()
textInserter?.refreshEditingAvailability()
lastInputEditCoordinator?.refreshContext()
}
public override func selectionDidChange(_ textInput: UITextInput?) {
super.selectionDidChange(textInput)
typingSession.synchronizeEnglishDocumentContext(caretMoved: true)
textInserter?.refreshEditingAvailability()
lastInputEditCoordinator?.refreshContext()
}
@@ -325,11 +353,13 @@ public final class KeyboardViewController: UIInputViewController {
refreshLayoutMode()
cursorDrag?.layoutChrome()
enforcePresentedKeyboardHeightIfNeeded()
logLayoutSnapshotIfChanged()
}
// MARK: - Services
private func installServices() {
editHintScheduler = EditHintScheduler(state: state)
textInserter = KeyboardTextInserter(
state: state,
insertText: { [weak self] text in self?.textDocumentProxy.insertText(text) },
@@ -337,7 +367,8 @@ public final class KeyboardViewController: UIInputViewController {
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() }
scheduleAutoClearError: { [weak self] in self?.scheduleAutoClearError() },
editHintScheduler: editHintScheduler
)
configSync = KeyboardConfigSync(
@@ -375,7 +406,8 @@ public final class KeyboardViewController: UIInputViewController {
abortFlow: { [weak self] in self?.flowCoordinator.abortEditRecording() },
acknowledge: { [weak self] outcome in
self?.flowCoordinator.acknowledgeEditResult(outcome)
}
},
editHintScheduler: editHintScheduler
)
flowCoordinator.onEditHostRecordingConfirmed = { [weak self] in
self?.lastInputEditCoordinator.hostRecordingConfirmed()
@@ -476,6 +508,9 @@ public final class KeyboardViewController: UIInputViewController {
state.sendAIAnswer = { [weak self] in
self?.aiKeyboardCoordinator.sendLatestAnswer()
}
state.submitAIHint = { [weak self] card in
self?.aiKeyboardCoordinator.submitHintCard(card)
}
state.openSettings = { [weak self] in self?.openHostApp() }
state.openInputMethodSetup = { [weak self] in self?.openHostApp(path: "deployrime") }
state.openClipboardSettings = { [weak self] in
@@ -594,18 +629,22 @@ public final class KeyboardViewController: UIInputViewController {
}
private func applyPreferredSurfaceOnOpen() {
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
let preference = TypingInputConfiguration.preferredOpenPreference()
let resolved = KeyboardOpenSurfacePolicy.resolve(
locksTypingSurface: state.locksTypingSurface,
preferred: preferred
preferred: preference.surface
)
OSGDiag.log(
"applyPreferredSurfaceOnOpen preferred=\(preferred.rawValue) "
"applyPreferredSurfaceOnOpen preferred=\(preference.surface.rawValue) "
+ "resolved=\(resolved.rawValue) "
+ "lang=\(preference.typingLanguage?.rawValue ?? "-") "
+ "locksTyping=\(state.locksTypingSurface ? 1 : 0)",
category: "boot"
)
applySurface(resolved)
if resolved == .typing, let language = preference.typingLanguage {
_ = typingSession.setLanguage(language)
}
if resolved == .ai {
aiKeyboardCoordinator.beginNewPresentation()
}
@@ -615,26 +654,20 @@ public final class KeyboardViewController: UIInputViewController {
/// so a reused keyboard instance does not animate voice typing on show.
private func prepareSurfaceForNextPresentation() {
guard !TypingInputConfiguration.remembersLastSurface() else { return }
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
guard state.surface != preferred else { return }
state.surface = preferred
let preference = TypingInputConfiguration.preferredOpenPreference()
if state.surface != preference.surface {
state.surface = preference.surface
}
if preference.surface == .typing, let language = preference.typingLanguage {
_ = typingSession.setLanguage(language)
}
}
private func refreshKeyboardHeight() {
// `applyPresentationHeightOffset()` is only a one-time presentation
// primer used before `viewDidAppear`. Reusing it after a surface
// switch subtracts the system's ~228 pt encapsulated height from the
// requested typing height and collapses the keyboard to a thin strip.
// Once presented, update our height constraint directly, matching the
// final assignment in `viewDidAppear`. Avoid synchronous layout here:
// this is also called during `viewDidLoad`, where re-entrant layout can
// observe partially initialized controller dependencies.
if heightPhase == .presented {
lockPresentedKeyboardHeight()
} else {
keyboardHeightConstraint?.constant = targetKeyboardHeight
view.setNeedsLayout()
}
// Avoid synchronous layout here: this is also called during
// `viewDidLoad`, where re-entrant layout can observe partially
// initialized controller dependencies.
lockPresentedKeyboardHeight()
}
private func refreshLayoutMode() {
@@ -659,7 +692,6 @@ public final class KeyboardViewController: UIInputViewController {
private var heightPhaseLog: String {
switch heightPhase {
case .idle: return "idle"
case .priming: return "priming"
case .presented: return "presented"
}
}
@@ -669,8 +701,8 @@ public final class KeyboardViewController: UIInputViewController {
view.setNeedsLayout()
}
/// After presentation, the constraint must stay at `target`. Spurious
/// lifecycle noise must not leave us primed at `target encapsulated`.
/// The constraint must stay at `target` once presented, whatever the system
/// did to the input view's height during the transition.
private func enforcePresentedKeyboardHeightIfNeeded() {
guard heightPhase == .presented else { return }
let target = targetKeyboardHeight
@@ -687,7 +719,8 @@ public final class KeyboardViewController: UIInputViewController {
private func refreshReturnKeyRole() {
state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
let isSecure = textDocumentProxy.isSecureTextEntry ?? false
state.isSecureTextEntry = isSecure
state.setSecureTextEntry(isSecure)
clipboardCapture?.secureEntryDidChange(isSecure: isSecure)
// Secure fields must not run English autocomplete / autocorrect / learning.
typingSession.suggestionsEnabled = !isSecure
typingSession.syncAutocapitalization()
@@ -697,6 +730,9 @@ public final class KeyboardViewController: UIInputViewController {
typingSession.precedingTextProvider = { [weak self] in
self?.textDocumentProxy.documentContextBeforeInput
}
typingSession.followingTextProvider = { [weak self] in
self?.textDocumentProxy.documentContextAfterInput
}
typingSession.autocapitalizationModeProvider = { [weak self] in
Self.typingAutocapitalizationMode(
for: self?.textDocumentProxy.autocapitalizationType ?? .sentences
@@ -766,18 +802,36 @@ public final class KeyboardViewController: UIInputViewController {
keyboardHeightConstraint = constraint
}
private func applyPresentationHeightOffset() {
// Only valid while priming the slide-in. Callers must not invoke this
// after `heightPhase == .presented`.
if let encapsulated = view.constraints.first(where: { constraint in
constraint.firstItem as? UIView === view
&& constraint.firstAttribute == .height
&& constraint !== keyboardHeightConstraint
}) {
systemEncapsulatedHeight = encapsulated.constant
/// Every height constraint the system and we put on the input view. The
/// system's own constant walks from the full screen height down to the
/// keyboard slot during the slide-in, so this is what to check whenever the
/// surface appears mis-sized or off-slot.
private func logHeightConstraints(tag: String) {
let heights = view.constraints.filter { constraint in
constraint.firstItem as? UIView === view && constraint.firstAttribute == .height
}
let primed = targetKeyboardHeight - systemEncapsulatedHeight
keyboardHeightConstraint?.constant = max(0, primed)
let described = heights.map { constraint in
let name = constraint === keyboardHeightConstraint
? "ours"
: (constraint.identifier ?? "system")
return "\(name)=\(Int(constraint.constant))@\(Int(constraint.priority.rawValue))"
+ (constraint.isActive ? "" : "(inactive)")
}
OSGDiag.log(
"KVC.heightConstraints[\(tag)] \(described.joined(separator: " "))",
category: "boot"
)
}
private func logLayoutSnapshotIfChanged() {
let snapshot = "phase=\(heightPhaseLog) "
+ "view=\(Int(view.bounds.height)) "
+ "host=\(Int(hosting?.view.bounds.height ?? -1)) "
+ "constraint=\(Int(keyboardHeightConstraint?.constant ?? -1)) "
+ "target=\(Int(targetKeyboardHeight))"
guard snapshot != lastLoggedLayoutSnapshot else { return }
lastLoggedLayoutSnapshot = snapshot
OSGDiag.log("KVC.layout \(snapshot)", category: "boot")
}
private func installSwiftUI() {
@@ -66,6 +66,38 @@ final class AIKeyboardCoordinator {
}
}
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
func submitHintCard(_ card: AIHintCard) {
switch state.aiSession.phase {
case .inactive, .idle, .failed:
break
case .preparing, .listening, .recognizing, .generating,
.ready, .awaitingSend, .inserted, .sent:
return
}
enterIfNeeded()
let resolution = AIHintPool.resolvePrompt(
for: card,
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
)
guard case .ready(let prompt) = resolution else {
// The clipboard window closed between rendering and this tap.
state.aiSession.fail(
ExtL10n.string("keyboard.ai.error.clipboardUnavailable"),
utteranceID: nil
)
return
}
guard let conversationID = state.aiSession.conversationID else { return }
let disposition = flow.submitAIQuestion(
text: prompt,
conversationID: conversationID
)
if case .rejected(let rejection) = disposition {
state.aiSession.fail(message(for: rejection), utteranceID: nil)
}
}
func cancel() {
guard state.aiSession.isBusy else { return }
flow.cancelAIRecording()
+19 -16
View File
@@ -34,7 +34,7 @@ public struct AppGroupPersistor {
// Both engines always polish; ignore legacy off/transcribe modeId.
state.mode = .polish
state.engineMode = store.engineMode
// v0.2.1 follow-up: only the target locale is persisted
// Only the target locale is persisted;
// `translationEnabled` is derived from it. Hydrate once at
// startup; `refreshRuntimeFlags` keeps the chip in sync while
// the keyboard stays open.
@@ -47,23 +47,26 @@ public struct AppGroupPersistor {
applyAPIKeyAvailability(store: store, into: state)
#if DEBUG
// Print a masked view of the live App Group config so we can see
// from the device console exactly what the keyboard extension
// actually sees (and whether it agrees with the main App).
let key = store.apiKey
let masked: String
if key.count > 8 {
masked = "\(key.prefix(4))\(key.suffix(4)) (\(key.count) chars)"
} else if key.isEmpty {
masked = "<empty>"
} else {
masked = "<\(key.count) chars>"
// Log only credential availability and the base URL origin. Never put
// credential fragments or URL path/query/userinfo into device logs.
let credentialStatus: String
switch Keychain.apiKeyOutcome(for: store.providerId, preferICloudSync: true) {
case .found(let value):
credentialStatus = value.isEmpty ? "empty" : "configured"
case .notFound:
credentialStatus = store.apiKey.isEmpty ? "empty" : "configured"
case .unavailable:
credentialStatus = "keychainUnavailable"
}
let components = URLComponents(string: store.baseURL)
let baseURLOrigin = components?.scheme.flatMap { scheme in
components?.host.map { host in "\(scheme)://\(host)" }
} ?? "<invalid>"
print("""
🔍 [AppGroupPersistor.load]
providerId = \(store.providerId)
baseURL = \(store.baseURL)
apiKey = \(masked)
baseURLOrigin = \(baseURLOrigin)
credential = \(credentialStatus)
model = \(store.model)
modeId = \(store.modeId)
localeId = \(store.localeId)
@@ -136,11 +139,11 @@ public struct AppGroupPersistor {
AppGroupStore().setEngineMode(engineMode)
}
/// v0.2.1: persist translation target locale id (e.g. `"en"`,
/// Persist translation target locale id (e.g. `"en"`,
/// `"ja"`, or `TranslationLanguageCatalog.offLocaleId`). The
/// chip / picker call this through `KeyboardState.setTranslationTargetLocaleId`.
///
/// v0.2.1 follow-up: removed `persist(translationEnabled:)` the
/// There is no `persist(translationEnabled:)` because the
/// enabled state is derived from the locale id, so callers only
/// need to write the locale. Keeping the legacy Bool overload
/// around would have implied that there's a separate on/off
@@ -8,20 +8,51 @@ import Foundation
import UIKit
import OSGKeyboardShared
@MainActor
protocol ClipboardPasteboardProviding: AnyObject {
var changeCount: Int { get }
var hasStrings: Bool { get }
var string: String? { get }
}
@MainActor
final class SystemClipboardPasteboard: ClipboardPasteboardProviding {
var changeCount: Int { UIPasteboard.general.changeCount }
var hasStrings: Bool { UIPasteboard.general.hasStrings }
var string: String? { UIPasteboard.general.string }
}
@MainActor
final class ClipboardCaptureCoordinator {
/// Universal Clipboard may synchronously fetch from another device for
/// seconds. System pasteboard reads must never block keyboard presentation.
private static let readQueue = DispatchQueue(
label: "com.osgkeyboard.clipboard.read",
qos: .utility
)
private static let pollInterval: TimeInterval = 0.8
private let state: KeyboardState
private let history: ClipboardHistoryStore
private let pasteboard: ClipboardPasteboardProviding
private var pollTimer: Timer?
private var isSecureProvider: () -> Bool = { false }
private var hasFullAccessProvider: () -> Bool = { false }
/// Ephemeral only: leaving a secure field must not resurrect old body text.
private var secureFieldSuppressedChangeCount: Int?
private var isSecureEntryActive = false
private var isSampling = false
private var forcesNextSample = true
private var isKeyboardVisible = false
init(
state: KeyboardState,
history: ClipboardHistoryStore = .shared
history: ClipboardHistoryStore = .shared,
pasteboard: ClipboardPasteboardProviding = SystemClipboardPasteboard()
) {
self.state = state
self.history = history
self.pasteboard = pasteboard
}
func configure(
@@ -33,22 +64,54 @@ final class ClipboardCaptureCoordinator {
}
func keyboardDidAppear() {
isKeyboardVisible = true
history.reload()
refreshSuggestionFromStore()
captureIfNeeded(force: true)
// A suggestion belongs to one keyboard presentation. Clear any
// presentation state left behind by a reused extension controller.
endCurrentSuggestion()
forcesNextSample = true
// Delay the system pasteboard read until the first poll tick. A
// Universal Clipboard fetch or paste alert during the appear sequence
// can otherwise freeze the keyboard before SwiftUI draws.
if !(pasteboard is SystemClipboardPasteboard) {
captureIfNeeded(forceRead: true)
}
startPolling()
}
func keyboardWillDisappear() {
isKeyboardVisible = false
stopPolling()
// A1 policy: closing the keyboard ends this generation's suggestion.
endCurrentSuggestion()
}
func refreshFlagsFromStore() {
// Called from App Group poll suggestion visibility may change.
refreshSuggestionFromStore()
// Settings changes may hide the active suggestion, but enabling the
// strip must wait for a new pasteboard generation.
if !state.clipboardHistoryEnabled || !state.clipboardCandidateBarEnabled {
endCurrentSuggestion()
}
}
func secureEntryDidChange(isSecure: Bool) {
if isSecure {
isSecureEntryActive = true
secureFieldSuppressedChangeCount = pasteboard.changeCount
} else if isSecureEntryActive {
// Capture the latest generation once more on exit so a pasteboard
// change near the secure-field transition cannot be persisted.
secureFieldSuppressedChangeCount = pasteboard.changeCount
isSecureEntryActive = false
} else {
return
}
endCurrentSuggestion()
state.clipboardOverlay = .none
}
func openPanelFromTopButton() {
guard state.canShowClipboardEntry else { return }
if state.clipboardHistoryEnabled {
history.reload()
state.clipboardOverlay = .historyPanel
@@ -62,15 +125,15 @@ final class ClipboardCaptureCoordinator {
}
func noteUserDidInputText() {
clearSuggestion(persistDismiss: false)
endCurrentSuggestion()
}
func dismissSuggestion() {
history.dismissSuggestion(forChangeCount: state.clipboardSuggestionChangeCount)
clearSuggestion(persistDismiss: true)
endCurrentSuggestion()
}
func insertText(_ text: String, via insert: (String) -> Void) {
guard state.canShowClipboardEntry else { return }
insert(text)
// Tapping a suggestion (or history row that shares this path) must not
// resurface the same clipboard changeCount until the pasteboard changes.
@@ -79,24 +142,28 @@ final class ClipboardCaptureCoordinator {
}
func clearHistory() {
endCurrentSuggestion()
history.clearAll()
clearSuggestion(persistDismiss: false)
}
func deleteEntry(id: UUID) {
let deletedChangeCount = history.entries.first(where: { $0.id == id })?.changeCount
history.remove(id: id)
refreshSuggestionFromStore()
if deletedChangeCount == state.clipboardSuggestionChangeCount {
endCurrentSuggestion()
}
}
// MARK: - Capture
private func startPolling() {
stopPolling()
let timer = Timer(timeInterval: 0.8, repeats: true) { [weak self] _ in
let timer = Timer(timeInterval: Self.pollInterval, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.captureIfNeeded(force: false)
self?.captureIfNeeded()
}
}
timer.tolerance = Self.pollInterval / 4
RunLoop.main.add(timer, forMode: .common)
pollTimer = timer
}
@@ -106,83 +173,170 @@ final class ClipboardCaptureCoordinator {
pollTimer = nil
}
private func captureIfNeeded(force: Bool) {
func captureIfNeeded(forceRead: Bool = false) {
guard state.clipboardHistoryEnabled else {
clearSuggestion(persistDismiss: false)
endCurrentSuggestion()
return
}
#if DEBUG
// What's New demo seeds history itself never touch the pasteboard
// (avoids the simulator alert mid-recording).
if WhatsNewDemoScenario.peek() != nil || WhatsNewDemoScenario.isPlaying() {
return
}
#endif
guard hasFullAccessProvider() else { return }
guard !isSecureProvider() else { return }
let pasteboard = UIPasteboard.general
let changeCount = pasteboard.changeCount
if !force, changeCount == history.lastObservedChangeCount {
refreshSuggestionFromStore()
guard !isSecureProvider() else {
secureEntryDidChange(isSecure: true)
return
}
if pasteboard is SystemClipboardPasteboard {
beginSystemSample(forceRead: forceRead || forcesNextSample)
forcesNextSample = false
return
}
captureInjectedPasteboard(forceRead: forceRead)
}
/// Synchronous path retained for deterministic tests and injected fakes.
/// Production always uses `beginSystemSample` below.
private func captureInjectedPasteboard(forceRead: Bool) {
let changeCount = pasteboard.changeCount
if ClipboardHistoryPolicy.shouldSuppressCapture(
changeCount: changeCount,
secureFieldSuppressedChangeCount: secureFieldSuppressedChangeCount
) {
history.lastObservedChangeCount = changeCount
clearSuggestion()
return
}
secureFieldSuppressedChangeCount = nil
let isCurrentGeneration = changeCount == history.lastObservedChangeCount
if isCurrentGeneration && !forceRead {
return
}
// A new generation replaces any previous transient suggestion,
// including generations that contain no acceptable text.
clearSuggestion()
// Prefer hasStrings peek before reading body (reduces empty reads).
guard pasteboard.hasStrings else {
history.lastObservedChangeCount = changeCount
refreshSuggestionFromStore()
return
}
let raw = pasteboard.string
// The forced appearance read exists only to establish/refresh iOS
// paste permission. It must not reinsert or republish old content.
if isCurrentGeneration {
return
}
if let entry = history.ingest(rawText: raw, changeCount: changeCount) {
updateSuggestion(with: entry, changeCount: changeCount)
} else {
history.lastObservedChangeCount = changeCount
refreshSuggestionFromStore()
}
}
private func refreshSuggestionFromStore() {
guard state.clipboardHistoryEnabled,
state.clipboardCandidateBarEnabled,
!isSecureProvider(),
let newest = history.newestEntry
else {
clearSuggestion(persistDismiss: false)
private struct Sample: Sendable {
let changeCount: Int
let hasStrings: Bool
let text: String?
}
private func beginSystemSample(forceRead: Bool) {
guard !isSampling else { return }
isSampling = true
let lastObserved = history.lastObservedChangeCount
Self.readQueue.async {
let pasteboard = UIPasteboard.general
let changeCount = pasteboard.changeCount
guard forceRead || changeCount != lastObserved else {
Task { @MainActor [weak self] in
self?.isSampling = false
}
return
}
let hasStrings = pasteboard.hasStrings
let sample = Sample(
changeCount: changeCount,
hasStrings: hasStrings,
text: hasStrings ? pasteboard.string : nil
)
Task { @MainActor [weak self] in
self?.finishSystemSample(sample)
}
}
}
private func finishSystemSample(_ sample: Sample) {
isSampling = false
guard isKeyboardVisible,
state.clipboardHistoryEnabled,
hasFullAccessProvider(),
!isSecureProvider()
else { return }
let changeCount = sample.changeCount
if ClipboardHistoryPolicy.shouldSuppressCapture(
changeCount: changeCount,
secureFieldSuppressedChangeCount: secureFieldSuppressedChangeCount
) {
history.lastObservedChangeCount = changeCount
clearSuggestion()
return
}
let changeCount = newest.changeCount ?? history.lastObservedChangeCount
guard history.shouldShowSuggestion(
forChangeCount: changeCount,
candidateBarEnabled: state.clipboardCandidateBarEnabled,
historyEnabled: state.clipboardHistoryEnabled
) else {
clearSuggestion(persistDismiss: false)
secureFieldSuppressedChangeCount = nil
let isCurrentGeneration = changeCount == history.lastObservedChangeCount
// A new generation replaces any previous transient suggestion,
// including generations that contain no acceptable text.
clearSuggestion()
guard sample.hasStrings else {
history.lastObservedChangeCount = changeCount
return
}
// Don't resurrect a strip the user already dismissed this session
// unless changeCount advanced (handled in ingest).
if state.clipboardSuggestionText == nil,
let dismissed = history.suggestionDismissedChangeCount,
dismissed == changeCount {
return
// Forced appearance reads establish iOS paste permission only. Never
// republish content from an already observed generation.
guard !isCurrentGeneration else { return }
if let entry = history.ingest(rawText: sample.text, changeCount: changeCount) {
updateSuggestion(with: entry, changeCount: changeCount)
} else {
history.lastObservedChangeCount = changeCount
}
state.clipboardSuggestionText = newest.text
state.clipboardSuggestionChangeCount = changeCount
}
private func updateSuggestion(with entry: ClipboardHistoryEntry, changeCount: Int) {
guard state.clipboardCandidateBarEnabled else {
clearSuggestion(persistDismiss: false)
guard state.canShowClipboardEntry,
state.clipboardCandidateBarEnabled,
changeCount != secureFieldSuppressedChangeCount
else {
clearSuggestion()
return
}
// Already used/dismissed this pasteboard generation keep it hidden.
if history.suggestionDismissedChangeCount == changeCount {
clearSuggestion(persistDismiss: false)
clearSuggestion()
return
}
state.clipboardSuggestionText = entry.text
state.clipboardSuggestionChangeCount = changeCount
}
private func clearSuggestion(persistDismiss: Bool) {
if persistDismiss {
// already written in dismissSuggestion
private func endCurrentSuggestion() {
history.dismissSuggestion(forChangeCount: state.clipboardSuggestionChangeCount)
clearSuggestion()
}
private func clearSuggestion() {
guard state.clipboardSuggestionText != nil
|| state.clipboardSuggestionChangeCount != nil
else {
return
}
state.clipboardSuggestionText = nil
state.clipboardSuggestionChangeCount = nil
@@ -0,0 +1,71 @@
// EditHintScheduler.swift
// OSGKeyboard · Keyboard Extension
//
// Owns the edit-hint lifetime so every producer shares one expiration order.
import Foundation
import OSGKeyboardShared
@MainActor
final class EditHintScheduler {
typealias Sleeper = @MainActor (Duration) async -> Void
private let state: KeyboardState
private let sleeper: Sleeper
private var task: Task<Void, Never>?
private var generation: UInt64 = 0
init(
state: KeyboardState,
sleeper: @escaping Sleeper = { duration in
try? await Task.sleep(for: duration)
}
) {
self.state = state
self.sleeper = sleeper
}
func show(message: String, isPositive: Bool, duration: Duration) {
let scheduledGeneration = advanceGeneration()
task?.cancel()
state.editHint = message
state.editHintIsPositive = isPositive
let sleeper = sleeper
task = Task { @MainActor [weak self, sleeper] in
await sleeper(duration)
guard let self, self.generation == scheduledGeneration else {
return
}
self.task = nil
self.clearHint()
}
}
func clearPositive() {
guard state.editHintIsPositive else { return }
advanceGeneration()
task?.cancel()
task = nil
clearHint()
}
func invalidate() {
advanceGeneration()
task?.cancel()
task = nil
clearHint()
}
@discardableResult
private func advanceGeneration() -> UInt64 {
generation &+= 1
return generation
}
private func clearHint() {
state.editHint = nil
state.editHintIsPositive = false
}
}
@@ -316,10 +316,6 @@ final class KeyboardFlowCoordinator {
snapshotReason: readySnapshot?.reason
)
state.flowSessionActive = sessionActive
state.debugPendingFlowStart = isPendingFlowStart
state.debugFlowRecording = isFlowRecording
state.debugAwaitingFlowResult = isAwaitingFlowResult
state.debugHasFullAccess = hasFullAccess()
state.micVoiceAvailability = MicVoiceAvailabilityResolver.resolve(
phase: state.phase,
micDisabled: state.micDisabled,
@@ -641,6 +637,17 @@ final class KeyboardFlowCoordinator {
startUtterance(.aiQuestion(conversationID: conversationID))
}
func submitAIQuestion(
text: String,
conversationID: UUID
) -> FlowUtteranceStartDisposition {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .rejected(.pipelineBusy) }
return startUtterance(
.aiQuestion(conversationID: conversationID, prefilledQuestion: trimmed)
)
}
func stopAIRecording() {
guard currentUtteranceRequest?.isAIQuestion == true else { return }
pressEnded()
@@ -1223,7 +1230,7 @@ final class KeyboardFlowCoordinator {
"status=\(result.status.rawValue) "
+ "kind=\(result.errorKind?.rawValue ?? "none") "
+ "utterance=\(result.utteranceId.uuidString.prefix(8)) "
+ "message=\(result.text ?? "nil")"
+ "messageLen=\(result.text?.count ?? 0)"
)
isAwaitingFlowResult = false
stopFlowWatchdog()
@@ -1738,6 +1745,26 @@ final class KeyboardFlowCoordinator {
}
FlowSessionBridge.setPendingKeyboardUtteranceId(currentUtteranceId)
lastStoppedUtteranceId = nil
// Prefilled AI hint: skip mic / ASR and ask the host to answer text.
if currentUtteranceRequest?.isAIQuestion == true,
let question = currentUtteranceRequest?.aiQuestionText?
.trimmingCharacters(in: .whitespacesAndNewlines),
!question.isEmpty {
writeSubmitAIQuestion(question)
isFlowRecording = false
isAwaitingFlowResult = true
state.lastTranscript = question
state.phase = .processing
if let currentUtteranceId {
onAIRecognitionStarted(currentUtteranceId)
}
startFlowResultWatchdog()
recomputeMicVoiceAvailability()
traceState("startFlowRecording.submitAIQuestion")
return
}
writeCommand(.startRecording)
isFlowRecording = true
state.lastTranscript = ""
@@ -1759,6 +1786,37 @@ final class KeyboardFlowCoordinator {
traceState("startFlowRecording.started")
}
private func writeSubmitAIQuestion(_ text: String) {
guard let activeSessionId, let currentUtteranceId else { return }
let command = FlowCommand(
sessionId: activeSessionId,
utteranceId: currentUtteranceId,
commandSeq: nextCommandSeq(),
action: .submitAIQuestion,
localeId: state.localeId,
utteranceMode: .aiQuestion,
aiConversationID: currentUtteranceRequest?.aiConversationID,
aiQuestionText: text,
startDeadlineAt: currentStartDeadlineAt
)
FlowSessionBridge.writeCommand(command)
if let currentStartDeadlineAt {
FlowSessionBridge.writeStartTransaction(
FlowStartTransaction(
sessionID: activeSessionId,
utteranceID: currentUtteranceId,
deadlineAt: currentStartDeadlineAt,
phase: .issued
)
)
}
FlowTrace.keyboard(
"command.submitAIQuestion",
"seq=\(command.commandSeq) utterance=\(currentUtteranceId.uuidString.prefix(8)) "
+ "chars=\(text.count)"
)
}
private func startUtteranceCountdown() {
utteranceStartedAt = Date().timeIntervalSince1970
state.utteranceRemainingSeconds = Int(FlowSessionKeys.maxUtteranceDuration)
@@ -2012,7 +2070,7 @@ final class KeyboardFlowCoordinator {
"via=resultWatchdog status=\(result.status.rawValue) "
+ "kind=\(error.kind.rawValue) "
+ "utterance=\(result.utteranceId.uuidString.prefix(8)) "
+ "message=\(error.message)"
+ "messageLen=\(error.message.count)"
)
self.state.phase = .error(
.fromFlowTranscription(error),
@@ -21,6 +21,7 @@ final class KeyboardTextInserter {
private let fieldContextProvider: () -> FlowFieldContext?
private let selectedText: () -> String?
private let scheduleAutoClearError: () -> Void
private unowned let editHintScheduler: EditHintScheduler
/// Exact string last inserted through this inserter dictation, AI answer,
/// edit result or clipboard paste (including any word-boundary separator).
@@ -33,7 +34,6 @@ final class KeyboardTextInserter {
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
@@ -45,7 +45,8 @@ final class KeyboardTextInserter {
contextBeforeInput: @escaping () -> String?,
fieldContextProvider: @escaping () -> FlowFieldContext?,
selectedText: @escaping () -> String?,
scheduleAutoClearError: @escaping () -> Void
scheduleAutoClearError: @escaping () -> Void,
editHintScheduler: EditHintScheduler
) {
self.state = state
self.insertText = insertText
@@ -54,6 +55,7 @@ final class KeyboardTextInserter {
self.fieldContextProvider = fieldContextProvider
self.selectedText = selectedText
self.scheduleAutoClearError = scheduleAutoClearError
self.editHintScheduler = editHintScheduler
}
func handleFlowTranscript(
@@ -510,28 +512,16 @@ final class KeyboardTextInserter {
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
}
editHintScheduler.show(
message: hint,
isPositive: true,
duration: .seconds(10)
)
}
private func clearEditHintIfPositive() {
guard state.editHintIsPositive else { return }
editHintTask?.cancel()
editHintTask = nil
state.editHint = nil
state.editHintIsPositive = false
editHintScheduler.clearPositive()
}
private func clearLastInsertion() {
@@ -15,7 +15,7 @@ final class LastInputEditCoordinator {
private let stopFlow: () -> Void
private let abortFlow: () -> Void
private let acknowledge: (FlowAck.DeliveryOutcome) -> Void
private var hintTask: Task<Void, Never>?
private unowned let editHintScheduler: EditHintScheduler
private var activeUtteranceID: UUID?
private var reviewedUtteranceID: UUID?
private var reviewedRevision: Int64?
@@ -26,7 +26,8 @@ final class LastInputEditCoordinator {
beginFlow: @escaping (EditableInputReference) -> FlowUtteranceStartDisposition,
stopFlow: @escaping () -> Void,
abortFlow: @escaping () -> Void,
acknowledge: @escaping (FlowAck.DeliveryOutcome) -> Void
acknowledge: @escaping (FlowAck.DeliveryOutcome) -> Void,
editHintScheduler: EditHintScheduler
) {
self.state = state
self.textInserter = textInserter
@@ -34,6 +35,7 @@ final class LastInputEditCoordinator {
self.stopFlow = stopFlow
self.abortFlow = abortFlow
self.acknowledge = acknowledge
self.editHintScheduler = editHintScheduler
}
func begin() {
@@ -222,35 +224,14 @@ final class LastInputEditCoordinator {
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) {
editHintScheduler.show(
message: message,
isPositive: false,
duration: .milliseconds(2_500)
)
}
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 {
@@ -0,0 +1,317 @@
// WhatsNewDemoDriver.swift
// OSGKeyboard · Keyboard Extension (DEBUG-only)
//
// Plays a scripted What's New timeline on the **real** keyboard surface while
// a Notes-like host sits underneath. Mutates the host document via the
// textDocumentProxy so the clip shows a closed loop (insert / replace).
#if DEBUG
import Foundation
import OSGKeyboardShared
@MainActor
enum WhatsNewDemoDriver {
private static var running = false
private static var activeTask: Task<Void, Never>?
/// Document mutations + Return for AI .
struct HostHooks {
var insertText: (String) -> Void
var deleteBackward: () -> Void
var contextBeforeInput: () -> String?
var performReturn: () -> Void
}
/// Keyboard extensions outlive host relaunches always allow a fresh arm.
static func resetForNewPresentation() {
activeTask?.cancel()
activeTask = nil
running = false
WhatsNewDemoScenario.finishPlaying()
}
static func startIfNeeded(state: KeyboardState, host: HostHooks) {
// Peek first so a brief appear/disappear does not burn the arm.
guard WhatsNewDemoScenario.peek() != nil else { return }
// A still-running timeline from a previous host launch must not block
// the next What's New recording.
if running {
resetForNewPresentation()
}
running = true
activeTask = Task { @MainActor in
// Wait for first layout / surface mount over the host.
try? await Task.sleep(nanoseconds: 900_000_000)
guard !Task.isCancelled else {
running = false
return
}
guard let armed = WhatsNewDemoScenario.consume() else {
running = false
return
}
OSGDiag.log(
"WhatsNewDemo start scenario=\(armed.scenario.rawValue) lang=\(armed.language.rawValue)",
category: "boot"
)
polishDemoChrome(state)
switch armed.scenario {
case .edit:
await runEdit(
state: state,
host: host,
original: armed.seedText,
language: armed.language
)
case .ai:
await runAI(state: state, host: host, language: armed.language)
case .clipboard:
await runClipboard(state: state, host: host, language: armed.language)
}
if !Task.isCancelled {
WhatsNewDemoScenario.finishPlaying()
}
running = false
activeTask = nil
}
}
// MARK: - Edit last input
private static func runEdit(
state: KeyboardState,
host: HostHooks,
original: String,
language: WhatsNewDemoScenario.Language
) async {
let edited = language == .en
? "Hi everyone — we'll hold a planning discussion in Conference Room A at 3pm tomorrow. Please be on time."
: "各位好,明天下午三点在 A 会议室召开方案讨论会,请准时参加。"
let reference = EditableInputReference(
displayText: original,
insertedText: original,
postInsertionFingerprint: nil,
extensionInstanceID: UUID()
)
let source = EditSessionSource(reference: reference)
let review = EditReview(
source: source,
resultText: edited,
utteranceID: UUID()
)
state.surface = .voice
state.editCanReplaceOriginal = true
state.phase = .idle
ClipboardHistoryStore.shared.clearAll()
clearClipboardChrome(state)
polishDemoChrome(state)
// Brief idle so the host note + voice mic are visible together.
try? await sleep(1.0)
state.editSession = .listening(source)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.listening")
state.level = 0.45
for _ in 0..<4 {
try? await sleep(0.28)
state.level = Double.random(in: 0.25...0.85)
polishDemoChrome(state)
}
state.editSession = .processing(source)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.processing")
try? await sleep(1.0)
state.editSession = .review(review)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.review")
try? await sleep(2.4)
state.editSession = .applying(review)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.applying")
// Replace host document so the clip closes the loop.
replaceHostText(from: original, to: edited, host: host)
try? await sleep(1.2)
state.editSession = .inactive
state.editCanReplaceOriginal = false
state.phase = .idle
state.lastTranscript = ""
clearClipboardChrome(state)
polishDemoChrome(state)
try? await sleep(1.2)
}
// MARK: - AI keyboard
private static func runAI(
state: KeyboardState,
host: HostHooks,
language: WhatsNewDemoScenario.Language
) async {
let question = language == .en
? "Where should I go this weekend?"
: "周末去哪儿玩比较合适?"
let answer = language == .en
? "Try a nearby town day trip: morning walk in a park or old street, afternoon café, then a local dinner."
: "可以去近郊走走:上午逛古镇或公园,下午找一家口碑好的咖啡馆休息,傍晚再吃顿当地特色菜。"
state.surface = .ai
state.aiServiceAvailable = true
ClipboardHistoryStore.shared.clearAll()
clearClipboardChrome(state)
polishDemoChrome(state)
// Chat host uses returnKeyType=.send; keep role locked for the clip.
state.returnKeyRole = .send
state.aiSession.enter()
try? await sleep(0.9)
let utteranceID = UUID()
state.aiSession.beginPreparing(utteranceID: utteranceID)
try? await sleep(0.25)
state.aiSession.beginListening(utteranceID: utteranceID)
state.level = 0.4
for _ in 0..<6 {
try? await sleep(0.18)
state.level = Double.random(in: 0.25...0.9)
state.aiSession.updateTranscript(question, utteranceID: utteranceID)
polishDemoChrome(state)
}
state.aiSession.beginRecognizing(utteranceID: utteranceID)
try? await sleep(0.4)
state.aiSession.beginGenerating(question: question, utteranceID: utteranceID)
try? await sleep(0.45)
let chars = Array(answer)
var index = 0
while index < chars.count {
index = min(chars.count, index + 5)
let draft = String(chars.prefix(index))
state.aiSession.receivePartialAnswer(draft, utteranceID: utteranceID)
try? await sleep(0.1)
}
state.aiSession.receiveAnswer(answer, utteranceID: utteranceID)
polishDemoChrome(state)
try? await sleep(1.3)
// Insert on a new line so seed + answer stay readable in the composer.
host.insertText("\n" + answer)
state.aiSession.markAnswerInserted(offersSend: true)
polishDemoChrome(state)
try? await sleep(1.1)
state.aiSession.markAnswerSent()
host.performReturn()
// Stay on AI surface so the beat is not buried by voice chrome.
state.surface = .ai
clearClipboardChrome(state)
polishDemoChrome(state)
try? await sleep(1.4)
}
// MARK: - Clipboard history
private static func runClipboard(
state: KeyboardState,
host: HostHooks,
language: WhatsNewDemoScenario.Language
) async {
let samples = language == .en
? [
"Meeting at 3pm tomorrow",
"Room moved to Building A, 3F",
"Bring the clicker and the deck"
]
: [
"明天下午三点开会",
"会议室改到 A 栋 3 楼",
"请带上投影笔和方案文档"
]
let store = ClipboardHistoryStore.shared
store.clearAll()
for text in samples.reversed() {
_ = store.ingest(rawText: text, changeCount: Int.random(in: 1...9_999))
}
store.reload()
// Persist flags so AppGroupPersistor cannot flip history off mid-demo.
if let defaults = AppGroup.defaultsIfAvailable {
defaults.set(true, forKey: AppGroupConfiguration.Keys.clipboardHistoryEnabled)
defaults.set(true, forKey: AppGroupConfiguration.Keys.clipboardCandidateBarEnabled)
defaults.synchronize()
}
state.surface = .voice
state.clipboardHistoryEnabled = true
state.clipboardCandidateBarEnabled = true
state.phase = .idle
state.clipboardOverlay = .none
polishDemoChrome(state)
// Suggestion strip first (matches the docs copy).
state.clipboardSuggestionText = samples[0]
state.clipboardSuggestionChangeCount = 1
try? await sleep(1.2)
polishDemoChrome(state)
state.clipboardOverlay = .historyPanel
try? await sleep(2.6)
polishDemoChrome(state)
// Tap-to-insert: close panel + write into the host field.
state.clipboardOverlay = .none
host.insertText(samples[0])
polishDemoChrome(state)
try? await sleep(0.9)
// Leave a fresh suggestion strip visible for the next copy cue.
state.clipboardSuggestionText = samples[1]
state.clipboardSuggestionChangeCount = 2
// Hold suggestion with chrome locked clean (no Flow warning flash).
for _ in 0..<8 {
polishDemoChrome(state)
try? await sleep(0.2)
}
}
// MARK: - Host helpers
private static func polishDemoChrome(_ state: KeyboardState) {
state.micDisabledHint = ""
state.micVoiceAvailability = .ready
}
private static func clearClipboardChrome(_ state: KeyboardState) {
state.clipboardOverlay = .none
state.clipboardSuggestionText = nil
state.clipboardSuggestionChangeCount = nil
}
private static func replaceHostText(
from original: String,
to edited: String,
host: HostHooks
) {
// Prefer deleting only the seed suffix so we don't wipe unrelated text.
let before = host.contextBeforeInput() ?? ""
let deleteCount: Int
if before.hasSuffix(original) {
deleteCount = original.count
} else if !before.isEmpty {
deleteCount = before.count
} else {
deleteCount = original.count
}
for _ in 0..<deleteCount {
host.deleteBackward()
}
host.insertText(edited)
}
/// ~1.6× slow-mo for screen recording readability (30 fps source).
private static func sleep(_ seconds: Double) async throws {
try await Task.sleep(nanoseconds: UInt64(seconds * 1.6 * 1_000_000_000))
}
}
#endif
+34 -19
View File
@@ -8,6 +8,7 @@ import OSGKeyboardShared
struct KeyboardSurfaceRoot: View {
@Environment(\.colorScheme) private var colorScheme
@Namespace private var keyboardTabSelectionNamespace
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
@@ -68,6 +69,16 @@ struct KeyboardSurfaceRoot: View {
// Overlays are siblings of the surfaces, so the palette has to be
// injected here or they fall back to the environment's dark default.
.environment(\.themePalette, palette)
// The input surfaces are replaced when switching tabs. Keep one
// namespace above that switch so the selected glass pill can morph
// between the outgoing and incoming top-control instances.
.environment(\.keyboardTabSelectionNamespace, keyboardTabSelectionNamespace)
// Bottom-anchored on purpose: UIKit hands the input view a container up
// to the full screen height while the keyboard slides in, and centering
// a fixed-height surface in it parks the whole keyboard above the
// visible slot which reads as a blank keyboard whenever that frame
// lingers (a slow Universal Clipboard read, a system alert).
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.animation(.easeInOut(duration: 0.15), value: state.surface)
.onChange(of: state.surface) { _, newSurface in
if newSurface != .typing {
@@ -90,26 +101,30 @@ struct KeyboardSurfaceRoot: View {
@ViewBuilder
private var clipboardOverlayLayer: some View {
switch state.clipboardOverlay {
case .none:
if !state.canShowClipboardEntry {
EmptyView()
case .enableGuide:
ClipboardEnableGuideView(
onClose: state.dismissClipboardOverlay,
onOpenSettings: {
state.dismissClipboardOverlay()
state.openClipboardSettings()
}
)
case .historyPanel:
ClipboardHistoryPanelView(
history: ClipboardHistoryStore.shared,
onClose: state.dismissClipboardOverlay,
onClear: state.clearClipboardHistory,
onInsert: { state.insertClipboardText($0) },
onDelete: { state.deleteClipboardHistoryEntry($0) },
pastePermissionHint: nil
)
} else {
switch state.clipboardOverlay {
case .none:
EmptyView()
case .enableGuide:
ClipboardEnableGuideView(
onClose: state.dismissClipboardOverlay,
onOpenSettings: {
state.dismissClipboardOverlay()
state.openClipboardSettings()
}
)
case .historyPanel:
ClipboardHistoryPanelView(
history: ClipboardHistoryStore.shared,
onClose: state.dismissClipboardOverlay,
onClear: state.clearClipboardHistory,
onInsert: { state.insertClipboardText($0) },
onDelete: { state.deleteClipboardHistoryEntry($0) },
pastePermissionHint: nil
)
}
}
}
}
+4 -6
View File
@@ -122,7 +122,9 @@ struct TypingRootView: View {
if hasCandidateContent {
// Composing Chinese/English candidates hide the clipboard strip.
candidateBar
} else if let suggestion = state.clipboardSuggestionText, !suggestion.isEmpty {
} else if state.canShowClipboardEntry,
let suggestion = state.clipboardSuggestionText,
!suggestion.isEmpty {
// Same slot as logo + capsule tabs hide chrome until dismissed.
ClipboardSuggestionBar(
text: suggestion,
@@ -548,6 +550,7 @@ struct TypingRootView: View {
)
.fill(visualKeyFill(for: key, pressed: showPressed))
)
// NativeKeyboardKeySurface +
.overlay(
RoundedRectangle(
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
@@ -555,11 +558,6 @@ struct TypingRootView: View {
)
.stroke(visualKeyBorder(for: key), lineWidth: 0.5)
)
.shadow(
color: Color.black.opacity(showPressed ? 0.04 : 0.13),
radius: showPressed ? 0.5 : 1,
y: showPressed ? 0 : 1
)
.scaleEffect(pressed ? 0.98 : 1)
.animation(.easeOut(duration: 0.08), value: pressed)
.accessibilityElement()
+4 -1
View File
@@ -11,7 +11,10 @@ import OSGKeyboardShared
enum ExtL10n {
private static let table = "Keyboard"
private static let container = Bundle(for: KeyboardViewController.self)
/// Anchor in whichever target compiles this file (extension or main-app
/// DEBUG what's-new host). Avoids coupling to `KeyboardViewController`.
private final class BundleAnchor {}
private static let container = Bundle(for: BundleAnchor.self)
private static var bundle: Bundle {
AppUILanguage.localizedBundle(
+111 -25
View File
@@ -1,8 +1,8 @@
// AIKeyboardView.swift
// OSGKeyboard · Keyboard Extension
//
// Temporary voice-to-AI surface. The latest answer remains visible while a
// follow-up is running and is inserted only through the explicit Send action.
// Product voice-to-AI conversation surface. The latest answer remains visible
// while a follow-up runs and is inserted only through the explicit Send action.
import SwiftUI
import OSGKeyboardShared
@@ -14,13 +14,23 @@ struct AIKeyboardView: View {
static let actionButtonHeight: CGFloat = 50
static let actionButtonMaxWidth: CGFloat = 150
static let statusHeight: CGFloat = 20
static let carouselInterval: TimeInterval = 4
}
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
/// A copy made while the keyboard is visible must reach the carousel
/// immediately, not on the next rotation tick.
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
let onInsert: (String) -> Void
@State private var currentHint: AIHintCard?
@State private var hintOpacity: Double = 1
@State private var carouselBag = AIHintCarouselBag()
@State private var poolCards: [AIHintCard] = []
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
@@ -37,6 +47,26 @@ struct AIKeyboardView: View {
.frame(maxWidth: .infinity)
.frame(height: resolvedHeight)
.environment(\.themePalette, palette)
.onAppear { resetCarousel() }
.onChange(of: state.aiSession.phase) { _, phase in
guard phase == .idle || phase == .failed else { return }
resetCarousel()
}
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in resetCarousel() }
.onReceive(
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
) { _ in
guard showsPlaceholder else { return }
// Reduce Motion stops the rotation, not the data: a card whose
// clipboard window has closed must still leave the carousel.
reloadHintPool(resetBag: false)
if reduceMotion, let hint = currentHint,
poolCards.contains(where: { $0.id == hint.id }) {
return
}
showNextHint(animated: !reduceMotion)
}
}
private var resolvedHeight: CGFloat {
@@ -69,7 +99,9 @@ struct AIKeyboardView: View {
)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
} else if let suggestion = state.clipboardSuggestionText, !suggestion.isEmpty {
} else if state.canShowClipboardEntry,
let suggestion = state.clipboardSuggestionText,
!suggestion.isEmpty {
// Replaces logo + capsule tabs until dismissed.
ClipboardSuggestionBar(
text: suggestion,
@@ -95,12 +127,7 @@ struct AIKeyboardView: View {
private var answerArea: some View {
ZStack(alignment: .bottom) {
if showsPlaceholder {
// Empty-state tip: geometric center of the answer plane.
Text(ExtL10n.string("keyboard.ai.placeholder"))
.font(TypeStyle.body)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.md)
hintCarousel
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollViewReader { proxy in
@@ -141,7 +168,36 @@ struct AIKeyboardView: View {
}
}
/// No draft/answer yet show the centered mic guidance instead of a scroll body.
private var hintCarousel: some View {
Button {
guard let hint = currentHint else { return }
state.submitAIHint(hint)
} label: {
Text(currentHint?.displayText ?? ExtL10n.string("keyboard.ai.placeholder"))
.font(TypeStyle.body)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.lineLimit(1)
.truncationMode(.tail)
.padding(.horizontal, Spacing.md)
.opacity(hintOpacity)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// A busy session already owns the surface; the status line explains a
// missing LLM. Both keep the hint from being a tap with no outcome.
.disabled(currentHint == nil || !state.aiServiceAvailable || state.aiSession.isBusy)
.accessibilityLabel(
Text(
currentHint.map {
"\(ExtL10n.string("keyboard.ai.hintA11yPrefix"))\($0.displayText)"
} ?? ExtL10n.string("keyboard.ai.placeholder")
)
)
}
/// No draft/answer yet show the centered hint carousel instead of a scroll body.
private var showsPlaceholder: Bool {
let hasDraft = !(state.aiSession.draftAnswerText?.isEmpty ?? true)
return !hasDraft && state.aiSession.answer == nil
@@ -173,7 +229,7 @@ struct AIKeyboardView: View {
private var aiMicrophoneButton: some View {
Button(action: state.tapAIMic) {
ZStack {
Capsule().fill(palette.accent)
Color.clear
if state.aiSession.phase == .listening {
Capsule()
.stroke(Color.white.opacity(0.28), lineWidth: 1.5)
@@ -187,6 +243,8 @@ struct AIKeyboardView: View {
minHeight: Layout.actionButtonHeight,
maxHeight: Layout.actionButtonHeight
)
//
.background(palette.accent, in: Capsule())
.contentShape(Capsule())
}
.buttonStyle(.plain)
@@ -228,11 +286,8 @@ struct AIKeyboardView: View {
minHeight: Layout.actionButtonHeight,
maxHeight: Layout.actionButtonHeight
)
.background(
answerActionFill,
in: Capsule()
)
.overlay(Capsule().stroke(answerActionBorder, lineWidth: 0.5))
//
.background(answerActionFill, in: Capsule())
.contentShape(Capsule())
}
.buttonStyle(.plain)
@@ -269,11 +324,11 @@ struct AIKeyboardView: View {
private var answerActionFill: Color {
guard state.aiSession.canPerformAnswerAction else {
return palette.surfaceElevated
return palette.surfaceElevated.opacity(0.55)
}
return state.aiSession.canSend
? palette.accent
: NativeKeyboardKeyColors.fill(for: colorScheme)
: palette.surfaceElevated
}
private var answerActionForeground: Color {
@@ -285,13 +340,6 @@ struct AIKeyboardView: View {
: NativeKeyboardKeyColors.text(for: colorScheme)
}
private var answerActionBorder: Color {
guard state.aiSession.canSend else {
return palette.divider
}
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
}
private var microphoneDisabled: Bool {
switch state.aiSession.phase {
case .preparing, .recognizing, .generating:
@@ -339,4 +387,42 @@ struct AIKeyboardView: View {
? "keyboard.ai.stopA11y"
: "keyboard.ai.startA11y"
}
// MARK: - Carousel
/// Rebuild the pool and show a card right away, without a fade.
private func resetCarousel() {
reloadHintPool(resetBag: true)
showNextHint(animated: false)
}
private func reloadHintPool(resetBag: Bool) {
let locale = AIHintLocaleResolver.packLocale()
let pack = AIHintStore.resolvedPack(locale: locale)
poolCards = AIHintPool.activeCards(
pack: pack,
clipboardHistoryEnabled: state.clipboardHistoryEnabled,
newestClipboard: clipboardHistory.newestEntry
)
if resetBag {
carouselBag.reset()
}
}
private func showNextHint(animated: Bool) {
guard let next = carouselBag.next(from: poolCards) else {
currentHint = nil
return
}
if animated, !reduceMotion {
withAnimation(Motion.soft) { hintOpacity = 0 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
currentHint = next
withAnimation(Motion.soft) { hintOpacity = 1 }
}
} else {
currentHint = next
hintOpacity = 1
}
}
}
+118 -52
View File
@@ -124,6 +124,7 @@ struct ClipboardEnableGuideView: View {
struct ClipboardHistoryPanelView: View {
@Environment(\.themePalette) private var palette
@ObservedObject var history: ClipboardHistoryStore
@State private var showClearConfirmation = false
let onClose: () -> Void
let onClear: () -> Void
@@ -132,57 +133,120 @@ struct ClipboardHistoryPanelView: View {
let pastePermissionHint: String?
var body: some View {
VStack(spacing: 0) {
ClipboardPanelHeader(onClose: onClose) {
Button(action: onClear) {
Image(systemName: "trash")
.font(.system(size: KeyboardTopBarMetrics.trailingChipIconSize, weight: .medium))
.foregroundStyle(palette.textSecondary)
.frame(
width: KeyboardTopBarMetrics.trailingChipSize,
height: KeyboardTopBarMetrics.trailingChipSize
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(history.entries.isEmpty)
.opacity(history.entries.isEmpty ? 0.35 : 1)
}
if let pastePermissionHint, !pastePermissionHint.isEmpty {
Text(pastePermissionHint)
.font(.system(size: 12))
.foregroundStyle(palette.warning)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.bottom, 6)
}
if history.entries.isEmpty {
ExtL10n.text("keyboard.clipboard.panel.empty")
.font(.system(size: 14))
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(history.entries) { entry in
ClipboardHistoryRow(
entry: entry,
onInsert: { onInsert(entry.text) },
onInsertToken: { onInsert($0) },
onDelete: { onDelete(entry.id) }
)
}
ZStack {
VStack(spacing: 0) {
ClipboardPanelHeader(onClose: onClose) {
Button {
showClearConfirmation = true
} label: {
Image(systemName: "trash")
.font(.system(
size: KeyboardTopBarMetrics.trailingChipIconSize,
weight: .medium
))
.foregroundStyle(palette.textSecondary)
// HIG minimum hit target; icon stays visually small and centered.
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
.buttonStyle(.plain)
.disabled(history.entries.isEmpty)
.opacity(history.entries.isEmpty ? 0.35 : 1)
}
if let pastePermissionHint, !pastePermissionHint.isEmpty {
Text(pastePermissionHint)
.font(.system(size: 12))
.foregroundStyle(palette.warning)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.bottom, 6)
}
if history.entries.isEmpty {
ExtL10n.text("keyboard.clipboard.panel.empty")
.font(.system(size: 14))
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(history.entries) { entry in
ClipboardHistoryRow(
entry: entry,
onInsert: { onInsert(entry.text) },
onInsertToken: { onInsert($0) },
onDelete: { onDelete(entry.id) }
)
}
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
}
}
.allowsHitTesting(!showClearConfirmation)
if showClearConfirmation {
clearConfirmationOverlay
.transition(.scale(scale: 0.96).combined(with: .opacity))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Transparent let the system keyboard chrome show through.
.background(Color.clear)
.animation(.easeOut(duration: 0.16), value: showClearConfirmation)
}
private var clearConfirmationOverlay: some View {
VStack(spacing: 12) {
Image(systemName: "trash")
.font(.system(size: 19, weight: .medium))
.foregroundStyle(palette.textSecondary)
.frame(width: 38, height: 38)
.background(palette.surface.opacity(0.35), in: Circle())
ExtL10n.text("keyboard.clipboard.clear.title")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
HStack(spacing: 10) {
Button {
showClearConfirmation = false
} label: {
ExtL10n.text("common.cancel")
.font(.system(size: 13, weight: .semibold))
.frame(maxWidth: .infinity)
.frame(height: 36)
}
.buttonStyle(.glass)
.buttonBorderShape(.capsule)
Button {
// Dismiss the popup before publishing an empty history
// so the keyboard never retains stale row content.
showClearConfirmation = false
onClear()
} label: {
ExtL10n.text("keyboard.clipboard.clear.confirm")
.font(.system(size: 13, weight: .semibold))
.frame(maxWidth: .infinity)
.frame(height: 36)
}
.buttonStyle(.glassProminent)
.buttonBorderShape(.capsule)
.tint(palette.accent)
}
}
.padding(16)
.frame(maxWidth: 300)
.glassEffect(
.regular,
in: RoundedRectangle(cornerRadius: 18, style: .continuous)
)
.padding(.horizontal, 24)
.accessibilityElement(children: .contain)
}
}
@@ -277,23 +341,25 @@ struct KeyboardClipboardMenuButton: View, Equatable {
var body: some View {
Button(action: action) {
// Neutral chip mirrors the translation button's off state.
// SF Symbol "clipboard" sits optically low; nudge up so it centres
// in the 34pt chip the same way "xmark" does.
Image(systemName: "clipboard")
.font(.system(size: KeyboardTopBarMetrics.trailingChipIconSize, weight: .medium))
.foregroundStyle(palette.textSecondary)
.foregroundStyle(palette.textPrimary.opacity(0.72))
.offset(y: -0.5)
.frame(
width: KeyboardTopBarMetrics.trailingChipSize,
height: KeyboardTopBarMetrics.trailingChipSize
)
.background(buttonFill, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
// Match KeyboardCancelButton: opaque key fill + hairline, no glass.
.background(NativeKeyboardKeyColors.fill(for: colorScheme), in: Circle())
.overlay(
Circle().stroke(palette.divider, lineWidth: 0.5)
)
.contentShape(Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(ExtL10n.text("keyboard.clipboard.a11y"))
.accessibilityHint(ExtL10n.text("keyboard.clipboard.a11yHint"))
}
private var buttonFill: Color {
colorScheme == .dark ? Color(white: 0.30) : .white
}
}
+5 -96
View File
@@ -4,8 +4,8 @@
// Typeless-inspired keyboard surface. The keyboard is laid out in three
// vertical bands, but the entire height is reserved for us we set
// `KeyboardViewController` drives height on `view` (priority 999) and mirrors
// `KeyboardLayoutMetrics.totalHeight` in SwiftUI see presentation offset
// in `applyPresentationHeightOffset()`.
// `KeyboardLayoutMetrics.totalHeight` in SwiftUI the input view is bottom-
// anchored so a transient over-tall system container cannot float the chrome.
//
//
// [OSG] EN header band (top)
@@ -296,6 +296,7 @@ public struct KeyboardRootView: View {
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
isEnabled: micButtonEnabled,
usesLiquidGlass: true,
onToggle: state.tapMic,
onPressingChanged: micButtonEnabled
? state.setMicTouchActive
@@ -425,6 +426,7 @@ public struct KeyboardRootView: View {
systemName: "arrow.uturn.backward",
label: ExtL10n.string("keyboard.undoA11y"),
disabled: disabled,
usesLiquidGlass: true,
hapticIntensity: state.keyboardHapticIntensity
) {
state.undoLastInsertion()
@@ -458,6 +460,7 @@ public struct KeyboardRootView: View {
}
private var shouldShowClipboardSuggestion: Bool {
guard state.canShowClipboardEntry else { return false }
guard let text = state.clipboardSuggestionText, !text.isEmpty else { return false }
return true
}
@@ -701,97 +704,3 @@ private struct TranscriptLine: View {
}
}
}
// MARK: - Cloud engine chip (cloud always ASR + LLM polish)
private struct CloudEngineChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
var body: some View {
HStack(spacing: 4) {
Image(systemName: "wand.and.stars")
ExtL10n.text("keyboard.placeholder.cloudBadge")
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 6)
.frame(minHeight: 28)
.background(palette.accent.opacity(0.15), in: Capsule())
.overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5))
}
}
// MARK: - Local engine chip (shown instead of ModeChip when engineMode == "local")
private struct LocalEngineChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
var body: some View {
HStack(spacing: 4) {
Image(systemName: "iphone.badge.checkmark")
ExtL10n.text("keyboard.placeholder.localBadge")
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 6)
.frame(minHeight: 28)
.background(palette.accent.opacity(0.15), in: Capsule())
.overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5))
}
}
// MARK: - Locale chip
private struct LocaleChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
let localeId: String
let onChange: (String) -> Void
private let options: [(id: String, labelKey: String)] = [
("auto", "locale.chip.auto"),
("zh-Hans", "locale.chip.zh-Hans"),
("zh-Hant", "locale.chip.zh-Hant"),
("en-US", "locale.chip.en-US"),
("ja-JP", "locale.chip.ja-JP"),
("ko-KR", "locale.chip.ko-KR")
]
var body: some View {
Menu {
ForEach(options, id: \.id) { o in
Button {
onChange(o.id)
} label: {
if o.id == localeId {
Label(ExtL10n.string(o.labelKey), systemImage: "checkmark")
} else {
Text(ExtL10n.string(o.labelKey))
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "globe")
Text(currentLabel)
Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 6)
.frame(minHeight: 28)
.background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
.menuStyle(.button)
}
private var currentLabel: String {
options.first(where: { $0.id == localeId }).map { ExtL10n.string($0.labelKey) }
?? ExtL10n.string("locale.chip.auto")
}
}
+99 -67
View File
@@ -7,6 +7,17 @@
import SwiftUI
import OSGKeyboardShared
private struct KeyboardTabSelectionNamespaceKey: EnvironmentKey {
static let defaultValue: Namespace.ID? = nil
}
extension EnvironmentValues {
var keyboardTabSelectionNamespace: Namespace.ID? {
get { self[KeyboardTabSelectionNamespaceKey.self] }
set { self[KeyboardTabSelectionNamespaceKey.self] = newValue }
}
}
enum KeyboardTopBarMetrics {
static let height: CGFloat = 44
static let horizontalInset: CGFloat = 12
@@ -44,8 +55,8 @@ struct KeyboardBrandLogo: View {
}
struct KeyboardCancelButton: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
let action: () -> Void
let accessibilityLabel: Text
@@ -61,18 +72,17 @@ struct KeyboardCancelButton: View {
width: KeyboardTopBarMetrics.trailingChipSize,
height: KeyboardTopBarMetrics.trailingChipSize
)
.background(buttonFill, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
//
.background(NativeKeyboardKeyColors.fill(for: colorScheme), in: Circle())
.overlay(
Circle().stroke(palette.divider, lineWidth: 0.5)
)
.contentShape(Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
.accessibilityHint(accessibilityHint)
}
private var buttonFill: Color {
colorScheme == .dark ? Color(white: 0.30) : .white
}
}
private enum KeyboardInputTab: CaseIterable {
@@ -83,16 +93,18 @@ private enum KeyboardInputTab: CaseIterable {
var title: String {
switch self {
case .ai: return "AI"
case .voice: return "语音"
case .chinese: return "中文"
case .english: return "EN"
case .ai: return ExtL10n.string("keyboard.tab.ai")
case .voice: return ExtL10n.string("keyboard.tab.voice")
case .chinese: return ExtL10n.string("keyboard.tab.chinese")
case .english: return ExtL10n.string("keyboard.tab.english")
}
}
}
struct KeyboardTopControls: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.keyboardTabSelectionNamespace) private var sharedSelectionNamespace
@Namespace private var fallbackSelectionNamespace
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
@@ -102,52 +114,69 @@ struct KeyboardTopControls: View {
var body: some View {
HStack(spacing: 6) {
// /
HStack(spacing: 2) {
ForEach(KeyboardInputTab.allCases, id: \.self) { tab in
Button {
select(tab)
} label: {
Text(tab.title)
.font(.system(size: 12, weight: isSelected(tab) ? .semibold : .medium))
.foregroundStyle(
isSelected(tab) ? palette.textPrimary : palette.textSecondary
)
.frame(
width: tab == .english || tab == .ai ? 34 : 42,
height: 30
)
.background {
if isSelected(tab) {
Capsule()
.fill(selectedFill)
.shadow(
color: Color.black.opacity(colorScheme == .dark ? 0.22 : 0.10),
radius: 1.5,
y: 1
)
}
}
}
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
.disabled(tab != .voice && !state.canEnterTypingSurface)
.opacity(tabOpacity(tab))
.accessibilityLabel(accessibilityLabel(for: tab))
.accessibilityAddTraits(isSelected(tab) ? .isSelected : [])
tabButton(tab)
}
}
.padding(2)
.background(trackFill, in: Capsule())
KeyboardClipboardMenuButton(
palette: palette,
action: state.openClipboardPanel
.background(tabTrackFill, in: Capsule())
.overlay(
Capsule().stroke(palette.divider, lineWidth: 0.5)
)
.equatable()
if state.canShowClipboardEntry {
KeyboardClipboardMenuButton(
palette: palette,
action: state.openClipboardPanel
)
.equatable()
}
}
}
private var selectedFill: Color {
colorScheme == .dark ? Color(white: 0.38) : .white
private func tabButton(_ tab: KeyboardInputTab) -> some View {
let selected = isSelected(tab)
let width: CGFloat = tab == .english || tab == .ai ? 34 : 42
return Button {
withAnimation(Motion.soft) {
select(tab)
}
} label: {
tabLabel(tab, selected: selected, width: width)
}
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
.disabled(tab != .voice && !state.canEnterTypingSurface)
.opacity(tabOpacity(tab))
.accessibilityLabel(accessibilityLabel(for: tab))
.accessibilityAddTraits(selected ? .isSelected : [])
}
@ViewBuilder
private func tabLabel(
_ tab: KeyboardInputTab,
selected: Bool,
width: CGFloat
) -> some View {
let label = Text(tab.title)
.font(.system(size: 12, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? palette.textPrimary : palette.textSecondary)
.frame(width: width, height: 30)
if selected {
let namespace = sharedSelectionNamespace ?? fallbackSelectionNamespace
//
label.background(
Capsule()
.fill(NativeKeyboardKeyColors.fill(for: colorScheme))
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
.matchedGeometryEffect(id: "keyboard-tab-selection", in: namespace)
)
} else {
label
}
}
private func tabOpacity(_ tab: KeyboardInputTab) -> Double {
@@ -158,14 +187,16 @@ struct KeyboardTopControls: View {
return 0.42
}
private var trackFill: Color {
colorScheme == .dark ? Color(white: 0.18) : Color.black.opacity(0.08)
}
private var pressedFill: Color {
colorScheme == .dark ? Color(white: 0.22) : Color(white: 0.84)
}
/// NativeKeyboardKeyColors.fill
///
private var tabTrackFill: Color {
colorScheme == .dark ? Color(white: 0.12) : Color(white: 0.87)
}
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
switch tab {
case .ai:
@@ -211,17 +242,15 @@ struct KeyboardTopControls: View {
private func accessibilityLabel(for tab: KeyboardInputTab) -> String {
switch tab {
case .ai: return "切换到 AI 问答"
case .voice: return "切换到语音输入"
case .chinese: return "切换到中文输入"
case .english: return "切换到英文输入"
case .ai: return ExtL10n.string("keyboard.tab.ai.a11y")
case .voice: return ExtL10n.string("keyboard.tab.voice.a11y")
case .chinese: return ExtL10n.string("keyboard.tab.chinese.a11y")
case .english: return ExtL10n.string("keyboard.tab.english.a11y")
}
}
}
struct KeyboardTranslationMenuButton: View, Equatable {
@Environment(\.colorScheme) private var colorScheme
let palette: ThemePalette
let targetLocaleId: String
let onSelect: (String) -> Void
@@ -251,22 +280,25 @@ struct KeyboardTranslationMenuButton: View, Equatable {
}
}
} label: {
// Match the adjacent undo key: 44×44 rounded-rect chrome, not a circle chip.
NativeKeyboardKeySurface(
isPressed: false,
fill: NativeKeyboardKeyColors.fill(for: colorScheme),
pressedFill: NativeKeyboardKeyColors.pressedFill(for: colorScheme),
border: palette.divider,
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius
) {
// Match the adjacent undo key: 44×44 rounded Liquid Glass control.
ZStack {
Color.clear
Image(systemName: isEnabled ? "character.bubble.fill" : "character.bubble")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(
isEnabled
? palette.accent
: NativeKeyboardKeyColors.text(for: colorScheme)
: palette.textSecondary
)
}
.contentShape(Rectangle())
.glassEffect(
.regular.interactive(),
in: RoundedRectangle(
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius,
style: .continuous
)
)
}
.menuStyle(.button)
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
+15 -6
View File
@@ -45,14 +45,19 @@ struct LastInputEditView: View {
.frame(height: KeyboardChromeLayout.totalHeight)
.environment(\.themePalette, palette)
.onChange(of: state.editSession) { _, newValue in
guard newValue.review != nil else {
guard let review = newValue.review else {
selectedPage = 0
return
}
if reduceMotion {
selectedPage = 1
} else {
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.86)) {
// Set page before the pager remounts (see `.id` on `pages`) so the
// fresh ScrollView opens oninstead of flipping the dots
// while still showing.
selectedPage = 1
if !reduceMotion {
// Re-assert after layout; spring is only for subsequent swipes.
Task { @MainActor in
await Task.yield()
guard state.editSession.review?.utteranceID == review.utteranceID else { return }
selectedPage = 1
}
}
@@ -83,6 +88,8 @@ struct LastInputEditView: View {
contentBottomInset: 30,
selectedPage: $selectedPage
)
// Remount when review text arrives so scrollPosition can open on page 1.
.id(state.editSession.review?.utteranceID.uuidString ?? "edit-source")
}
}
@@ -113,7 +120,7 @@ struct LastInputEditView: View {
helperText(leftHelper)
Button(action: primaryAction) {
ZStack {
Capsule().fill(palette.accent)
Color.clear
if case .listening = state.editSession {
Capsule()
.stroke(Color.white.opacity(0.28), lineWidth: 1.5)
@@ -126,6 +133,8 @@ struct LastInputEditView: View {
width: Layout.primaryButtonWidth,
height: Layout.primaryButtonHeight
)
//
.background(palette.accent, in: Capsule())
.contentShape(Capsule())
}
.buttonStyle(.plain)
@@ -47,15 +47,11 @@ struct NativeKeyboardKeySurface<Content: View>: View {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(isPressed ? pressedFill : fill)
)
// + 0.5pt
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(border, lineWidth: 0.5)
)
.shadow(
color: Color.black.opacity(isPressed ? 0.04 : 0.13),
radius: isPressed ? 0.5 : 1,
y: isPressed ? 0 : 1
)
.scaleEffect(isPressed ? 0.98 : 1)
.animation(.easeOut(duration: 0.08), value: isPressed)
}
+58 -56
View File
@@ -189,39 +189,6 @@ struct RepeatingDeleteButton: View {
}
}
// MARK: - Press-down typing key
/// Fires on touch-down (not release) so click sound / haptic match the stock
/// keyboard and the voice toolbars RectangularToolbarButton.
struct PressDownKeyButton<Label: View>: View {
var disabled: Bool = false
let action: () -> Void
@ViewBuilder let label: (_ isPressed: Bool) -> Label
@State private var isPressing = false
var body: some View {
label(isPressing)
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityAddTraits(.isButton)
}
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !disabled, !isPressing else { return }
isPressing = true
action()
}
.onEnded { _ in
isPressing = false
}
}
}
// MARK: - Rectangular toolbar button
struct RectangularToolbarButton: View {
@@ -233,6 +200,7 @@ struct RectangularToolbarButton: View {
let label: String
let disabled: Bool
let isSend: Bool
let usesLiquidGlass: Bool
/// Settings General Haptics; space / return use `.action` role.
var hapticIntensity: KeyboardHapticIntensity = .off
let action: () -> Void
@@ -241,6 +209,7 @@ struct RectangularToolbarButton: View {
systemName: String,
label: String,
disabled: Bool = false,
usesLiquidGlass: Bool = false,
hapticIntensity: KeyboardHapticIntensity = .off,
action: @escaping () -> Void
) {
@@ -250,6 +219,7 @@ struct RectangularToolbarButton: View {
self.label = label
self.disabled = disabled
self.isSend = false
self.usesLiquidGlass = usesLiquidGlass
self.hapticIntensity = hapticIntensity
self.action = action
}
@@ -259,6 +229,7 @@ struct RectangularToolbarButton: View {
label: String,
disabled: Bool = false,
isSend: Bool = false,
usesLiquidGlass: Bool = false,
hapticIntensity: KeyboardHapticIntensity = .off,
action: @escaping () -> Void
) {
@@ -267,6 +238,7 @@ struct RectangularToolbarButton: View {
self.label = label
self.disabled = disabled
self.isSend = isSend
self.usesLiquidGlass = usesLiquidGlass
self.hapticIntensity = hapticIntensity
self.action = action
self.title = title
@@ -276,6 +248,7 @@ struct RectangularToolbarButton: View {
spaceStyle: Bool,
label: String,
disabled: Bool = false,
usesLiquidGlass: Bool = false,
hapticIntensity: KeyboardHapticIntensity = .off,
action: @escaping () -> Void
) {
@@ -285,6 +258,7 @@ struct RectangularToolbarButton: View {
self.label = label
self.disabled = disabled
self.isSend = false
self.usesLiquidGlass = usesLiquidGlass
self.hapticIntensity = hapticIntensity
self.action = action
}
@@ -292,31 +266,59 @@ struct RectangularToolbarButton: View {
@State private var isPressing = false
var body: some View {
ToolbarKeySurface(
isPressed: isPressing,
cornerRadius: ToolbarButtonMetrics.cornerRadius,
emphasis: isSend ? .send : .standard
) {
if spaceStyle {
Capsule()
.fill(buttonForeground)
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(buttonForeground)
} else if let title {
Text(title)
.font(.system(size: ToolbarButtonMetrics.titleSize, weight: .semibold))
.foregroundStyle(buttonForeground)
buttonSurface
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text(label))
.accessibilityAddTraits(.isButton)
}
@ViewBuilder
private var buttonSurface: some View {
if usesLiquidGlass {
ZStack {
Color.clear
buttonContent
}
.glassEffect(
.regular.interactive(),
in: RoundedRectangle(
cornerRadius: ToolbarButtonMetrics.cornerRadius,
style: .continuous
)
)
// The custom press gesture fires on touch-down; mirror that state
// visually while Liquid Glass supplies its native light response.
.scaleEffect(isPressing ? 0.97 : 1)
.animation(.easeOut(duration: 0.08), value: isPressing)
} else {
ToolbarKeySurface(
isPressed: isPressing,
cornerRadius: ToolbarButtonMetrics.cornerRadius,
emphasis: isSend ? .send : .standard
) {
buttonContent
}
}
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text(label))
.accessibilityAddTraits(.isButton)
}
@ViewBuilder
private var buttonContent: some View {
if spaceStyle {
Capsule()
.fill(buttonForeground)
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(buttonForeground)
} else if let title {
Text(title)
.font(.system(size: ToolbarButtonMetrics.titleSize, weight: .semibold))
.foregroundStyle(buttonForeground)
}
}
private var buttonForeground: Color {
+14 -15
View File
@@ -114,13 +114,19 @@
"preview.localeChip.cycle" = "Cycle recognition language";
/* Keyboard (ext) */
"keyboard.tab.ai" = "AI";
"keyboard.tab.voice" = "Voice";
"keyboard.tab.chinese" = "中文";
"keyboard.tab.english" = "EN";
"keyboard.tab.ai.a11y" = "Switch to AI Q&A";
"keyboard.tab.voice.a11y" = "Switch to voice input";
"keyboard.tab.chinese.a11y" = "Switch to Chinese typing";
"keyboard.tab.english.a11y" = "Switch to English typing";
"keyboard.placeholder.idle" = "Tap to talk";
"keyboard.placeholder.preparing" = "Preparing";
"keyboard.placeholder.preparingRecording" = "Preparing mic…";
"keyboard.placeholder.processing" = "Processing";
"keyboard.placeholder.error" = "Polishing failed";
"keyboard.placeholder.localBadge" = "On-device";
"keyboard.placeholder.cloudBadge" = "Cloud";
"keyboard.models.notDownloaded" = "On-device models not downloaded";
"keyboard.models.downloadHint" = "Open OSGKeyboard to download models";
"keyboard.rec" = "REC";
@@ -183,20 +189,8 @@
"keyboard.dictation.failed" = "Recording failed. Try again.";
"keyboard.dictation.resultTimeout" = "Timed out waiting for dictation. Finish in OSGKeyboard and retry.";
/* Locale chip (short labels) */
"locale.chip.auto" = "Auto";
"locale.chip.zh-Hans" = "简";
"locale.chip.zh-Hant" = "繁";
"locale.chip.en-US" = "EN";
"locale.chip.ja-JP" = "日";
"locale.chip.ko-KR" = "韩";
/* Translation chip (v0.3) */
"keyboard.translation.chip" = "Translate";
/* Translation menu */
"keyboard.translation.offMenu" = "Don't translate";
"keyboard.translation.off" = "Don't translate";
"keyboard.translation.enable" = "Enable translation";
"keyboard.translation.disable" = "Disable translation";
"keyboard.translation.a11y" = "Translation";
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
"keyboard.clipboard.a11y" = "Clipboard";
@@ -208,6 +202,9 @@
"keyboard.clipboard.panel.delete" = "Delete";
"keyboard.clipboard.panel.close" = "Close clipboard";
"keyboard.clipboard.panel.closeHint" = "Return to the keyboard.";
"keyboard.clipboard.clear.title" = "Clear clipboard history?";
"keyboard.clipboard.clear.message" = "This permanently removes all saved clipboard items from this device. This cannot be undone.";
"keyboard.clipboard.clear.confirm" = "Clear history";
"keyboard.clipboard.suggestion.dismissA11y" = "Dismiss clipboard suggestion";
"keyboard.clipboard.suggestion.dismissHint" = "Hide this clipboard suggestion strip.";
"keyboard.voice.cancel" = "Cancel voice input";
@@ -276,6 +273,7 @@
/* AI question mode */
"keyboard.ai.placeholder" = "Tap the microphone to ask AI";
"keyboard.ai.hintA11yPrefix" = "Suggestion: ";
"keyboard.ai.hint" = "Insert the AI answer, then tap Send";
"keyboard.ai.listening" = "Listening…";
"keyboard.ai.recognizing" = "Recognizing your question…";
@@ -295,3 +293,4 @@
"keyboard.ai.error.startTimeout" = "Microphone startup timed out. Try again";
"keyboard.ai.error.requestTimeout" = "AI response timed out. Try again";
"keyboard.ai.error.requestFailed" = "AI response failed. Try again";
"keyboard.ai.error.clipboardUnavailable" = "This clipboard suggestion expired. Copy the text again";
+14 -15
View File
@@ -114,13 +114,19 @@
"preview.localeChip.cycle" = "切换识别语言";
/* Keyboard (ext) */
"keyboard.tab.ai" = "AI";
"keyboard.tab.voice" = "语音";
"keyboard.tab.chinese" = "中文";
"keyboard.tab.english" = "EN";
"keyboard.tab.ai.a11y" = "切换到 AI 问答";
"keyboard.tab.voice.a11y" = "切换到语音输入";
"keyboard.tab.chinese.a11y" = "切换到中文输入";
"keyboard.tab.english.a11y" = "切换到英文输入";
"keyboard.placeholder.idle" = "点按说话";
"keyboard.placeholder.preparing" = "准备中…";
"keyboard.placeholder.preparingRecording" = "准备录音…";
"keyboard.placeholder.processing" = "处理中…";
"keyboard.placeholder.error" = "润色失败";
"keyboard.placeholder.localBadge" = "本地";
"keyboard.placeholder.cloudBadge" = "云端";
"keyboard.models.notDownloaded" = "本地模型尚未下载";
"keyboard.models.downloadHint" = "打开 OSGKeyboard 下载模型";
"keyboard.rec" = "REC";
@@ -183,20 +189,8 @@
"keyboard.dictation.failed" = "录音失败,请重试";
"keyboard.dictation.resultTimeout" = "等待录音结果超时,请返回 OSGKeyboard 完成录音后重试";
/* Locale chip (short labels) */
"locale.chip.auto" = "自动";
"locale.chip.zh-Hans" = "简";
"locale.chip.zh-Hant" = "繁";
"locale.chip.en-US" = "EN";
"locale.chip.ja-JP" = "日";
"locale.chip.ko-KR" = "韩";
/* Translation chip (v0.3) */
"keyboard.translation.chip" = "翻译";
/* 翻译菜单 */
"keyboard.translation.offMenu" = "不翻译";
"keyboard.translation.off" = "不翻译";
"keyboard.translation.enable" = "开启翻译";
"keyboard.translation.disable" = "关闭翻译";
"keyboard.translation.a11y" = "翻译";
"keyboard.translation.a11yHint" = "切换翻译开关或修改目标语言。";
"keyboard.clipboard.a11y" = "剪贴板";
@@ -208,6 +202,9 @@
"keyboard.clipboard.panel.delete" = "删除";
"keyboard.clipboard.panel.close" = "关闭剪贴板";
"keyboard.clipboard.panel.closeHint" = "返回键盘输入界面。";
"keyboard.clipboard.clear.title" = "清空剪贴板历史?";
"keyboard.clipboard.clear.message" = "将从本机永久删除全部剪贴板历史,且无法撤销。";
"keyboard.clipboard.clear.confirm" = "清空历史";
"keyboard.clipboard.suggestion.dismissA11y" = "关闭剪贴板建议";
"keyboard.clipboard.suggestion.dismissHint" = "隐藏本次剪贴板建议条。";
"keyboard.voice.cancel" = "取消本次语音输入";
@@ -276,6 +273,7 @@
/* AI 问答模式 */
"keyboard.ai.placeholder" = "点击麦克风向 AI 提问";
"keyboard.ai.hintA11yPrefix" = "建议:";
"keyboard.ai.hint" = "先插入 AI 回答,再按发送";
"keyboard.ai.listening" = "正在聆听…";
"keyboard.ai.recognizing" = "正在识别问题…";
@@ -295,3 +293,4 @@
"keyboard.ai.error.startTimeout" = "麦克风启动超时,请重试";
"keyboard.ai.error.requestTimeout" = "AI 回答超时,请重试";
"keyboard.ai.error.requestFailed" = "AI 回答失败,请重试";
"keyboard.ai.error.clipboardUnavailable" = "剪贴板建议已过期,请重新复制文本";