From 8201dd3a2514363a3b17c0e1f6c825bdcfb86572 Mon Sep 17 00:00:00 2001 From: Yukihiro Shinoda Date: Sat, 5 Sep 2026 12:38:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(input):=20=E5=85=83=E3=81=AE=E8=8B=B1?= =?UTF-8?q?=E5=AD=97=E3=82=AD=E3=83=BC=E5=88=97=E3=81=8B=E3=82=89=E5=A4=A7?= =?UTF-8?q?=E5=B0=8F=E6=96=87=E5=AD=97=E3=81=AE=E5=A4=89=E6=8F=9B=E5=80=99?= =?UTF-8?q?=E8=A3=9C=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/InputUtils/RomanCaseCandidates.swift | 15 ++++++ .../Core/InputUtils/SegmentsManager.swift | 15 +++++- .../RomanCaseCandidatesTests.swift | 47 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 Core/Sources/Core/InputUtils/RomanCaseCandidates.swift create mode 100644 Core/Tests/CoreTests/InputUtilsTests/RomanCaseCandidatesTests.swift diff --git a/Core/Sources/Core/InputUtils/RomanCaseCandidates.swift b/Core/Sources/Core/InputUtils/RomanCaseCandidates.swift new file mode 100644 index 00000000..ef4c51d6 --- /dev/null +++ b/Core/Sources/Core/InputUtils/RomanCaseCandidates.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Case variants of the original keyboard input, before kana conversion. +enum RomanCaseCandidates { + static func variants(for input: String) -> [String] { + guard input.unicodeScalars.allSatisfy({ (0x21...0x7e).contains($0.value) }), + input.unicodeScalars.contains(where: { (0x41...0x5a).contains($0.value) || (0x61...0x7a).contains($0.value) }) else { + return [] + } + let lower = input.lowercased() + let title = lower.prefix(1).uppercased() + lower.dropFirst() + var seen = Set() + return [lower, title, input.uppercased()].filter { seen.insert($0).inserted } + } +} diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 3188ca36..03a795a1 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -564,7 +564,7 @@ public final class SegmentsManager { let leftSideContext = forcedLeftSideContext ?? self.getCleanLeftSideContext(maxCount: ContextLength.conversion) let rightSideContext = forcedRightSideContext ?? self.getCleanRightSideContext(maxCount: ContextLength.conversion) - let result = self.kanaKanjiConverter.requestCandidates( + var result = self.kanaKanjiConverter.requestCandidates( self.composingText, options: options( leftSideContext: leftSideContext, @@ -574,6 +574,19 @@ public final class SegmentsManager { requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled ) ) + let romanInput = self.composingText.input.map(\.piece).inputString(preferIntention: false) + let romanTexts = Set(result.mainResults.map(\.text)) + let romanCandidates = RomanCaseCandidates.variants(for: romanInput) + .filter { !romanTexts.contains($0) }.map { text in + Candidate( + text: text, value: -18, + composingCount: .inputCount(self.composingText.input.count), + lastMid: MIDData.一般.mid, + data: [.init(word: text, ruby: self.composingText.convertTarget.toKatakana(), cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18)], + isLearningTarget: false + ) + } + result.mainResults.insert(contentsOf: romanCandidates, at: min(5, result.mainResults.count)) self.rawCandidates = result } diff --git a/Core/Tests/CoreTests/InputUtilsTests/RomanCaseCandidatesTests.swift b/Core/Tests/CoreTests/InputUtilsTests/RomanCaseCandidatesTests.swift new file mode 100644 index 00000000..5a98ece1 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/RomanCaseCandidatesTests.swift @@ -0,0 +1,47 @@ +@testable import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +@MainActor +struct RomanCaseCandidatesTests { + @Test func variantsAndExclusions() { + #expect(RomanCaseCandidates.variants(for: "fable") == ["fable", "Fable", "FABLE"]) + #expect(RomanCaseCandidates.variants(for: "GitHub") == ["github", "Github", "GITHUB"]) + #expect(RomanCaseCandidates.variants(for: "a") == ["a", "A"]) + for input in ["", "1111", "ふぁbぇ", "コーヒー", "abc\ndef"] { + #expect(RomanCaseCandidates.variants(for: input).isEmpty) + } + } + + @Test(arguments: ["fable", "github", "FABLE"], ["lower", "title", "upper"]) + func originalKeysCanBeConvertedAndFullyCommitted(input: String, form: String) throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let manager = SegmentsManager(kanaKanjiConverter: .withDefaultDictionary(), applicationDirectoryURL: directory, + containerURL: nil, context: .init(useZenzai: false)) + for character in input { + manager.insertAtCursorPosition(String(character), inputStyle: .roman2kana) + } + if input == "fable" { #expect(manager.convertTarget == "ふぁbぇ") } + let lower = input.lowercased() + let expected = form == "lower" ? lower : form == "title" ? lower.prefix(1).uppercased() + lower.dropFirst() : input.uppercased() + for rich in [false, true] { + manager.update(requestRichCandidates: rich) + manager.requestSetCandidateWindowState(visible: true) + guard case .selecting(let choices, _) = manager.getCurrentCandidateWindow(inputState: .selecting) else { + Issue.record("Expected conversion candidates") + return + } + #expect(choices.filter { $0.text == expected }.count == 1) + if rich { + let row = try #require(choices.firstIndex { $0.text == expected }) + manager.requestSelectingRow(row) + let candidate = try #require(manager.selectedCandidate) + manager.prefixCandidateCommited(candidate, leftSideContext: "") + #expect(manager.isEmpty) + } + } + } +} From 2776f9e975865a5ca6a476a6cad6dce02c0b4865 Mon Sep 17 00:00:00 2001 From: Yukihiro Shinoda Date: Sat, 5 Sep 2026 13:03:38 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(input):=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AB=E5=AF=BE=E5=BF=9C=E3=81=97?= =?UTF-8?q?=E5=A4=89=E6=8F=9B=E7=AF=84=E5=9B=B2=E3=81=A8=E5=80=99=E8=A3=9C?= =?UTF-8?q?=E5=87=A6=E7=90=86=E3=82=92=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/InputUtils/SegmentsManager.swift | 17 ++++++++------ .../EditedVariantRangeTests.swift | 23 +++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 Core/Tests/CoreTests/InputUtilsTests/EditedVariantRangeTests.swift diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 03a795a1..b51b8f51 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -574,19 +574,22 @@ public final class SegmentsManager { requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled ) ) - let romanInput = self.composingText.input.map(\.piece).inputString(preferIntention: false) - let romanTexts = Set(result.mainResults.map(\.text)) - let romanCandidates = RomanCaseCandidates.variants(for: romanInput) - .filter { !romanTexts.contains($0) }.map { text in + let romanComposingText = self.composingText.prefixToCursorPosition() + let romanInput = romanComposingText.input.map(\.piece).inputString(preferIntention: false) + let romanVariants = RomanCaseCandidates.variants(for: romanInput) + if !romanVariants.isEmpty { + let romanTexts = Set(result.mainResults.map(\.text)) + let romanCandidates = romanVariants.filter { !romanTexts.contains($0) }.map { text in Candidate( text: text, value: -18, - composingCount: .inputCount(self.composingText.input.count), + composingCount: .inputCount(romanComposingText.input.count), lastMid: MIDData.一般.mid, - data: [.init(word: text, ruby: self.composingText.convertTarget.toKatakana(), cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18)], + data: [.init(word: text, ruby: romanInput, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18)], isLearningTarget: false ) } - result.mainResults.insert(contentsOf: romanCandidates, at: min(5, result.mainResults.count)) + result.mainResults.insert(contentsOf: romanCandidates, at: min(5, result.mainResults.count)) + } self.rawCandidates = result } diff --git a/Core/Tests/CoreTests/InputUtilsTests/EditedVariantRangeTests.swift b/Core/Tests/CoreTests/InputUtilsTests/EditedVariantRangeTests.swift new file mode 100644 index 00000000..6d525266 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/EditedVariantRangeTests.swift @@ -0,0 +1,23 @@ +import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +@MainActor +@Test func editedVariantConsumesOnlySelectedPrefix() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let manager = SegmentsManager(kanaKanjiConverter: .withDefaultDictionary(), applicationDirectoryURL: directory, + containerURL: nil, context: .init(useZenzai: false)) + manager.insertAtCursorPosition("sushi", inputStyle: .roman2kana) + manager.editSegment(count: -1) + manager.requestSetCandidateWindowState(visible: true) + guard case .selecting(let choices, _) = manager.getCurrentCandidateWindow(inputState: .selecting) else { + Issue.record("Expected candidates for edited segment") + return + } + let candidate = try #require(choices.first { $0.text == "SU" }) + manager.prefixCandidateCommited(candidate, leftSideContext: "") + #expect(manager.convertTarget == "し") +}