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:
Rocky
2026-08-11 02:49:54 +08:00
parent 3de665d254
commit fd6e0d3e7e
30 changed files with 1478 additions and 118 deletions
+45 -1
View File
@@ -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
+77 -21
View File
@@ -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
}
+20 -6
View File
@@ -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
}
}
+63 -17
View File
@@ -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,
+29 -30
View File
@@ -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")
+12 -1
View File
@@ -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";
+12 -1
View File
@@ -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" = "润色场景";