From feaba4870d4eb313fb66a0a4596a69345f63702e Mon Sep 17 00:00:00 2001 From: ensan-hcl Date: Sat, 5 Sep 2026 16:51:44 +0900 Subject: [PATCH 1/2] feat(input): adopt converter prediction acceptance API --- Core/Package.swift | 2 +- .../ConverterServer+KeyEvent.swift | 8 +- .../Core/InputUtils/SegmentsManager.swift | 41 ++++++--- ...mentsManagerPredictionCandidateTests.swift | 88 ++++++++++++++++++- 4 files changed, 113 insertions(+), 26 deletions(-) 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..84eb8318 100644 --- a/Core/Sources/ConverterServer/ConverterServer+KeyEvent.swift +++ b/Core/Sources/ConverterServer/ConverterServer+KeyEvent.swift @@ -367,12 +367,6 @@ extension ConverterServer { 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) + manager.acceptPredictionCandidate(prediction) } } diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 3188ca36..1d924f65 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -65,6 +65,8 @@ public final class SegmentsManager { public var displayText: String public var appendText: String public var deleteCount: Int = 0 + // 通常の予測候補は、受理時に Converter へ元の候補を渡して表記を引き継ぐ。 + var candidate: Candidate? } struct BackspaceTypoCorrectionLock: Sendable { @@ -72,10 +74,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 @@ -894,14 +892,9 @@ public final class SegmentsManager { } 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 + candidate: candidate ) { return [predictionCandidate] } @@ -912,8 +905,7 @@ public final class SegmentsManager { static func makePredictionCandidate( currentTarget: String, - candidateReading: String, - displayText: String + candidate: Candidate ) -> PredictionCandidate? { var matchTarget = currentTarget var deleteCount = 0 @@ -926,7 +918,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 +932,28 @@ public final class SegmentsManager { return nil } - return .init(displayText: displayText, appendText: appendText, deleteCount: deleteCount) + return .init(displayText: candidate.text, appendText: appendText, deleteCount: deleteCount, candidate: candidate) + } + + @MainActor + public func acceptPredictionCandidate(_ prediction: PredictionCandidate) { + if let candidate = prediction.candidate { + guard self.kanaKanjiConverter.acceptPredictionCandidate(candidate, composingText: &self.composingText) else { + return + } + self.lastInputStyle = .direct + self.lastOperation = .insert + self.shouldShowCandidateWindow = !self.liveConversionEnabled + self.updateRawCandidate() + } else { + // 誤入力訂正候補は読みの置換として適用する。 + 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..40a3d266 100644 --- a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionCandidateTests.swift +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionCandidateTests.swift @@ -1,26 +1,106 @@ @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 == "おはようございます") #expect(candidate?.appendText == "ます") #expect(candidate?.deleteCount == 1) + #expect(candidate?.candidate == source) } @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) + let prediction = try #require(SegmentsManager.makePredictionCandidate( + currentTarget: manager.convertTarget, + candidate: makeCandidate(text: "←", reading: "ひだり") + )) + + manager.acceptPredictionCandidate(prediction) + #expect(manager.convertTarget == "ひだり") + + manager.insertAtCursorPosition("nimagaru", inputStyle: .roman2kana) + #expect(manager.convertTarget == "ひだりにまがる") +} + +@MainActor +@Test func testAcceptPredictionCandidateReplacesPendingRomanSuffix() throws { + let manager = makePredictionSegmentsManager() + manager.insertAtCursorPosition("arigat", inputStyle: .roman2kana) + let prediction = try #require(SegmentsManager.makePredictionCandidate( + currentTarget: manager.convertTarget, + candidate: makeCandidate(text: "有難う", reading: "ありがとう") + )) + + manager.acceptPredictionCandidate(prediction) + + #expect(manager.convertTarget == "ありがとう") +} + +@MainActor +@Test func testAcceptPredictionCandidateRejectsStaleCandidateWithoutEditingInput() throws { + let manager = makePredictionSegmentsManager() + manager.insertAtCursorPosition("こんにちは", inputStyle: .direct) + let prediction = try #require(SegmentsManager.makePredictionCandidate( + currentTarget: "こんば", + candidate: makeCandidate(text: "今晩は", reading: "こんばんは") + )) + + manager.acceptPredictionCandidate(prediction) + + #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.acceptPredictionCandidate(prediction) + + #expect(manager.convertTarget == "こんばんは") +} From 2e300749151068c148cc2cd767919f74fed2d863 Mon Sep 17 00:00:00 2001 From: ensan-hcl Date: Sat, 5 Sep 2026 16:56:14 +0900 Subject: [PATCH 2/2] refactor(input): resolve accepted predictions from conversion results --- .../ConverterServer+KeyEvent.swift | 14 +--- .../Core/InputUtils/SegmentsManager.swift | 72 ++++++++++--------- ...mentsManagerPredictionCandidateTests.swift | 24 ++----- 3 files changed, 45 insertions(+), 65 deletions(-) diff --git a/Core/Sources/ConverterServer/ConverterServer+KeyEvent.swift b/Core/Sources/ConverterServer/ConverterServer+KeyEvent.swift index 84eb8318..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,16 +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 - } - manager.acceptPredictionCandidate(prediction) - } } diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 1d924f65..84259388 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -65,8 +65,6 @@ public final class SegmentsManager { public var displayText: String public var appendText: String public var deleteCount: Int = 0 - // 通常の予測候補は、受理時に Converter へ元の候補を渡して表記を引き継ぐ。 - var candidate: Candidate? } struct BackspaceTypoCorrectionLock: Sendable { @@ -878,29 +876,30 @@ 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 { - if let predictionCandidate = Self.makePredictionCandidate( - currentTarget: target, - candidate: candidate - ) { - return [predictionCandidate] - } + return rawCandidates.predictionResults.first { + Self.makePredictionCandidate(currentTarget: target, candidate: $0) != nil } - - return [] } static func makePredictionCandidate( @@ -932,27 +931,36 @@ public final class SegmentsManager { return nil } - return .init(displayText: candidate.text, appendText: appendText, deleteCount: deleteCount, candidate: candidate) + return .init(displayText: candidate.text, appendText: appendText, deleteCount: deleteCount) } @MainActor - public func acceptPredictionCandidate(_ prediction: PredictionCandidate) { - if let candidate = prediction.candidate { - guard self.kanaKanjiConverter.acceptPredictionCandidate(candidate, composingText: &self.composingText) else { - return - } - self.lastInputStyle = .direct - self.lastOperation = .insert - self.shouldShowCandidateWindow = !self.liveConversionEnabled - self.updateRawCandidate() - } else { - // 誤入力訂正候補は読みの置換として適用する。 - if prediction.deleteCount > 0 { - self.deleteBackwardFromCursorPosition(count: prediction.deleteCount) - } - if !prediction.appendText.isEmpty { - self.insertAtCursorPosition(prediction.appendText, inputStyle: .direct) - } + 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) } } diff --git a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionCandidateTests.swift b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionCandidateTests.swift index 40a3d266..ffbec4a8 100644 --- a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionCandidateTests.swift +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionCandidateTests.swift @@ -32,7 +32,6 @@ private func makePredictionSegmentsManager() -> SegmentsManager { #expect(candidate?.displayText == "おはようございます") #expect(candidate?.appendText == "ます") #expect(candidate?.deleteCount == 1) - #expect(candidate?.candidate == source) } @Test func testMakePredictionCandidateKeepsDeleteCountZeroWithoutTrailingASCII() async throws { @@ -50,12 +49,7 @@ private func makePredictionSegmentsManager() -> SegmentsManager { @Test func testAcceptPredictionCandidateCompletesReadingAndContinuesRomanInput() throws { let manager = makePredictionSegmentsManager() manager.insertAtCursorPosition("hida", inputStyle: .roman2kana) - let prediction = try #require(SegmentsManager.makePredictionCandidate( - currentTarget: manager.convertTarget, - candidate: makeCandidate(text: "←", reading: "ひだり") - )) - - manager.acceptPredictionCandidate(prediction) + manager.acceptPredictionCandidate(makeCandidate(text: "←", reading: "ひだり")) #expect(manager.convertTarget == "ひだり") manager.insertAtCursorPosition("nimagaru", inputStyle: .roman2kana) @@ -66,12 +60,7 @@ private func makePredictionSegmentsManager() -> SegmentsManager { @Test func testAcceptPredictionCandidateReplacesPendingRomanSuffix() throws { let manager = makePredictionSegmentsManager() manager.insertAtCursorPosition("arigat", inputStyle: .roman2kana) - let prediction = try #require(SegmentsManager.makePredictionCandidate( - currentTarget: manager.convertTarget, - candidate: makeCandidate(text: "有難う", reading: "ありがとう") - )) - - manager.acceptPredictionCandidate(prediction) + manager.acceptPredictionCandidate(makeCandidate(text: "有難う", reading: "ありがとう")) #expect(manager.convertTarget == "ありがとう") } @@ -80,12 +69,7 @@ private func makePredictionSegmentsManager() -> SegmentsManager { @Test func testAcceptPredictionCandidateRejectsStaleCandidateWithoutEditingInput() throws { let manager = makePredictionSegmentsManager() manager.insertAtCursorPosition("こんにちは", inputStyle: .direct) - let prediction = try #require(SegmentsManager.makePredictionCandidate( - currentTarget: "こんば", - candidate: makeCandidate(text: "今晩は", reading: "こんばんは") - )) - - manager.acceptPredictionCandidate(prediction) + manager.acceptPredictionCandidate(makeCandidate(text: "今晩は", reading: "こんばんは")) #expect(manager.convertTarget == "こんにちは") } @@ -100,7 +84,7 @@ private func makePredictionSegmentsManager() -> SegmentsManager { displayText: "今晩は" )) - manager.acceptPredictionCandidate(prediction) + manager.acceptTypoCorrectionPredictionCandidate(prediction) #expect(manager.convertTarget == "こんばんは") }