feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish

Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
Rocky
2026-08-13 01:00:51 +08:00
parent fd6e0d3e7e
commit 9f308fadd2
202 changed files with 10897 additions and 5962 deletions
@@ -35,6 +35,9 @@ public final class TypingSessionController: ObservableObject {
/// Live document prefix ahead of the caret (from `UITextDocumentProxy`).
public var precedingTextProvider: (() -> String?)?
/// Live document suffix after the caret. A non-empty word suffix means
/// the caret is inside a word, where backward-only replacement is unsafe.
public var followingTextProvider: (() -> String?)?
/// Host field autocapitalization preference.
public var autocapitalizationModeProvider: (() -> TypingAutocapitalizationMode)?
@@ -65,6 +68,7 @@ public final class TypingSessionController: ObservableObject {
// English word-level state (characters are already in the document).
private var englishCurrentWord: String = ""
private var englishPreviousWord: String = ""
private var englishFollowingWordSuffix: String = ""
private var pendingAutocorrection: EnglishCorrectionDecision?
private var personalTermsCache: [String] = []
/// User tapped Shift for a one-shot capital; autocap must not overwrite this.
@@ -204,6 +208,7 @@ public final class TypingSessionController: ObservableObject {
englishEngine.prepare()
refreshEnglishSuggestions()
syncAutocapitalization()
synchronizeEnglishDocumentContext(caretMoved: true)
} else {
clearEnglishWordState(keepPrevious: false)
composition = engine.composition
@@ -357,7 +362,7 @@ public final class TypingSessionController: ObservableObject {
}
// Punctuation / digit: commit current word first, then insert.
var output = commitEnglishWord(suffix: "")
let output = commitEnglishWord(suffix: "")
clearOneShotShiftIfNeeded()
if output.isEmpty {
return .insert(String(ch))
@@ -386,11 +391,6 @@ public final class TypingSessionController: ObservableObject {
if !englishCurrentWord.isEmpty {
englishCurrentWord.removeLast()
refreshEnglishSuggestions()
} else if !englishPreviousWord.isEmpty {
// Stepping back into the previous word.
englishCurrentWord = englishPreviousWord
englishPreviousWord = ""
refreshEnglishSuggestions()
} else {
composition = .empty
}
@@ -404,7 +404,9 @@ public final class TypingSessionController: ObservableObject {
clearOneShotShiftIfNeeded()
}
guard suggestionsEnabled, !word.isEmpty else {
guard suggestionsEnabled,
englishFollowingWordSuffix.isEmpty,
!word.isEmpty else {
if !word.isEmpty {
englishPreviousWord = word
englishCurrentWord = ""
@@ -413,7 +415,8 @@ public final class TypingSessionController: ObservableObject {
return suffix.isEmpty ? .none : .insert(suffix)
}
if var decision = englishEngine.correctionDecision(
if englishCurrentWordMatchesDocument(),
var decision = englishEngine.correctionDecision(
for: word,
personalTerms: personalTermsCache,
learnedBoosts: learningStore.snapshot()
@@ -441,6 +444,7 @@ public final class TypingSessionController: ObservableObject {
private func selectEnglishCandidate(at index: Int) -> TypingOutput {
guard composition.candidates.indices.contains(index) else { return .none }
guard englishCandidateAnchorMatchesDocument() else { return .none }
let chosen = composition.candidates[index].text
// Restoring original after autocorrect (no current word).
@@ -483,6 +487,11 @@ public final class TypingSessionController: ObservableObject {
private func refreshEnglishSuggestions(afterCommittedWord word: String? = nil) {
guard language == .english else { return }
guard suggestionsEnabled else {
clearEnglishWordState(keepPrevious: false)
composition = .empty
return
}
guard englishFollowingWordSuffix.isEmpty else {
composition = .empty
return
}
@@ -514,18 +523,59 @@ public final class TypingSessionController: ObservableObject {
deleteCount: Int = 0
) {
guard language == .english, page == .letters else { return }
guard !capsLock, !shiftHeld, !shiftPrimedByUser else { return }
let mode = autocapitalizationModeProvider?() ?? .sentences
let preceding = resolvedPrecedingText(
accountingForInsert: insert,
deleteCount: deleteCount
)
if !insert.isEmpty || deleteCount > 0 {
synchronizeEnglishWordState(
precedingText: preceding ?? "",
followingText: followingTextProvider?()
)
}
guard !capsLock, !shiftHeld, !shiftPrimedByUser else { return }
let mode = autocapitalizationModeProvider?() ?? .sentences
shiftActive = TypingAutocapitalization.shouldCapitalize(
precedingText: preceding,
mode: mode
)
}
/// Rebuilds English suggestion state from the real caret context.
/// At the document end, callbacks keep the local shadow when a host
/// briefly reports the immediately preceding edit (common in Notes).
public func synchronizeEnglishDocumentContext(caretMoved: Bool = false) {
guard language == .english else { return }
guard suggestionsEnabled else {
clearEnglishWordState(keepPrevious: false)
composition = .empty
return
}
guard let preceding = precedingTextProvider?() else {
if caretMoved {
clearEnglishWordState(keepPrevious: false)
composition = .empty
}
return
}
if shouldPreserveLocalEnglishContext(over: preceding) {
return
}
applyEnglishDocumentSnapshot(preceding)
}
private func applyEnglishDocumentSnapshot(_ preceding: String) {
if let pending = pendingAutocorrection,
!preceding.hasSuffix(pending.replacement + pending.appliedSuffix) {
pendingAutocorrection = nil
}
_ = storePrecedingShadow(preceding)
synchronizeEnglishWordState(
precedingText: preceding,
followingText: followingTextProvider?()
)
}
/// Prefer a fresh proxy; when the host lags (Notes), merge our just-applied edit.
private func resolvedPrecedingText(
accountingForInsert insert: String,
@@ -634,9 +684,97 @@ public final class TypingSessionController: ObservableObject {
private func clearEnglishWordState(keepPrevious: Bool) {
englishCurrentWord = ""
if !keepPrevious { englishPreviousWord = "" }
englishFollowingWordSuffix = ""
pendingAutocorrection = nil
}
private func synchronizeEnglishWordState(
precedingText: String,
followingText: String?
) {
let current = Self.englishWordBeforeCaret(in: precedingText)
let textBeforeCurrent = String(precedingText.dropLast(current.count))
englishCurrentWord = current
englishPreviousWord = Self.previousEnglishWord(in: textBeforeCurrent)
englishFollowingWordSuffix = Self.englishWordAfterCaret(in: followingText ?? "")
refreshEnglishSuggestions()
}
private func shouldPreserveLocalEnglishContext(over proxyText: String) -> Bool {
guard !englishCurrentWord.isEmpty,
(followingTextProvider?() ?? "").isEmpty,
!precedingShadow.isEmpty,
proxyText != precedingShadow,
Self.englishWordBeforeCaret(in: precedingShadow) == englishCurrentWord,
Self.englishWordBeforeCaret(in: proxyText) != englishCurrentWord else {
return false
}
return precedingShadow.hasPrefix(proxyText) || proxyText.hasPrefix(precedingShadow)
}
private func englishCandidateAnchorMatchesDocument() -> Bool {
guard englishCurrentWordMatchesDocument() else {
if let preceding = precedingTextProvider?() {
applyEnglishDocumentSnapshot(preceding)
} else {
clearEnglishWordState(keepPrevious: false)
composition = .empty
}
return false
}
return true
}
private func englishCurrentWordMatchesDocument() -> Bool {
guard englishFollowingWordSuffix.isEmpty else { return false }
if let following = followingTextProvider?(),
!Self.englishWordAfterCaret(in: following).isEmpty {
return false
}
guard let preceding = precedingTextProvider?() else {
return true
}
return Self.englishWordBeforeCaret(in: preceding) == englishCurrentWord
}
private static func englishWordBeforeCaret(in text: String) -> String {
var reversed: [Character] = []
for character in text.reversed() {
guard isEnglishWordCharacter(character) else { break }
reversed.append(character)
}
return String(reversed.reversed())
}
private static func englishWordAfterCaret(in text: String) -> String {
String(text.prefix(while: isEnglishWordCharacter))
}
private static func previousEnglishWord(in text: String) -> String {
var remainder = text
while let last = remainder.last, !isEnglishWordCharacter(last) {
remainder.removeLast()
}
return englishWordBeforeCaret(in: remainder)
}
private static func isEnglishWordCharacter(_ character: Character) -> Bool {
if character == "'" || character == "" || character == "-" {
return true
}
guard character.isLetter else { return false }
return character.unicodeScalars.allSatisfy { scalar in
switch scalar.value {
case 0x0041...0x007A,
0x00C0...0x024F,
0x1E00...0x1EFF:
return true
default:
return false
}
}
}
private func refreshPersonalTerms() {
// English keyboard only accepts Latin hotwords; Chinese terms stay for ASR/polish.
personalTermsCache = AppGroupStore().personalDictionary.englishTypingHotwords()