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:
@@ -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(©.aiResponseLength, value: configuration.aiResponseLength)
|
||||
patch(©.activePolishStyleId, value: configuration.activePolishStyleId)
|
||||
patch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
patch(©.clipboardHistoryEnabled, value: configuration.clipboardHistoryEnabled)
|
||||
patch(©.clipboardCandidateBarEnabled, value: configuration.clipboardCandidateBarEnabled)
|
||||
patch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
patch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
||||
return copy
|
||||
@@ -447,6 +497,8 @@ public extension SyncedAppSettingsV2 {
|
||||
touch(©.aiResponseLength, value: configuration.aiResponseLength)
|
||||
touch(©.activePolishStyleId, value: configuration.activePolishStyleId)
|
||||
touch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
touch(©.clipboardHistoryEnabled, value: configuration.clipboardHistoryEnabled)
|
||||
touch(©.clipboardCandidateBarEnabled, value: configuration.clipboardCandidateBarEnabled)
|
||||
touch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
touch(©.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 4…8 → 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 Group–backed 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")
|
||||
}
|
||||
Reference in New Issue
Block a user