feat(keyboard): add clipboard history and undo pastes
Ship optional keyboard clipboard history with settings, suggestion strip, and paste-permission guidance; let undo roll back clipboard inserts; bump build to 64.
This commit is contained in:
@@ -66,6 +66,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
private var flowCoordinator: KeyboardFlowCoordinator!
|
||||
private var lastInputEditCoordinator: LastInputEditCoordinator!
|
||||
private var aiKeyboardCoordinator: AIKeyboardCoordinator!
|
||||
private var clipboardCapture: ClipboardCaptureCoordinator!
|
||||
private var configSync: KeyboardConfigSync!
|
||||
/// UIKit may synchronously lay out the view during `viewDidLoad`.
|
||||
/// Keep this optional so an early layout pass is harmless.
|
||||
@@ -166,6 +167,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
clipboardCapture?.keyboardWillDisappear()
|
||||
OSGDiag.log(
|
||||
"KVC.viewWillDisappear surface=\(state.surface.rawValue) "
|
||||
+ "preserve=\(flowCoordinator.preservesLifecycleOnDisappear) \(OSGDiag.memoryTag())",
|
||||
@@ -214,6 +216,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
flowCoordinator.startSessionMonitor()
|
||||
configSync.syncOnboardingStateFromAppGroup()
|
||||
configSync.refreshConfigFromAppGroup()
|
||||
clipboardCapture.refreshFlagsFromStore()
|
||||
// Settings may have changed while the extension stayed alive.
|
||||
applyPreferredSurfaceOnOpen()
|
||||
// Re-warm Taptic after host app switches: SwiftUI `onAppear` often
|
||||
@@ -223,6 +226,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot")
|
||||
typingSession.enterTypingMode()
|
||||
}
|
||||
clipboardCapture.keyboardDidAppear()
|
||||
OSGDiag.log(
|
||||
"KVC.viewWillAppear done surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
@@ -392,6 +396,15 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
self?.textDocumentProxy.insertText("\n")
|
||||
}
|
||||
)
|
||||
clipboardCapture = ClipboardCaptureCoordinator(state: state)
|
||||
clipboardCapture.configure(
|
||||
isSecure: { [weak self] in
|
||||
self?.textDocumentProxy.isSecureTextEntry ?? false
|
||||
},
|
||||
hasFullAccess: { [weak self] in
|
||||
self?.hasFullAccess ?? false
|
||||
}
|
||||
)
|
||||
flowCoordinator.onAIUtterancePrepared = { [weak self] utteranceID in
|
||||
self?.aiKeyboardCoordinator.utterancePrepared(utteranceID)
|
||||
}
|
||||
@@ -465,6 +478,35 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
state.openSettings = { [weak self] in self?.openHostApp() }
|
||||
state.openInputMethodSetup = { [weak self] in self?.openHostApp(path: "deployrime") }
|
||||
state.openClipboardSettings = { [weak self] in
|
||||
SettingsDeepLink.setPending(.clipboard)
|
||||
self?.openHostApp(path: "settings/clipboard")
|
||||
}
|
||||
state.openClipboardPanel = { [weak self] in
|
||||
self?.clipboardCapture.openPanelFromTopButton()
|
||||
}
|
||||
state.dismissClipboardOverlay = { [weak self] in
|
||||
self?.clipboardCapture.dismissOverlay()
|
||||
}
|
||||
state.insertClipboardText = { [weak self] text in
|
||||
guard let self else { return }
|
||||
self.clipboardCapture.insertText(text) { insertText in
|
||||
// Via the inserter so the undo key can roll a paste back.
|
||||
self.textInserter.insertPasteboardText(insertText)
|
||||
}
|
||||
}
|
||||
state.dismissClipboardSuggestion = { [weak self] in
|
||||
self?.clipboardCapture.dismissSuggestion()
|
||||
}
|
||||
state.clearClipboardHistory = { [weak self] in
|
||||
self?.clipboardCapture.clearHistory()
|
||||
}
|
||||
state.deleteClipboardHistoryEntry = { [weak self] id in
|
||||
self?.clipboardCapture.deleteEntry(id: id)
|
||||
}
|
||||
state.noteUserDidInputText = { [weak self] in
|
||||
self?.clipboardCapture.noteUserDidInputText()
|
||||
}
|
||||
// The globe UIButton registers this controller's standard
|
||||
// `handleInputModeList(from:with:)` action for all touch events.
|
||||
state.inputModeController = self
|
||||
@@ -644,8 +686,10 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
private func refreshReturnKeyRole() {
|
||||
state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
|
||||
let isSecure = textDocumentProxy.isSecureTextEntry ?? false
|
||||
state.isSecureTextEntry = isSecure
|
||||
// Secure fields must not run English autocomplete / autocorrect / learning.
|
||||
typingSession.suggestionsEnabled = !(textDocumentProxy.isSecureTextEntry ?? false)
|
||||
typingSession.suggestionsEnabled = !isSecure
|
||||
typingSession.syncAutocapitalization()
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ public struct AppGroupPersistor {
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
state.handednessPreference = store.handednessPreference
|
||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||
applyAPIKeyAvailability(store: store, into: state)
|
||||
|
||||
@@ -91,6 +93,8 @@ public struct AppGroupPersistor {
|
||||
}
|
||||
state.handednessPreference = store.handednessPreference
|
||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||
applyAPIKeyAvailability(store: store, into: state)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// ClipboardCaptureCoordinator.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Samples the general pasteboard on keyboard appear and while visible
|
||||
// (changeCount-driven). Writes accepted text into ClipboardHistoryStore.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
@MainActor
|
||||
final class ClipboardCaptureCoordinator {
|
||||
private let state: KeyboardState
|
||||
private let history: ClipboardHistoryStore
|
||||
private var pollTimer: Timer?
|
||||
private var isSecureProvider: () -> Bool = { false }
|
||||
private var hasFullAccessProvider: () -> Bool = { false }
|
||||
|
||||
init(
|
||||
state: KeyboardState,
|
||||
history: ClipboardHistoryStore = .shared
|
||||
) {
|
||||
self.state = state
|
||||
self.history = history
|
||||
}
|
||||
|
||||
func configure(
|
||||
isSecure: @escaping () -> Bool,
|
||||
hasFullAccess: @escaping () -> Bool
|
||||
) {
|
||||
isSecureProvider = isSecure
|
||||
hasFullAccessProvider = hasFullAccess
|
||||
}
|
||||
|
||||
func keyboardDidAppear() {
|
||||
history.reload()
|
||||
refreshSuggestionFromStore()
|
||||
captureIfNeeded(force: true)
|
||||
startPolling()
|
||||
}
|
||||
|
||||
func keyboardWillDisappear() {
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
func refreshFlagsFromStore() {
|
||||
// Called from App Group poll — suggestion visibility may change.
|
||||
refreshSuggestionFromStore()
|
||||
}
|
||||
|
||||
func openPanelFromTopButton() {
|
||||
if state.clipboardHistoryEnabled {
|
||||
history.reload()
|
||||
state.clipboardOverlay = .historyPanel
|
||||
} else {
|
||||
state.clipboardOverlay = .enableGuide
|
||||
}
|
||||
}
|
||||
|
||||
func dismissOverlay() {
|
||||
state.clipboardOverlay = .none
|
||||
}
|
||||
|
||||
func noteUserDidInputText() {
|
||||
clearSuggestion(persistDismiss: false)
|
||||
}
|
||||
|
||||
func dismissSuggestion() {
|
||||
history.dismissSuggestion(forChangeCount: state.clipboardSuggestionChangeCount)
|
||||
clearSuggestion(persistDismiss: true)
|
||||
}
|
||||
|
||||
func insertText(_ text: String, via insert: (String) -> Void) {
|
||||
insert(text)
|
||||
// Tapping a suggestion (or history row that shares this path) must not
|
||||
// resurface the same clipboard changeCount until the pasteboard changes.
|
||||
dismissSuggestion()
|
||||
dismissOverlay()
|
||||
}
|
||||
|
||||
func clearHistory() {
|
||||
history.clearAll()
|
||||
clearSuggestion(persistDismiss: false)
|
||||
}
|
||||
|
||||
func deleteEntry(id: UUID) {
|
||||
history.remove(id: id)
|
||||
refreshSuggestionFromStore()
|
||||
}
|
||||
|
||||
// MARK: - Capture
|
||||
|
||||
private func startPolling() {
|
||||
stopPolling()
|
||||
let timer = Timer(timeInterval: 0.8, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.captureIfNeeded(force: false)
|
||||
}
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
pollTimer = timer
|
||||
}
|
||||
|
||||
private func stopPolling() {
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = nil
|
||||
}
|
||||
|
||||
private func captureIfNeeded(force: Bool) {
|
||||
guard state.clipboardHistoryEnabled else {
|
||||
clearSuggestion(persistDismiss: false)
|
||||
return
|
||||
}
|
||||
guard hasFullAccessProvider() else { return }
|
||||
guard !isSecureProvider() else { return }
|
||||
|
||||
let pasteboard = UIPasteboard.general
|
||||
let changeCount = pasteboard.changeCount
|
||||
if !force, changeCount == history.lastObservedChangeCount {
|
||||
refreshSuggestionFromStore()
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer hasStrings peek before reading body (reduces empty reads).
|
||||
guard pasteboard.hasStrings else {
|
||||
history.lastObservedChangeCount = changeCount
|
||||
refreshSuggestionFromStore()
|
||||
return
|
||||
}
|
||||
|
||||
let raw = pasteboard.string
|
||||
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)
|
||||
return
|
||||
}
|
||||
let changeCount = newest.changeCount ?? history.lastObservedChangeCount
|
||||
guard history.shouldShowSuggestion(
|
||||
forChangeCount: changeCount,
|
||||
candidateBarEnabled: state.clipboardCandidateBarEnabled,
|
||||
historyEnabled: state.clipboardHistoryEnabled
|
||||
) else {
|
||||
clearSuggestion(persistDismiss: false)
|
||||
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
|
||||
}
|
||||
state.clipboardSuggestionText = newest.text
|
||||
state.clipboardSuggestionChangeCount = changeCount
|
||||
}
|
||||
|
||||
private func updateSuggestion(with entry: ClipboardHistoryEntry, changeCount: Int) {
|
||||
guard state.clipboardCandidateBarEnabled else {
|
||||
clearSuggestion(persistDismiss: false)
|
||||
return
|
||||
}
|
||||
// Already used/dismissed this pasteboard generation — keep it hidden.
|
||||
if history.suggestionDismissedChangeCount == changeCount {
|
||||
clearSuggestion(persistDismiss: false)
|
||||
return
|
||||
}
|
||||
state.clipboardSuggestionText = entry.text
|
||||
state.clipboardSuggestionChangeCount = changeCount
|
||||
}
|
||||
|
||||
private func clearSuggestion(persistDismiss: Bool) {
|
||||
if persistDismiss {
|
||||
// already written in dismissSuggestion
|
||||
}
|
||||
state.clipboardSuggestionText = nil
|
||||
state.clipboardSuggestionChangeCount = nil
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import OSGKeyboardShared
|
||||
|
||||
@MainActor
|
||||
final class KeyboardTextInserter {
|
||||
/// Hosts truncate `documentContextBeforeInput` (often to the current
|
||||
/// paragraph), so a long insertion can only ever be matched by its tail.
|
||||
private static let caretVerificationLimit = 80
|
||||
|
||||
private let state: KeyboardState
|
||||
private let insertText: (String) -> Void
|
||||
private let deleteBackward: () -> Void
|
||||
@@ -18,9 +22,10 @@ final class KeyboardTextInserter {
|
||||
private let selectedText: () -> String?
|
||||
private let scheduleAutoClearError: () -> Void
|
||||
|
||||
/// Exact string last inserted by voice (including any word-boundary
|
||||
/// separator). Cleared after a successful undo or when the caret no
|
||||
/// longer sits after that text.
|
||||
/// Exact string last inserted through this inserter — dictation, AI answer,
|
||||
/// edit result or clipboard paste (including any word-boundary separator).
|
||||
/// Cleared after a successful undo or when the caret no longer sits after
|
||||
/// that text.
|
||||
private var lastInsertedText: String?
|
||||
/// Text captured when the last insertion was undone, so redo can
|
||||
/// re-apply it. Cleared by any new insertion or external edit.
|
||||
@@ -80,6 +85,7 @@ final class KeyboardTextInserter {
|
||||
)
|
||||
let inserted = separator + trimmed
|
||||
insertText(inserted)
|
||||
state.noteUserDidInputText()
|
||||
recordLastInsertion(
|
||||
inserted,
|
||||
displayText: trimmed,
|
||||
@@ -111,6 +117,7 @@ final class KeyboardTextInserter {
|
||||
)
|
||||
let inserted = separator + trimmed
|
||||
insertText(inserted)
|
||||
state.noteUserDidInputText()
|
||||
|
||||
let mutation = HistoryMutation(
|
||||
action: .append,
|
||||
@@ -134,13 +141,28 @@ final class KeyboardTextInserter {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Roll back the last voice insertion when it is still at the caret.
|
||||
/// Insert clipboard text verbatim and make it undoable. Pasted text is not
|
||||
/// a dictation result: it never becomes an editable "last input" reference
|
||||
/// and never reaches the history outbox, so it only takes the undo record.
|
||||
func insertPasteboardText(_ text: String) {
|
||||
guard !text.isEmpty else { return }
|
||||
// Verbatim on purpose — paste must reproduce exactly what was copied,
|
||||
// unlike dictation which needs word-boundary hygiene.
|
||||
insertText(text)
|
||||
recordUndoableInsertion(text)
|
||||
// The paste pushed any previous input away from the caret, so the
|
||||
// "editable last input" hint no longer applies.
|
||||
clearEditHintIfPositive()
|
||||
OSGLog.keyboardExt.info("clipboard insert length=\(text.count, privacy: .public)")
|
||||
}
|
||||
|
||||
/// Roll back the last insertion when it is still at the caret.
|
||||
func undoLastInsertion() {
|
||||
if undoLastEditIfPossible() {
|
||||
return
|
||||
}
|
||||
guard let text = lastInsertedText, !text.isEmpty else { return }
|
||||
guard let preceding = contextBeforeInput(), preceding.hasSuffix(text) else {
|
||||
guard caretSitsAfter(text) else {
|
||||
clearLastInsertion()
|
||||
return
|
||||
}
|
||||
@@ -148,6 +170,9 @@ final class KeyboardTextInserter {
|
||||
isUndoing = true
|
||||
defer { isUndoing = false }
|
||||
|
||||
// `UITextDocumentProxy` has no ranged delete, so the whole insertion is
|
||||
// walked back one grapheme at a time. Staying synchronous keeps it in a
|
||||
// single run-loop turn, which the host coalesces into one visual update.
|
||||
for _ in 0..<text.count {
|
||||
deleteBackward()
|
||||
}
|
||||
@@ -157,11 +182,11 @@ final class KeyboardTextInserter {
|
||||
redoContextBefore = contextBeforeInput()
|
||||
lastInsertedText = nil
|
||||
state.undoAvailable = false
|
||||
OSGLog.keyboardExt.info("voice undo length=\(text.count, privacy: .public)")
|
||||
OSGLog.keyboardExt.info("undo length=\(text.count, privacy: .public)")
|
||||
}
|
||||
|
||||
/// Re-apply the last undone voice insertion when it is still absent from
|
||||
/// the caret (i.e. the undo was not overwritten by an external edit).
|
||||
/// Re-apply the last undone insertion when it is still absent from the
|
||||
/// caret (i.e. the undo was not overwritten by an external edit).
|
||||
func redoLastInsertion() {
|
||||
guard let text = redoText, !text.isEmpty,
|
||||
contextBeforeInput() == redoContextBefore else {
|
||||
@@ -170,17 +195,14 @@ final class KeyboardTextInserter {
|
||||
state.redoAvailable = false
|
||||
return
|
||||
}
|
||||
guard let preceding = contextBeforeInput(), !preceding.hasSuffix(text) else {
|
||||
guard !caretSitsAfter(text) else {
|
||||
redoText = nil
|
||||
state.redoAvailable = false
|
||||
return
|
||||
}
|
||||
insertText(text)
|
||||
lastInsertedText = text
|
||||
redoText = nil
|
||||
redoContextBefore = nil
|
||||
state.undoAvailable = true
|
||||
OSGLog.keyboardExt.info("voice redo length=\(text.count, privacy: .public)")
|
||||
recordUndoableInsertion(text)
|
||||
OSGLog.keyboardExt.info("redo length=\(text.count, privacy: .public)")
|
||||
}
|
||||
|
||||
/// Copy the host field's current selection to the pasteboard. Needs Full
|
||||
@@ -207,7 +229,7 @@ final class KeyboardTextInserter {
|
||||
// `deleteBackward`); the undo method manages availability itself.
|
||||
if !isUndoing {
|
||||
if let text = lastInsertedText, !text.isEmpty {
|
||||
let available = contextBeforeInput()?.hasSuffix(text) == true
|
||||
let available = caretSitsAfter(text)
|
||||
if !available {
|
||||
// Caret moved or the user edited the insertion — drop it.
|
||||
lastInsertedText = nil
|
||||
@@ -345,7 +367,6 @@ final class KeyboardTextInserter {
|
||||
HistoryMutationOutbox.enqueue(mutation)
|
||||
transaction.phase = .committed
|
||||
PendingTextEditTransactionStore.save(transaction)
|
||||
lastEditUndo = transaction
|
||||
recordLastInsertion(
|
||||
inserted,
|
||||
displayText: result,
|
||||
@@ -355,6 +376,9 @@ final class KeyboardTextInserter {
|
||||
: 0,
|
||||
pendingHistoryMutationID: mutation.id
|
||||
)
|
||||
// Set after recording: the shared bookkeeping drops any stale
|
||||
// transaction, and this one must survive as the undo target.
|
||||
lastEditUndo = transaction
|
||||
PendingTextEditTransactionStore.clear()
|
||||
return true
|
||||
}
|
||||
@@ -389,12 +413,11 @@ final class KeyboardTextInserter {
|
||||
}
|
||||
|
||||
private func undoLastEditIfPossible() -> Bool {
|
||||
guard let transaction = lastEditUndo,
|
||||
let preceding = contextBeforeInput() else {
|
||||
guard let transaction = lastEditUndo else {
|
||||
return false
|
||||
}
|
||||
let insertedAfter = lastInsertedText ?? transaction.afterText
|
||||
guard preceding.hasSuffix(insertedAfter) else {
|
||||
guard caretSitsAfter(insertedAfter) else {
|
||||
lastEditUndo = nil
|
||||
return false
|
||||
}
|
||||
@@ -436,6 +459,38 @@ final class KeyboardTextInserter {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Is the caret still sitting right after `text`?
|
||||
///
|
||||
/// The comparison is limited to the tail of the insertion's last line: a
|
||||
/// truncated host context can never contain the earlier part, and text
|
||||
/// before a line break is usually stripped from `documentContextBeforeInput`.
|
||||
private func caretSitsAfter(_ text: String) -> Bool {
|
||||
guard let preceding = contextBeforeInput() else { return false }
|
||||
let lastLine: String
|
||||
if let breakRange = text.rangeOfCharacter(from: .newlines, options: .backwards) {
|
||||
lastLine = String(text[breakRange.upperBound...])
|
||||
} else {
|
||||
lastLine = text
|
||||
}
|
||||
let expected = String(lastLine.suffix(Self.caretVerificationLimit))
|
||||
// The insertion ended on a line break, so nothing measurable is left
|
||||
// before the caret — an empty context is the expected state.
|
||||
guard !expected.isEmpty else { return preceding.isEmpty }
|
||||
return preceding.hasSuffix(expected)
|
||||
}
|
||||
|
||||
/// Undo bookkeeping shared by every insertion path. Callers that also own
|
||||
/// history / editable-reference state layer `recordLastInsertion` on top.
|
||||
private func recordUndoableInsertion(_ text: String) {
|
||||
lastInsertedText = text
|
||||
redoText = nil
|
||||
redoContextBefore = nil
|
||||
// A newer insertion supersedes any edit transaction: undo must roll back
|
||||
// this text, not re-apply the original text of an older edit.
|
||||
lastEditUndo = nil
|
||||
state.undoAvailable = true
|
||||
}
|
||||
|
||||
private func recordLastInsertion(
|
||||
_ text: String,
|
||||
displayText: String,
|
||||
@@ -443,10 +498,7 @@ final class KeyboardTextInserter {
|
||||
historyEntryRevision: Int64?,
|
||||
pendingHistoryMutationID: UUID?
|
||||
) {
|
||||
lastInsertedText = text
|
||||
redoText = nil
|
||||
redoContextBefore = nil
|
||||
state.undoAvailable = true
|
||||
recordUndoableInsertion(text)
|
||||
EditableInputReferenceStore.save(
|
||||
EditableInputReference(
|
||||
historyEntryID: historyEntryID,
|
||||
@@ -474,6 +526,14 @@ final class KeyboardTextInserter {
|
||||
}
|
||||
}
|
||||
|
||||
private func clearEditHintIfPositive() {
|
||||
guard state.editHintIsPositive else { return }
|
||||
editHintTask?.cancel()
|
||||
editHintTask = nil
|
||||
state.editHint = nil
|
||||
state.editHintIsPositive = false
|
||||
}
|
||||
|
||||
private func clearLastInsertion() {
|
||||
lastInsertedText = nil
|
||||
state.undoAvailable = false
|
||||
|
||||
@@ -7,12 +7,18 @@ import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct KeyboardSurfaceRoot: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@ObservedObject var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
|
||||
var onInsert: (String) -> Void
|
||||
var onDeleteBackward: () -> Void
|
||||
|
||||
private var palette: ThemePalette {
|
||||
colorScheme == .dark ? Palette.dark : Palette.light
|
||||
}
|
||||
|
||||
/// Height is deliberately independent of the surface: the voice surface
|
||||
/// adopts the typing surface's content-driven height and parks the surplus
|
||||
/// above its action cluster, so switching surfaces never resizes the
|
||||
@@ -26,29 +32,42 @@ struct KeyboardSurfaceRoot: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch state.surface {
|
||||
case .voice:
|
||||
KeyboardRootView(
|
||||
state: state,
|
||||
typing: typing,
|
||||
onInsert: onInsert
|
||||
)
|
||||
case .typing:
|
||||
TypingRootView(
|
||||
state: state,
|
||||
typing: typing,
|
||||
onInsert: onInsert,
|
||||
onDeleteBackward: onDeleteBackward
|
||||
)
|
||||
case .ai:
|
||||
AIKeyboardView(
|
||||
state: state,
|
||||
typing: typing,
|
||||
onInsert: onInsert
|
||||
)
|
||||
ZStack {
|
||||
Group {
|
||||
switch state.surface {
|
||||
case .voice:
|
||||
KeyboardRootView(
|
||||
state: state,
|
||||
typing: typing,
|
||||
onInsert: wrappedInsert
|
||||
)
|
||||
case .typing:
|
||||
TypingRootView(
|
||||
state: state,
|
||||
typing: typing,
|
||||
onInsert: wrappedInsert,
|
||||
onDeleteBackward: wrappedDeleteBackward
|
||||
)
|
||||
case .ai:
|
||||
AIKeyboardView(
|
||||
state: state,
|
||||
typing: typing,
|
||||
onInsert: wrappedInsert
|
||||
)
|
||||
}
|
||||
}
|
||||
.opacity(state.clipboardOverlay == .none ? 1 : 0)
|
||||
.allowsHitTesting(state.clipboardOverlay == .none)
|
||||
|
||||
// Match every surface's outer chrome so the panel title / X sit in
|
||||
// the same slot as the logo + clipboard chip (not 4 pt higher).
|
||||
clipboardOverlayLayer
|
||||
.padding(.top, TypingSurfaceMetrics.outerPaddingTop)
|
||||
.padding(.bottom, TypingSurfaceMetrics.outerPaddingBottom)
|
||||
}
|
||||
// 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)
|
||||
.animation(.easeInOut(duration: 0.15), value: state.surface)
|
||||
.onChange(of: state.surface) { _, newSurface in
|
||||
if newSurface != .typing {
|
||||
@@ -56,4 +75,41 @@ struct KeyboardSurfaceRoot: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func wrappedInsert(_ text: String) {
|
||||
if !text.isEmpty {
|
||||
state.noteUserDidInputText()
|
||||
}
|
||||
onInsert(text)
|
||||
}
|
||||
|
||||
private func wrappedDeleteBackward() {
|
||||
state.noteUserDidInputText()
|
||||
onDeleteBackward()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var clipboardOverlayLayer: some View {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,16 @@ struct TypingRootView: View {
|
||||
@ViewBuilder
|
||||
private var topRegion: some View {
|
||||
if hasCandidateContent {
|
||||
// Composing Chinese/English candidates hide the clipboard strip.
|
||||
candidateBar
|
||||
} else if let suggestion = state.clipboardSuggestionText, !suggestion.isEmpty {
|
||||
// Same slot as logo + capsule tabs — hide chrome until dismissed.
|
||||
ClipboardSuggestionBar(
|
||||
text: suggestion,
|
||||
onInsert: { state.insertClipboardText(suggestion) },
|
||||
onDismiss: state.dismissClipboardSuggestion
|
||||
)
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
} else {
|
||||
idleTopBar
|
||||
}
|
||||
|
||||
@@ -56,17 +56,31 @@ struct AIKeyboardView: View {
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var topBar: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
Spacer(minLength: 0)
|
||||
if state.canCancelAIInput {
|
||||
if state.canCancelAIInput {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
Spacer(minLength: 0)
|
||||
KeyboardCancelButton(
|
||||
action: state.cancelAIInput,
|
||||
accessibilityLabel: ExtL10n.text("keyboard.ai.cancel"),
|
||||
accessibilityHint: ExtL10n.text("keyboard.ai.cancelHint")
|
||||
)
|
||||
} else {
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
} else if let suggestion = state.clipboardSuggestionText, !suggestion.isEmpty {
|
||||
// Replaces logo + capsule tabs until dismissed.
|
||||
ClipboardSuggestionBar(
|
||||
text: suggestion,
|
||||
onInsert: { state.insertClipboardText(suggestion) },
|
||||
onDismiss: state.dismissClipboardSuggestion
|
||||
)
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
} else {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
Spacer(minLength: 0)
|
||||
KeyboardTopControls(
|
||||
state: state,
|
||||
typing: typing,
|
||||
@@ -74,8 +88,8 @@ struct AIKeyboardView: View {
|
||||
onInsert: onInsert
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
}
|
||||
|
||||
private var answerArea: some View {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// ClipboardKeyboardViews.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Clipboard suggestion strip, enable-guide sheet, and history panel.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
// MARK: - Suggestion strip (Doubao-style)
|
||||
|
||||
struct ClipboardSuggestionBar: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let text: String
|
||||
let onInsert: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "clipboard")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
Button(action: onInsert) {
|
||||
Text(text)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
KeyboardCancelButton(
|
||||
action: onDismiss,
|
||||
accessibilityLabel: ExtL10n.text("keyboard.clipboard.suggestion.dismissA11y"),
|
||||
accessibilityHint: ExtL10n.text("keyboard.clipboard.suggestion.dismissHint")
|
||||
)
|
||||
}
|
||||
.frame(height: KeyboardTopBarMetrics.height)
|
||||
// No fill — sit in the logo/tab slot over the system keyboard chrome.
|
||||
.background(Color.clear)
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared panel header
|
||||
|
||||
/// Title (+ optional accessory) on the leading edge, cancel X on the trailing
|
||||
/// edge — same 12 pt inset / 44 pt row as the keyboard top bar so the X lands
|
||||
/// on the clipboard chip's slot when the overlay replaces the surface.
|
||||
private struct ClipboardPanelHeader<Accessory: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let onClose: () -> Void
|
||||
@ViewBuilder let trailingAccessory: () -> Accessory
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
ExtL10n.text("keyboard.clipboard.panel.title")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
|
||||
trailingAccessory()
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Same chip as edit-mode close — occupies the clipboard button slot.
|
||||
KeyboardCancelButton(
|
||||
action: onClose,
|
||||
accessibilityLabel: ExtL10n.text("keyboard.clipboard.panel.close"),
|
||||
accessibilityHint: ExtL10n.text("keyboard.clipboard.panel.closeHint")
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.horizontalInset)
|
||||
.frame(height: KeyboardTopBarMetrics.height)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enable guide
|
||||
|
||||
struct ClipboardEnableGuideView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let onClose: () -> Void
|
||||
let onOpenSettings: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ClipboardPanelHeader(
|
||||
onClose: onClose,
|
||||
trailingAccessory: { EmptyView() }
|
||||
)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
ExtL10n.text("keyboard.clipboard.guide.body")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 28)
|
||||
|
||||
Button(action: onOpenSettings) {
|
||||
ExtL10n.text("keyboard.clipboard.guide.cta")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(palette.accent, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - History panel
|
||||
|
||||
struct ClipboardHistoryPanelView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject var history: ClipboardHistoryStore
|
||||
|
||||
let onClose: () -> Void
|
||||
let onClear: () -> Void
|
||||
let onInsert: (String) -> Void
|
||||
let onDelete: (UUID) -> Void
|
||||
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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// Transparent — let the system keyboard chrome show through.
|
||||
.background(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ClipboardHistoryRow: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let entry: ClipboardHistoryEntry
|
||||
let onInsert: () -> Void
|
||||
let onInsertToken: (String) -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
private var tokens: [String] {
|
||||
ClipboardHistoryPolicy.whitespaceTokens(from: entry.text)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Button(action: onInsert) {
|
||||
Text(entry.text)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(3)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Menu {
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Label(
|
||||
ExtL10n.string("keyboard.clipboard.panel.delete"),
|
||||
systemImage: "trash"
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(width: 28, height: 28)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
if tokens.count >= 2, tokens.count <= 12 {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(tokens, id: \.self) { token in
|
||||
Button {
|
||||
onInsertToken(token)
|
||||
} label: {
|
||||
Text(token)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
palette.surfaceElevated,
|
||||
in: Capsule()
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
// Half opacity so the keyboard chrome still reads through the card.
|
||||
.background(
|
||||
palette.surface.opacity(0.5),
|
||||
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Top clipboard button (replaces translation chip slot)
|
||||
|
||||
struct KeyboardClipboardMenuButton: View, Equatable {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let palette: ThemePalette
|
||||
let action: () -> Void
|
||||
|
||||
nonisolated static func == (
|
||||
lhs: KeyboardClipboardMenuButton,
|
||||
rhs: KeyboardClipboardMenuButton
|
||||
) -> Bool {
|
||||
lhs.palette == rhs.palette
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
// Neutral chip — mirrors the translation button's off state.
|
||||
Image(systemName: "clipboard")
|
||||
.font(.system(size: KeyboardTopBarMetrics.trailingChipIconSize, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(
|
||||
width: KeyboardTopBarMetrics.trailingChipSize,
|
||||
height: KeyboardTopBarMetrics.trailingChipSize
|
||||
)
|
||||
.background(buttonFill, in: Circle())
|
||||
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.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
|
||||
}
|
||||
}
|
||||
@@ -215,26 +215,41 @@ public struct KeyboardRootView: View {
|
||||
// MARK: - Top bar
|
||||
|
||||
private var topBar: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
// Globe key now lives at the bottom-left of the keyboard (matching
|
||||
// iOS system layout); see micActionRow's bottom HStack.
|
||||
// Engine controls remain available in the host app.
|
||||
// App context is auto-detected on each mic press — no UI.
|
||||
Spacer(minLength: 0)
|
||||
Group {
|
||||
if state.canCancelVoiceInput {
|
||||
KeyboardCancelButton(
|
||||
action: state.cancelVoiceInput,
|
||||
accessibilityLabel: ExtL10n.text("keyboard.voice.cancel"),
|
||||
accessibilityHint: ExtL10n.text("keyboard.voice.cancelHint")
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
Spacer(minLength: 0)
|
||||
KeyboardCancelButton(
|
||||
action: state.cancelVoiceInput,
|
||||
accessibilityLabel: ExtL10n.text("keyboard.voice.cancel"),
|
||||
accessibilityHint: ExtL10n.text("keyboard.voice.cancelHint")
|
||||
)
|
||||
}
|
||||
} else if shouldShowClipboardSuggestion {
|
||||
// Occupies the logo + capsule-tab slot until dismissed.
|
||||
ClipboardSuggestionBar(
|
||||
text: state.clipboardSuggestionText ?? "",
|
||||
onInsert: {
|
||||
if let text = state.clipboardSuggestionText {
|
||||
state.insertClipboardText(text)
|
||||
}
|
||||
},
|
||||
onDismiss: state.dismissClipboardSuggestion
|
||||
)
|
||||
} else {
|
||||
KeyboardTopControls(
|
||||
state: state,
|
||||
typing: typing,
|
||||
palette: palette,
|
||||
onInsert: onInsert
|
||||
)
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
// Globe key now lives at the bottom-left of the keyboard (matching
|
||||
// iOS system layout); see micActionRow's bottom HStack.
|
||||
Spacer(minLength: 0)
|
||||
KeyboardTopControls(
|
||||
state: state,
|
||||
typing: typing,
|
||||
palette: palette,
|
||||
onInsert: onInsert
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.horizontalInset)
|
||||
@@ -269,6 +284,10 @@ public struct KeyboardRootView: View {
|
||||
disabled: editingBlocked || !state.undoAvailable,
|
||||
visible: undoVisible
|
||||
)
|
||||
} else {
|
||||
// Right-handed undo is on the trailing pad; put
|
||||
// translation on the leading (mic-left) side.
|
||||
translationButton(visible: undoVisible)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +316,10 @@ public struct KeyboardRootView: View {
|
||||
disabled: editingBlocked || !state.undoAvailable,
|
||||
visible: undoVisible
|
||||
)
|
||||
} else {
|
||||
// Default: translation sits on the mic's right,
|
||||
// symmetric with undo on the left.
|
||||
translationButton(visible: undoVisible)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,6 +439,29 @@ public struct KeyboardRootView: View {
|
||||
.accessibilityHidden(!visible)
|
||||
}
|
||||
|
||||
/// Translation chip relocated from the top bar — mirrors undo across the mic.
|
||||
private func translationButton(visible: Bool) -> some View {
|
||||
KeyboardTranslationMenuButton(
|
||||
palette: palette,
|
||||
targetLocaleId: state.translationTargetLocaleId,
|
||||
onSelect: state.setTranslationTargetLocaleId
|
||||
)
|
||||
.equatable()
|
||||
.frame(
|
||||
width: KeyboardLayoutMetrics.undoButtonSize,
|
||||
height: KeyboardLayoutMetrics.undoButtonSize
|
||||
)
|
||||
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
|
||||
.opacity(visible ? 1 : 0)
|
||||
.allowsHitTesting(visible)
|
||||
.accessibilityHidden(!visible)
|
||||
}
|
||||
|
||||
private var shouldShowClipboardSuggestion: Bool {
|
||||
guard let text = state.clipboardSuggestionText, !text.isEmpty else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private func bottomSpaceButton(disabled: Bool) -> some View {
|
||||
RectangularToolbarButton(
|
||||
spaceStyle: true,
|
||||
|
||||
@@ -14,6 +14,9 @@ enum KeyboardTopBarMetrics {
|
||||
static let nestedHorizontalInset: CGFloat = horizontalInset - KeyboardChromeLayout.horizontalInset
|
||||
static let logoHeight: CGFloat = 22
|
||||
static let logoWidth: CGFloat = logoHeight * 952 / 291
|
||||
/// Shared footprint for top-trailing chips (clipboard, cancel/X, translation).
|
||||
static let trailingChipSize: CGFloat = 34
|
||||
static let trailingChipIconSize: CGFloat = 15
|
||||
}
|
||||
|
||||
struct KeyboardBrandLogo: View {
|
||||
@@ -49,17 +52,17 @@ struct KeyboardCancelButton: View {
|
||||
let accessibilityHint: Text
|
||||
|
||||
var body: some View {
|
||||
// Same 34×34 chip as KeyboardClipboardMenuButton / translation.
|
||||
Button(action: action) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.font(.system(size: KeyboardTopBarMetrics.trailingChipIconSize, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(buttonFill, in: Circle())
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
.frame(
|
||||
width: KeyboardTopBarMetrics.trailingChipSize,
|
||||
height: KeyboardTopBarMetrics.trailingChipSize
|
||||
)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(buttonFill, in: Circle())
|
||||
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -135,13 +138,10 @@ struct KeyboardTopControls: View {
|
||||
.padding(2)
|
||||
.background(trackFill, in: Capsule())
|
||||
|
||||
KeyboardTranslationMenuButton(
|
||||
KeyboardClipboardMenuButton(
|
||||
palette: palette,
|
||||
targetLocaleId: state.translationTargetLocaleId,
|
||||
onSelect: state.setTranslationTargetLocaleId
|
||||
action: state.openClipboardPanel
|
||||
)
|
||||
// Decouple the open picker from the keyboard's 1 Hz App Group
|
||||
// poll so scrolling does not reset or dismiss the menu.
|
||||
.equatable()
|
||||
}
|
||||
}
|
||||
@@ -219,7 +219,7 @@ struct KeyboardTopControls: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct KeyboardTranslationMenuButton: View, Equatable {
|
||||
struct KeyboardTranslationMenuButton: View, Equatable {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let palette: ThemePalette
|
||||
@@ -251,29 +251,28 @@ private struct KeyboardTranslationMenuButton: View, Equatable {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isEnabled ? "character.bubble.fill" : "character.bubble")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(isEnabled ? palette.accent : palette.textSecondary)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(buttonFill, in: Circle())
|
||||
.overlay(Circle().stroke(buttonStroke, lineWidth: 0.5))
|
||||
// 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
|
||||
) {
|
||||
Image(systemName: isEnabled ? "character.bubble.fill" : "character.bubble")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
isEnabled
|
||||
? palette.accent
|
||||
: NativeKeyboardKeyColors.text(for: colorScheme)
|
||||
)
|
||||
}
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
|
||||
.accessibilityHint(Text(SharedL10n.string("keyboard.translation.a11yHint")))
|
||||
}
|
||||
|
||||
private var buttonFill: Color {
|
||||
if isEnabled {
|
||||
return palette.accent.opacity(colorScheme == .dark ? 0.28 : 0.16)
|
||||
}
|
||||
return colorScheme == .dark ? Color(white: 0.30) : .white
|
||||
}
|
||||
|
||||
private var buttonStroke: Color {
|
||||
isEnabled ? palette.accent.opacity(0.35) : palette.divider
|
||||
}
|
||||
|
||||
private func displayLabel(for language: TranslationLanguage) -> String {
|
||||
if language.id == TranslationLanguageCatalog.offLocaleId {
|
||||
return SharedL10n.string("keyboard.translation.offMenu")
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
"keyboard.openSettingsA11y" = "Open OSGKeyboard settings";
|
||||
"keyboard.deniedHint" = "Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access.";
|
||||
"keyboard.tapToTalkA11y" = "Tap to talk";
|
||||
"keyboard.undoA11y" = "Undo last dictation";
|
||||
"keyboard.undoA11y" = "Undo last input";
|
||||
"keyboard.redoA11y" = "Redo";
|
||||
"keyboard.copyA11y" = "Copy";
|
||||
"keyboard.cutA11y" = "Cut";
|
||||
@@ -199,6 +199,17 @@
|
||||
"keyboard.translation.disable" = "Disable translation";
|
||||
"keyboard.translation.a11y" = "Translation";
|
||||
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
|
||||
"keyboard.clipboard.a11y" = "Clipboard";
|
||||
"keyboard.clipboard.a11yHint" = "Open clipboard history or enable clipboard capture.";
|
||||
"keyboard.clipboard.guide.body" = "Turn on clipboard history in Settings first.";
|
||||
"keyboard.clipboard.guide.cta" = "Open Settings";
|
||||
"keyboard.clipboard.panel.title" = "Clipboard";
|
||||
"keyboard.clipboard.panel.empty" = "No history yet";
|
||||
"keyboard.clipboard.panel.delete" = "Delete";
|
||||
"keyboard.clipboard.panel.close" = "Close clipboard";
|
||||
"keyboard.clipboard.panel.closeHint" = "Return to the keyboard.";
|
||||
"keyboard.clipboard.suggestion.dismissA11y" = "Dismiss clipboard suggestion";
|
||||
"keyboard.clipboard.suggestion.dismissHint" = "Hide this clipboard suggestion strip.";
|
||||
"keyboard.voice.cancel" = "Cancel voice input";
|
||||
"keyboard.voice.cancelHint" = "Discard the current recording, recognition, and polish result.";
|
||||
"keyboard.scenario.a11y" = "Polish scenario";
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
"keyboard.openSettingsA11y" = "打开 OSGKeyboard 设置";
|
||||
"keyboard.deniedHint" = "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。";
|
||||
"keyboard.tapToTalkA11y" = "点按说话";
|
||||
"keyboard.undoA11y" = "撤销上次听写";
|
||||
"keyboard.undoA11y" = "撤销上次输入";
|
||||
"keyboard.redoA11y" = "重做";
|
||||
"keyboard.copyA11y" = "拷贝";
|
||||
"keyboard.cutA11y" = "剪切";
|
||||
@@ -199,6 +199,17 @@
|
||||
"keyboard.translation.disable" = "关闭翻译";
|
||||
"keyboard.translation.a11y" = "翻译";
|
||||
"keyboard.translation.a11yHint" = "切换翻译开关或修改目标语言。";
|
||||
"keyboard.clipboard.a11y" = "剪贴板";
|
||||
"keyboard.clipboard.a11yHint" = "查看剪贴板历史或开启剪贴板功能。";
|
||||
"keyboard.clipboard.guide.body" = "请先在设置中开启剪贴板历史。";
|
||||
"keyboard.clipboard.guide.cta" = "去设置开启";
|
||||
"keyboard.clipboard.panel.title" = "剪贴板";
|
||||
"keyboard.clipboard.panel.empty" = "暂无历史记录";
|
||||
"keyboard.clipboard.panel.delete" = "删除";
|
||||
"keyboard.clipboard.panel.close" = "关闭剪贴板";
|
||||
"keyboard.clipboard.panel.closeHint" = "返回键盘输入界面。";
|
||||
"keyboard.clipboard.suggestion.dismissA11y" = "关闭剪贴板建议";
|
||||
"keyboard.clipboard.suggestion.dismissHint" = "隐藏本次剪贴板建议条。";
|
||||
"keyboard.voice.cancel" = "取消本次语音输入";
|
||||
"keyboard.voice.cancelHint" = "放弃当前录音、识别和润色结果。";
|
||||
"keyboard.scenario.a11y" = "润色场景";
|
||||
|
||||
Reference in New Issue
Block a user