feat(typing): add English autocomplete and Chinese candidate expand panel
Offline English lexicon suggestions with autocapitalization, plus a same-height Chinese more-candidates grid over the key area.
This commit is contained in:
@@ -16,18 +16,45 @@ public final class TypingSessionController: ObservableObject {
|
||||
@Published public private(set) var composition: TypingComposition = .empty
|
||||
@Published public private(set) var engineReady: Bool = false
|
||||
@Published public private(set) var schema: TypingInputSchema
|
||||
/// Chinese-only: key grid replaced by a same-height candidate grid.
|
||||
@Published public private(set) var isCandidatePanelExpanded: Bool = false
|
||||
@Published public var lastError: String?
|
||||
|
||||
/// When true, English suggestions / autocorrect stay off (secure fields).
|
||||
@Published public var suggestionsEnabled: Bool = true
|
||||
|
||||
/// Chevron appears only for Chinese composition with at least two candidates.
|
||||
public var canExpandCandidatePanel: Bool {
|
||||
language == .chinese && composition.candidates.count >= 2
|
||||
}
|
||||
|
||||
/// Live document prefix ahead of the caret (from `UITextDocumentProxy`).
|
||||
public var precedingTextProvider: (() -> String?)?
|
||||
/// Host field autocapitalization preference.
|
||||
public var autocapitalizationModeProvider: (() -> TypingAutocapitalizationMode)?
|
||||
|
||||
public let layout: TypingLayoutProviding
|
||||
private let engine: RimeEngineBridging
|
||||
private let englishEngine: EnglishSuggestionEngine
|
||||
private let learningStore: EnglishLearningStore
|
||||
private var prepared = false
|
||||
|
||||
// English word-level state (characters are already in the document).
|
||||
private var englishCurrentWord: String = ""
|
||||
private var englishPreviousWord: String = ""
|
||||
private var pendingAutocorrection: EnglishCorrectionDecision?
|
||||
private var personalTermsCache: [String] = []
|
||||
|
||||
public init(
|
||||
engine: RimeEngineBridging = LibrimeEngine(),
|
||||
layout: TypingLayoutProviding = StandardTypingLayout()
|
||||
layout: TypingLayoutProviding = StandardTypingLayout(),
|
||||
englishEngine: EnglishSuggestionEngine = EnglishSuggestionEngine(),
|
||||
learningStore: EnglishLearningStore = EnglishLearningStore()
|
||||
) {
|
||||
self.engine = engine
|
||||
self.layout = layout
|
||||
self.englishEngine = englishEngine
|
||||
self.learningStore = learningStore
|
||||
schema = engine.schema
|
||||
}
|
||||
|
||||
@@ -47,6 +74,9 @@ public final class TypingSessionController: ObservableObject {
|
||||
|
||||
public func enterTypingMode() {
|
||||
TypingInputConfiguration.shared.reload()
|
||||
refreshPersonalTerms()
|
||||
englishEngine.prepare()
|
||||
syncAutocapitalization()
|
||||
Task { await prepareIfNeeded() }
|
||||
}
|
||||
|
||||
@@ -55,32 +85,60 @@ public final class TypingSessionController: ObservableObject {
|
||||
prepared = false
|
||||
engineReady = false
|
||||
composition = .empty
|
||||
isCandidatePanelExpanded = false
|
||||
page = .letters
|
||||
shiftActive = false
|
||||
capsLock = false
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
}
|
||||
|
||||
public func toggleLanguage() -> String {
|
||||
public func toggleCandidatePanelExpanded() {
|
||||
guard canExpandCandidatePanel else {
|
||||
isCandidatePanelExpanded = false
|
||||
return
|
||||
}
|
||||
isCandidatePanelExpanded.toggle()
|
||||
}
|
||||
|
||||
public func collapseCandidatePanel() {
|
||||
isCandidatePanelExpanded = false
|
||||
}
|
||||
|
||||
public func toggleLanguage() -> TypingOutput {
|
||||
let next: TypingInputLanguage = language == .chinese ? .english : .chinese
|
||||
return setLanguage(next)
|
||||
}
|
||||
|
||||
/// Selects a specific language for the shared voice / Chinese / English
|
||||
/// capsule. Any active preedit is returned so callers can commit it
|
||||
/// before switching modes.
|
||||
public func setLanguage(_ newLanguage: TypingInputLanguage) -> String {
|
||||
guard language != newLanguage else { return "" }
|
||||
let raw = composition.preedit.isEmpty ? "" : engine.flushPreedit()
|
||||
/// capsule. Chinese preedit is flushed; English half-words are already
|
||||
/// in the document so we only clear suggestion state.
|
||||
public func setLanguage(_ newLanguage: TypingInputLanguage) -> TypingOutput {
|
||||
guard language != newLanguage else { return .none }
|
||||
var output = TypingOutput.none
|
||||
if language == .chinese, !composition.preedit.isEmpty {
|
||||
let raw = engine.flushPreedit()
|
||||
if !raw.isEmpty { output = .insert(raw) }
|
||||
}
|
||||
language = newLanguage
|
||||
engine.setLanguage(newLanguage)
|
||||
composition = engine.composition
|
||||
page = .letters
|
||||
return raw
|
||||
isCandidatePanelExpanded = false
|
||||
if newLanguage == .english {
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
refreshPersonalTerms()
|
||||
englishEngine.prepare()
|
||||
refreshEnglishSuggestions()
|
||||
syncAutocapitalization()
|
||||
} else {
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
composition = engine.composition
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
/// Flushes raw preedit, selects the next built-in scheme, and returns the
|
||||
/// raw text that the caller should insert before switching.
|
||||
public func cycleSchema() -> String {
|
||||
/// text that the caller should insert before switching.
|
||||
public func cycleSchema() -> TypingOutput {
|
||||
let raw = composition.preedit.isEmpty ? "" : engine.flushPreedit()
|
||||
let schemas = TypingInputSchema.allCases
|
||||
let current = schemas.firstIndex(of: schema) ?? 0
|
||||
@@ -90,17 +148,20 @@ public final class TypingSessionController: ObservableObject {
|
||||
TypingInputConfiguration.shared.schema = next
|
||||
}
|
||||
composition = engine.composition
|
||||
return raw
|
||||
syncCandidatePanelVisibility()
|
||||
return raw.isEmpty ? .none : .insert(raw)
|
||||
}
|
||||
|
||||
public func setPage(_ page: TypingKeyPage) {
|
||||
self.page = page
|
||||
shiftActive = false
|
||||
if page == .letters {
|
||||
syncAutocapitalization()
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a visible key label. Returns text the proxy should insert now
|
||||
/// (may be empty when composing Chinese).
|
||||
public func handleKey(_ label: String) -> String {
|
||||
/// Handle a visible key label.
|
||||
public func handleKey(_ label: String) -> TypingOutput {
|
||||
switch label {
|
||||
case "⇧":
|
||||
if shiftActive {
|
||||
@@ -111,67 +172,266 @@ public final class TypingSessionController: ObservableObject {
|
||||
} else {
|
||||
shiftActive = true
|
||||
}
|
||||
return ""
|
||||
return .none
|
||||
case "⌫":
|
||||
if language == .chinese, !engine.composition.preedit.isEmpty {
|
||||
let committed = engine.processBackspace() ?? ""
|
||||
composition = engine.composition
|
||||
return committed
|
||||
}
|
||||
return "\u{8}" // sentinel: caller deletes backward
|
||||
return handleBackspace()
|
||||
case "123":
|
||||
setPage(.numbers)
|
||||
return ""
|
||||
return .none
|
||||
case "#+=":
|
||||
setPage(.symbols)
|
||||
return ""
|
||||
return .none
|
||||
case "ABC", "abc":
|
||||
setPage(.letters)
|
||||
return ""
|
||||
return .none
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if page != .letters {
|
||||
// Number/symbol: insert directly
|
||||
let out = label
|
||||
let out = commitEnglishWordIfNeededBeforeNonLetter()
|
||||
if !capsLock { shiftActive = false }
|
||||
return out
|
||||
if out.isEmpty {
|
||||
return .insert(label)
|
||||
}
|
||||
return TypingOutput(deleteCount: out.deleteCount, text: out.text + label)
|
||||
}
|
||||
|
||||
guard let ch = label.first else { return "" }
|
||||
guard let ch = label.first else { return .none }
|
||||
|
||||
if language == .english {
|
||||
let out = String(ch)
|
||||
if !capsLock { shiftActive = false }
|
||||
return out
|
||||
return handleEnglishCharacter(ch)
|
||||
}
|
||||
|
||||
// Chinese letters → compose
|
||||
let committed = engine.processCharacter(ch) ?? ""
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
if !capsLock { shiftActive = false }
|
||||
return committed
|
||||
return committed.isEmpty ? .none : .insert(committed)
|
||||
}
|
||||
|
||||
public func handleSpace() -> String {
|
||||
if language == .english { return " " }
|
||||
public func handleSpace() -> TypingOutput {
|
||||
if language == .english {
|
||||
return commitEnglishWord(suffix: " ")
|
||||
}
|
||||
let text = engine.processSpace() ?? " "
|
||||
composition = engine.composition
|
||||
return text
|
||||
syncCandidatePanelVisibility()
|
||||
return .insert(text)
|
||||
}
|
||||
|
||||
public func handleReturn() -> String {
|
||||
if language == .english { return "\n" }
|
||||
public func handleReturn() -> TypingOutput {
|
||||
if language == .english {
|
||||
return commitEnglishWord(suffix: "\n")
|
||||
}
|
||||
let text = engine.processReturn() ?? "\n"
|
||||
composition = engine.composition
|
||||
return text
|
||||
syncCandidatePanelVisibility()
|
||||
return .insert(text)
|
||||
}
|
||||
|
||||
public func selectCandidate(at index: Int) -> String {
|
||||
let text = engine.selectCandidate(at: index)
|
||||
public func selectCandidate(at index: Int) -> TypingOutput {
|
||||
if language == .english {
|
||||
return selectEnglishCandidate(at: index)
|
||||
}
|
||||
guard composition.candidates.indices.contains(index) else { return .none }
|
||||
// Display order may put phrases before first-syllable chars; select by engine index.
|
||||
let engineIndex = composition.candidates[index].engineIndex
|
||||
let text = engine.selectCandidate(at: engineIndex)
|
||||
composition = engine.composition
|
||||
return text
|
||||
// Selecting always collapses; follow-up composition may reopen ▼.
|
||||
isCandidatePanelExpanded = false
|
||||
syncCandidatePanelVisibility()
|
||||
return text.isEmpty ? .none : .insert(text)
|
||||
}
|
||||
|
||||
// MARK: - English
|
||||
|
||||
private func handleEnglishCharacter(_ ch: Character) -> TypingOutput {
|
||||
pendingAutocorrection = nil
|
||||
if ch.isLetter {
|
||||
englishCurrentWord.append(ch)
|
||||
if !capsLock { shiftActive = false }
|
||||
refreshEnglishSuggestions()
|
||||
return .insert(String(ch))
|
||||
}
|
||||
|
||||
// Punctuation / digit: commit current word first, then insert.
|
||||
var output = commitEnglishWord(suffix: "")
|
||||
if !capsLock { shiftActive = false }
|
||||
if output.isEmpty {
|
||||
return .insert(String(ch))
|
||||
}
|
||||
return TypingOutput(deleteCount: output.deleteCount, text: output.text + String(ch))
|
||||
}
|
||||
|
||||
private func handleBackspace() -> TypingOutput {
|
||||
if language == .chinese, !engine.composition.preedit.isEmpty {
|
||||
let committed = engine.processBackspace() ?? ""
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
return committed.isEmpty ? .none : .insert(committed)
|
||||
}
|
||||
|
||||
if language == .english {
|
||||
if let pending = pendingAutocorrection {
|
||||
// Undo last autocorrection: delete replacement+suffix, restore original.
|
||||
let deleteCount = pending.undoDeleteCount
|
||||
pendingAutocorrection = nil
|
||||
englishCurrentWord = pending.original
|
||||
learningStore.recordDefense(of: pending.original)
|
||||
refreshEnglishSuggestions()
|
||||
return .replace(deleteCount: deleteCount, with: pending.original)
|
||||
}
|
||||
if !englishCurrentWord.isEmpty {
|
||||
englishCurrentWord.removeLast()
|
||||
refreshEnglishSuggestions()
|
||||
} else if !englishPreviousWord.isEmpty {
|
||||
// Stepping back into the previous word.
|
||||
englishCurrentWord = englishPreviousWord
|
||||
englishPreviousWord = ""
|
||||
refreshEnglishSuggestions()
|
||||
} else {
|
||||
composition = .empty
|
||||
}
|
||||
}
|
||||
return .backspace
|
||||
}
|
||||
|
||||
private func commitEnglishWord(suffix: String) -> TypingOutput {
|
||||
let word = englishCurrentWord
|
||||
defer {
|
||||
if !capsLock { shiftActive = false }
|
||||
}
|
||||
|
||||
guard suggestionsEnabled, !word.isEmpty else {
|
||||
if !word.isEmpty {
|
||||
englishPreviousWord = word
|
||||
englishCurrentWord = ""
|
||||
}
|
||||
refreshEnglishSuggestions(afterCommittedWord: word.isEmpty ? nil : word)
|
||||
return suffix.isEmpty ? .none : .insert(suffix)
|
||||
}
|
||||
|
||||
if var decision = englishEngine.correctionDecision(
|
||||
for: word,
|
||||
personalTerms: personalTermsCache,
|
||||
learnedBoosts: learningStore.snapshot()
|
||||
) {
|
||||
decision.appliedSuffix = suffix
|
||||
pendingAutocorrection = decision
|
||||
englishPreviousWord = decision.replacement
|
||||
englishCurrentWord = ""
|
||||
learningStore.recordAcceptance(of: decision.replacement)
|
||||
// Suggestions stay hidden until the user starts the next word.
|
||||
composition = .empty
|
||||
return .replace(
|
||||
deleteCount: word.count,
|
||||
with: decision.replacement + suffix
|
||||
)
|
||||
}
|
||||
|
||||
englishPreviousWord = word
|
||||
englishCurrentWord = ""
|
||||
pendingAutocorrection = nil
|
||||
learningStore.recordAcceptance(of: word, amount: 1)
|
||||
refreshEnglishSuggestions(afterCommittedWord: word)
|
||||
return suffix.isEmpty ? .none : .insert(suffix)
|
||||
}
|
||||
|
||||
private func selectEnglishCandidate(at index: Int) -> TypingOutput {
|
||||
guard composition.candidates.indices.contains(index) else { return .none }
|
||||
let chosen = composition.candidates[index].text
|
||||
|
||||
// Restoring original after autocorrect (no current word).
|
||||
if englishCurrentWord.isEmpty,
|
||||
let pending = pendingAutocorrection,
|
||||
chosen.compare(pending.original, options: [.caseInsensitive]) == .orderedSame {
|
||||
let deleteCount = pending.undoDeleteCount
|
||||
pendingAutocorrection = nil
|
||||
englishPreviousWord = pending.original
|
||||
englishCurrentWord = ""
|
||||
learningStore.recordDefense(of: pending.original)
|
||||
refreshEnglishSuggestions(afterCommittedWord: pending.original)
|
||||
return .replace(deleteCount: deleteCount, with: pending.original + " ")
|
||||
}
|
||||
|
||||
if !englishCurrentWord.isEmpty {
|
||||
let deleteCount = englishCurrentWord.count
|
||||
englishPreviousWord = chosen
|
||||
englishCurrentWord = ""
|
||||
pendingAutocorrection = nil
|
||||
learningStore.recordAcceptance(of: chosen)
|
||||
refreshEnglishSuggestions(afterCommittedWord: chosen)
|
||||
return .replace(deleteCount: deleteCount, with: chosen + " ")
|
||||
}
|
||||
|
||||
// Next-word prediction tap.
|
||||
englishPreviousWord = chosen
|
||||
englishCurrentWord = ""
|
||||
pendingAutocorrection = nil
|
||||
learningStore.recordAcceptance(of: chosen)
|
||||
refreshEnglishSuggestions(afterCommittedWord: chosen)
|
||||
return .insert(chosen + " ")
|
||||
}
|
||||
|
||||
private func commitEnglishWordIfNeededBeforeNonLetter() -> TypingOutput {
|
||||
guard language == .english, !englishCurrentWord.isEmpty else { return .none }
|
||||
return commitEnglishWord(suffix: "")
|
||||
}
|
||||
|
||||
private func refreshEnglishSuggestions(afterCommittedWord word: String? = nil) {
|
||||
guard language == .english else { return }
|
||||
guard suggestionsEnabled else {
|
||||
composition = .empty
|
||||
return
|
||||
}
|
||||
// Idle / between words: no candidate bar. Completions start after
|
||||
// the first letter of the current word.
|
||||
guard !englishCurrentWord.isEmpty else {
|
||||
composition = .empty
|
||||
return
|
||||
}
|
||||
let previous = word ?? englishPreviousWord
|
||||
let context = EnglishSuggestionContext(
|
||||
currentWord: englishCurrentWord,
|
||||
previousWord: previous,
|
||||
personalTerms: personalTermsCache,
|
||||
learnedBoosts: learningStore.snapshot(),
|
||||
includeOriginalAfterCorrection: nil
|
||||
)
|
||||
composition = englishEngine.compositionWhileTyping(context)
|
||||
}
|
||||
|
||||
/// Arms Shift for sentence / word starts using the host field traits.
|
||||
public func syncAutocapitalization() {
|
||||
guard language == .english, page == .letters else { return }
|
||||
guard !capsLock else { return }
|
||||
let mode = autocapitalizationModeProvider?() ?? .sentences
|
||||
let preceding = precedingTextProvider?()
|
||||
shiftActive = TypingAutocapitalization.shouldCapitalize(
|
||||
precedingText: preceding,
|
||||
mode: mode
|
||||
)
|
||||
}
|
||||
|
||||
private func clearEnglishWordState(keepPrevious: Bool) {
|
||||
englishCurrentWord = ""
|
||||
if !keepPrevious { englishPreviousWord = "" }
|
||||
pendingAutocorrection = nil
|
||||
}
|
||||
|
||||
private func refreshPersonalTerms() {
|
||||
// English keyboard only accepts Latin hotwords; Chinese terms stay for ASR/polish.
|
||||
personalTermsCache = AppGroupStore().personalDictionary.englishTypingHotwords()
|
||||
}
|
||||
|
||||
/// Collapse the expand panel when Chinese no longer has enough candidates.
|
||||
private func syncCandidatePanelVisibility() {
|
||||
if !canExpandCandidatePanel {
|
||||
isCandidatePanelExpanded = false
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareIfNeeded() async {
|
||||
@@ -183,6 +443,9 @@ public final class TypingSessionController: ObservableObject {
|
||||
engineReady = engine.isReady
|
||||
schema = engine.schema
|
||||
lastError = nil
|
||||
if language == .english {
|
||||
refreshEnglishSuggestions()
|
||||
}
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
engineReady = false
|
||||
|
||||
Reference in New Issue
Block a user