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
+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" = "打开键盘时,默认使用文字输入键盘而非语音输入";