diff --git a/macSKK.xcodeproj/project.pbxproj b/macSKK.xcodeproj/project.pbxproj index dcab215c..b9fc7f8f 100644 --- a/macSKK.xcodeproj/project.pbxproj +++ b/macSKK.xcodeproj/project.pbxproj @@ -10,11 +10,14 @@ CE06CA2B2AAC171B00E80E5E /* FileDictTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE06CA292AAC171B00E80E5E /* FileDictTests.swift */; }; CE06CA2D2AAC172F00E80E5E /* empty.txt in Resources */ = {isa = PBXBuildFile; fileRef = CE06CA2C2AAC172F00E80E5E /* empty.txt */; }; CE06CA342AAC199500E80E5E /* UserDict+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE06CA332AAC199500E80E5E /* UserDict+Utilities.swift */; }; + CE313A1F2AF5213700A49142 /* Candidate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE313A1E2AF5213700A49142 /* Candidate.swift */; }; CE39DB212A8DFD8F00BC619F /* MarkedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE39DB202A8DFD8F00BC619F /* MarkedText.swift */; }; CE40D9A12A6D0C2F00D44799 /* SystemDictView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE40D9A02A6D0C2F00D44799 /* SystemDictView.swift */; }; CE40D9A32A6D0C3900D44799 /* SystemDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE40D9A22A6D0C3900D44799 /* SystemDict.swift */; }; CE485A882A8FA195008271EF /* Release+UNNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE485A872A8FA195008271EF /* Release+UNNotification.swift */; }; CE485A8A2A8FA5C6008271EF /* UserNotificationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE485A892A8FA5C6008271EF /* UserNotificationDelegate.swift */; }; + CE4CB5CC2AD557D90046FA34 /* NumberEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE4CB5CB2AD557D90046FA34 /* NumberEntry.swift */; }; + CE4CB5CE2AD55DF90046FA34 /* NumberEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE4CB5CD2AD55DF90046FA34 /* NumberEntryTests.swift */; }; CE5EB6AD2AAC0DE000389B98 /* FileDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5EB6AC2AAC0DE000389B98 /* FileDict.swift */; }; CE5EB6AF2AAC0DEB00389B98 /* MemoryDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5EB6AE2AAC0DEB00389B98 /* MemoryDict.swift */; }; CE5ECF362957034B00E7BE7D /* macSKKApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5ECF352957034B00E7BE7D /* macSKKApp.swift */; }; @@ -65,7 +68,7 @@ CED7CA5B2A83DE7F004EF988 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED7CA5A2A83DE7F004EF988 /* SettingsViewModel.swift */; }; CED7F51F2AB5F4A7007FC6BD /* Character+Additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED7F51E2AB5F4A7007FC6BD /* Character+Additions.swift */; }; CEE2D9772A99FE1B00A4CD76 /* Word.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE2D9762A99FE1B00A4CD76 /* Word.swift */; }; - CEE2D9792A99FEC700A4CD76 /* WordTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE2D9782A99FEC700A4CD76 /* WordTest.swift */; }; + CEE2D9792A99FEC700A4CD76 /* CandidateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE2D9782A99FEC700A4CD76 /* CandidateTest.swift */; }; CEE3717529653112000DB2C3 /* SoftwareUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE3717429653112000DB2C3 /* SoftwareUpdateView.swift */; }; CEF0823629685C0800646366 /* StateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF0823529685C0800646366 /* StateTests.swift */; }; CEF0823A296BCDB000646366 /* InputModePanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF08239296BCDB000646366 /* InputModePanel.swift */; }; @@ -109,11 +112,14 @@ CE06CA292AAC171B00E80E5E /* FileDictTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileDictTests.swift; sourceTree = ""; }; CE06CA2C2AAC172F00E80E5E /* empty.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = empty.txt; sourceTree = ""; }; CE06CA332AAC199500E80E5E /* UserDict+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UserDict+Utilities.swift"; sourceTree = ""; }; + CE313A1E2AF5213700A49142 /* Candidate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Candidate.swift; sourceTree = ""; }; CE39DB202A8DFD8F00BC619F /* MarkedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkedText.swift; sourceTree = ""; }; CE40D9A02A6D0C2F00D44799 /* SystemDictView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemDictView.swift; sourceTree = ""; }; CE40D9A22A6D0C3900D44799 /* SystemDict.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemDict.swift; sourceTree = ""; }; CE485A872A8FA195008271EF /* Release+UNNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Release+UNNotification.swift"; sourceTree = ""; }; CE485A892A8FA5C6008271EF /* UserNotificationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationDelegate.swift; sourceTree = ""; }; + CE4CB5CB2AD557D90046FA34 /* NumberEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberEntry.swift; sourceTree = ""; }; + CE4CB5CD2AD55DF90046FA34 /* NumberEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberEntryTests.swift; sourceTree = ""; }; CE5EB6AC2AAC0DE000389B98 /* FileDict.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDict.swift; sourceTree = ""; }; CE5EB6AE2AAC0DEB00389B98 /* MemoryDict.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryDict.swift; sourceTree = ""; }; CE5ECF322957034B00E7BE7D /* macSKK.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = macSKK.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -172,7 +178,7 @@ CED7CA5A2A83DE7F004EF988 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; CED7F51E2AB5F4A7007FC6BD /* Character+Additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Character+Additions.swift"; sourceTree = ""; }; CEE2D9762A99FE1B00A4CD76 /* Word.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Word.swift; sourceTree = ""; }; - CEE2D9782A99FEC700A4CD76 /* WordTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordTest.swift; sourceTree = ""; }; + CEE2D9782A99FEC700A4CD76 /* CandidateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CandidateTest.swift; sourceTree = ""; }; CEE3717429653112000DB2C3 /* SoftwareUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftwareUpdateView.swift; sourceTree = ""; }; CEF0823529685C0800646366 /* StateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateTests.swift; sourceTree = ""; }; CEF08239296BCDB000646366 /* InputModePanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputModePanel.swift; sourceTree = ""; }; @@ -241,6 +247,7 @@ CE84A3DB2957174D009394C4 /* State.swift */, CE84A3DF295717CB009394C4 /* StateMachine.swift */, CEE2D9762A99FE1B00A4CD76 /* Word.swift */, + CE4CB5CB2AD557D90046FA34 /* NumberEntry.swift */, CE84A3EA295DA715009394C4 /* Dict.swift */, CE5EB6AC2AAC0DE000389B98 /* FileDict.swift */, CE5EB6AE2AAC0DEB00389B98 /* MemoryDict.swift */, @@ -267,6 +274,7 @@ CE6DBAC62A85BF3E00F5A227 /* Localizable.strings */, CEB0888B2A7F342000EFD1E3 /* Credits.rtf */, CE5ECF3B2957034D00E7BE7D /* Preview Content */, + CE313A1E2AF5213700A49142 /* Candidate.swift */, ); path = macSKK; sourceTree = ""; @@ -284,7 +292,8 @@ isa = PBXGroup; children = ( CE84A3E8295DA504009394C4 /* RomajiTests.swift */, - CEE2D9782A99FEC700A4CD76 /* WordTest.swift */, + CEE2D9782A99FEC700A4CD76 /* CandidateTest.swift */, + CE4CB5CD2AD55DF90046FA34 /* NumberEntryTests.swift */, CE84A3EC295DA818009394C4 /* MemoryDictTests.swift */, CE06CA292AAC171B00E80E5E /* FileDictTests.swift */, CEA78FB129646CA100B67E25 /* UserDictTests.swift */, @@ -540,12 +549,14 @@ CED7CA3C2A839603004EF988 /* UpdateChecker.swift in Sources */, CE5ECF362957034B00E7BE7D /* macSKKApp.swift in Sources */, CEC061C82ABB0A0100A11614 /* CompletionPanel.swift in Sources */, + CE4CB5CC2AD557D90046FA34 /* NumberEntry.swift in Sources */, CE84A3DE29571797009394C4 /* Action.swift in Sources */, CE485A882A8FA195008271EF /* Release+UNNotification.swift in Sources */, CED7CA3A2A839505004EF988 /* FetchUpdateServiceProtocol.swift in Sources */, CEA78FB02964209B00B67E25 /* UserDict.swift in Sources */, CEF08257296D8CBD00646366 /* CandidatesPanel.swift in Sources */, CE97887B2A9B93EB00F9B196 /* DirectModeView.swift in Sources */, + CE313A1F2AF5213700A49142 /* Candidate.swift in Sources */, CE6DBA8F2A845E8B00F5A227 /* ReleaseVersion.swift in Sources */, CEB088902A7F73B400EFD1E3 /* Pasteboard.swift in Sources */, CE84A3DC2957174D009394C4 /* State.swift in Sources */, @@ -569,8 +580,9 @@ CE84A3ED295DA818009394C4 /* MemoryDictTests.swift in Sources */, CE06CA342AAC199500E80E5E /* UserDict+Utilities.swift in Sources */, CE84A3E9295DA504009394C4 /* RomajiTests.swift in Sources */, + CE4CB5CE2AD55DF90046FA34 /* NumberEntryTests.swift in Sources */, CEA78FAA295EBCAC00B67E25 /* StateMachineTests.swift in Sources */, - CEE2D9792A99FEC700A4CD76 /* WordTest.swift in Sources */, + CEE2D9792A99FEC700A4CD76 /* CandidateTest.swift in Sources */, CEF0823629685C0800646366 /* StateTests.swift in Sources */, CED7CA3E2A8397E4004EF988 /* UpdateCheckerTests.swift in Sources */, CEA78FB229646CA100B67E25 /* UserDictTests.swift in Sources */, diff --git a/macSKK/Candidate.swift b/macSKK/Candidate.swift new file mode 100644 index 00000000..838f056a --- /dev/null +++ b/macSKK/Candidate.swift @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2023 mtgto +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/** + * 変換候補 + */ +struct Candidate: Hashable { + /** + * 辞書上での表記 + */ + struct Original: Hashable { + /** + * 辞書上の見出し語の表記。 + * 数値変換の場合 "だい#" のような数値部分を "#" で表す表記がされている。 + */ + let midashi: String + /** + * 辞書上の変換結果の表記。 + * 数値変換の場合 "第#1" のような変換フォーマットを表す表記がされている。 + */ + let word: Word.Word + } + + /** + * 変換結果。数値変換の場合は例外あり。 + * 数値変換の場合、辞書には "第#1" のように登録されているが "第5" のようにユーザー入力で置換されている。 + */ + let word: Word.Word + + /** + * 辞書上の表記。現在は数値変換時のみ設定される。 + */ + let original: Original? + + /** + * 注釈。複数の辞書によるものがあればまとめられている。 + */ + private(set) var annotations: [Annotation] + + /** + * 辞書に登録されている読み。 + */ + func toMidashiString(yomi: String) -> String { + original?.midashi ?? yomi + } + + /** + * 辞書に登録されている変換候補。 + */ + var candidateString: String { + original?.word ?? word + } + + init(_ word: Word.Word, annotations: [Annotation] = [], original: Original? = nil) { + self.word = word + self.annotations = annotations + self.original = original + } + + func hash(into hasher: inout Hasher) { + hasher.combine(word) + } + + /// 注釈を追加する。すでに同じテキストをもつ注釈があれば追加されない。 + mutating func appendAnnotations(_ annotations: [Annotation]) { + for annotation in annotations { + if self.annotations.allSatisfy({ $0.text != annotation.text }) { + self.annotations.append(annotation) + } + } + } +} diff --git a/macSKK/Character+Additions.swift b/macSKK/Character+Additions.swift index 3b16714f..a49076a7 100644 --- a/macSKK/Character+Additions.swift +++ b/macSKK/Character+Additions.swift @@ -10,4 +10,8 @@ extension Character { var isAlphabet: Bool { "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".contains(self) } + + var isNumber: Bool { + "0123456789".contains(self) + } } diff --git a/macSKK/Dict.swift b/macSKK/Dict.swift index 6a2aecd9..99791cf0 100644 --- a/macSKK/Dict.swift +++ b/macSKK/Dict.swift @@ -53,6 +53,7 @@ protocol DictProtocol { * - prefixが空文字列ならnilを返す * - ユーザー辞書の送りなしの読みのうち、最近変換したものから選択する。 * - prefixと読みが完全に一致する場合は補完候補とはしない + * - 数値変換用の読みは補完候補としない */ func findCompletion(prefix: String) -> String? } diff --git a/macSKK/MemoryDict.swift b/macSKK/MemoryDict.swift index d09450f5..efcf980a 100644 --- a/macSKK/MemoryDict.swift +++ b/macSKK/MemoryDict.swift @@ -160,7 +160,7 @@ struct MemoryDict: DictProtocol { func findCompletion(prefix: String) -> String? { if !prefix.isEmpty { for yomi in okuriNashiYomis.reversed() { - if yomi.count > prefix.count && yomi.hasPrefix(prefix) { + if yomi.count > prefix.count && yomi.hasPrefix(prefix) && !yomi.contains(where: { $0 == "#" }) { return yomi } } diff --git a/macSKK/NumberEntry.swift b/macSKK/NumberEntry.swift new file mode 100644 index 00000000..18f201e6 --- /dev/null +++ b/macSKK/NumberEntry.swift @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: 2023 mtgto +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/** + * 数値変換用のユーザーが入力した読み部分。"だい1" のような入力を受け取り、整数部分と非整数部分に分ける。 + */ +struct NumberYomi { + static let pattern = /#([01234589])/ + + // TODO: Stringを作るコストをなくしyomiのSubstringでもっておく。Substringだとユニットテストが書きにくくなるしこれでもいいかも。 + enum Element: Equatable { + /// 正規表現だと /[0-9]+/ となる文字列。UInt64で収まらない数値はあきらめる + case number(UInt64) + /// ``number`` 以外 + case other(String) + } + + let elements: [Element] + + /** + * ユーザーが入力した読み部分を受け取り初期化する。 + * + * - Returns: 整数が一つも含まれない (整数が異常に巨大な場合を含む) ときは nil。 + */ + init?(_ yomi: String) { + // 連続する整数と連続する非整数をパースする + var elements: [Element] = [] + var current = yomi[...] + var containsNumber: Bool = false + while !current.isEmpty { + let numberString = current.prefix(while: { $0.isNumber }) + if !numberString.isEmpty { + if let number = UInt64(numberString) { + elements.append(.number(number)) + current = yomi.suffix(from: numberString.endIndex) + containsNumber = true + } else { + logger.log("巨大な数値が含まれているためパースできませんでした") + return nil + } + } + let other = current.prefix(while: { !$0.isNumber }) + if !other.isEmpty { + elements.append(.other(String(other))) + current = yomi.suffix(from: other.endIndex) + } + } + self.elements = elements + if !containsNumber { + return nil + } + } + + /** + * 辞書の見出し語となる文字列に変換して返す + */ + func toMidashiString() -> String { + return elements.map { element in + switch element { + case .number: + return "#" + case .other(let string): + return string + } + }.joined() + } +} + +// 数値入りの変換候補 +struct NumberCandidate { + static let pattern = /#([01234589])/ + static let kanjiFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "ja-JP") + formatter.numberStyle = .spellOut + return formatter + }() + + enum Element: Equatable { + /// 正規表現だと /#[01234589]/ となる文字列。引数は数値部分 + case number(Int) + /// ``number`` 以外 + case other(String) + } + + let elements: [Element] + + init(yomi: String) throws { + var result: [Element] = [] + var current = yomi[...] + while !current.isEmpty { + if let match = try Self.pattern.firstMatch(in: current) { + let prefix = current.prefix(upTo: match.range.lowerBound) + if !prefix.isEmpty { + result.append(.other(String(prefix))) + } + if let type = Int(match.1) { + result.append(.number(type)) + } + current = current.suffix(from: match.range.upperBound) + } else { + break + } + } + if !current.isEmpty { + result.append(.other(String(current))) + } + self.elements = result + } + + /** + * 数値入りの変換候補を読みに含まれる数値の情報を使って文字列に変換します + * + * もし読みと変換候補で数値情報の数に差があったり文字列に変換できない数値を含む場合はnilを返します + */ + func toString(yomi: NumberYomi) -> String? { + var result: String = "" + if yomi.elements.count != elements.count { + return nil + } + for (i, yomiElement) in yomi.elements.enumerated() { + switch yomiElement { + case .number(let number): + if case .number(let type) = elements[i] { + switch type { + case 0: + result.append(String(number)) + case 1: // 全角 + result.append(String(number).toZenkaku()) + case 2: // 漢数字(位取りあり) + result.append(toKanjiString(number: number)) + case 3: // 漢数字(位取りなし) + result.append(Self.kanjiFormatter.string(from: NSNumber(value: number))!) + case 4: // 数字部分で辞書を引く + // TODO: あとで対応する + return nil + case 5: // 小切手や手形の金額記入の際用いられる表記 + // TODO: あとで対応する + return nil + case 8: // 3桁ごとに区切る + result.append(number.formatted(.number)) + case 9: // 将棋の棋譜入力用 + if number < 10 || number > 99 || number % 10 == 0 { + return nil + } + result.append(String(number / 10).toZenkaku() + toKanjiString(number: number % 10)) + default: + fatalError("未サポートの数値変換タイプ \(type) が指定されています") + } + } else { + return nil + } + case .other: + if case .other(let other) = elements[i] { + result.append(other) + } else { + return nil + } + } + } + return result + } + + func toKanjiString(number: UInt64) -> String { + if number == 0 { + return "" + } + return toKanjiString(number: number / 10) + ["〇", "一", "二", "三", "四", "五", "六", "七", "八", "九"][Int(number % 10)] + } +} diff --git a/macSKK/State.swift b/macSKK/State.swift index 0b2c751e..c71bab14 100644 --- a/macSKK/State.swift +++ b/macSKK/State.swift @@ -300,17 +300,28 @@ struct SelectingState: Equatable, MarkedTextProtocol { } /// 候補選択状態に遷移する前の状態。 let prev: PrevState - /// 辞書登録する際の読み。ひらがなのみ、もしくは `ひらがな + アルファベット` もしくは `":" + アルファベット` (abbrev) のパターンがある + /** + * 辞書登録する際の読み。ただし数値変換エントリの場合は例外あり。 + * + * ひらがなのみ、もしくは `ひらがな + アルファベット` もしくは `":" + アルファベット` (abbrev) のパターンがある。 + * 数値変換の場合は辞書上は "だい#" のように数値が入る部分が "#" になっているが、 + * yomiの場合は "だい5" のように実際のユーザー入力で置換されたものになる。 + * 辞書に読み "だい#" と "だい5" がある場合、変換後にユーザー辞書に変換結果として保存するときは + * 数値変換したら前者の読み、しなかった場合は後者の読みで登録される。 + */ let yomi: String /// 変換候補 - let candidates: [ReferredWord] + let candidates: [Candidate] var candidateIndex: Int = 0 /// カーソル位置。この位置を基に変換候補パネルを表示する let cursorPosition: NSRect func addCandidateIndex(diff: Int) -> Self { return SelectingState( - prev: prev, yomi: yomi, candidates: candidates, candidateIndex: candidateIndex + diff, + prev: prev, + yomi: yomi, + candidates: candidates, + candidateIndex: candidateIndex + diff, cursorPosition: cursorPosition) } @@ -343,7 +354,10 @@ struct RegisterState: SpecialStateProtocol { } /// 辞書登録状態に遷移する前の状態。 let prev: PrevState - /// 辞書登録する際の読み。ひらがなのみ、もしくは `ひらがな + アルファベット` もしくは `":" + アルファベット` (abbrev) のパターンがある + /** + * 辞書登録する際の読み。 + * ひらがなのみ、もしくは `ひらがな + アルファベット` もしくは `":" + アルファベット` (abbrev) のパターンがある。 + */ let yomi: String /// 入力中の登録単語。変換中のように未確定の文字列は含まず確定済文字列のみが入る var text: String = "" @@ -519,7 +533,7 @@ struct Candidates: Equatable { /// パネル形式のときの現在ページと最大ページ数。 struct Page: Equatable { /// 現在表示される変換候補。全体の変換候補の一部。 - let words: [ReferredWord] + let words: [Candidate] /// 全体の変換候補表示の何ページ目かという数値 (0オリジン) let current: Int /// 全体の変換候補表示の最大ページ数 @@ -528,7 +542,7 @@ struct Candidates: Equatable { /// パネル形式のときの現在ページと最大ページ数。インライン変換中はnil let page: Page? - let selected: ReferredWord + let selected: Candidate let cursorPosition: NSRect } @@ -578,7 +592,8 @@ struct IMEState { } case .unregister(let unregisterState): let selectingState = unregisterState.prev.selecting - elements.append(.plain("\(selectingState.yomi) /\(selectingState.candidates[selectingState.candidateIndex].word)/ を削除します(yes/no)")) + let selectingCandidate = selectingState.candidates[selectingState.candidateIndex] + elements.append(.plain("\(selectingCandidate.toMidashiString(yomi: selectingState.yomi)) /\(selectingCandidate.candidateString)/ を削除します(yes/no)")) if !unregisterState.text.isEmpty { elements.append(.plain(unregisterState.text)) } diff --git a/macSKK/StateMachine.swift b/macSKK/StateMachine.swift index c4ab17ff..e1892386 100644 --- a/macSKK/StateMachine.swift +++ b/macSKK/StateMachine.swift @@ -653,7 +653,9 @@ class StateMachine { } else { let selectingState = SelectingState( prev: SelectingState.PrevState(mode: state.inputMode, composing: newComposing), - yomi: yomiText, candidates: candidates, candidateIndex: 0, + yomi: yomiText, + candidates: candidates, + candidateIndex: 0, cursorPosition: action.cursorPosition) updateCandidates(selecting: selectingState) state.inputMethod = .selecting(selectingState) @@ -736,7 +738,7 @@ class StateMachine { // 変換候補がないときは辞書登録へ let trimmedComposing = composing.trim() var yomiText = trimmedComposing.yomi(for: state.inputMode) - let candidateWords: [ReferredWord] + let candidateWords: [Candidate] // FIXME: Abbrevモードでも接頭辞、接尾辞を検索するべきか再検討する。 // いまは ">"で終わる・始まる場合は、Abbrevモードであっても接頭辞・接尾辞を探しているものとして検索する if yomiText.hasSuffix(">") { @@ -765,7 +767,9 @@ class StateMachine { } else { let selectingState = SelectingState( prev: SelectingState.PrevState(mode: state.inputMode, composing: trimmedComposing), - yomi: yomiText, candidates: candidateWords, candidateIndex: 0, + yomi: yomiText, + candidates: candidateWords, + candidateIndex: 0, cursorPosition: action.cursorPosition) updateCandidates(selecting: selectingState) state.inputMethod = .selecting(selectingState) @@ -777,7 +781,7 @@ class StateMachine { func handleSelecting(_ action: Action, selecting: SelectingState, specialState: SpecialState?) -> Bool { switch action.keyEvent { case .enter: - addWordToUserDict(yomi: selecting.yomi, word: selecting.candidates[selecting.candidateIndex].word) + addWordToUserDict(yomi: selecting.yomi, candidate: selecting.candidates[selecting.candidateIndex]) updateCandidates(selecting: nil) state.inputMethod = .normal addFixedText(selecting.fixedText()) @@ -837,7 +841,7 @@ class StateMachine { return true case .stickyShift, .ctrlJ, .ctrlQ: // 選択中候補で確定 - addWordToUserDict(yomi: selecting.yomi, word: selecting.candidates[selecting.candidateIndex].word) + addWordToUserDict(yomi: selecting.yomi, candidate: selecting.candidates[selecting.candidateIndex]) updateCandidates(selecting: nil) addFixedText(selecting.fixedText()) state.inputMethod = .normal @@ -853,7 +857,7 @@ class StateMachine { return true } else if input == "." && action.shiftIsPressed() { // 選択中候補で確定し、接尾辞入力に移行 - addWordToUserDict(yomi: selecting.yomi, word: selecting.candidates[selecting.candidateIndex].word) + addWordToUserDict(yomi: selecting.yomi, candidate: selecting.candidates[selecting.candidateIndex]) updateCandidates(selecting: nil) addFixedText(selecting.fixedText()) state.inputMethod = .composing(ComposingState(isShift: true, text: [], okuri: nil, romaji: "")) @@ -863,7 +867,7 @@ class StateMachine { let diff = index - 1 - (selecting.candidateIndex - inlineCandidateCount) % displayCandidateCount if selecting.candidateIndex + diff < selecting.candidates.count { let newSelecting = selecting.addCandidateIndex(diff: diff) - addWordToUserDict(yomi: newSelecting.yomi, word: newSelecting.candidates[newSelecting.candidateIndex].word) + addWordToUserDict(yomi: selecting.yomi, candidate: newSelecting.candidates[newSelecting.candidateIndex]) updateCandidates(selecting: nil) state.inputMethod = .normal addFixedText(newSelecting.fixedText()) @@ -872,7 +876,7 @@ class StateMachine { } } // 選択中候補で確定 - addWordToUserDict(yomi: selecting.yomi, word: selecting.candidates[selecting.candidateIndex].word) + addWordToUserDict(yomi: selecting.yomi, candidate: selecting.candidates[selecting.candidateIndex]) updateCandidates(selecting: nil) addFixedText(selecting.fixedText()) state.inputMethod = .normal @@ -1010,26 +1014,8 @@ class StateMachine { } /// 見出し語で辞書を引く。同じ文字列である変換候補が複数の辞書にある場合は最初の1つにまとめる。 - func candidates(for yomi: String, option: DictReferringOption? = nil) -> [ReferredWord] { - let candidates = dictionary.refer(yomi, option: option) - var result = [ReferredWord]() - for candidate in candidates { - if let index = result.firstIndex(where: { $0.word == candidate.word }) { - // 注釈だけマージする - if let annotation = candidate.annotation { - result[index].appendAnnotation(annotation) - } - } else { - let annotations: [Annotation] - if let annotation = candidate.annotation { - annotations = [annotation] - } else { - annotations = [] - } - result.append(ReferredWord(candidate.word, annotations: annotations)) - } - } - return result + func candidates(for yomi: String, option: DictReferringOption? = nil) -> [Candidate] { + return dictionary.referDicts(yomi, option: option) } /** @@ -1037,15 +1023,16 @@ class StateMachine { * * 他の辞書から選択した変換を追加する場合はその辞書の注釈は保存しないこと。 * - * FIXME: 単語登録時にユーザーが独自の注釈を登録できるようにする。 + * - Parameters: + * - yomi: ユーザーが入力した見出し語。整数変換エントリの辞書の見出しは "だい#" のような形式だが、この値は "だい5" のようにユーザーが入力したときの文字列なので "#" を含まない。 + * - candidate: 追加したい変換候補 */ - func addWordToUserDict(yomi: String, word: Word.Word, annotation: Annotation? = nil) { - let word = Word(word, annotation: annotation) - dictionary.add(yomi: yomi, word: word) + func addWordToUserDict(yomi: String, candidate: Candidate, annotation: Annotation? = nil) { + dictionary.add(yomi: candidate.toMidashiString(yomi: yomi), word: Word(candidate.candidateString, annotation: annotation)) } /// StateMachine外で選択されている変換候補が更新されたときに通知される - func didSelectCandidate(_ candidate: ReferredWord) { + func didSelectCandidate(_ candidate: Candidate) { if case .selecting(var selecting) = state.inputMethod { if let candidateIndex = selecting.candidates.firstIndex(of: candidate) { selecting.candidateIndex = candidateIndex @@ -1056,9 +1043,9 @@ class StateMachine { } /// StateMachine外で選択されている変換候補が二回選択されたときに通知される - func didDoubleSelectCandidate(_ candidate: ReferredWord) { + func didDoubleSelectCandidate(_ candidate: Candidate) { if case .selecting(let selecting) = state.inputMethod { - addWordToUserDict(yomi: selecting.yomi, word: candidate.word) + addWordToUserDict(yomi: selecting.yomi, candidate: candidate) updateCandidates(selecting: nil) state.inputMethod = .normal addFixedText(candidate.word) diff --git a/macSKK/UserDict.swift b/macSKK/UserDict.swift index d9aaf3d5..1c3b8967 100644 --- a/macSKK/UserDict.swift +++ b/macSKK/UserDict.swift @@ -97,6 +97,46 @@ class UserDict: NSObject, DictProtocol { NSFileCoordinator.removeFilePresenter(self) } + /** + * 保持する辞書を順に引き変換候補順に返す。 + * + * 複数の辞書に同じ変換がある場合、注釈を結合して返す + * + * - Parameters: + * - yomi: SKK辞書の見出し。複数のひらがな、もしくは複数のひらがな + ローマ字からなる文字列 + * - option: 辞書を引くときに接頭辞や接尾辞から検索するかどうか。nilなら通常のエントリから検索する + */ + func referDicts(_ yomi: String, option: DictReferringOption? = nil) -> [Candidate] { + var result: [Candidate] = [] + var candidates = refer(yomi, option: option).map { word in + let annotations: [Annotation] = if let annotation = word.annotation { [annotation] } else { [] } + return Candidate(word.word, annotations: annotations) + } + if candidates.isEmpty { + // yomiが数値を含む場合は "#" に置換して辞書を引く + if let numberYomi = NumberYomi(yomi) { + let midashi = numberYomi.toMidashiString() + candidates = refer(midashi, option: nil).compactMap({ word in + guard let numberCandidate = try? NumberCandidate(yomi: word.word) else { return nil } + guard let convertedWord = numberCandidate.toString(yomi: numberYomi) else { return nil } + let annotations: [Annotation] = if let annotation = word.annotation { [annotation] } else { [] } + return Candidate(convertedWord, + annotations: annotations, + original: Candidate.Original(midashi: midashi, word: word.word)) + }) + } + } + for candidate in candidates { + if let index = result.firstIndex(where: { $0.word == candidate.word }) { + // 注釈だけマージする + result[index].appendAnnotations(candidate.annotations) + } else { + result.append(candidate) + } + } + return result + } + // MARK: DictProtocol func refer(_ yomi: String, option: DictReferringOption? = nil) -> [Word] { var result = userDict.refer(yomi, option: option) @@ -180,7 +220,7 @@ class UserDict: NSObject, DictProtocol { * - prefixが空文字列ならnilを返す * - ユーザー辞書の送りなしの読みのうち、最近変換したものから選択する。 * - prefixと読みが完全に一致する場合は補完候補とはしない - + * - 数値変換用の読みは補完候補としない */ func findCompletion(prefix: String) -> String? { if privateMode.value { diff --git a/macSKK/View/CandidatesPanel.swift b/macSKK/View/CandidatesPanel.swift index 80fd3bde..7e33e8e9 100644 --- a/macSKK/View/CandidatesPanel.swift +++ b/macSKK/View/CandidatesPanel.swift @@ -18,7 +18,7 @@ final class CandidatesPanel: NSPanel { contentViewController = viewController } - func setCandidates(_ candidates: CurrentCandidates, selected: ReferredWord?) { + func setCandidates(_ candidates: CurrentCandidates, selected: Candidate?) { viewModel.candidates = candidates viewModel.selected = selected } diff --git a/macSKK/View/CandidatesView.swift b/macSKK/View/CandidatesView.swift index 1c070ab5..c66f4ee4 100644 --- a/macSKK/View/CandidatesView.swift +++ b/macSKK/View/CandidatesView.swift @@ -86,9 +86,9 @@ struct CandidatesView: View { } struct CandidatesView_Previews: PreviewProvider { - private static let words: [ReferredWord] = (1..<9).map { - ReferredWord(String(repeating: "例文\($0)", count: $0), - annotations: [Annotation(dictId: "SKK-JISYO.L", text: "注釈\($0)")]) + private static let words: [Candidate] = (1..<9).map { + Candidate(String(repeating: "例文\($0)", count: $0), + annotations: [Annotation(dictId: "SKK-JISYO.L", text: "注釈\($0)")]) } private static func pageViewModel() -> CandidatesViewModel { diff --git a/macSKK/View/CandidatesViewModel.swift b/macSKK/View/CandidatesViewModel.swift index abdc07ca..b07e9bf9 100644 --- a/macSKK/View/CandidatesViewModel.swift +++ b/macSKK/View/CandidatesViewModel.swift @@ -14,7 +14,7 @@ enum CurrentCandidates { /// - words: 現在表示されている変換候補 /// - currentPage: wordsが全体の変換候補表示の何ページ目かという数値 (0オリジン) /// - totalPageCount: 全体の変換候補表示の最大ページ数 - case panel(words: [ReferredWord], /// 現在表示されている変換候補 + case panel(words: [Candidate], /// 現在表示されている変換候補 currentPage: Int, totalPageCount: Int) } @@ -22,9 +22,9 @@ enum CurrentCandidates { @MainActor final class CandidatesViewModel: ObservableObject { @Published var candidates: CurrentCandidates - @Published var selected: ReferredWord? + @Published var selected: Candidate? /// 二回連続で同じ値がセットされた (マウスで選択されたとき) - @Published var doubleSelected: ReferredWord? + @Published var doubleSelected: Candidate? /// 選択中の変換候補のシステム辞書での注釈。キーは変換候補 @Published var systemAnnotations = Dictionary() @Published var popoverIsPresented: Bool = false @@ -33,7 +33,7 @@ final class CandidatesViewModel: ObservableObject { @Published var selectedAnnotations: [Annotation] = [] private var cancellables: Set = [] - init(candidates: [ReferredWord], currentPage: Int, totalPageCount: Int) { + init(candidates: [Candidate], currentPage: Int, totalPageCount: Int) { self.candidates = .panel(words: candidates, currentPage: currentPage, totalPageCount: totalPageCount) if let first = candidates.first { self.selected = first diff --git a/macSKK/Word.swift b/macSKK/Word.swift index bf3ee310..3eb553c0 100644 --- a/macSKK/Word.swift +++ b/macSKK/Word.swift @@ -28,25 +28,3 @@ struct Word: Hashable { hasher.combine(word) } } - -/// 複数の辞書から引いた、辞書ごとの注釈をもつことが可能なWord。 -struct ReferredWord: Hashable { - let word: Word.Word - private(set) var annotations: [Annotation] - - init(_ word: Word.Word, annotations: [Annotation] = []) { - self.word = word - self.annotations = annotations - } - - func hash(into hasher: inout Hasher) { - hasher.combine(word) - } - - /// 注釈を追加する。すでに同じテキストをもつ注釈があれば追加されない。 - mutating func appendAnnotation(_ annotation: Annotation) { - if annotations.allSatisfy({ $0.text != annotation.text }) { - annotations.append(annotation) - } - } -} diff --git a/macSKK/macSKKApp.swift b/macSKK/macSKKApp.swift index 75f9a2ad..362aef26 100644 --- a/macSKK/macSKKApp.swift +++ b/macSKK/macSKKApp.swift @@ -107,7 +107,7 @@ struct macSKKApp: App { }.keyboardShortcut("S") #if DEBUG Button("AnnotationsPanel") { - let word = ReferredWord("インライン", annotations: [Annotation(dictId: "", text: String(repeating: "これはインラインのテスト用注釈です", count: 5))]) + let word = Candidate("インライン", annotations: [Annotation(dictId: "", text: String(repeating: "これはインラインのテスト用注釈です", count: 5))]) candidatesPanel.setCandidates(.inline, selected: word) candidatesPanel.setCursorPosition(NSRect(origin: NSPoint(x: 100, y: 640), size: CGSize(width: 0, height: 30))) candidatesPanel.show() @@ -119,18 +119,18 @@ struct macSKKApp: App { candidatesPanel.viewModel.systemAnnotations = ["インライン": (String(repeating: "これはシステム辞書の注釈です。", count: 5))] } Button("Show CandidatesPanel") { - let words = [ReferredWord("こんにちは", annotations: [Annotation(dictId: "", text: "辞書の注釈")]), - ReferredWord("こんばんは"), - ReferredWord("おはようございます")] + let words = [Candidate("こんにちは", annotations: [Annotation(dictId: "", text: "辞書の注釈")]), + Candidate("こんばんは"), + Candidate("おはようございます")] candidatesPanel.setCandidates(.panel(words: words, currentPage: 0, totalPageCount: 1), selected: words.first) candidatesPanel.setCursorPosition(NSRect(origin: NSPoint(x: 100, y: 20), size: CGSize(width: 0, height: 30))) candidatesPanel.show() } Button("Add Word") { - let words = [ReferredWord("こんにちは", annotations: [Annotation(dictId: "", text: "辞書の注釈")]), - ReferredWord("こんばんは"), - ReferredWord("おはようございます"), - ReferredWord("追加したよ", annotations: [Annotation(dictId: "", text: "辞書の注釈")])] + let words = [Candidate("こんにちは", annotations: [Annotation(dictId: "", text: "辞書の注釈")]), + Candidate("こんばんは"), + Candidate("おはようございます"), + Candidate("追加したよ", annotations: [Annotation(dictId: "", text: "辞書の注釈")])] candidatesPanel.setCandidates(.panel(words: words, currentPage: 0, totalPageCount: 1), selected: words.last) candidatesPanel.viewModel.systemAnnotations = [words.last!.word: String(repeating: "これはシステム辞書の注釈です。", count: 5)] } diff --git a/macSKKTests/CandidateTest.swift b/macSKKTests/CandidateTest.swift new file mode 100644 index 00000000..40393145 --- /dev/null +++ b/macSKKTests/CandidateTest.swift @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2023 mtgto +// SPDX-License-Identifier: GPL-3.0-or-later + +import XCTest + +@testable import macSKK + +final class CandidateTest: XCTestCase { + func testAppendAnnotations() throws { + var candidate = Candidate("注釈") + XCTAssertEqual(candidate.annotations, []) + let annotation1 = Annotation(dictId: "d1", text: "a1") + candidate.appendAnnotations([annotation1]) + XCTAssertEqual(candidate.annotations, [annotation1]) + let annotation2 = Annotation(dictId: "d2", text: "a1") + candidate.appendAnnotations([annotation2]) + XCTAssertEqual(candidate.annotations, [annotation1], "注釈が同じテキストなら追加されない") + let annotation3 = Annotation(dictId: "d3", text: "a3") + candidate.appendAnnotations([annotation3]) + XCTAssertEqual(candidate.annotations, [annotation1, annotation3]) + } +} diff --git a/macSKKTests/MemoryDictTests.swift b/macSKKTests/MemoryDictTests.swift index 0c477f78..2f2b5daa 100644 --- a/macSKKTests/MemoryDictTests.swift +++ b/macSKKTests/MemoryDictTests.swift @@ -102,6 +102,8 @@ class MemoryDictTests: XCTestCase { dict.add(yomi: "あいうえお", word: Word("アイウエオ")) XCTAssertEqual(dict.findCompletion(prefix: "あいうえ"), "あいうえお", "あとで追加したエントリの読みを優先する") XCTAssertEqual(dict.findCompletion(prefix: "あいうえお"), "あいうえおか") + dict.add(yomi: "だい#", word: Word("第1")) + XCTAssertNil(dict.findCompletion(prefix: "だい"), "数値変換の読みはnil") } func testReferWithOption() { diff --git a/macSKKTests/NumberEntryTests.swift b/macSKKTests/NumberEntryTests.swift new file mode 100644 index 00000000..6ff2f8a1 --- /dev/null +++ b/macSKKTests/NumberEntryTests.swift @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2023 mtgto +// SPDX-License-Identifier: GPL-3.0-or-later + +import XCTest + +@testable import macSKK + +final class NumberEntryTests: XCTestCase { + func testNumberYomi() { + XCTAssertNil(NumberYomi("")) + XCTAssertNil(NumberYomi("あい"), "整数が入ってないとnil") + XCTAssertEqual(NumberYomi("あい1うえ")?.elements, [.other("あい"), .number(1), .other("うえ")]) + XCTAssertEqual(NumberYomi("123456789あい")?.elements, [.number(123456789), .other("あい")]) + XCTAssertEqual(NumberYomi("あ1い2う3")?.elements, [.other("あ"), .number(1), .other("い"), .number(2), .other("う"), .number(3)]) + XCTAssertEqual(NumberYomi("18446744073709551615")?.elements, [.number(18446744073709551615)], "UInt64の最大値") + XCTAssertNil(NumberYomi("18446744073709551616")) + } + + func testToMidashiString() { + XCTAssertEqual(NumberYomi("あい123う")?.toMidashiString(), "あい#う") + XCTAssertEqual(NumberYomi("0123456789あい")?.toMidashiString(), "#あい") + XCTAssertEqual(NumberYomi("あ1い2う3")?.toMidashiString(), "あ#い#う#") + } + + func testNumberCandidate() throws { + XCTAssertEqual(try NumberCandidate(yomi: "").elements, []) + XCTAssertEqual(try NumberCandidate(yomi: "#0").elements, [.number(0)]) + XCTAssertEqual(try NumberCandidate(yomi: "あ#1い#2").elements, [.other("あ"), .number(1), .other("い"), .number(2)]) + XCTAssertEqual(try NumberCandidate(yomi: "#3うえお##4か#5#6き#8く#9け").elements, [.number(3), + .other("うえお#"), + .number(4), + .other("か"), + .number(5), + .other("#6き"), + .number(8), + .other("く"), + .number(9), + .other("け")]) + } + + func testNumberCandidateToString() throws { + XCTAssertEqual(try NumberCandidate(yomi: "第#0回").toString(yomi: NumberYomi("だい100かい")!), "第100回") + XCTAssertEqual(try NumberCandidate(yomi: "#1位").toString(yomi: NumberYomi("100い")!), "100位") + XCTAssertEqual(try NumberCandidate(yomi: "#2").toString(yomi: NumberYomi("2309")!), "二三〇九") + XCTAssertEqual(try NumberCandidate(yomi: "#3").toString(yomi: NumberYomi("123456789")!), "一億二千三百四十五万六千七百八十九") + XCTAssertEqual(try NumberCandidate(yomi: "#0").toString(yomi: NumberYomi("9223372036854775807")!), "9223372036854775807") + XCTAssertEqual(try NumberCandidate(yomi: "#1").toString(yomi: NumberYomi("9223372036854775807")!), "9223372036854775807") + XCTAssertEqual(try NumberCandidate(yomi: "#2").toString(yomi: NumberYomi("9223372036854775807")!), "九二二三三七二〇三六八五四七七五八〇七") + XCTAssertEqual(try NumberCandidate(yomi: "#8").toString(yomi: NumberYomi("9223372036854775807")!), "9,223,372,036,854,775,807") + XCTAssertEqual(try NumberCandidate(yomi: "#9金").toString(yomi: NumberYomi("34きん")!), "3四金") + XCTAssertEqual(try NumberCandidate(yomi: "#9").toString(yomi: NumberYomi("3")!), nil, "数値は2桁で11 - 99である必要がある") + XCTAssertEqual(try NumberCandidate(yomi: "#9").toString(yomi: NumberYomi("111")!), nil) + XCTAssertEqual(try NumberCandidate(yomi: "#9").toString(yomi: NumberYomi("50")!), nil) + XCTAssertEqual(try NumberCandidate(yomi: "#0").toString(yomi: NumberYomi("1,2")!), nil, "数値の数が合わないとnil") + // SKK-JISYO.Lには数値変換らしきエントリで候補に "#数字" を含まないものがある。 + // 例えば "だい#" という見出しに "第" だけが登録されている。数値を切り捨ててほしいのかな…? + XCTAssertEqual(try NumberCandidate(yomi: "第").toString(yomi: NumberYomi("だい2")!), nil) + } + + func testNumberCandidateToStringTodo() throws { + XCTExpectFailure("未実装") + XCTAssertEqual(try NumberCandidate(yomi: "#5").toString(yomi: NumberYomi("1995")!), "壱阡九百九拾伍") + } +} diff --git a/macSKKTests/StateMachineTests.swift b/macSKKTests/StateMachineTests.swift index 20214022..9534a736 100644 --- a/macSKKTests/StateMachineTests.swift +++ b/macSKKTests/StateMachineTests.swift @@ -691,6 +691,54 @@ final class StateMachineTests: XCTestCase { wait(for: [expectation], timeout: 1.0) } + func testHandleComposingNumber() { + let entries = ["だい#": [Word("第#1"), Word("第#0"), Word("第#2"), Word("第#3")], "だい2": [Word("第2")]] + dictionary.dicts.append(MemoryDict(entries: entries, readonly: true)) + + let expectation = XCTestExpectation() + stateMachine.inputMethodEvent.collect(18).sink { events in + XCTAssertEqual(events[0], .markedText(MarkedText([.markerCompose, .plain("d")]))) + XCTAssertEqual(events[1], .markedText(MarkedText([.markerCompose, .plain("だ")]))) + XCTAssertEqual(events[2], .markedText(MarkedText([.markerCompose, .plain("だい")]))) + XCTAssertEqual(events[3], .markedText(MarkedText([.markerCompose, .plain("だい1")]))) + XCTAssertEqual(events[4], .markedText(MarkedText([.markerCompose, .plain("だい10")]))) + XCTAssertEqual(events[5], .markedText(MarkedText([.markerCompose, .plain("だい102")]))) + XCTAssertEqual(events[6], .markedText(MarkedText([.markerCompose, .plain("だい1024")]))) + XCTAssertEqual(events[7], .markedText(MarkedText([.markerSelect, .emphasized("第1024")]))) + XCTAssertEqual(events[8], .markedText(MarkedText([.markerSelect, .emphasized("第1024")]))) + XCTAssertEqual(events[9], .markedText(MarkedText([.markerSelect, .emphasized("第一〇二四")]))) + XCTAssertEqual(events[10], .markedText(MarkedText([.markerSelect, .emphasized("第千二十四")]))) + XCTAssertEqual(events[11], .fixedText("第千二十四")) + XCTAssertEqual(events[12], .markedText(MarkedText([.markerCompose, .plain("d")]))) + XCTAssertEqual(events[13], .markedText(MarkedText([.markerCompose, .plain("だ")]))) + XCTAssertEqual(events[14], .markedText(MarkedText([.markerCompose, .plain("だい")]))) + XCTAssertEqual(events[15], .markedText(MarkedText([.markerCompose, .plain("だい2")]))) + XCTAssertEqual(events[16], .markedText(MarkedText([.markerSelect, .emphasized("第2")]))) + XCTAssertEqual(events[17], .fixedText("第2")) + expectation.fulfill() + }.store(in: &cancellables) + XCTAssertTrue(stateMachine.handle(printableKeyEventAction(character: "d", withShift: true))) + XCTAssertTrue(stateMachine.handle(printableKeyEventAction(character: "a"))) + XCTAssertTrue(stateMachine.handle(printableKeyEventAction(character: "i"))) + XCTAssertTrue(stateMachine.handle(printableKeyEventAction(character: "1"))) + XCTAssertTrue(stateMachine.handle(printableKeyEventAction(character: "0"))) + XCTAssertTrue(stateMachine.handle(printableKeyEventAction(character: "2"))) + XCTAssertTrue(stateMachine.handle(printableKeyEventAction(character: "4"))) + XCTAssertTrue(stateMachine.handle(Action(keyEvent: .space, originalEvent: nil, cursorPosition: .zero))) + XCTAssertTrue(stateMachine.handle(Action(keyEvent: .space, originalEvent: nil, cursorPosition: .zero))) + XCTAssertTrue(stateMachine.handle(Action(keyEvent: .space, originalEvent: nil, cursorPosition: .zero))) + XCTAssertTrue(stateMachine.handle(Action(keyEvent: .space, originalEvent: nil, cursorPosition: .zero))) + XCTAssertTrue(stateMachine.handle(printableKeyEventAction(character: "d", withShift: true))) + XCTAssertEqual(dictionary.userDict.refer("だい#", option: nil), [Word("第#3")], "ユーザー辞書には#形式で保存する") + XCTAssertTrue(stateMachine.handle(printableKeyEventAction(character: "a"))) + XCTAssertTrue(stateMachine.handle(printableKeyEventAction(character: "i"))) + XCTAssertTrue(stateMachine.handle(printableKeyEventAction(character: "2"))) + XCTAssertTrue(stateMachine.handle(Action(keyEvent: .space, originalEvent: nil, cursorPosition: .zero))) + XCTAssertTrue(stateMachine.handle(Action(keyEvent: .enter, originalEvent: nil, cursorPosition: .zero))) + XCTAssertEqual(dictionary.userDict.refer("だい2", option: nil), [Word("第2")], "数値変換より通常のエントリを優先する") + wait(for: [expectation], timeout: 1.0) + } + // 送り仮名入力でShiftキーを押すのを子音側でするパターン func testHandleComposingOkuriari() { dictionary.setEntries(["とr": [Word("取"), Word("撮")]]) @@ -1910,11 +1958,13 @@ final class StateMachineTests: XCTestCase { } func testAddWordToUserDict() { - stateMachine.addWordToUserDict(yomi: "あ", word: "あああ") + stateMachine.addWordToUserDict(yomi: "あ", candidate: Candidate("あああ")) XCTAssertEqual(dictionary.refer("あ"), [Word("あああ", annotation: nil)]) let annotation = Annotation(dictId: "test", text: "test辞書の注釈") - stateMachine.addWordToUserDict(yomi: "い", word: "いいい", annotation: annotation) + stateMachine.addWordToUserDict(yomi: "い", candidate: Candidate("いいい"), annotation: annotation) XCTAssertEqual(dictionary.refer("い"), [Word("いいい", annotation: annotation)]) + stateMachine.addWordToUserDict(yomi: "だい1", candidate: Candidate("第一", original: Candidate.Original(midashi: "だい#", word: "第#3"))) + XCTAssertEqual(dictionary.refer("だい#"), [Word("第#3", annotation: nil)]) } private func nextInputMethodEvent() async -> InputMethodEvent { diff --git a/macSKKTests/StateTests.swift b/macSKKTests/StateTests.swift index f010a6b4..ff2ddcf8 100644 --- a/macSKKTests/StateTests.swift +++ b/macSKKTests/StateTests.swift @@ -157,7 +157,7 @@ final class StateTests: XCTestCase { mode: .hiragana, composing: ComposingState(isShift: true, text: ["あ"], romaji: "")), yomi: "あ", - candidates: [ReferredWord("亜")], + candidates: [Candidate("亜")], candidateIndex: 0, cursorPosition: .zero ) @@ -176,7 +176,7 @@ final class StateTests: XCTestCase { ) ), yomi: "あ", - candidates: [ReferredWord("有")], + candidates: [Candidate("有")], candidateIndex: 0, cursorPosition: .zero ) @@ -187,7 +187,7 @@ final class StateTests: XCTestCase { let composingState = ComposingState(isShift: true, text: ["お"], romaji: "") let selectingState = SelectingState(prev: SelectingState.PrevState(mode: .hiragana, composing: composingState), yomi: "お", - candidates: [ReferredWord("尾")], + candidates: [Candidate("尾")], candidateIndex: 0, cursorPosition: .zero) XCTAssertEqual(selectingState.markedTextElements(inputMode: .hiragana), [.markerSelect, .emphasized("尾")]) @@ -234,7 +234,7 @@ final class StateTests: XCTestCase { ) ), yomi: "あ", - candidates: [ReferredWord("有")], + candidates: [Candidate("有")], candidateIndex: 0, cursorPosition: .zero ) @@ -270,7 +270,7 @@ final class StateTests: XCTestCase { let composingState = ComposingState(isShift: true, text: ["い"], romaji: "") let selectingState = SelectingState(prev: SelectingState.PrevState(mode: .hiragana, composing: composingState), yomi: "い", - candidates: [ReferredWord("井")], + candidates: [Candidate("井")], candidateIndex: 0, cursorPosition: .zero) let state = IMEState(inputMode: .hiragana, @@ -289,7 +289,7 @@ final class StateTests: XCTestCase { let composingState = ComposingState(isShift: true, text: ["お"], romaji: "") let selectingState = SelectingState(prev: SelectingState.PrevState(mode: .hiragana, composing: composingState), yomi: "お", - candidates: [ReferredWord("尾")], + candidates: [Candidate("尾")], candidateIndex: 0, cursorPosition: .zero) let state = IMEState(inputMode: .hiragana, @@ -309,7 +309,7 @@ final class StateTests: XCTestCase { let composingState = ComposingState(isShift: true, text: ["お"], romaji: "") let selectingState = SelectingState(prev: SelectingState.PrevState(mode: .hiragana, composing: composingState), yomi: "お", - candidates: [ReferredWord("尾")], + candidates: [Candidate("尾")], candidateIndex: 0, cursorPosition: .zero) let state = IMEState(inputMode: .hiragana, @@ -331,8 +331,33 @@ final class StateTests: XCTestCase { romaji: "" ) ), - yomi: "あ", - candidates: [ReferredWord("有")], + yomi: "あr", + candidates: [Candidate("有")], + candidateIndex: 0, + cursorPosition: .zero + ) + let unregisterState = UnregisterState(prev: UnregisterState.PrevState(mode: .hiragana, selecting: prevSelectingState), text: "yes") + let state = IMEState(inputMode: .hiragana, + inputMethod: .normal, + specialState: .unregister(unregisterState), + candidates: []) + let displayText = state.displayText() + XCTAssertEqual(displayText.elements, [.plain("あr /有/ を削除します(yes/no)"), .plain("yes")]) + } + + func testIMEStateDisplayTextUnregisterNumberEntry() { + let prevSelectingState = SelectingState( + prev: SelectingState.PrevState( + mode: .hiragana, + composing: ComposingState( + isShift: true, + text: ["だい2"], + okuri: nil, + romaji: "" + ) + ), + yomi: "だい2", + candidates: [Candidate("第2", original: Candidate.Original(midashi: "だい#", word: "第#"))], candidateIndex: 0, cursorPosition: .zero ) @@ -342,6 +367,6 @@ final class StateTests: XCTestCase { specialState: .unregister(unregisterState), candidates: []) let displayText = state.displayText() - XCTAssertEqual(displayText.elements, [.plain("あ /有/ を削除します(yes/no)"), .plain("yes")]) + XCTAssertEqual(displayText.elements, [.plain("だい# /第#/ を削除します(yes/no)"), .plain("yes")]) } } diff --git a/macSKKTests/UserDictTests.swift b/macSKKTests/UserDictTests.swift index 7d8db9c6..651fd268 100644 --- a/macSKKTests/UserDictTests.swift +++ b/macSKKTests/UserDictTests.swift @@ -20,8 +20,10 @@ final class UserDictTests: XCTestCase { let dict1 = MemoryDict(entries: ["い": [Word("胃", annotation: Annotation(dictId: "dict1", text: "d1ann")), Word("伊")]], readonly: true) let dict2 = MemoryDict(entries: ["い": [Word("胃", annotation: Annotation(dictId: "dict2", text: "d2ann")), Word("意")]], readonly: true) let userDict = try UserDict(dicts: [dict1, dict2], userDictEntries: [:], privateMode: privateMode) - XCTAssertEqual(userDict.refer("い").map({ $0.word }).sorted(), ["伊", "意", "胃", "胃"], "dict1, dict2に胃が1つずつある") + XCTAssertEqual(userDict.refer("い").map({ $0.word }), ["胃", "伊", "胃", "意"], "dict1, dict2に胃が1つずつある") XCTAssertEqual(userDict.refer("い").compactMap({ $0.annotation?.dictId }), ["dict1", "dict2"]) + XCTAssertEqual(userDict.referDicts("い").map({ $0.word }), ["胃", "伊", "意"]) + XCTAssertEqual(userDict.referDicts("い").map({ $0.annotations.map({ $0.dictId }) }), [["dict1", "dict2"], [], []]) } func testReferWithOption() throws { diff --git a/macSKKTests/WordTest.swift b/macSKKTests/WordTest.swift deleted file mode 100644 index 8705234c..00000000 --- a/macSKKTests/WordTest.swift +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-FileCopyrightText: 2023 mtgto -// SPDX-License-Identifier: GPL-3.0-or-later - -import XCTest - -@testable import macSKK - -final class WordTest: XCTestCase { - func testReferredWordAppendAnnotation() throws { - var referredWord = ReferredWord("注釈") - XCTAssertEqual(referredWord.annotations, []) - let annotation1 = Annotation(dictId: "d1", text: "a1") - referredWord.appendAnnotation(annotation1) - XCTAssertEqual(referredWord.annotations, [annotation1]) - let annotation2 = Annotation(dictId: "d2", text: "a1") - referredWord.appendAnnotation(annotation2) - XCTAssertEqual(referredWord.annotations, [annotation1], "注釈が同じテキストなら追加されない") - let annotation3 = Annotation(dictId: "d3", text: "a3") - referredWord.appendAnnotation(annotation3) - XCTAssertEqual(referredWord.annotations, [annotation1, annotation3]) - } -}