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
+12
View File
@@ -8,13 +8,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Clipboard history**: optional keyboard clipboard history (latest 15 plain-text items, App Group local), top-bar clipboard entry, history panel with whitespace token chips, and a suggestion strip across voice / typing / AI when enabled. / **剪贴板历史**:可选的键盘剪贴板历史(最近 15 条纯文本、App Group 本地),顶栏剪贴板入口、带空白分词芯片的历史面板,以及开启后在语音 / 打字 / AI 面显示的建议条。
- **Clipboard settings**: Settings home adds a Clipboard page under AI Agent with History and Suggestion-strip toggles (both default off); keyboard deep-links open that page. / **剪贴板设置**:设置首页在 AI Agent 下方新增「剪贴板」页,含历史记录与候选栏展示开关(均默认关闭);键盘可深链打开该页。
- **Paste permission guidance**: the Clipboard settings page explains iOS's durable “Paste from Other Apps” permission and opens iOS Settings directly, so users can set it to Allow once and stop the per-copy paste prompt. / **粘贴授权引导**:剪贴板设置页说明 iOS 的「从其他 App 粘贴」持久授权并可直接跳转系统设置,用户设为「允许」一次即不再每次复制都弹窗。
- **Clipboard settings copy**: shorten history, suggestion-strip, and paste-permission wording to brief user-facing lines. / **剪贴板设置文案**:精简历史、建议条与粘贴授权说明,改为简短面向用户的表述。
- **Settings summary placement**: navigation-row summaries (AI Agent, Clipboard, etc.) sit trailing before the chevron, matching the Version row. / **设置摘要位置**:导航行摘要(AI Agent、剪贴板等)移到右侧 chevron 前,与「版本」行一致。
- **AI answer streaming**: AI mode streams visible answer text into the keyboard as the model writes (all AI-mode transports), with throttled App Group updates, search-fallback draft restart, and a “thinking” status before the first token; dictation polish stays non-streaming. / **AI 回答流式输出**:AI 模式在模型开始写正文后将可见答案增量推送到键盘(覆盖全部 AI 传输路径),经 App Group 节流更新;搜索失败回退会清空半截草稿;首 token 前显示「思考中」;听写润色仍为整段返回。
- **AI Agent settings**: Settings home adds an AI Agent row under General, with a Response length preference (Short / Medium / Detailed, default Medium). AI mode injects soft length guidance into the system prompt and syncs the choice via iCloud settings. / **AI Agent 设置**:设置首页在「通用」下方新增 AI Agent 入口,支持「回复篇幅」(简短 / 中等 / 详细,默认中等)。AI 模式将篇幅作为软约束写入 system prompt,并纳入 iCloud 设置同步。
### Changed
- **Clipboard history row opacity**: history panel entry cards use 50% surface opacity so the keyboard chrome shows through. / **剪贴板历史条目透明度**:历史面板每条记录背景改为 50% 透明度,键盘底色可透出。
- **Translation button chrome**: voice mic-row translation control matches the adjacent undo key (44×44 rounded rectangle) instead of a circular chip. / **翻译按钮样式**:语音麦克风行的翻译控件改为与相邻撤销键一致的 44×44 圆角矩形,不再使用圆形芯片。
- **Undo covers clipboard pastes**: the undo key now rolls back text inserted from the clipboard suggestion strip or history panel, in one step regardless of length. Pasted text stays out of dictation history and is never offered for last-input editing. / **撤销支持剪贴板粘贴**:撤销键现在可回滚从剪贴板建议条或历史面板插入的文字,无论长度都一次撤销到底。粘贴内容不进入听写历史,也不会成为「编辑上次输入」的对象。
- **Undo key label**: rename the accessibility label from “Undo last dictation” to “Undo last input” now that it covers dictation, AI answers, edits and pastes. / **撤销键文案**:无障碍标签由「撤销上次听写」改为「撤销上次输入」,因其现已覆盖听写、AI 答案、编辑与粘贴。
- **Translation chip placement**: move the top-bar translation control onto the voice mic row, mirrored with Undo; the top-bar slot becomes the clipboard button. / **翻译入口位置**:顶栏翻译控件移到语音麦克风行并与撤销对称;原顶栏位置改为剪贴板按钮。
- **AI empty-state tip**: center “Tap the microphone to ask AI” in the answer area (horizontal + vertical). / **AI 空状态指引**:「点击麦克风向 AI 提问」在答案区域水平与垂直居中。
### Fixed
- **Undo of long insertions**: caret verification now compares the tail of the inserted text's last line instead of the whole string, so long or multi-line insertions no longer lose undo the moment the host returns a truncated context. / **长文本撤销**:光标校验改为比对插入文本最后一行的尾部而非整段,长文本或多行插入不再因宿主返回截断上下文而立刻失去撤销能力。
- **Undo after editing last input**: a newer insertion now clears the pending edit transaction, so undo rolls back that insertion instead of deleting it and restoring the older edit's original text. / **编辑上次输入后的撤销**:新的插入会清除待撤销的编辑事务,撤销将回滚该次插入,而不再是删除它并还原上一次编辑前的旧文本。
- **AI waiting spinner duplicate**: remove the mini ProgressView beside the AI status caption; the mic button spinner remains the sole loading indicator while recognizing or generating. / **AI 等待转圈重复**:去掉 AI 状态文案旁的迷你 ProgressView;识别/生成中仅保留麦克风按钮上的 loading。
- **AI stream UTF-8 mojibake**: SSE framing now accumulates raw bytes and decodes each line as UTF-8, so Chinese AI answers (e.g. weather) no longer appear as Latin-1 garbage like `今天…`. / **AI 流式 UTF-8 乱码**:SSE 行缓冲改为累积原始字节并以 UTF-8 解码,中文 AI 回答(如天气)不再显示为 `今天…` 一类 Latin-1 乱码。
- **Local ASR dictionary correction**: apply deterministic personal-dictionary alias correction before iOS Flow branches into dictation polish or AI question handling, so AI mode keeps local transcript optimization while still skipping cloud LLM polish. / **本地 ASR 词库纠错**:在 iOS Flow 分流到听写润色或 AI 问答前统一应用个人词库别名确定性纠错,使 AI 模式保留本地转写优化,同时继续跳过云端 LLM 润色。
+6
View File
@@ -176,6 +176,12 @@ struct MainAppRoot: View {
// The keyboard sends the user here precisely because typing
// resources are missing deploy without waiting for warmup.
RimeDeploymentController.shared.deployNow(reason: "url.deployrime")
case "settings":
// Path may be `settings/clipboard` (host = settings, path = /clipboard).
if url.path.contains("clipboard") {
SettingsDeepLink.setPending(.clipboard)
}
NotificationCenter.default.post(name: .osgOpenSettingsDeepLink, object: nil)
#if DEBUG
case "seed-demo":
DemoDataSeeder.seedRichPlaceholderData()
+3
View File
@@ -26,6 +26,9 @@ struct MainTabView: View {
}
.background(palette.background)
.ignoresSafeArea(.keyboard, edges: .bottom)
.onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in
tab = .settings
}
}
// MARK: - Phone layout
+109 -17
View File
@@ -8,7 +8,7 @@
import SwiftUI
import OSGKeyboardShared
// MARK: - Navigation row (title + optional summary subtitle)
// MARK: - Navigation row (title + optional trailing summary before chevron)
struct SettingsNavigationRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
@@ -32,24 +32,24 @@ struct SettingsNavigationRow: View {
var body: some View {
HStack(spacing: Spacing.sm) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
if let resolvedTitle {
Text(resolvedTitle)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
} else if let localizedTitle {
Text(localizedTitle)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.lineLimit(1)
}
if let resolvedTitle {
Text(resolvedTitle)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
} else if let localizedTitle {
Text(localizedTitle)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
Spacer(minLength: Spacing.xs)
// Trailing summary same slot as SettingsVersionRow's value.
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.trailing)
.lineLimit(1)
}
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
@@ -306,6 +306,98 @@ struct AIAgentSettingsView: View {
}
}
// MARK: - Clipboard
struct ClipboardSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
CardPageContent {
CardSection("settings.clipboard.section") {
VStack(spacing: 0) {
Toggle(isOn: $config.clipboardHistoryEnabled) {
VStack(alignment: .leading, spacing: 4) {
Text("settings.clipboard.history.title")
.foregroundStyle(palette.textPrimary)
Text("settings.clipboard.history.footer")
.font(.footnote)
.foregroundStyle(palette.textSecondary)
}
}
.tint(palette.accent)
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider().background(palette.divider)
Toggle(isOn: clipboardCandidateBinding) {
VStack(alignment: .leading, spacing: 4) {
Text("settings.clipboard.candidate.title")
.foregroundStyle(palette.textPrimary)
Text("settings.clipboard.candidate.footer")
.font(.footnote)
.foregroundStyle(palette.textSecondary)
}
}
.tint(palette.accent)
.disabled(!config.clipboardHistoryEnabled)
.opacity(config.clipboardHistoryEnabled ? 1 : 0.45)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.surfaceCard()
}
// iOS asks per read unless the user flips the durable
// "Paste from Other Apps" permission to Allow.
CardSection("settings.clipboard.paste.section") {
VStack(spacing: 0) {
Text("settings.clipboard.paste.body")
.font(.footnote)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider().background(palette.divider)
Button {
AppPermissions.openSystemSettings()
} label: {
SettingsNavigationRow(
titleText: AppL10n.string(
"settings.clipboard.paste.open",
language: config.uiLanguage
)
)
}
.buttonStyle(.plain)
}
.surfaceCard()
}
}
}
.background(palette.background.ignoresSafeArea())
.navigationTitle(AppL10n.string("settings.clipboard.title", language: config.uiLanguage))
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
.onChange(of: config.clipboardHistoryEnabled) { _, enabled in
if !enabled {
config.clipboardCandidateBarEnabled = false
}
}
}
private var clipboardCandidateBinding: Binding<Bool> {
Binding(
get: { config.clipboardHistoryEnabled && config.clipboardCandidateBarEnabled },
set: { config.clipboardCandidateBarEnabled = $0 }
)
}
}
// MARK: - About
struct AboutSettingsView: View {
+30
View File
@@ -22,6 +22,7 @@ private enum SettingsRoute: Hashable {
case textPolish
case general
case aiAgent
case clipboard
case about
}
@@ -96,9 +97,21 @@ struct SettingsView: View {
settingsDestination(for: route)
}
.task { await loadDynamicLocales() }
.onAppear { consumeClipboardDeepLinkIfNeeded() }
.onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in
consumeClipboardDeepLinkIfNeeded()
}
}
}
private func consumeClipboardDeepLinkIfNeeded() {
guard SettingsDeepLink.consumePending() == .clipboard else { return }
if !path.isEmpty {
path = NavigationPath()
}
path.append(SettingsRoute.clipboard)
}
@ViewBuilder
private func settingsDestination(for route: SettingsRoute) -> some View {
switch route {
@@ -110,6 +123,8 @@ struct SettingsView: View {
GeneralSettingsView(config: config)
case .aiAgent:
AIAgentSettingsView(config: config)
case .clipboard:
ClipboardSettingsView(config: config)
case .about:
AboutSettingsView(config: config)
}
@@ -135,6 +150,14 @@ struct SettingsView: View {
Divider().background(palette.divider)
settingsRouteButton(
.clipboard,
title: "settings.clipboard.title",
subtitle: clipboardSettingsSubtitle
)
Divider().background(palette.divider)
LocalePickerRow(
locales: effectiveLocales,
selection: Binding(
@@ -191,6 +214,13 @@ struct SettingsView: View {
.surfaceCard()
}
private var clipboardSettingsSubtitle: String {
if config.clipboardHistoryEnabled {
return AppL10n.string("settings.clipboard.subtitle.on", language: config.uiLanguage)
}
return AppL10n.string("settings.clipboard.subtitle.off", language: config.uiLanguage)
}
private func settingsRouteButton(
_ route: SettingsRoute,
title: LocalizedStringKey,
+11
View File
@@ -200,6 +200,17 @@
"settings.aiAgent.title" = "AI Agent";
"settings.aiAgent.responseLength.section" = "Answers";
"settings.aiAgent.responseLength.title" = "Response length";
"settings.clipboard.title" = "Clipboard";
"settings.clipboard.section" = "Features";
"settings.clipboard.subtitle.on" = "On";
"settings.clipboard.subtitle.off" = "Off";
"settings.clipboard.history.title" = "History";
"settings.clipboard.history.footer" = "Keeps the latest 15 plain-text copies on this device.";
"settings.clipboard.candidate.title" = "Suggestion strip";
"settings.clipboard.candidate.footer" = "Shows the newest copy above the keys for one-tap insert.";
"settings.clipboard.paste.section" = "System access";
"settings.clipboard.paste.body" = "Set Paste from Other Apps to Allow to stop paste prompts. If the option is missing, copy text and tap the keyboard suggestion once first.";
"settings.clipboard.paste.open" = "Open iOS Settings";
"settings.typingInput.title" = "Text Input";
"settings.typingInput.default.title" = "Default to Text Input";
"settings.typingInput.default.description" = "Open the text input keyboard instead of voice input by default";
@@ -200,6 +200,17 @@
"settings.aiAgent.title" = "AI Agent";
"settings.aiAgent.responseLength.section" = "回答";
"settings.aiAgent.responseLength.title" = "回复篇幅";
"settings.clipboard.title" = "剪贴板";
"settings.clipboard.section" = "功能";
"settings.clipboard.subtitle.on" = "已开启";
"settings.clipboard.subtitle.off" = "已关闭";
"settings.clipboard.history.title" = "历史记录";
"settings.clipboard.history.footer" = "本机保存最近 15 条纯文本。";
"settings.clipboard.candidate.title" = "建议条";
"settings.clipboard.candidate.footer" = "最新复制显示在键盘上方,点一下即可插入。";
"settings.clipboard.paste.section" = "系统授权";
"settings.clipboard.paste.body" = "将「从其他 App 粘贴」设为「允许」,即可不再弹窗。若没有此项,先复制文字并点一次键盘建议条。";
"settings.clipboard.paste.open" = "打开系统设置";
"settings.typingInput.title" = "文本输入";
"settings.typingInput.default.title" = "默认进行文字输入";
"settings.typingInput.default.description" = "打开键盘时,默认使用文字输入键盘而非语音输入";
+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" = "润色场景";
@@ -39,6 +39,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let polishIntensity = "config.polishIntensity"
public static let aiResponseLength = "config.aiResponseLength"
public static let llmThinkingEnabled = "config.llmThinkingEnabled"
/// When true, the keyboard records system clipboard text into local history.
public static let clipboardHistoryEnabled = "config.clipboardHistoryEnabled"
/// When true (and history is on), show the newest clipboard item as a suggestion strip.
public static let clipboardCandidateBarEnabled = "config.clipboardCandidateBarEnabled"
public static let detectedAppContext = "config.detectedAppContext"
public static let detectedAppContextAt = "config.detectedAppContextAt"
public static let personalDictionary = "config.personalDictionary.v1"
@@ -94,6 +98,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var aiResponseLength: AIResponseLength
/// Enables provider-specific reasoning / thinking controls for polish LLM requests.
public var llmThinkingEnabled: Bool
/// Opt-in clipboard history capture in the keyboard extension.
public var clipboardHistoryEnabled: Bool
/// Opt-in clipboard suggestion strip above the key surfaces.
public var clipboardCandidateBarEnabled: Bool
public var personalDictionary: PersonalDictionary
public var polishStyleCatalog: PolishStyleCatalog
public var activePolishStyleId: String
@@ -269,6 +277,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
storedRawValue: defaults.string(forKey: Keys.aiResponseLength)
),
llmThinkingEnabled: defaults.bool(forKey: Keys.llmThinkingEnabled),
clipboardHistoryEnabled: defaults.bool(forKey: Keys.clipboardHistoryEnabled),
clipboardCandidateBarEnabled: defaults.bool(forKey: Keys.clipboardCandidateBarEnabled),
personalDictionary: decodePersonalDictionary(from: defaults),
polishStyleCatalog: decodePolishStyleCatalog(from: defaults),
activePolishStyleId: defaults.string(forKey: Keys.activePolishStyleId)
@@ -401,6 +411,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
defaults.set(aiResponseLength.rawValue, forKey: Keys.aiResponseLength)
defaults.set(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled)
defaults.set(clipboardHistoryEnabled, forKey: Keys.clipboardHistoryEnabled)
defaults.set(clipboardCandidateBarEnabled, forKey: Keys.clipboardCandidateBarEnabled)
defaults.set(activePolishStyleId, forKey: Keys.activePolishStyleId)
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
@@ -0,0 +1,26 @@
// ClipboardHistoryEntry.swift
// OSGKeyboard · Shared
//
// One persisted clipboard history row (plain text / Unicode emoji only).
import Foundation
public struct ClipboardHistoryEntry: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public var text: String
public var createdAt: Date
/// Pasteboard changeCount when this row was captured (best-effort).
public var changeCount: Int?
public init(
id: UUID = UUID(),
text: String,
createdAt: Date = Date(),
changeCount: Int? = nil
) {
self.id = id
self.text = text
self.createdAt = createdAt
self.changeCount = changeCount
}
}
@@ -260,6 +260,26 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
}
/// When enabled, the keyboard records clipboard text into a local history list.
@Published public var clipboardHistoryEnabled: Bool {
didSet {
guard !isApplyingConfiguration,
clipboardHistoryEnabled != configuration.clipboardHistoryEnabled else { return }
configuration.clipboardHistoryEnabled = clipboardHistoryEnabled
persistConfiguration(postConfigChanged: true)
}
}
/// When enabled (and history is on), show the newest clipboard item as a suggestion strip.
@Published public var clipboardCandidateBarEnabled: Bool {
didSet {
guard !isApplyingConfiguration,
clipboardCandidateBarEnabled != configuration.clipboardCandidateBarEnabled else { return }
configuration.clipboardCandidateBarEnabled = clipboardCandidateBarEnabled
persistConfiguration(postConfigChanged: true)
}
}
/// When enabled, the host app tries to return to the source app after a cold-start handoff.
@Published public var flowSkipAppSwitch: Bool {
didSet {
@@ -401,6 +421,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
polishIntensity = configuration.polishIntensity
aiResponseLength = configuration.aiResponseLength
llmThinkingEnabled = configuration.llmThinkingEnabled
clipboardHistoryEnabled = configuration.clipboardHistoryEnabled
clipboardCandidateBarEnabled = configuration.clipboardCandidateBarEnabled
flowSkipAppSwitch = configuration.flowSkipAppSwitch
flowInactivityDuration = configuration.flowInactivityDuration
localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled
@@ -431,6 +453,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
aiResponseLength = .default
localASRCustomLanguageModelEnabled = true
llmThinkingEnabled = false
clipboardHistoryEnabled = false
clipboardCandidateBarEnabled = false
hasAcknowledgedCloudSharing = false
configuration.providerId = polishPreset.id
configuration.baseURL = polishPreset.defaultBaseURL
@@ -444,6 +468,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
configuration.aiResponseLength = .default
configuration.localASRCustomLanguageModelEnabled = true
configuration.llmThinkingEnabled = false
configuration.clipboardHistoryEnabled = false
configuration.clipboardCandidateBarEnabled = false
configuration.hasAcknowledgedCloudSharing = false
isApplyingConfiguration = false
persistConfiguration()
@@ -495,6 +521,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
polishIntensity = fresh.polishIntensity
aiResponseLength = fresh.aiResponseLength
llmThinkingEnabled = fresh.llmThinkingEnabled
clipboardHistoryEnabled = fresh.clipboardHistoryEnabled
clipboardCandidateBarEnabled = fresh.clipboardCandidateBarEnabled
flowSkipAppSwitch = fresh.flowSkipAppSwitch
flowInactivityDuration = fresh.flowInactivityDuration
localASRCustomLanguageModelEnabled = fresh.localASRCustomLanguageModelEnabled
@@ -31,6 +31,8 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
public var aiResponseLength: SyncedField<AIResponseLength>
public var activePolishStyleId: SyncedField<String>
public var llmThinkingEnabled: SyncedField<Bool>
public var clipboardHistoryEnabled: SyncedField<Bool>
public var clipboardCandidateBarEnabled: SyncedField<Bool>
public var flowSkipAppSwitch: SyncedField<Bool>
public var flowInactivityDuration: SyncedField<FlowInactivityDuration>
@@ -55,6 +57,8 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
aiResponseLength: SyncedField<AIResponseLength>? = nil,
activePolishStyleId: SyncedField<String>,
llmThinkingEnabled: SyncedField<Bool>,
clipboardHistoryEnabled: SyncedField<Bool>? = nil,
clipboardCandidateBarEnabled: SyncedField<Bool>? = nil,
flowSkipAppSwitch: SyncedField<Bool>,
flowInactivityDuration: SyncedField<FlowInactivityDuration>
) {
@@ -86,6 +90,16 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
)
self.activePolishStyleId = activePolishStyleId
self.llmThinkingEnabled = llmThinkingEnabled
self.clipboardHistoryEnabled = clipboardHistoryEnabled ?? SyncedField(
value: false,
updatedAt: llmThinkingEnabled.updatedAt,
deviceID: llmThinkingEnabled.deviceID
)
self.clipboardCandidateBarEnabled = clipboardCandidateBarEnabled ?? SyncedField(
value: false,
updatedAt: llmThinkingEnabled.updatedAt,
deviceID: llmThinkingEnabled.deviceID
)
self.flowSkipAppSwitch = flowSkipAppSwitch
self.flowInactivityDuration = flowInactivityDuration
}
@@ -111,6 +125,8 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
case aiResponseLength
case activePolishStyleId
case llmThinkingEnabled
case clipboardHistoryEnabled
case clipboardCandidateBarEnabled
case flowSkipAppSwitch
case flowInactivityDuration
}
@@ -182,6 +198,22 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
updatedAt: keyboardHapticIntensity.updatedAt,
deviceID: keyboardHapticIntensity.deviceID
)
clipboardHistoryEnabled = try container.decodeIfPresent(
SyncedField<Bool>.self,
forKey: .clipboardHistoryEnabled
) ?? SyncedField(
value: false,
updatedAt: keyboardHapticIntensity.updatedAt,
deviceID: keyboardHapticIntensity.deviceID
)
clipboardCandidateBarEnabled = try container.decodeIfPresent(
SyncedField<Bool>.self,
forKey: .clipboardCandidateBarEnabled
) ?? SyncedField(
value: false,
updatedAt: keyboardHapticIntensity.updatedAt,
deviceID: keyboardHapticIntensity.deviceID
)
flowSkipAppSwitch = try container.decode(SyncedField<Bool>.self, forKey: .flowSkipAppSwitch)
flowInactivityDuration = try container.decode(
SyncedField<FlowInactivityDuration>.self,
@@ -237,6 +269,8 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
aiResponseLength.updatedAt,
activePolishStyleId.updatedAt,
llmThinkingEnabled.updatedAt,
clipboardHistoryEnabled.updatedAt,
clipboardCandidateBarEnabled.updatedAt,
flowSkipAppSwitch.updatedAt,
flowInactivityDuration.updatedAt,
].max() ?? .distantPast
@@ -277,6 +311,8 @@ public extension SyncedAppSettingsV2 {
aiResponseLength: field(configuration.aiResponseLength),
activePolishStyleId: field(configuration.activePolishStyleId),
llmThinkingEnabled: field(configuration.llmThinkingEnabled),
clipboardHistoryEnabled: field(configuration.clipboardHistoryEnabled),
clipboardCandidateBarEnabled: field(configuration.clipboardCandidateBarEnabled),
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
flowInactivityDuration: field(configuration.flowInactivityDuration)
)
@@ -309,6 +345,8 @@ public extension SyncedAppSettingsV2 {
aiResponseLength: field(AIResponseLength.default),
activePolishStyleId: field(PolishStylePackCatalog.defaultID),
llmThinkingEnabled: field(false),
clipboardHistoryEnabled: field(false),
clipboardCandidateBarEnabled: field(false),
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
flowInactivityDuration: field(legacy.flowInactivityDuration)
)
@@ -356,6 +394,14 @@ public extension SyncedAppSettingsV2 {
remote: remote.activePolishStyleId
),
llmThinkingEnabled: .merge(local: local.llmThinkingEnabled, remote: remote.llmThinkingEnabled),
clipboardHistoryEnabled: .merge(
local: local.clipboardHistoryEnabled,
remote: remote.clipboardHistoryEnabled
),
clipboardCandidateBarEnabled: .merge(
local: local.clipboardCandidateBarEnabled,
remote: remote.clipboardCandidateBarEnabled
),
flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch),
flowInactivityDuration: .merge(
local: local.flowInactivityDuration,
@@ -384,6 +430,8 @@ public extension SyncedAppSettingsV2 {
configuration.aiResponseLength = aiResponseLength.value
configuration.activePolishStyleId = activePolishStyleId.value
configuration.llmThinkingEnabled = llmThinkingEnabled.value
configuration.clipboardHistoryEnabled = clipboardHistoryEnabled.value
configuration.clipboardCandidateBarEnabled = clipboardCandidateBarEnabled.value
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
configuration.flowInactivityDuration = flowInactivityDuration.value
}
@@ -414,6 +462,8 @@ public extension SyncedAppSettingsV2 {
patch(&copy.aiResponseLength, value: configuration.aiResponseLength)
patch(&copy.activePolishStyleId, value: configuration.activePolishStyleId)
patch(&copy.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
patch(&copy.clipboardHistoryEnabled, value: configuration.clipboardHistoryEnabled)
patch(&copy.clipboardCandidateBarEnabled, value: configuration.clipboardCandidateBarEnabled)
patch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
patch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration)
return copy
@@ -447,6 +497,8 @@ public extension SyncedAppSettingsV2 {
touch(&copy.aiResponseLength, value: configuration.aiResponseLength)
touch(&copy.activePolishStyleId, value: configuration.activePolishStyleId)
touch(&copy.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
touch(&copy.clipboardHistoryEnabled, value: configuration.clipboardHistoryEnabled)
touch(&copy.clipboardCandidateBarEnabled, value: configuration.clipboardCandidateBarEnabled)
touch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
touch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration)
return copy
@@ -84,6 +84,8 @@ public struct AppGroupStore: @unchecked Sendable {
PolishStylePackCatalog.resolve(id: activePolishStyleId, userCatalog: polishStyleCatalog)
}
public var llmThinkingEnabled: Bool { configuration.llmThinkingEnabled }
public var clipboardHistoryEnabled: Bool { configuration.clipboardHistoryEnabled }
public var clipboardCandidateBarEnabled: Bool { configuration.clipboardCandidateBarEnabled }
public var isPolishKeyMissing: Bool { configuration.isPolishKeyMissing }
public var isTranslationEffective: Bool { configuration.isTranslationEffective }
public var isLocalEngine: Bool { configuration.isLocalEngine }
@@ -182,6 +184,16 @@ public struct AppGroupStore: @unchecked Sendable {
AppGroupConfigDarwin.postConfigChanged()
}
public func setClipboardHistoryEnabled(_ enabled: Bool) {
mutateConfiguration { $0.clipboardHistoryEnabled = enabled }
AppGroupConfigDarwin.postConfigChanged()
}
public func setClipboardCandidateBarEnabled(_ enabled: Bool) {
mutateConfiguration { $0.clipboardCandidateBarEnabled = enabled }
AppGroupConfigDarwin.postConfigChanged()
}
public func setLocalASRCustomLanguageModelEnabled(_ enabled: Bool) {
mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled }
}
@@ -0,0 +1,58 @@
// ClipboardHistoryPolicy.swift
// OSGKeyboard · Shared
//
// Pure rules for accepting clipboard text and simple English/whitespace tokens.
import Foundation
public enum ClipboardHistoryPolicy: Sendable {
public static let maxEntries = 15
/// Reject short all-digit strings (OTP / verification-code shaped).
public static let otpDigitMaxLength = 8
/// Returns trimmed text when it should be stored; otherwise `nil`.
public static func acceptedText(from raw: String?) -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if looksLikeOTP(trimmed) { return nil }
return trimmed
}
/// Pure digits (optionally with spaces/dashes) of length 48 treat as OTP.
public static func looksLikeOTP(_ text: String) -> Bool {
let digits = text.filter(\.isNumber)
guard digits.count == text.filter({ !$0.isWhitespace && $0 != "-" }).count else {
return false
}
return (4...otpDigitMaxLength).contains(digits.count)
}
/// Simple whitespace / ASCII-punctuation split for English-ish snippets.
public static func whitespaceTokens(from text: String) -> [String] {
let separators = CharacterSet.whitespacesAndNewlines
.union(.punctuationCharacters)
return text
.components(separatedBy: separators)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
// Skip pure CJK-only single chars that aren't useful as chips.
.filter { token in
token.count > 1 || token.unicodeScalars.contains { $0.isASCII }
}
}
/// Merge `incoming` onto `existing` (newest first), dedupe by exact text.
public static func merging(
incoming: ClipboardHistoryEntry,
into existing: [ClipboardHistoryEntry],
limit: Int = maxEntries
) -> [ClipboardHistoryEntry] {
var next = existing.filter { $0.text != incoming.text }
next.insert(incoming, at: 0)
if next.count > limit {
next = Array(next.prefix(limit))
}
return next
}
}
@@ -0,0 +1,141 @@
// ClipboardHistoryStore.swift
// OSGKeyboard · Shared
//
// App Groupbacked clipboard history (local only; not iCloud-synced).
import Foundation
import Combine
@MainActor
public final class ClipboardHistoryStore: ObservableObject {
public static let shared = ClipboardHistoryStore()
public enum Keys {
public static let entries = "clipboard.history.v1"
public static let lastChangeCount = "clipboard.history.lastChangeCount"
public static let suggestionDismissedChangeCount =
"clipboard.history.suggestionDismissedChangeCount"
}
@Published public private(set) var entries: [ClipboardHistoryEntry] = []
private let defaults: UserDefaults
public init(defaults: UserDefaults? = nil) {
if let defaults {
self.defaults = defaults
} else if let suite = AppGroup.defaultsIfAvailable {
self.defaults = suite
} else {
self.defaults = .standard
}
entries = Self.loadEntries(from: self.defaults)
}
public var lastObservedChangeCount: Int {
get { defaults.integer(forKey: Keys.lastChangeCount) }
set { defaults.set(newValue, forKey: Keys.lastChangeCount) }
}
public var suggestionDismissedChangeCount: Int? {
get {
guard defaults.object(forKey: Keys.suggestionDismissedChangeCount) != nil else {
return nil
}
return defaults.integer(forKey: Keys.suggestionDismissedChangeCount)
}
set {
if let newValue {
defaults.set(newValue, forKey: Keys.suggestionDismissedChangeCount)
} else {
defaults.removeObject(forKey: Keys.suggestionDismissedChangeCount)
}
}
}
/// Inserts accepted text (dedupe + pin). Returns the new head when stored.
@discardableResult
public func ingest(
rawText: String?,
changeCount: Int?
) -> ClipboardHistoryEntry? {
guard let text = ClipboardHistoryPolicy.acceptedText(from: rawText) else {
return nil
}
let entry = ClipboardHistoryEntry(text: text, changeCount: changeCount)
entries = ClipboardHistoryPolicy.merging(incoming: entry, into: entries)
persist()
if let changeCount {
lastObservedChangeCount = changeCount
// New content clears a previous suggestion dismiss for that older change.
if suggestionDismissedChangeCount != changeCount {
suggestionDismissedChangeCount = nil
}
}
return entry
}
public func remove(id: UUID) {
entries.removeAll { $0.id == id }
persist()
}
public func clearAll() {
entries = []
persist()
}
public func reload() {
entries = Self.loadEntries(from: defaults)
}
public var newestEntry: ClipboardHistoryEntry? {
entries.first
}
/// Whether the suggestion strip should offer `newestEntry` for this changeCount.
public func shouldShowSuggestion(
forChangeCount changeCount: Int?,
candidateBarEnabled: Bool,
historyEnabled: Bool
) -> Bool {
guard historyEnabled, candidateBarEnabled else { return false }
guard newestEntry != nil else { return false }
if let changeCount,
let dismissed = suggestionDismissedChangeCount,
dismissed == changeCount {
return false
}
return true
}
public func dismissSuggestion(forChangeCount changeCount: Int?) {
if let changeCount {
suggestionDismissedChangeCount = changeCount
}
}
private func persist() {
do {
let data = try JSONEncoder().encode(entries)
defaults.set(data, forKey: Keys.entries)
} catch {
OSGLog.config.warning(
"clipboard history encode failed: \(error.localizedDescription, privacy: .public)"
)
}
}
private static func loadEntries(from defaults: UserDefaults) -> [ClipboardHistoryEntry] {
guard let data = defaults.data(forKey: Keys.entries) else { return [] }
do {
let decoded = try JSONDecoder().decode([ClipboardHistoryEntry].self, from: data)
return Array(decoded.prefix(ClipboardHistoryPolicy.maxEntries))
} catch {
OSGLog.config.warning(
"clipboard history decode failed: \(error.localizedDescription, privacy: .public)"
)
return []
}
}
}
@@ -16,6 +16,13 @@ import UIKit
public final class KeyboardState: ObservableObject {
public init() {}
/// Full-height clipboard UI layered over the active keyboard surface.
public enum ClipboardKeyboardOverlay: Equatable {
case none
case enableGuide
case historyPanel
}
/// Pipeline phase. Errors are structured so the UI layer can choose
/// the right icon / copy for each failure mode without
/// reverse-parsing a free-form string.
@@ -129,6 +136,18 @@ public final class KeyboardState: ObservableObject {
@Published public var returnKeyRole: ReturnKeyRole = .newline
/// Press-and-drag pads beside the mic for four-way caret movement.
@Published public var cursorDragNavigationEnabled: Bool = true
/// Opt-in clipboard history capture (mirrored from App Group).
@Published public var clipboardHistoryEnabled: Bool = false
/// Opt-in clipboard suggestion strip (requires history enabled).
@Published public var clipboardCandidateBarEnabled: Bool = false
/// Host field is a password / secure entry never read pasteboard.
@Published public var isSecureTextEntry: Bool = false
/// Full-keyboard clipboard overlay (enable guide or history list).
@Published public var clipboardOverlay: ClipboardKeyboardOverlay = .none
/// Suggestion strip above keys (newest clipboard item).
@Published public var clipboardSuggestionText: String?
/// Pasteboard changeCount associated with the current suggestion (for dismiss).
@Published public var clipboardSuggestionChangeCount: Int?
/// Typing-grid haptic strength (off / light / strong).
@Published public var keyboardHapticIntensity: KeyboardHapticIntensity = .default
/// Single source of truth for selecting iPad-scale keyboard metrics.
@@ -253,6 +272,17 @@ public final class KeyboardState: ObservableObject {
/// Opens the host app straight to input-resource deployment. Used by the
/// typing surface when Rime resources have not been deployed yet.
public var openInputMethodSetup: () -> Void = {}
/// Opens the host app Settings Clipboard page (enable history toggle).
public var openClipboardSettings: () -> Void = {}
/// Top-bar clipboard button: guide when history off, else history panel.
public var openClipboardPanel: () -> Void = {}
public var dismissClipboardOverlay: () -> Void = {}
public var insertClipboardText: (String) -> Void = { _ in }
public var dismissClipboardSuggestion: () -> Void = {}
public var clearClipboardHistory: () -> Void = {}
public var deleteClipboardHistoryEntry: (UUID) -> Void = { _ in }
/// Notify that the user inserted text (hides suggestion strip).
public var noteUserDidInputText: () -> Void = {}
/// System globe (🌐) key target. Kept weak to avoid a state controller
/// ownership cycle; UIKit's standard all-touch-events action provides both
/// tap-to-advance and long-press input-mode selection.
@@ -0,0 +1,34 @@
// SettingsDeepLink.swift
// OSGKeyboard · Shared
//
// One-shot deep-link target for the host Settings stack (keyboard app).
import Foundation
public enum SettingsDeepLink: String, Sendable {
case clipboard
private static let pendingKey = "settings.pendingDeepLink"
public static func setPending(_ link: SettingsDeepLink?) {
guard let defaults = AppGroup.defaultsIfAvailable else { return }
if let link {
defaults.set(link.rawValue, forKey: pendingKey)
} else {
defaults.removeObject(forKey: pendingKey)
}
defaults.synchronize()
}
public static func consumePending() -> SettingsDeepLink? {
guard let defaults = AppGroup.defaultsIfAvailable else { return nil }
guard let raw = defaults.string(forKey: pendingKey) else { return nil }
defaults.removeObject(forKey: pendingKey)
defaults.synchronize()
return SettingsDeepLink(rawValue: raw)
}
}
public extension Notification.Name {
static let osgOpenSettingsDeepLink = Notification.Name("osg.OpenSettingsDeepLink")
}
@@ -0,0 +1,59 @@
// ClipboardHistoryPolicyTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class ClipboardHistoryPolicyTests: XCTestCase {
func testRejectsEmptyAndWhitespace() {
XCTAssertNil(ClipboardHistoryPolicy.acceptedText(from: nil))
XCTAssertNil(ClipboardHistoryPolicy.acceptedText(from: " \n"))
}
func testRejectsOTPShapedDigits() {
XCTAssertNil(ClipboardHistoryPolicy.acceptedText(from: "123456"))
XCTAssertNil(ClipboardHistoryPolicy.acceptedText(from: "12-34-56"))
XCTAssertNotNil(ClipboardHistoryPolicy.acceptedText(from: "订单号 1234567890"))
}
func testAcceptsPlainTextAndEmoji() {
XCTAssertEqual(ClipboardHistoryPolicy.acceptedText(from: " hello "), "hello")
XCTAssertEqual(ClipboardHistoryPolicy.acceptedText(from: "你好😀"), "你好😀")
}
func testMergeDedupesAndPinsNewest() {
let a = ClipboardHistoryEntry(text: "a")
let b = ClipboardHistoryEntry(text: "b")
let a2 = ClipboardHistoryEntry(text: "a")
let merged = ClipboardHistoryPolicy.merging(
incoming: a2,
into: [a, b],
limit: 15
)
XCTAssertEqual(merged.map(\.text), ["a", "b"])
XCTAssertEqual(merged.first?.id, a2.id)
}
func testWhitespaceTokens() {
let tokens = ClipboardHistoryPolicy.whitespaceTokens(
from: "great experience - he writes"
)
XCTAssertEqual(tokens, ["great", "experience", "he", "writes"])
}
}
@MainActor
final class ClipboardHistoryStoreTests: XCTestCase {
func testIngestPersistsAndCapsAtFifteen() {
let suiteName = "ClipboardHistoryStoreTests.\(UUID().uuidString)"
let suite = UserDefaults(suiteName: suiteName)!
defer { suite.removePersistentDomain(forName: suiteName) }
let store = ClipboardHistoryStore(defaults: suite)
for index in 0..<20 {
store.ingest(rawText: "item-\(index)", changeCount: index)
}
XCTAssertEqual(store.entries.count, 15)
XCTAssertEqual(store.entries.first?.text, "item-19")
XCTAssertEqual(store.entries.last?.text, "item-5")
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ settings:
STRING_CATALOG_GENERATE_SYMBOLS: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17
MARKETING_VERSION: "1.7.0"
CURRENT_PROJECT_VERSION: "63"
CURRENT_PROJECT_VERSION: "64"
# 签名配置来自 Signing.local.xcconfiggitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target