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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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 on「编辑后」instead 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)
|
||||
}
|
||||
|
||||
@@ -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 toolbar’s 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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" = "剪贴板建议已过期,请重新复制文本";
|
||||
|
||||
Reference in New Issue
Block a user