diff --git a/Core/Package.swift b/Core/Package.swift index b88764d5..87d7c3f0 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -68,7 +68,7 @@ let package = Package( platforms: [.macOS(.v13)], products: products, dependencies: [ - .package(url: "https://github.com/azooKey/AzooKeyKanaKanjiConverter", revision: "67ec8e68d17a534b5b9929b920995ef3811e89fe", traits: kanaKanjiConverterTraits), + .package(url: "https://github.com/azooKey/AzooKeyKanaKanjiConverter", revision: "ad714fea8cb2fe113aea86ba5c42563cdaf77cfb", traits: kanaKanjiConverterTraits), .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), .package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.0") ], diff --git a/Core/Sources/ConverterServer/ConverterServer+KeyEvent.swift b/Core/Sources/ConverterServer/ConverterServer+KeyEvent.swift index 56cba218..b90ba16c 100644 --- a/Core/Sources/ConverterServer/ConverterServer+KeyEvent.swift +++ b/Core/Sources/ConverterServer/ConverterServer+KeyEvent.swift @@ -205,7 +205,7 @@ extension ConverterServer { manager.insertAtCursorPosition("つづき", inputStyle: inputStyle) effects.append(.requestReplaceSuggestion) case .acceptPredictionCandidate: - acceptPredictionCandidate(manager: manager, leftSideContext: leftSideContext) + manager.acceptPredictionCandidate() case .requestReplaceSuggestion: session.clearReplaceSuggestions() effects.append(.requestReplaceSuggestion) @@ -357,22 +357,4 @@ extension ConverterServer { session.clearReplaceSuggestions() return true } - - @MainActor - func acceptPredictionCandidate(manager: SegmentsManager, leftSideContext _: String?) { - let prediction = SegmentsManager.preferredPredictionCandidates( - typoCorrectionCandidates: manager.requestTypoCorrectionPredictionCandidates(), - predictionCandidates: manager.requestPredictionCandidates() - ).first - guard let prediction else { - return - } - if prediction.deleteCount > 0 { - manager.deleteBackwardFromCursorPosition(count: prediction.deleteCount) - } - guard !prediction.appendText.isEmpty else { - return - } - manager.insertAtCursorPosition(prediction.appendText, inputStyle: .direct) - } } diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 3188ca36..84259388 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -72,10 +72,6 @@ public final class SegmentsManager { var targetReading: String } - private func candidateReading(_ candidate: Candidate) -> String { - candidate.data.map(\.ruby).joined() - } - public func makeCandidatePresentations(_ candidates: [Candidate]) -> [CandidatePresentation] { let additionalPresentations = self.additionalCandidatePresentationsForSelectionIndex return candidates.indices.map { index in @@ -880,40 +876,35 @@ public final class SegmentsManager { } public func requestPredictionCandidates() -> [PredictionCandidate] { - guard Config.DebugPredictiveTyping().value else { + guard let candidate = self.firstPredictionCandidate(), + let prediction = Self.makePredictionCandidate(currentTarget: self.composingText.convertTarget, candidate: candidate) else { return [] } + return [prediction] + } + + private func firstPredictionCandidate() -> Candidate? { + guard Config.DebugPredictiveTyping().value else { + return nil + } let target = self.composingText.convertTarget guard !target.isEmpty else { - return [] + return nil } guard let rawCandidates else { - return [] + return nil } - for candidate in rawCandidates.predictionResults { - let reading = candidateReading(candidate) - guard !reading.isEmpty else { - continue - } - if let predictionCandidate = Self.makePredictionCandidate( - currentTarget: target, - candidateReading: reading, - displayText: candidate.text - ) { - return [predictionCandidate] - } + return rawCandidates.predictionResults.first { + Self.makePredictionCandidate(currentTarget: target, candidate: $0) != nil } - - return [] } static func makePredictionCandidate( currentTarget: String, - candidateReading: String, - displayText: String + candidate: Candidate ) -> PredictionCandidate? { var matchTarget = currentTarget var deleteCount = 0 @@ -926,7 +917,7 @@ public final class SegmentsManager { return nil } - let readingHiragana = candidateReading.toHiragana() + let readingHiragana = candidate.data.map(\.ruby).joined().toHiragana() let matchTargetHiragana = matchTarget.toHiragana() guard readingHiragana.hasPrefix(matchTargetHiragana) else { return nil @@ -940,7 +931,37 @@ public final class SegmentsManager { return nil } - return .init(displayText: displayText, appendText: appendText, deleteCount: deleteCount) + return .init(displayText: candidate.text, appendText: appendText, deleteCount: deleteCount) + } + + @MainActor + public func acceptPredictionCandidate() { + if let prediction = self.requestTypoCorrectionPredictionCandidates().first { + self.acceptTypoCorrectionPredictionCandidate(prediction) + } else if let candidate = self.firstPredictionCandidate() { + self.acceptPredictionCandidate(candidate) + } + } + + @MainActor + func acceptPredictionCandidate(_ candidate: Candidate) { + guard self.kanaKanjiConverter.acceptPredictionCandidate(candidate, composingText: &self.composingText) else { + return + } + self.lastInputStyle = .direct + self.lastOperation = .insert + self.shouldShowCandidateWindow = !self.liveConversionEnabled + self.updateRawCandidate() + } + + @MainActor + func acceptTypoCorrectionPredictionCandidate(_ prediction: PredictionCandidate) { + if prediction.deleteCount > 0 { + self.deleteBackwardFromCursorPosition(count: prediction.deleteCount) + } + if !prediction.appendText.isEmpty { + self.insertAtCursorPosition(prediction.appendText, inputStyle: .direct) + } } private func requestTypoCorrectionCandidates(composingText targetComposingText: ComposingText, inputStyle: InputStyle) -> [String] { diff --git a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionCandidateTests.swift b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionCandidateTests.swift index 52938de4..ffbec4a8 100644 --- a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionCandidateTests.swift +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionCandidateTests.swift @@ -1,11 +1,32 @@ @testable import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary import Testing +private func makeCandidate(text: String, reading: String) -> Candidate { + Candidate( + text: text, + value: 0, + composingCount: .inputCount(reading.count), + lastMid: MIDData.一般.mid, + data: [.init(word: text, ruby: reading.toKatakana(), cid: CIDData.一般名詞.cid, mid: MIDData.一般.mid, value: 0)] + ) +} + +private func makePredictionSegmentsManager() -> SegmentsManager { + SegmentsManager( + kanaKanjiConverter: .withDefaultDictionary(), + applicationDirectoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true), + containerURL: nil, + context: .init(useZenzai: false) + ) +} + @Test func testMakePredictionCandidateDeletesTrailingASCIIUsedForMatching() async throws { + let source = makeCandidate(text: "おはようございます", reading: "おはようございます") let candidate = SegmentsManager.makePredictionCandidate( currentTarget: "おはようございm", - candidateReading: "おはようございます", - displayText: "おはようございます" + candidate: source ) #expect(candidate?.displayText == "おはようございます") @@ -16,11 +37,54 @@ import Testing @Test func testMakePredictionCandidateKeepsDeleteCountZeroWithoutTrailingASCII() async throws { let candidate = SegmentsManager.makePredictionCandidate( currentTarget: "おはようござい", - candidateReading: "おはようございます", - displayText: "おはようございます" + candidate: makeCandidate(text: "おはようございます", reading: "おはようございます") ) #expect(candidate?.displayText == "おはようございます") #expect(candidate?.appendText == "ます") #expect(candidate?.deleteCount == 0) } + +@MainActor +@Test func testAcceptPredictionCandidateCompletesReadingAndContinuesRomanInput() throws { + let manager = makePredictionSegmentsManager() + manager.insertAtCursorPosition("hida", inputStyle: .roman2kana) + manager.acceptPredictionCandidate(makeCandidate(text: "←", reading: "ひだり")) + #expect(manager.convertTarget == "ひだり") + + manager.insertAtCursorPosition("nimagaru", inputStyle: .roman2kana) + #expect(manager.convertTarget == "ひだりにまがる") +} + +@MainActor +@Test func testAcceptPredictionCandidateReplacesPendingRomanSuffix() throws { + let manager = makePredictionSegmentsManager() + manager.insertAtCursorPosition("arigat", inputStyle: .roman2kana) + manager.acceptPredictionCandidate(makeCandidate(text: "有難う", reading: "ありがとう")) + + #expect(manager.convertTarget == "ありがとう") +} + +@MainActor +@Test func testAcceptPredictionCandidateRejectsStaleCandidateWithoutEditingInput() throws { + let manager = makePredictionSegmentsManager() + manager.insertAtCursorPosition("こんにちは", inputStyle: .direct) + manager.acceptPredictionCandidate(makeCandidate(text: "今晩は", reading: "こんばんは")) + + #expect(manager.convertTarget == "こんにちは") +} + +@MainActor +@Test func testAcceptTypoCorrectionPredictionCandidateReplacesReading() throws { + let manager = makePredictionSegmentsManager() + manager.insertAtCursorPosition("こんびんは", inputStyle: .direct) + let prediction = try #require(SegmentsManager.makeBackspaceTypoCorrectionPredictionCandidate( + currentConvertTarget: manager.convertTarget, + targetReading: "こんばんは", + displayText: "今晩は" + )) + + manager.acceptTypoCorrectionPredictionCandidate(prediction) + + #expect(manager.convertTarget == "こんばんは") +}