From f91f4beb7f78cf674035b08b4367a8f3d15186dc Mon Sep 17 00:00:00 2001 From: zonble Date: Sun, 24 Dec 2023 23:29:53 +0800 Subject: [PATCH 01/18] Fixes a potential crash in the candidate window. --- .../Sources/CandidateUI/VerticalCandidateController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Packages/CandidateUI/Sources/CandidateUI/VerticalCandidateController.swift b/Packages/CandidateUI/Sources/CandidateUI/VerticalCandidateController.swift index 0f4be1fd..a5efa107 100644 --- a/Packages/CandidateUI/Sources/CandidateUI/VerticalCandidateController.swift +++ b/Packages/CandidateUI/Sources/CandidateUI/VerticalCandidateController.swift @@ -281,6 +281,10 @@ public class VerticalCandidateController: CandidateController { newIndex = 0 } + if newIndex == UInt.max { + return + } + var lastVisibleRow = newValue if selectedRow != -1 && itemCount > 0 && itemCount > labelCount { From 713541ae60c65add63f576156fe12a6e804b5d50 Mon Sep 17 00:00:00 2001 From: zonble Date: Mon, 25 Dec 2023 02:16:15 +0800 Subject: [PATCH 02/18] Implements the feature to lookup a candidate in a dictionary. --- McBopomofo.xcodeproj/project.pbxproj | 4 + Source/DictionaryService.swift | 113 +++++++++++++++++++++++++++ Source/InputMethodController.swift | 72 +++++++++++++---- Source/InputState.swift | 24 ++++++ Source/KeyHandler.mm | 27 ++++++- 5 files changed, 225 insertions(+), 15 deletions(-) create mode 100644 Source/DictionaryService.swift diff --git a/McBopomofo.xcodeproj/project.pbxproj b/McBopomofo.xcodeproj/project.pbxproj index bda66af3..3fba6cc3 100644 --- a/McBopomofo.xcodeproj/project.pbxproj +++ b/McBopomofo.xcodeproj/project.pbxproj @@ -67,6 +67,7 @@ D4A13D5A27A59F0B003BE359 /* InputMethodController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A13D5927A59D5C003BE359 /* InputMethodController.swift */; }; D4A8E43627A9E982002F7A07 /* KeyHandlerPlainBopomofoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A8E43527A9E982002F7A07 /* KeyHandlerPlainBopomofoTests.swift */; }; D4C9CAB127AAC9690058DFEA /* NSStringUtils in Frameworks */ = {isa = PBXBuildFile; productRef = D4C9CAB027AAC9690058DFEA /* NSStringUtils */; }; + D4CB1A5B2B389B78006EA984 /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CB1A5A2B3891FB006EA984 /* DictionaryService.swift */; }; D4E33D8A27A838CF006DB1CF /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D4E33D8827A838CF006DB1CF /* Localizable.strings */; }; D4E33D8F27A838F0006DB1CF /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D4E33D8D27A838F0006DB1CF /* InfoPlist.strings */; }; D4E569DC27A34D0E00AC2CEF /* KeyHandler.mm in Sources */ = {isa = PBXBuildFile; fileRef = D4E569DB27A34CC100AC2CEF /* KeyHandler.mm */; }; @@ -193,6 +194,7 @@ D4A13D5927A59D5C003BE359 /* InputMethodController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMethodController.swift; sourceTree = ""; }; D4A8E43527A9E982002F7A07 /* KeyHandlerPlainBopomofoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyHandlerPlainBopomofoTests.swift; sourceTree = ""; }; D4C9CAAF27AAC8EC0058DFEA /* NSStringUtils */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = NSStringUtils; path = Packages/NSStringUtils; sourceTree = ""; }; + D4CB1A5A2B3891FB006EA984 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = ""; }; D4E33D8927A838CF006DB1CF /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; D4E33D8B27A838D5006DB1CF /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; D4E33D8C27A838D8006DB1CF /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; @@ -281,6 +283,7 @@ 6AE215102A2849BB005A6A02 /* UTF8Helper.cpp */, 6AE2150F2A2849BB005A6A02 /* UTF8Helper.h */, D43FC40A2B23788400ED5A1C /* InputMacro.swift */, + D4CB1A5A2B3891FB006EA984 /* DictionaryService.swift */, D456576D279E4F7B00DF6BC9 /* KeyHandlerInput.swift */, D461B791279DAC010070E734 /* InputState.swift */, D427F76B278CA1BA004A2160 /* AppDelegate.swift */, @@ -673,6 +676,7 @@ D47F7DD3278C1263002F9DD7 /* UserOverrideModel.cpp in Sources */, 6A0D4F4515FC0EB100ABF4B3 /* Mandarin.cpp in Sources */, 6ACC3D452793701600F1B140 /* ParselessLM.cpp in Sources */, + D4CB1A5B2B389B78006EA984 /* DictionaryService.swift in Sources */, D41355DE278EA3ED005E5CBD /* UserPhrasesLM.cpp in Sources */, D43FC40B2B23788400ED5A1C /* InputMacro.swift in Sources */, 6AE215112A2849BB005A6A02 /* UTF8Helper.cpp in Sources */, diff --git a/Source/DictionaryService.swift b/Source/DictionaryService.swift new file mode 100644 index 00000000..33607d2b --- /dev/null +++ b/Source/DictionaryService.swift @@ -0,0 +1,113 @@ +import Foundation +import Cocoa + +protocol DictionaryService { + var name: String { get } + func lookup(phrase: String) +} + +fileprivate struct MacOSBuiltInDictionary: DictionaryService { + var name: String { + return NSLocalizedString("Dictionary app", comment: "") + } + func lookup(phrase: String) { + guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { + return + } + if let url = URL(string: "dict://\(encoded)") { + NSWorkspace.shared.open(url) + } + } +} + +fileprivate struct MoeDictionary: DictionaryService { + var name: String { + return NSLocalizedString("MOE Dictionary", comment: "") + } + func lookup(phrase: String) { + guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { + return + } + if let url = URL(string: "https://www.moedict.tw/\(encoded)") { + NSWorkspace.shared.open(url) + } + } +} + +fileprivate struct GoogleSearch: DictionaryService { + var name: String { + return NSLocalizedString("Google", comment: "") + } + func lookup(phrase: String) { + guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { + return + } + if let url = URL(string: "https://www.google.com/search?q=\(encoded)") { + NSWorkspace.shared.open(url) + } + } +} + +fileprivate struct MoeRevisedDictionary: DictionaryService { + var name: String { + return NSLocalizedString("教育部重編國語詞典修訂本", comment: "") + } + func lookup(phrase: String) { + guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { + return + } + if let url = URL(string: "https://dict.revised.moe.edu.tw/search.jsp?md=1&word=\(encoded)") { + NSWorkspace.shared.open(url) + } + } +} + +fileprivate struct MoeConcisedDictionary: DictionaryService { + var name: String { + return NSLocalizedString("教育部國語詞典簡邊本", comment: "") + } + func lookup(phrase: String) { + guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { + return + } + if let url = URL(string: "https://dict.concised.moe.edu.tw/search.jsp?md=1&word=\(encoded)") { + NSWorkspace.shared.open(url) + } + } +} + +fileprivate struct MoeIdionmDictionary: DictionaryService { + var name: String { + return NSLocalizedString("教育部成語典", comment: "") + } + func lookup(phrase: String) { + guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { + return + } + if let url = URL(string: "https://dict.idioms.moe.edu.tw/idiomList.jsp?idiom=\(encoded)&qMd=0&qTp=1&qTp=2") { + NSWorkspace.shared.open(url) + } + } +} + + + +class DictionaryServices: NSObject { + @objc static var shared = DictionaryServices() + var services:[DictionaryService] = [ + MacOSBuiltInDictionary(), + MoeDictionary(), + GoogleSearch(), + MoeRevisedDictionary(), + MoeConcisedDictionary(), + MoeIdionmDictionary(), + ] + + func lookup(phrase: String, serviceIndex: Int) { + if serviceIndex >= services.count { + return + } + let service = services[serviceIndex] + service.lookup(phrase: phrase) + } +} diff --git a/Source/InputMethodController.swift b/Source/InputMethodController.swift index d51802fa..60739af4 100644 --- a/Source/InputMethodController.swift +++ b/Source/InputMethodController.swift @@ -289,6 +289,8 @@ extension McBopomofoInputMethodController { handle(state: newState, previous: previous, client: client) case let newState as InputState.Big5: handle(state: newState, previous: previous, client: client) + case let newState as InputState.SelectingDictionaryService: + handle(state: newState, previous: previous, client: client) default: break } @@ -444,6 +446,19 @@ extension McBopomofoInputMethodController { } client.setMarkedText(state.composingBuffer, selectionRange: NSMakeRange(state.composingBuffer.count, 0), replacementRange: NSMakeRange(NSNotFound, NSNotFound)) } + + private func handle(state: InputState.SelectingDictionaryService, previous: InputState, client: Any?) { + hideTooltip() + guard let client = client as? IMKTextInput else { + gCurrentCandidateController?.visible = false + return + } + let candidateDate = state.previousState + // the selection range is where the cursor is, with the length being 0 and replacement range NSNotFound, + // i.e. the client app needs to take care of where to put this composing buffer + client.setMarkedText(candidateDate.attributedString, selectionRange: NSMakeRange(Int(candidateDate.cursorIndex), 0), replacementRange: NSMakeRange(NSNotFound, NSNotFound)) + show(candidateWindowWith: state, client: client) + } } // MARK: - @@ -454,15 +469,24 @@ extension McBopomofoInputMethodController { let useVerticalMode: Bool = { var useVerticalMode = false var candidates: [InputState.Candidate] = [] - if let state = state as? InputState.ChoosingCandidate { + switch state { + case let state as InputState.ChoosingCandidate: useVerticalMode = state.useVerticalMode candidates = state.candidates - } else if let state = state as? InputState.AssociatedPhrases { + case let state as InputState.AssociatedPhrases: useVerticalMode = state.useVerticalMode candidates = state.candidates.map { InputState.Candidate(reading: "", value: $0, displayText: $0) } + case let state as InputState.SelectingDictionaryService: + useVerticalMode = true + candidates = state.menu.map { + InputState.Candidate(reading: "", value: $0, displayText: $0) + } + default: + break } + if useVerticalMode == true { return true } @@ -477,14 +501,18 @@ extension McBopomofoInputMethodController { return false }() +// gCurrentCandidateController?.visible = false gCurrentCandidateController?.delegate = nil if useVerticalMode { gCurrentCandidateController = .vertical + CandidateController.horizontal.window?.orderOut(nil) } else if Preferences.useHorizontalCandidateList { gCurrentCandidateController = .horizontal + CandidateController.vertical.window?.orderOut(nil) } else { gCurrentCandidateController = .vertical + CandidateController.horizontal.window?.orderOut(nil) } // set the attributes for the candidate panel (which uses NSAttributedString) @@ -562,6 +590,9 @@ extension McBopomofoInputMethodController: KeyHandlerDelegate { } func keyHandler(_ keyHandler: KeyHandler, didSelectCandidateAt index: Int, candidateController controller: Any) { + if index < 0 { + return + } if let controller = controller as? CandidateController { self.candidateController(controller, didSelectCandidateAtIndex: UInt(index)) } @@ -623,27 +654,36 @@ extension McBopomofoInputMethodController: KeyHandlerDelegate { extension McBopomofoInputMethodController: CandidateControllerDelegate { func candidateCountForController(_ controller: CandidateController) -> UInt { - if let state = state as? InputState.ChoosingCandidate { - return UInt(state.candidates.count) - } else if let state = state as? InputState.AssociatedPhrases { - return UInt(state.candidates.count) + return switch state { + case let state as InputState.ChoosingCandidate: + UInt(state.candidates.count) + case let state as InputState.AssociatedPhrases: + UInt(state.candidates.count) + case let state as InputState.SelectingDictionaryService: + UInt(state.menu.count) + default: + 0 } - return 0 } func candidateController(_ controller: CandidateController, candidateAtIndex index: UInt) -> String { - if let state = state as? InputState.ChoosingCandidate { - return state.candidates[Int(index)].displayText - } else if let state = state as? InputState.AssociatedPhrases { - return state.candidates[Int(index)] + return switch state { + case let state as InputState.ChoosingCandidate: + state.candidates[Int(index)].displayText + case let state as InputState.AssociatedPhrases: + state.candidates[Int(index)] + case let state as InputState.SelectingDictionaryService: + state.menu[Int(index)] + default: + "" } - return "" } func candidateController(_ controller: CandidateController, didSelectCandidateAtIndex index: UInt) { let client = currentClient - if let state = state as? InputState.ChoosingCandidate { + switch state { + case let state as InputState.ChoosingCandidate: let selectedCandidate = state.candidates[Int(index)] keyHandler.fixNode(reading: selectedCandidate.reading, value: selectedCandidate.value, useMoveCursorAfterSelectionSetting: true) @@ -664,7 +704,7 @@ extension McBopomofoInputMethodController: CandidateControllerDelegate { } else { handle(state: inputting, client: client) } - } else if let state = state as? InputState.AssociatedPhrases { + case let state as InputState.AssociatedPhrases: let selectedValue = state.candidates[Int(index)] handle(state: .Committing(poppedText: selectedValue), client: currentClient) if Preferences.associatedPhrasesEnabled, @@ -673,6 +713,10 @@ extension McBopomofoInputMethodController: CandidateControllerDelegate { } else { handle(state: .Empty(), client: client) } + case let state as InputState.SelectingDictionaryService: + state.lookup(usingServiceAtIndex: Int(index)) + default: + break } } } diff --git a/Source/InputState.swift b/Source/InputState.swift index d8c4547a..04d228a0 100644 --- a/Source/InputState.swift +++ b/Source/InputState.swift @@ -350,4 +350,28 @@ class InputState: NSObject { "" } } + + @objc (InputStateSelectingDictionaryService) + class SelectingDictionaryService: NotEmpty { + @objc private (set) var previousState: ChoosingCandidate + @objc private(set) var selectedPhrase: String = "" + @objc private(set) var selectedIndex: Int = 0 + @objc var menu: [String] + + @objc + init(previousState: ChoosingCandidate, selectedString: String, selectedIndex: Int) { + self.previousState = previousState + self.selectedPhrase = selectedString + self.selectedIndex = selectedIndex + self.menu = DictionaryServices.shared.services.map { service in + String(format: NSLocalizedString("Lookup \"%@\" in %@", comment: ""), + selectedString, service.name) + } + super.init(composingBuffer: previousState.composingBuffer, cursorIndex: previousState.cursorIndex) + } + + func lookup(usingServiceAtIndex index: Int) { + DictionaryServices.shared.lookup(phrase: selectedPhrase, serviceIndex: index) + } + } } diff --git a/Source/KeyHandler.mm b/Source/KeyHandler.mm index 24acadbd..5278dd63 100644 --- a/Source/KeyHandler.mm +++ b/Source/KeyHandler.mm @@ -293,6 +293,11 @@ - (BOOL)handleInput:(KeyHandlerInput *)input state:(InputState *)inState stateCa stateCallback(state); } + // MARK: Handle Selecting Dictionary Service + if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { + return [self _handleCandidateState:state input:input stateCallback:stateCallback errorCallback:errorCallback]; + } + // MARK: Handle Marking if ([state isKindOfClass:[InputStateMarking class]]) { InputStateMarking *marking = (InputStateMarking *)state; @@ -1031,10 +1036,25 @@ - (BOOL)_handleCandidateState:(InputState *)state UniChar charCode = input.charCode; VTCandidateController *gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; + if ([input isTab]) { + if ([state isKindOfClass:[InputStateChoosingCandidate class]]) { + InputStateChoosingCandidate *currentState = (InputStateChoosingCandidate *)state; + NSInteger index = gCurrentCandidateController.selectedCandidateIndex; + NSString *selectedPhrase = currentState.candidates[index].displayText; + InputStateSelectingDictionaryService *newState = [[InputStateSelectingDictionaryService alloc] initWithPreviousState:currentState selectedString:selectedPhrase selectedIndex:index]; + stateCallback(newState); + return YES; + } + } + BOOL cancelCandidateKey = (charCode == 27) || (charCode == 8) || [input isDelete]; if (cancelCandidateKey) { - if ([state isKindOfClass:[InputStateAssociatedPhrases class]]) { + if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { + InputStateChoosingCandidate *newState = [(InputStateSelectingDictionaryService *)state previousState]; + stateCallback(newState); + gCurrentCandidateController.selectedCandidateIndex = [(InputStateSelectingDictionaryService *)state selectedIndex]; + } else if ([state isKindOfClass:[InputStateAssociatedPhrases class]]) { [self clear]; InputStateEmptyIgnoringPreviousState *empty = [[InputStateEmptyIgnoringPreviousState alloc] init]; stateCallback(empty); @@ -1050,6 +1070,11 @@ - (BOOL)_handleCandidateState:(InputState *)state } if (charCode == 13 || [input isEnter]) { + if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { + [self.delegate keyHandler:self didSelectCandidateAtIndex:gCurrentCandidateController.selectedCandidateIndex candidateController:gCurrentCandidateController]; + InputStateChoosingCandidate *newState = [(InputStateSelectingDictionaryService *)state previousState]; + stateCallback(newState); + } if ([state isKindOfClass:[InputStateAssociatedPhrases class]]) { [self clear]; InputStateEmptyIgnoringPreviousState *empty = [[InputStateEmptyIgnoringPreviousState alloc] init]; From feca5b5869b1d8c4b00d78c5da471d5df462cac3 Mon Sep 17 00:00:00 2001 From: zonble Date: Mon, 25 Dec 2023 02:49:28 +0800 Subject: [PATCH 03/18] Minor bug fixes for dictionary lookup. --- Source/InputMethodController.swift | 19 ++++++++----------- Source/KeyHandler.mm | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/Source/InputMethodController.swift b/Source/InputMethodController.swift index 60739af4..b5845f11 100644 --- a/Source/InputMethodController.swift +++ b/Source/InputMethodController.swift @@ -478,11 +478,8 @@ extension McBopomofoInputMethodController { candidates = state.candidates.map { InputState.Candidate(reading: "", value: $0, displayText: $0) } - case let state as InputState.SelectingDictionaryService: - useVerticalMode = true - candidates = state.menu.map { - InputState.Candidate(reading: "", value: $0, displayText: $0) - } + case _ as InputState.SelectingDictionaryService: + return true default: break } @@ -501,18 +498,15 @@ extension McBopomofoInputMethodController { return false }() -// gCurrentCandidateController?.visible = false gCurrentCandidateController?.delegate = nil + gCurrentCandidateController?.visible = false if useVerticalMode { gCurrentCandidateController = .vertical - CandidateController.horizontal.window?.orderOut(nil) } else if Preferences.useHorizontalCandidateList { gCurrentCandidateController = .horizontal - CandidateController.vertical.window?.orderOut(nil) } else { gCurrentCandidateController = .vertical - CandidateController.horizontal.window?.orderOut(nil) } // set the attributes for the candidate panel (which uses NSAttributedString) @@ -691,7 +685,8 @@ extension McBopomofoInputMethodController: CandidateControllerDelegate { return } - if keyHandler.inputMode == .plainBopomofo { + switch keyHandler.inputMode { + case .plainBopomofo: keyHandler.clear() let composingBuffer = inputting.composingBuffer handle(state: .Committing(poppedText: composingBuffer), client: client) @@ -701,8 +696,10 @@ extension McBopomofoInputMethodController: CandidateControllerDelegate { } else { handle(state: .Empty(), client: client) } - } else { + case .bopomofo: handle(state: inputting, client: client) + default: + break } case let state as InputState.AssociatedPhrases: let selectedValue = state.candidates[Int(index)] diff --git a/Source/KeyHandler.mm b/Source/KeyHandler.mm index 5278dd63..deff08f1 100644 --- a/Source/KeyHandler.mm +++ b/Source/KeyHandler.mm @@ -1051,9 +1051,12 @@ - (BOOL)_handleCandidateState:(InputState *)state if (cancelCandidateKey) { if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { - InputStateChoosingCandidate *newState = [(InputStateSelectingDictionaryService *)state previousState]; + InputStateSelectingDictionaryService *current = (InputStateSelectingDictionaryService *)state; + NSInteger selectedIndex = current.selectedIndex; + InputStateChoosingCandidate *newState = [current previousState]; stateCallback(newState); - gCurrentCandidateController.selectedCandidateIndex = [(InputStateSelectingDictionaryService *)state selectedIndex]; + gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; + gCurrentCandidateController.selectedCandidateIndex = selectedIndex; } else if ([state isKindOfClass:[InputStateAssociatedPhrases class]]) { [self clear]; InputStateEmptyIgnoringPreviousState *empty = [[InputStateEmptyIgnoringPreviousState alloc] init]; @@ -1072,8 +1075,13 @@ - (BOOL)_handleCandidateState:(InputState *)state if (charCode == 13 || [input isEnter]) { if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { [self.delegate keyHandler:self didSelectCandidateAtIndex:gCurrentCandidateController.selectedCandidateIndex candidateController:gCurrentCandidateController]; - InputStateChoosingCandidate *newState = [(InputStateSelectingDictionaryService *)state previousState]; + InputStateSelectingDictionaryService *current = (InputStateSelectingDictionaryService *)state; + NSInteger selectedIndex = current.selectedIndex; + InputStateChoosingCandidate *newState = [current previousState]; stateCallback(newState); + gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; + gCurrentCandidateController.selectedCandidateIndex = selectedIndex; + return YES; } if ([state isKindOfClass:[InputStateAssociatedPhrases class]]) { [self clear]; @@ -1193,6 +1201,8 @@ - (BOOL)_handleCandidateState:(InputState *)state candidates = [(InputStateChoosingCandidate *)state candidates]; } else if ([state isKindOfClass:[InputStateAssociatedPhrases class]]) { candidates = [(InputStateAssociatedPhrases *)state candidates]; + } else if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { + candidates = [(InputStateSelectingDictionaryService *)state menu]; } if (!candidates) { From 9907a32e8fb1063c6c0498d8e0c7cbf3b013a7f3 Mon Sep 17 00:00:00 2001 From: zonble Date: Mon, 25 Dec 2023 12:25:19 +0800 Subject: [PATCH 04/18] Updates localization and unit tests. --- ...uous-integration-workflow-xcode-latest.yml | 8 +- McBopomofo.xcodeproj/project.pbxproj | 8 + McBopomofoTests/InputMacroTests.swift | 274 ++++++++++++++++++ McBopomofoTests/KeyHandlerBopomofoTests.swift | 70 +++++ McBopomofoTests/ServiceProviderTests.swift | 40 +++ Source/Base.lproj/Localizable.strings | 2 + Source/DictionaryService.swift | 22 +- Source/InputMethodController.swift | 2 +- Source/InputState.swift | 6 +- Source/KeyHandler.mm | 9 +- Source/Preferences.swift | 26 +- Source/ServiceProvider.swift | 38 ++- Source/en.lproj/Localizable.strings | 3 + Source/zh-Hant.lproj/Localizable.strings | 2 + 14 files changed, 465 insertions(+), 45 deletions(-) create mode 100644 McBopomofoTests/InputMacroTests.swift create mode 100644 McBopomofoTests/ServiceProviderTests.swift diff --git a/.github/workflows/continuous-integration-workflow-xcode-latest.yml b/.github/workflows/continuous-integration-workflow-xcode-latest.yml index a714a21a..04f9e31e 100644 --- a/.github/workflows/continuous-integration-workflow-xcode-latest.yml +++ b/.github/workflows/continuous-integration-workflow-xcode-latest.yml @@ -4,14 +4,14 @@ on: [push, pull_request] jobs: build: name: Build and Test with Latest Xcode - runs-on: macOS-latest - env: - DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + runs-on: macOS-13 + # env: + # DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer steps: - uses: actions/checkout@v1 - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: latest-stable + xcode-version: "^15.1.0" - name: Build McBopomofoLMLibTest run: cmake -S . -B build working-directory: Source/Engine diff --git a/McBopomofo.xcodeproj/project.pbxproj b/McBopomofo.xcodeproj/project.pbxproj index 3fba6cc3..15160525 100644 --- a/McBopomofo.xcodeproj/project.pbxproj +++ b/McBopomofo.xcodeproj/project.pbxproj @@ -47,6 +47,8 @@ D427F7B6279086F6004A2160 /* InputSourceHelper in Frameworks */ = {isa = PBXBuildFile; productRef = D427F7B5279086F6004A2160 /* InputSourceHelper */; }; D427F7C127908EFC004A2160 /* OpenCCBridge in Frameworks */ = {isa = PBXBuildFile; productRef = D427F7C027908EFC004A2160 /* OpenCCBridge */; }; D43FC40B2B23788400ED5A1C /* InputMacro.swift in Sources */ = {isa = PBXBuildFile; fileRef = D43FC40A2B23788400ED5A1C /* InputMacro.swift */; }; + D449AD5F2B393C00000C5812 /* InputMacroTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D449AD5E2B393C00000C5812 /* InputMacroTests.swift */; }; + D449AD612B39506D000C5812 /* ServiceProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D449AD602B39506D000C5812 /* ServiceProviderTests.swift */; }; D44FB74527915565003C80A6 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44FB74427915555003C80A6 /* Preferences.swift */; }; D44FB74A2791B829003C80A6 /* VXHanConvert in Frameworks */ = {isa = PBXBuildFile; productRef = D44FB7492791B829003C80A6 /* VXHanConvert */; }; D44FB74D2792189A003C80A6 /* PhraseReplacementMap.cpp in Sources */ = {isa = PBXBuildFile; fileRef = D44FB74B2792189A003C80A6 /* PhraseReplacementMap.cpp */; }; @@ -170,6 +172,8 @@ D427F7B2279086B5004A2160 /* InputSourceHelper */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = InputSourceHelper; path = Packages/InputSourceHelper; sourceTree = ""; }; D427F7BF27908EAC004A2160 /* OpenCCBridge */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = OpenCCBridge; path = Packages/OpenCCBridge; sourceTree = ""; }; D43FC40A2B23788400ED5A1C /* InputMacro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMacro.swift; sourceTree = ""; }; + D449AD5E2B393C00000C5812 /* InputMacroTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMacroTests.swift; sourceTree = ""; }; + D449AD602B39506D000C5812 /* ServiceProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceProviderTests.swift; sourceTree = ""; }; D44FB74427915555003C80A6 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; D44FB7482791B346003C80A6 /* VXHanConvert */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = VXHanConvert; path = Packages/VXHanConvert; sourceTree = ""; }; D44FB74B2792189A003C80A6 /* PhraseReplacementMap.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = PhraseReplacementMap.cpp; sourceTree = ""; }; @@ -441,6 +445,8 @@ D4A8E43527A9E982002F7A07 /* KeyHandlerPlainBopomofoTests.swift */, D485D3B82796A8A000657FF3 /* PreferencesTests.swift */, D485D3BF2796CE3200657FF3 /* VersionUpdateTests.swift */, + D449AD5E2B393C00000C5812 /* InputMacroTests.swift */, + D449AD602B39506D000C5812 /* ServiceProviderTests.swift */, ); path = McBopomofoTests; sourceTree = ""; @@ -701,8 +707,10 @@ buildActionMask = 2147483647; files = ( D4A8E43627A9E982002F7A07 /* KeyHandlerPlainBopomofoTests.swift in Sources */, + D449AD5F2B393C00000C5812 /* InputMacroTests.swift in Sources */, D47D73A427A5D43900255A50 /* KeyHandlerBopomofoTests.swift in Sources */, D485D3B92796A8A000657FF3 /* PreferencesTests.swift in Sources */, + D449AD612B39506D000C5812 /* ServiceProviderTests.swift in Sources */, D485D3C02796CE3200657FF3 /* VersionUpdateTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/McBopomofoTests/InputMacroTests.swift b/McBopomofoTests/InputMacroTests.swift new file mode 100644 index 00000000..7d668f2b --- /dev/null +++ b/McBopomofoTests/InputMacroTests.swift @@ -0,0 +1,274 @@ +import XCTest +@testable import McBopomofo + +class InputMacroTests: XCTestCase { + + func testNotMacro() { + let macro = "MACRO@NONE" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(macro == output) + } + + func testThisYear() { + let macro = "MACRO@THIS_YEAR" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output.starts(with: "西元")) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + + func testThisYearRoc() { + let macro = "MACRO@THIS_YEAR_ROC" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output.starts(with: "民國")) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + + func testThisYearJapanese() { + let macro = "MACRO@THIS_YEAR_JAPANESE" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + + func testLastYear() { + let macro = "MACRO@LAST_YEAR" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output.starts(with: "西元")) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + + func testLastYearRoc() { + let macro = "MACRO@LAST_YEAR_ROC" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output.starts(with: "民國")) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + + func testLastYearJapanese() { + let macro = "MACRO@LAST_YEAR_JAPANESE" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + + func testNextYear() { + let macro = "MACRO@NEXT_YEAR" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output.starts(with: "西元")) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + + func testNextYearRoc() { + let macro = "MACRO@NEXT_YEAR_ROC" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output.starts(with: "民國")) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + + func testNextYearJapanese() { + let macro = "MACRO@NEXT_YEAR_JAPANESE" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + + func testTodayShort() { + let macro = "MACRO@DATE_TODAY_SHORT" + let output = InputMacroController.shared.handle(macro) + let components = output.split(separator: "/").map { substring in + String(substring) + } + let year = components[0] + let month = components[1] + let day = components[2] + XCTAssertTrue(Int(year) ?? 0 >= 2023) + XCTAssertTrue(Int(month) ?? 0 >= 1 && Int(month) ?? 0 <= 12) + XCTAssertTrue(Int(day) ?? 0 >= 1 && Int(month) ?? 0 <= 31) + } + + func testYesterdayShort() { + let macro = "MACRO@DATE_YESTERDAY_SHORT" + let output = InputMacroController.shared.handle(macro) + let components = output.split(separator: "/").map { substring in + String(substring) + } + let year = components[0] + let month = components[1] + let day = components[2] + XCTAssertTrue(Int(year) ?? 0 >= 2023) + XCTAssertTrue(Int(month) ?? 0 >= 1 && Int(month) ?? 0 <= 12) + XCTAssertTrue(Int(day) ?? 0 >= 1 && Int(month) ?? 0 <= 31) + } + + func testTomorrowShort() { + let macro = "MACRO@DATE_TOMORROW_SHORT" + let output = InputMacroController.shared.handle(macro) + let components = output.split(separator: "/").map { substring in + String(substring) + } + let year = components[0] + let month = components[1] + let day = components[2] + XCTAssertTrue(Int(year) ?? 0 >= 2023) + XCTAssertTrue(Int(month) ?? 0 >= 1 && Int(month) ?? 0 <= 12) + XCTAssertTrue(Int(day) ?? 0 >= 1 && Int(month) ?? 0 <= 31) + } + + func testTodayMedium() { + let macro = "MACRO@DATE_TODAY_MEDIUM" + let output = InputMacroController.shared.handle(macro) + XCTAssert(output.contains("年")) + XCTAssert(output.contains("月")) + XCTAssert(output.contains("日")) + } + + func testYesterdayMedium() { + let macro = "MACRO@DATE_YESTERDAY_MEDIUM" + let output = InputMacroController.shared.handle(macro) + XCTAssert(output.contains("年")) + XCTAssert(output.contains("月")) + XCTAssert(output.contains("日")) + } + + func testTomorrowMedium() { + let macro = "MACRO@DATE_TOMORROW_MEDIUM" + let output = InputMacroController.shared.handle(macro) + XCTAssert(output.contains("年")) + XCTAssert(output.contains("月")) + XCTAssert(output.contains("日")) + } + + func testTodayMediumRoc() { + let macro = "MACRO@DATE_TODAY_MEDIUM_ROC" + let output = InputMacroController.shared.handle(macro) + XCTAssert(output.contains("民國")) + XCTAssert(output.contains("年")) + XCTAssert(output.contains("月")) + XCTAssert(output.contains("日")) + } + + func testYesterdayMediumRoc() { + let macro = "MACRO@DATE_YESTERDAY_MEDIUM_ROC" + let output = InputMacroController.shared.handle(macro) + XCTAssert(output.contains("民國")) + XCTAssert(output.contains("年")) + XCTAssert(output.contains("月")) + XCTAssert(output.contains("日")) + } + + func testYesterdayTomorrowRoc() { + let macro = "MACRO@DATE_TOMORROW_MEDIUM_ROC" + let output = InputMacroController.shared.handle(macro) + XCTAssert(output.contains("民國")) + XCTAssert(output.contains("年")) + XCTAssert(output.contains("月")) + XCTAssert(output.contains("日")) + } + + func testTodayMediumChinese() { + let macro = "MACRO@DATE_TODAY_MEDIUM_CHINESE" + let output = InputMacroController.shared.handle(macro) + XCTAssert(output.contains("年")) + XCTAssert(output.contains("月")) + } + + func testYesterdayMediumChinese() { + let macro = "MACRO@DATE_YESTERDAY_MEDIUM_CHINESE" + let output = InputMacroController.shared.handle(macro) + XCTAssert(output.contains("年")) + XCTAssert(output.contains("月")) + } + + func testTomorrowMediumChinese() { + let macro = "MACRO@DATE_TOMORROW_MEDIUM_CHINESE" + let output = InputMacroController.shared.handle(macro) + XCTAssert(output.contains("年")) + XCTAssert(output.contains("月")) + } + + func testTodayLongJapanese() { + let macro = "MACRO@DATE_TODAY_FULL_JAPANESE" + let output = InputMacroController.shared.handle(macro) + XCTAssert(output.contains("年")) + XCTAssert(output.contains("月")) + XCTAssert(output.contains("日")) + XCTAssert(output.contains("曜日")) + } + + func testYesterdayLongJapanese() { + let macro = "MACRO@DATE_YESTERDAY_FULL_JAPANESE" + let output = InputMacroController.shared.handle(macro) + XCTAssert(output.contains("年")) + XCTAssert(output.contains("月")) + XCTAssert(output.contains("日")) + XCTAssert(output.contains("曜日")) + } + + func testTomorrowLongJapanese() { + let macro = "MACRO@DATE_TOMORROW_FULL_JAPANESE" + let output = InputMacroController.shared.handle(macro) + XCTAssert(output.contains("年")) + XCTAssert(output.contains("月")) + XCTAssert(output.contains("日")) + XCTAssert(output.contains("曜日")) + } + + func testTimeNowShort() { + let macro = "MACRO@TIME_NOW_SHORT" + let output = InputMacroController.shared.handle(macro) + let numberPath = output[output.index(output.startIndex, offsetBy: 2)...] + let numberComponents = String(numberPath).split(separator: ":") + let hour = numberComponents[0] + let min = numberComponents[1] + XCTAssert(Int(hour) ?? -1 >= 0 && Int(hour) ?? -1 <= 12) + XCTAssert(Int(min) ?? -1 >= 0 && Int(min) ?? -1 <= 59) + } + + func testTimeNowMedium() { + let macro = "MACRO@TIME_NOW_MEDIUM" + let output = InputMacroController.shared.handle(macro) + let numberPath = output[output.index(output.startIndex, offsetBy: 2)...] + let numberComponents = String(numberPath).split(separator: ":") + let hour = numberComponents[0] + let min = numberComponents[1] + let sec = numberComponents[2] + XCTAssert(Int(hour) ?? -1 >= 0 && Int(hour) ?? -1 <= 12) + XCTAssert(Int(min) ?? -1 >= 0 && Int(min) ?? -1 <= 59) + XCTAssert(Int(sec) ?? -1 >= 0 && Int(sec) ?? -1 <= 59) + } + + func testThisYearGanzhi() { + let macro = "MACRO@THIS_YEAR_GANZHI" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + + func testLastYearGanzhi() { + let macro = "MACRO@LAST_YEAR_GANZHI" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + + func testNextYearGanzhi() { + let macro = "MACRO@NEXT_YEAR_GANZHI" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + + func testThisYearChineseZodiac() { + let macro = "MACRO@THIS_YEAR_CHINESE_ZODIAC" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + + func testLastYearChineseZodiac() { + let macro = "MACRO@LAST_YEAR_CHINESE_ZODIAC" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + + func testNextYearChineseZodiac() { + let macro = "MACRO@NEXT_YEAR_CHINESE_ZODIAC" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + +} + diff --git a/McBopomofoTests/KeyHandlerBopomofoTests.swift b/McBopomofoTests/KeyHandlerBopomofoTests.swift index a5358787..7d193b9b 100644 --- a/McBopomofoTests/KeyHandlerBopomofoTests.swift +++ b/McBopomofoTests/KeyHandlerBopomofoTests.swift @@ -1557,4 +1557,74 @@ class KeyHandlerBopomofoTests: XCTestCase { Preferences.escToCleanInputBuffer = enabled } + func testLookUpCandidateInDictionaryAndCancelWithTabKey() { + var state: InputState = InputState.Empty() + let keys = Array("wu0 dj/ ").map { + String($0) + } + for key in keys { + let input = KeyHandlerInput(inputText: key, keyCode: 0, charCode: charCode(key), flags: [], isVerticalMode: false) + handler.handle(input: input, state: state) { newState in + state = newState + } errorCallback: { + } + } + var input = KeyHandlerInput(inputText: " ", keyCode: KeyCode.down.rawValue, charCode: 0, flags: [], isVerticalMode: false) + handler.handle(input: input, state: state) { newState in + state = newState + } errorCallback: { + } + + input = KeyHandlerInput(inputText: " ", keyCode: KeyCode.tab.rawValue, charCode: 0, flags: [], isVerticalMode: false) + handler.handle(input: input, state: state) { newState in + state = newState + } errorCallback: { + } + + XCTAssert(state is InputState.SelectingDictionaryService) + + input = KeyHandlerInput(inputText: " ", keyCode: KeyCode.tab.rawValue, charCode: 0, flags: [], isVerticalMode: false) + handler.handle(input: input, state: state) { newState in + state = newState + } errorCallback: { + } + + XCTAssert(state is InputState.ChoosingCandidate) + } + + func testLookUpCandidateInDictionaryAndCancelWithEscKey() { + var state: InputState = InputState.Empty() + let keys = Array("wu0 dj/ ").map { + String($0) + } + for key in keys { + let input = KeyHandlerInput(inputText: key, keyCode: 0, charCode: charCode(key), flags: [], isVerticalMode: false) + handler.handle(input: input, state: state) { newState in + state = newState + } errorCallback: { + } + } + var input = KeyHandlerInput(inputText: " ", keyCode: KeyCode.down.rawValue, charCode: 0, flags: [], isVerticalMode: false) + handler.handle(input: input, state: state) { newState in + state = newState + } errorCallback: { + } + + input = KeyHandlerInput(inputText: " ", keyCode: KeyCode.tab.rawValue, charCode: 0, flags: [], isVerticalMode: false) + handler.handle(input: input, state: state) { newState in + state = newState + } errorCallback: { + } + + XCTAssert(state is InputState.SelectingDictionaryService) + + input = KeyHandlerInput(inputText: " ", keyCode: 0, charCode: 27, flags: [], isVerticalMode: false) + handler.handle(input: input, state: state) { newState in + state = newState + } errorCallback: { + } + + XCTAssert(state is InputState.ChoosingCandidate) + } + } diff --git a/McBopomofoTests/ServiceProviderTests.swift b/McBopomofoTests/ServiceProviderTests.swift new file mode 100644 index 00000000..f4447dc2 --- /dev/null +++ b/McBopomofoTests/ServiceProviderTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import McBopomofo + +final class ServiceProviderTests: XCTestCase { + func testExtractReading0() { + let provider = ServiceProvider() + let output = provider.extractReading(from: "") + XCTAssert(output == "", output) + } + + func testExtractReading1() { + let provider = ServiceProvider() + let output = provider.extractReading(from: "消波塊") + XCTAssert(output == "ㄒㄧㄠ-ㄆㄛ-ㄎㄨㄞˋ") + } + + func testExtractReading2() { + let provider = ServiceProvider() + let output = provider.extractReading(from: "🔥🔥🔥") + XCTAssert(output == "ㄏㄨㄛˇ-ㄏㄨㄛˇ-ㄏㄨㄛˇ") + } + + func testExtractReading3() { + let provider = ServiceProvider() + let output = provider.extractReading(from: "🔥") + XCTAssert(output == "ㄏㄨㄛˇ") + } + + func testExtractReading4() { + let provider = ServiceProvider() + let output = provider.extractReading(from: " ") + XCTAssert(output == "?", output) + } + + func testExtractReading5() { + let provider = ServiceProvider() + let output = provider.extractReading(from: "!") + XCTAssert(output == "_ctrl_punctuation_!", output) + } +} diff --git a/Source/Base.lproj/Localizable.strings b/Source/Base.lproj/Localizable.strings index bfbe3b74..b5d840e8 100644 --- a/Source/Base.lproj/Localizable.strings +++ b/Source/Base.lproj/Localizable.strings @@ -100,3 +100,5 @@ "Check for Update Completed" = "Check for Update Completed"; "McBopomofo is up to date." = "McBopomofo is up to date."; + +"Look up \"%1$@\" in %2$@" = "Look up \"%1$@\" in %2$@"; diff --git a/Source/DictionaryService.swift b/Source/DictionaryService.swift index 33607d2b..e4da6749 100644 --- a/Source/DictionaryService.swift +++ b/Source/DictionaryService.swift @@ -3,14 +3,14 @@ import Cocoa protocol DictionaryService { var name: String { get } - func lookup(phrase: String) + func lookUp(phrase: String) } fileprivate struct MacOSBuiltInDictionary: DictionaryService { var name: String { return NSLocalizedString("Dictionary app", comment: "") } - func lookup(phrase: String) { + func lookUp(phrase: String) { guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { return } @@ -24,7 +24,7 @@ fileprivate struct MoeDictionary: DictionaryService { var name: String { return NSLocalizedString("MOE Dictionary", comment: "") } - func lookup(phrase: String) { + func lookUp(phrase: String) { guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { return } @@ -38,7 +38,7 @@ fileprivate struct GoogleSearch: DictionaryService { var name: String { return NSLocalizedString("Google", comment: "") } - func lookup(phrase: String) { + func lookUp(phrase: String) { guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { return } @@ -52,7 +52,7 @@ fileprivate struct MoeRevisedDictionary: DictionaryService { var name: String { return NSLocalizedString("教育部重編國語詞典修訂本", comment: "") } - func lookup(phrase: String) { + func lookUp(phrase: String) { guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { return } @@ -66,7 +66,7 @@ fileprivate struct MoeConcisedDictionary: DictionaryService { var name: String { return NSLocalizedString("教育部國語詞典簡邊本", comment: "") } - func lookup(phrase: String) { + func lookUp(phrase: String) { guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { return } @@ -76,11 +76,11 @@ fileprivate struct MoeConcisedDictionary: DictionaryService { } } -fileprivate struct MoeIdionmDictionary: DictionaryService { +fileprivate struct MoeIdiomsDictionary: DictionaryService { var name: String { return NSLocalizedString("教育部成語典", comment: "") } - func lookup(phrase: String) { + func lookUp(phrase: String) { guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { return } @@ -100,14 +100,14 @@ class DictionaryServices: NSObject { GoogleSearch(), MoeRevisedDictionary(), MoeConcisedDictionary(), - MoeIdionmDictionary(), + MoeIdiomsDictionary(), ] - func lookup(phrase: String, serviceIndex: Int) { + func lookUp(phrase: String, serviceIndex: Int) { if serviceIndex >= services.count { return } let service = services[serviceIndex] - service.lookup(phrase: phrase) + service.lookUp(phrase: phrase) } } diff --git a/Source/InputMethodController.swift b/Source/InputMethodController.swift index b5845f11..037d1ff1 100644 --- a/Source/InputMethodController.swift +++ b/Source/InputMethodController.swift @@ -711,7 +711,7 @@ extension McBopomofoInputMethodController: CandidateControllerDelegate { handle(state: .Empty(), client: client) } case let state as InputState.SelectingDictionaryService: - state.lookup(usingServiceAtIndex: Int(index)) + state.lookUp(usingServiceAtIndex: Int(index)) default: break } diff --git a/Source/InputState.swift b/Source/InputState.swift index 04d228a0..0889c214 100644 --- a/Source/InputState.swift +++ b/Source/InputState.swift @@ -364,14 +364,14 @@ class InputState: NSObject { self.selectedPhrase = selectedString self.selectedIndex = selectedIndex self.menu = DictionaryServices.shared.services.map { service in - String(format: NSLocalizedString("Lookup \"%@\" in %@", comment: ""), + String(format: NSLocalizedString("Look up \"%1$@\" in %2$@", comment: ""), selectedString, service.name) } super.init(composingBuffer: previousState.composingBuffer, cursorIndex: previousState.cursorIndex) } - func lookup(usingServiceAtIndex index: Int) { - DictionaryServices.shared.lookup(phrase: selectedPhrase, serviceIndex: index) + func lookUp(usingServiceAtIndex index: Int) { + DictionaryServices.shared.lookUp(phrase: selectedPhrase, serviceIndex: index) } } } diff --git a/Source/KeyHandler.mm b/Source/KeyHandler.mm index deff08f1..eafd28b4 100644 --- a/Source/KeyHandler.mm +++ b/Source/KeyHandler.mm @@ -1037,7 +1037,14 @@ - (BOOL)_handleCandidateState:(InputState *)state VTCandidateController *gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; if ([input isTab]) { - if ([state isKindOfClass:[InputStateChoosingCandidate class]]) { + if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { + InputStateSelectingDictionaryService *current = (InputStateSelectingDictionaryService *)state; + NSInteger selectedIndex = current.selectedIndex; + InputStateChoosingCandidate *newState = [current previousState]; + stateCallback(newState); + gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; + gCurrentCandidateController.selectedCandidateIndex = selectedIndex; + } else if ([state isKindOfClass:[InputStateChoosingCandidate class]]) { InputStateChoosingCandidate *currentState = (InputStateChoosingCandidate *)state; NSInteger index = gCurrentCandidateController.selectedCandidateIndex; NSString *selectedPhrase = currentState.candidates[index].displayText; diff --git a/Source/Preferences.swift b/Source/Preferences.swift index 3b421c07..bf183039 100644 --- a/Source/Preferences.swift +++ b/Source/Preferences.swift @@ -140,19 +140,19 @@ struct CandidateListTextSize { case IBM = 5 var name: String { - switch (self) { + return switch (self) { case .standard: - return "Standard" + "Standard" case .eten: - return "ETen" + "ETen" case .hsu: - return "Hsu" + "Hsu" case .eten26: - return "ETen26" + "ETen26" case .hanyuPinyin: - return "HanyuPinyin" + "HanyuPinyin" case .IBM: - return "IBM" + "IBM" } } } @@ -162,11 +162,11 @@ struct CandidateListTextSize { case vxHanConvert var name: String { - switch (self) { + return switch (self) { case .openCC: - return "OpenCC" + "OpenCC" case .vxHanConvert: - return "VXHanConvert" + "VXHanConvert" } } } @@ -176,11 +176,11 @@ struct CandidateListTextSize { case model var name: String { - switch (self) { + return switch (self) { case .output: - return "output" + "output" case .model: - return "model" + "model" } } } diff --git a/Source/ServiceProvider.swift b/Source/ServiceProvider.swift index ccac14ec..5137333d 100644 --- a/Source/ServiceProvider.swift +++ b/Source/ServiceProvider.swift @@ -23,22 +23,16 @@ import AppKit -class ServiceProvider: NSObject { - @objc func addUserPhrase(_ pasteboard: NSPasteboard, userData: String?, error: NSErrorPointer) { - guard let string = pasteboard.string(forType: .string), - let firstWord = string.components(separatedBy: .whitespacesAndNewlines).first - else { - return - } - +class ServiceProvider: NSObject { + func extractReading(from firstWord:String) -> String { var matches: [String] = [] - + // greedily find the longest possible matches var matchFrom = firstWord.startIndex while matchFrom < firstWord.endIndex { let substring = firstWord.suffix(from: matchFrom) let substringCount = substring.count - + // if an exact match fails, try dropping successive characters from the end to see // if we can find shorter matches var drop = 0 @@ -52,15 +46,35 @@ class ServiceProvider: NSObject { } drop += 1 } - + if drop >= substringCount { // didn't match anything?! matches.append("?") matchFrom = firstWord.index(matchFrom, offsetBy: 1) } } - + let reading = matches.joined(separator: "-") + return reading + } + + @objc func addUserPhrase(_ pasteboard: NSPasteboard, userData: String?, error: NSErrorPointer) { + guard let string = pasteboard.string(forType: .string), + let firstWord = string.components(separatedBy: .whitespacesAndNewlines).first + else { + return + } + + if firstWord.isEmpty { + return + } + + let reading = extractReading(from: firstWord) + + if reading.isEmpty { + return + } + LanguageModelManager.writeUserPhrase("\(firstWord) \(reading)") (NSApp.delegate as? AppDelegate)?.openUserPhrases(self) } diff --git a/Source/en.lproj/Localizable.strings b/Source/en.lproj/Localizable.strings index d8a4d5be..4a7dd224 100644 --- a/Source/en.lproj/Localizable.strings +++ b/Source/en.lproj/Localizable.strings @@ -96,3 +96,6 @@ "Check for Update Completed" = "Check for Update Completed"; "McBopomofo is up to date." = "McBopomofo is up to date."; + +"Look up \"%1$@\" in %2$@" = "Look up \"%1$@\" in %2$@"; + diff --git a/Source/zh-Hant.lproj/Localizable.strings b/Source/zh-Hant.lproj/Localizable.strings index 40e81926..8dec39e7 100644 --- a/Source/zh-Hant.lproj/Localizable.strings +++ b/Source/zh-Hant.lproj/Localizable.strings @@ -96,3 +96,5 @@ "Check for Update Completed" = "版本檢查完成"; "McBopomofo is up to date." = "您的小麥注音不需要更新。"; + +"Look up \"%1$@\" in %2$@" = "在%2$@查詢 \"%1$@\""; From 43134773c953f45bfbb43d567678616cce5e2436 Mon Sep 17 00:00:00 2001 From: zonble Date: Mon, 25 Dec 2023 15:45:34 +0800 Subject: [PATCH 05/18] Adds tests. --- McBopomofo.xcodeproj/project.pbxproj | 4 ++ McBopomofoTests/DictionaryServiceTests.swift | 14 +++++ McBopomofoTests/InputMacroTests.swift | 23 ++++++++ McBopomofoTests/KeyHandlerBopomofoTests.swift | 24 ++++++++ McBopomofoTests/ServiceProviderTests.swift | 23 ++++++++ Source/DictionaryService.swift | 59 +++++++++++-------- 6 files changed, 123 insertions(+), 24 deletions(-) create mode 100644 McBopomofoTests/DictionaryServiceTests.swift diff --git a/McBopomofo.xcodeproj/project.pbxproj b/McBopomofo.xcodeproj/project.pbxproj index 15160525..7c5f144c 100644 --- a/McBopomofo.xcodeproj/project.pbxproj +++ b/McBopomofo.xcodeproj/project.pbxproj @@ -75,6 +75,7 @@ D4E569DC27A34D0E00AC2CEF /* KeyHandler.mm in Sources */ = {isa = PBXBuildFile; fileRef = D4E569DB27A34CC100AC2CEF /* KeyHandler.mm */; }; D4E569E427A414CB00AC2CEF /* data-plain-bpmf.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6AD7CBC715FE555000691B5B /* data-plain-bpmf.txt */; }; D4E569E527A414CB00AC2CEF /* data.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6A38BBF615FC117A00A8A51F /* data.txt */; }; + D4EE67582B39685F00F062DE /* DictionaryServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4EE67572B39685F00F062DE /* DictionaryServiceTests.swift */; }; D4F0BBDF279AF1AF0071253C /* ArchiveUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F0BBDE279AF1AF0071253C /* ArchiveUtil.swift */; }; D4F0BBE1279AF8B30071253C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F0BBE0279AF8B30071253C /* AppDelegate.swift */; }; D4F0BBE4279B08900071253C /* BundleTranslocate.m in Sources */ = {isa = PBXBuildFile; fileRef = D4F0BBE3279B08900071253C /* BundleTranslocate.m */; }; @@ -207,6 +208,7 @@ D4E33D9127A838F7006DB1CF /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = ""; }; D4E569DA27A34CC100AC2CEF /* KeyHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KeyHandler.h; sourceTree = ""; }; D4E569DB27A34CC100AC2CEF /* KeyHandler.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = KeyHandler.mm; sourceTree = ""; }; + D4EE67572B39685F00F062DE /* DictionaryServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryServiceTests.swift; sourceTree = ""; }; D4F0BBDE279AF1AF0071253C /* ArchiveUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveUtil.swift; sourceTree = ""; }; D4F0BBE0279AF8B30071253C /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; D4F0BBE2279B08900071253C /* BundleTranslocate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BundleTranslocate.h; sourceTree = ""; }; @@ -447,6 +449,7 @@ D485D3BF2796CE3200657FF3 /* VersionUpdateTests.swift */, D449AD5E2B393C00000C5812 /* InputMacroTests.swift */, D449AD602B39506D000C5812 /* ServiceProviderTests.swift */, + D4EE67572B39685F00F062DE /* DictionaryServiceTests.swift */, ); path = McBopomofoTests; sourceTree = ""; @@ -712,6 +715,7 @@ D485D3B92796A8A000657FF3 /* PreferencesTests.swift in Sources */, D449AD612B39506D000C5812 /* ServiceProviderTests.swift in Sources */, D485D3C02796CE3200657FF3 /* VersionUpdateTests.swift in Sources */, + D4EE67582B39685F00F062DE /* DictionaryServiceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/McBopomofoTests/DictionaryServiceTests.swift b/McBopomofoTests/DictionaryServiceTests.swift new file mode 100644 index 00000000..356adbbc --- /dev/null +++ b/McBopomofoTests/DictionaryServiceTests.swift @@ -0,0 +1,14 @@ +import XCTest +@testable import McBopomofo + +final class DictionaryServiceTests: XCTestCase { + + func testDictionaryService() { + let count = DictionaryServices.shared.services.count + for index in 0.. Bool } fileprivate struct MacOSBuiltInDictionary: DictionaryService { var name: String { return NSLocalizedString("Dictionary app", comment: "") } - func lookUp(phrase: String) { + + func lookUp(phrase: String) -> Bool { guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return + return false } if let url = URL(string: "dict://\(encoded)") { - NSWorkspace.shared.open(url) + return NSWorkspace.shared.open(url) } + return false } } @@ -24,13 +26,15 @@ fileprivate struct MoeDictionary: DictionaryService { var name: String { return NSLocalizedString("MOE Dictionary", comment: "") } - func lookUp(phrase: String) { + + func lookUp(phrase: String) -> Bool { guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return + return false } if let url = URL(string: "https://www.moedict.tw/\(encoded)") { - NSWorkspace.shared.open(url) + return NSWorkspace.shared.open(url) } + return false } } @@ -38,13 +42,15 @@ fileprivate struct GoogleSearch: DictionaryService { var name: String { return NSLocalizedString("Google", comment: "") } - func lookUp(phrase: String) { + + func lookUp(phrase: String) -> Bool { guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return + return false } if let url = URL(string: "https://www.google.com/search?q=\(encoded)") { - NSWorkspace.shared.open(url) + return NSWorkspace.shared.open(url) } + return false } } @@ -52,13 +58,15 @@ fileprivate struct MoeRevisedDictionary: DictionaryService { var name: String { return NSLocalizedString("教育部重編國語詞典修訂本", comment: "") } - func lookUp(phrase: String) { + + func lookUp(phrase: String) -> Bool { guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return + return false } if let url = URL(string: "https://dict.revised.moe.edu.tw/search.jsp?md=1&word=\(encoded)") { - NSWorkspace.shared.open(url) + return NSWorkspace.shared.open(url) } + return false } } @@ -66,13 +74,15 @@ fileprivate struct MoeConcisedDictionary: DictionaryService { var name: String { return NSLocalizedString("教育部國語詞典簡邊本", comment: "") } - func lookUp(phrase: String) { + + func lookUp(phrase: String) -> Bool { guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return + return false } if let url = URL(string: "https://dict.concised.moe.edu.tw/search.jsp?md=1&word=\(encoded)") { - NSWorkspace.shared.open(url) + return NSWorkspace.shared.open(url) } + return false } } @@ -80,21 +90,22 @@ fileprivate struct MoeIdiomsDictionary: DictionaryService { var name: String { return NSLocalizedString("教育部成語典", comment: "") } - func lookUp(phrase: String) { + + func lookUp(phrase: String) -> Bool { guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return + return false } if let url = URL(string: "https://dict.idioms.moe.edu.tw/idiomList.jsp?idiom=\(encoded)&qMd=0&qTp=1&qTp=2") { - NSWorkspace.shared.open(url) + return NSWorkspace.shared.open(url) } + return false } } - class DictionaryServices: NSObject { @objc static var shared = DictionaryServices() - var services:[DictionaryService] = [ + var services: [DictionaryService] = [ MacOSBuiltInDictionary(), MoeDictionary(), GoogleSearch(), @@ -103,11 +114,11 @@ class DictionaryServices: NSObject { MoeIdiomsDictionary(), ] - func lookUp(phrase: String, serviceIndex: Int) { + func lookUp(phrase: String, serviceIndex: Int) -> Bool { if serviceIndex >= services.count { - return + return false } let service = services[serviceIndex] - service.lookUp(phrase: phrase) + return service.lookUp(phrase: phrase) } } From c99820b821bcdbe0cd7956698f3d7d1de13c12f7 Mon Sep 17 00:00:00 2001 From: zonble Date: Mon, 25 Dec 2023 16:07:00 +0800 Subject: [PATCH 06/18] Adds additional dictionary services. --- Source/Base.lproj/Localizable.strings | 8 ++-- Source/DictionaryService.swift | 53 +++++++++++++++++++++++- Source/en.lproj/Localizable.strings | 4 ++ Source/zh-Hant.lproj/Localizable.strings | 7 +++- 4 files changed, 66 insertions(+), 6 deletions(-) diff --git a/Source/Base.lproj/Localizable.strings b/Source/Base.lproj/Localizable.strings index b5d840e8..ac2e0883 100644 --- a/Source/Base.lproj/Localizable.strings +++ b/Source/Base.lproj/Localizable.strings @@ -85,10 +85,6 @@ "Certain Unicode symbols or characters not supported as user phrases." = "Certain Unicode symbols or characters not supported as user phrases."; -"Cursor is before \"%@\"." = "Cursor is before \"%@\"."; - -"Cursor is after \"%@\"." = "Cursor is after \"%@\"."; - "Cursor is between \"%@\" and \"%@\"." = "Cursor is between \"%@\" and \"%@\"."; "The phrase being marked \"%@\" already exists." = "The phrase being marked \"%@\" already exists."; @@ -102,3 +98,7 @@ "McBopomofo is up to date." = "McBopomofo is up to date."; "Look up \"%1$@\" in %2$@" = "Look up \"%1$@\" in %2$@"; + +"Dictionary app" = "Dictionary app"; + +"MOE Dictionary" = "MOE Dictionary"; diff --git a/Source/DictionaryService.swift b/Source/DictionaryService.swift index 0009ce83..013e3f8f 100644 --- a/Source/DictionaryService.swift +++ b/Source/DictionaryService.swift @@ -72,7 +72,7 @@ fileprivate struct MoeRevisedDictionary: DictionaryService { fileprivate struct MoeConcisedDictionary: DictionaryService { var name: String { - return NSLocalizedString("教育部國語詞典簡邊本", comment: "") + return NSLocalizedString("教育部國語詞典簡編本", comment: "") } func lookUp(phrase: String) -> Bool { @@ -102,6 +102,54 @@ fileprivate struct MoeIdiomsDictionary: DictionaryService { } } +fileprivate struct MoeVariantsDictionary: DictionaryService { + var name: String { + return NSLocalizedString("教育部異體字字典", comment: "") + } + + func lookUp(phrase: String) -> Bool { + guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { + return false + } + if let url = URL(string: "https://dict.variants.moe.edu.tw/variants/rbt/query_result.do?from=standard&search_text=\(encoded)") { + return NSWorkspace.shared.open(url) + } + return false + } +} + +fileprivate struct KangXiictionary: DictionaryService { + var name: String { + return NSLocalizedString("康熙字典網上版", comment: "") + } + + func lookUp(phrase: String) -> Bool { + guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { + return false + } + if let url = URL(string: "https://www.kangxizidian.com/search/index.php?stype=Word&sword=\(encoded)&detail=n") { + return NSWorkspace.shared.open(url) + } + return false + } +} + +fileprivate struct UnihanDatabase: DictionaryService { + var name: String { + return NSLocalizedString("Unihan Database", comment: "") + } + + func lookUp(phrase: String) -> Bool { + guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { + return false + } + if let url = URL(string: "https://www.unicode.org/cgi-bin/GetUnihanData.pl?codepoint=\(encoded)") { + return NSWorkspace.shared.open(url) + } + return false + } +} + class DictionaryServices: NSObject { @objc static var shared = DictionaryServices() @@ -112,6 +160,9 @@ class DictionaryServices: NSObject { MoeRevisedDictionary(), MoeConcisedDictionary(), MoeIdiomsDictionary(), + MoeVariantsDictionary(), + KangXiictionary(), + UnihanDatabase(), ] func lookUp(phrase: String, serviceIndex: Int) -> Bool { diff --git a/Source/en.lproj/Localizable.strings b/Source/en.lproj/Localizable.strings index 4a7dd224..81329a53 100644 --- a/Source/en.lproj/Localizable.strings +++ b/Source/en.lproj/Localizable.strings @@ -99,3 +99,7 @@ "Look up \"%1$@\" in %2$@" = "Look up \"%1$@\" in %2$@"; +"Dictionary app" = "Dictionary app"; + +"MOE Dictionary" = "MOE Dictionary"; + diff --git a/Source/zh-Hant.lproj/Localizable.strings b/Source/zh-Hant.lproj/Localizable.strings index 8dec39e7..6dbe91a1 100644 --- a/Source/zh-Hant.lproj/Localizable.strings +++ b/Source/zh-Hant.lproj/Localizable.strings @@ -97,4 +97,9 @@ "McBopomofo is up to date." = "您的小麥注音不需要更新。"; -"Look up \"%1$@\" in %2$@" = "在%2$@查詢 \"%1$@\""; +"Look up \"%1$@\" in %2$@" = "在%2$@查詢「%1$@」"; + +"Dictionary app" = "字典App"; + +"MOE Dictionary" = "萌典"; + From 8b310e1ce9318fb6f61a2697993253ce00627c87 Mon Sep 17 00:00:00 2001 From: zonble Date: Mon, 25 Dec 2023 17:10:15 +0800 Subject: [PATCH 07/18] Adds additional dictionary services. --- McBopomofoTests/DictionaryServiceTests.swift | 5 ++ Source/Base.lproj/Localizable.strings | 6 +- Source/DictionaryService.swift | 75 +++++++++++++++++++- Source/InputState.swift | 8 +-- Source/KeyHandler.mm | 11 +++ Source/en.lproj/Localizable.strings | 5 +- Source/zh-Hant.lproj/Localizable.strings | 8 ++- 7 files changed, 110 insertions(+), 8 deletions(-) diff --git a/McBopomofoTests/DictionaryServiceTests.swift b/McBopomofoTests/DictionaryServiceTests.swift index 356adbbc..6a9dcf65 100644 --- a/McBopomofoTests/DictionaryServiceTests.swift +++ b/McBopomofoTests/DictionaryServiceTests.swift @@ -3,6 +3,11 @@ import XCTest final class DictionaryServiceTests: XCTestCase { + func testSpeak() { + let result = DictionaryServices.shared.lookUp(phrase: "你", serviceIndex: 0) + XCTAssertTrue(result) + } + func testDictionaryService() { let count = DictionaryServices.shared.services.count for index in 0.. Bool + func textForMenu(selectedString: String) -> String +} + +extension DictionaryService { + func textForMenu(selectedString: String) -> String { + String(format: NSLocalizedString("Look up \"%1$@\" in %2$@", comment: ""), + selectedString, name) + } +} + +fileprivate struct Speak: DictionaryService { + let speechSynthesizer: NSSpeechSynthesizer? = { + let voices = NSSpeechSynthesizer.availableVoices + let firstZhTWVoice = voices.first { voice in + voice.rawValue.contains("zh-TW") + } + guard let firstZhTWVoice else { + return nil + } + return NSSpeechSynthesizer.init(voice: firstZhTWVoice) + }() + + var name: String { + return NSLocalizedString("Speak", comment: "") + } + + func lookUp(phrase: String) -> Bool { + guard let speechSynthesizer else { + return false + } + speechSynthesizer.stopSpeaking() + speechSynthesizer.startSpeaking(phrase) + return true + } + + func textForMenu(selectedString: String) -> String { + String(format: NSLocalizedString("Speak \"%@\"…", comment: ""), selectedString) + } } fileprivate struct MacOSBuiltInDictionary: DictionaryService { @@ -24,7 +62,7 @@ fileprivate struct MacOSBuiltInDictionary: DictionaryService { fileprivate struct MoeDictionary: DictionaryService { var name: String { - return NSLocalizedString("MOE Dictionary", comment: "") + return NSLocalizedString("MOE Dict", comment: "") } func lookUp(phrase: String) -> Bool { @@ -38,6 +76,38 @@ fileprivate struct MoeDictionary: DictionaryService { } } +fileprivate struct MoeDicHoloDictionary: DictionaryService { + var name: String { + return NSLocalizedString("MOE Dict (Holo)", comment: "") + } + + func lookUp(phrase: String) -> Bool { + guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { + return false + } + if let url = URL(string: "https://www.moedict.tw/'\(encoded)") { + return NSWorkspace.shared.open(url) + } + return false + } +} + +fileprivate struct MoeDicHakkaDictionary: DictionaryService { + var name: String { + return NSLocalizedString("MOE Dict (Hakka)", comment: "") + } + + func lookUp(phrase: String) -> Bool { + guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { + return false + } + if let url = URL(string: "https://www.moedict.tw/:\(encoded)") { + return NSWorkspace.shared.open(url) + } + return false + } +} + fileprivate struct GoogleSearch: DictionaryService { var name: String { return NSLocalizedString("Google", comment: "") @@ -154,8 +224,11 @@ fileprivate struct UnihanDatabase: DictionaryService { class DictionaryServices: NSObject { @objc static var shared = DictionaryServices() var services: [DictionaryService] = [ + Speak(), MacOSBuiltInDictionary(), MoeDictionary(), + MoeDicHoloDictionary(), + MoeDicHakkaDictionary(), GoogleSearch(), MoeRevisedDictionary(), MoeConcisedDictionary(), diff --git a/Source/InputState.swift b/Source/InputState.swift index 0889c214..a207730b 100644 --- a/Source/InputState.swift +++ b/Source/InputState.swift @@ -363,15 +363,15 @@ class InputState: NSObject { self.previousState = previousState self.selectedPhrase = selectedString self.selectedIndex = selectedIndex - self.menu = DictionaryServices.shared.services.map { service in - String(format: NSLocalizedString("Look up \"%1$@\" in %2$@", comment: ""), - selectedString, service.name) + self.menu = DictionaryServices.shared.services.map + { service in + service.textForMenu(selectedString: selectedString) } super.init(composingBuffer: previousState.composingBuffer, cursorIndex: previousState.cursorIndex) } func lookUp(usingServiceAtIndex index: Int) { - DictionaryServices.shared.lookUp(phrase: selectedPhrase, serviceIndex: index) + _ = DictionaryServices.shared.lookUp(phrase: selectedPhrase, serviceIndex: index) } } } diff --git a/Source/KeyHandler.mm b/Source/KeyHandler.mm index eafd28b4..8c2d47e0 100644 --- a/Source/KeyHandler.mm +++ b/Source/KeyHandler.mm @@ -1044,6 +1044,7 @@ - (BOOL)_handleCandidateState:(InputState *)state stateCallback(newState); gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; gCurrentCandidateController.selectedCandidateIndex = selectedIndex; + return YES; } else if ([state isKindOfClass:[InputStateChoosingCandidate class]]) { InputStateChoosingCandidate *currentState = (InputStateChoosingCandidate *)state; NSInteger index = gCurrentCandidateController.selectedCandidateIndex; @@ -1251,6 +1252,16 @@ - (BOOL)_handleCandidateState:(InputState *)state NSUInteger candidateIndex = [gCurrentCandidateController candidateIndexAtKeyLabelIndex:index]; if (candidateIndex != NSUIntegerMax) { [self.delegate keyHandler:self didSelectCandidateAtIndex:candidateIndex candidateController:gCurrentCandidateController]; + + if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { + InputStateSelectingDictionaryService *current = (InputStateSelectingDictionaryService *)state; + NSInteger selectedIndex = current.selectedIndex; + InputStateChoosingCandidate *newState = [current previousState]; + stateCallback(newState); + gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; + gCurrentCandidateController.selectedCandidateIndex = selectedIndex; + } + return YES; } } diff --git a/Source/en.lproj/Localizable.strings b/Source/en.lproj/Localizable.strings index 81329a53..0f96d7a1 100644 --- a/Source/en.lproj/Localizable.strings +++ b/Source/en.lproj/Localizable.strings @@ -101,5 +101,8 @@ "Dictionary app" = "Dictionary app"; -"MOE Dictionary" = "MOE Dictionary"; +"MOE Dict" = "MOE Dict"; +"MOE Dict (Holo)" = "MOE Dict (Holo)"; + +"Speak \"%@\"…" = "Speak \"%@\"…"; diff --git a/Source/zh-Hant.lproj/Localizable.strings b/Source/zh-Hant.lproj/Localizable.strings index 6dbe91a1..f3e87acd 100644 --- a/Source/zh-Hant.lproj/Localizable.strings +++ b/Source/zh-Hant.lproj/Localizable.strings @@ -101,5 +101,11 @@ "Dictionary app" = "字典App"; -"MOE Dictionary" = "萌典"; +"MOE Dict" = "萌典"; + +"MOE Dict (Holo)" = "台語萌典"; + +"MOE Dict (Holo)" = "客語萌典"; + +"Speak \"%@\"…" = "朗讀「%@」…"; From aeed90348f8bb74581b896eb28b3003f784fec16 Mon Sep 17 00:00:00 2001 From: zonble Date: Mon, 25 Dec 2023 17:32:23 +0800 Subject: [PATCH 08/18] Prevents duplicated code. --- Source/InputMethodController.swift | 6 ++++++ Source/KeyHandler.mm | 20 -------------------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/Source/InputMethodController.swift b/Source/InputMethodController.swift index 037d1ff1..a2baacaf 100644 --- a/Source/InputMethodController.swift +++ b/Source/InputMethodController.swift @@ -712,6 +712,12 @@ extension McBopomofoInputMethodController: CandidateControllerDelegate { } case let state as InputState.SelectingDictionaryService: state.lookUp(usingServiceAtIndex: Int(index)) + let previous = state.previousState + let candidateIndex = state.selectedIndex + handle(state: previous, client: client) + if candidateIndex > 0 { + gCurrentCandidateController?.selectedCandidateIndex = UInt(candidateIndex) + } default: break } diff --git a/Source/KeyHandler.mm b/Source/KeyHandler.mm index 8c2d47e0..40809a8b 100644 --- a/Source/KeyHandler.mm +++ b/Source/KeyHandler.mm @@ -1081,16 +1081,6 @@ - (BOOL)_handleCandidateState:(InputState *)state } if (charCode == 13 || [input isEnter]) { - if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { - [self.delegate keyHandler:self didSelectCandidateAtIndex:gCurrentCandidateController.selectedCandidateIndex candidateController:gCurrentCandidateController]; - InputStateSelectingDictionaryService *current = (InputStateSelectingDictionaryService *)state; - NSInteger selectedIndex = current.selectedIndex; - InputStateChoosingCandidate *newState = [current previousState]; - stateCallback(newState); - gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; - gCurrentCandidateController.selectedCandidateIndex = selectedIndex; - return YES; - } if ([state isKindOfClass:[InputStateAssociatedPhrases class]]) { [self clear]; InputStateEmptyIgnoringPreviousState *empty = [[InputStateEmptyIgnoringPreviousState alloc] init]; @@ -1252,16 +1242,6 @@ - (BOOL)_handleCandidateState:(InputState *)state NSUInteger candidateIndex = [gCurrentCandidateController candidateIndexAtKeyLabelIndex:index]; if (candidateIndex != NSUIntegerMax) { [self.delegate keyHandler:self didSelectCandidateAtIndex:candidateIndex candidateController:gCurrentCandidateController]; - - if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { - InputStateSelectingDictionaryService *current = (InputStateSelectingDictionaryService *)state; - NSInteger selectedIndex = current.selectedIndex; - InputStateChoosingCandidate *newState = [current previousState]; - stateCallback(newState); - gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; - gCurrentCandidateController.selectedCandidateIndex = selectedIndex; - } - return YES; } } From 9668794703db5b77a3d6fd1bfe5c53dcf58dd2bd Mon Sep 17 00:00:00 2001 From: zonble Date: Mon, 25 Dec 2023 19:34:19 +0800 Subject: [PATCH 09/18] Simplifies the dictionary services. --- McBopomofo.xcodeproj/project.pbxproj | 6 + McBopomofoTests/DictionaryServiceTests.swift | 4 +- Source/Base.lproj/Localizable.strings | 2 + Source/DictionaryService.swift | 205 +++---------------- Source/InputState.swift | 2 +- Source/dictionary_service.json | 64 ++++++ Source/en.lproj/Localizable.strings | 3 + Source/zh-Hant.lproj/Localizable.strings | 1 + 8 files changed, 110 insertions(+), 177 deletions(-) create mode 100644 Source/dictionary_service.json diff --git a/McBopomofo.xcodeproj/project.pbxproj b/McBopomofo.xcodeproj/project.pbxproj index 7c5f144c..1875add3 100644 --- a/McBopomofo.xcodeproj/project.pbxproj +++ b/McBopomofo.xcodeproj/project.pbxproj @@ -76,6 +76,8 @@ D4E569E427A414CB00AC2CEF /* data-plain-bpmf.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6AD7CBC715FE555000691B5B /* data-plain-bpmf.txt */; }; D4E569E527A414CB00AC2CEF /* data.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6A38BBF615FC117A00A8A51F /* data.txt */; }; D4EE67582B39685F00F062DE /* DictionaryServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4EE67572B39685F00F062DE /* DictionaryServiceTests.swift */; }; + D4EE675A2B39968900F062DE /* dictionary_service.json in Resources */ = {isa = PBXBuildFile; fileRef = D4EE67592B39968900F062DE /* dictionary_service.json */; }; + D4EE675B2B399D0200F062DE /* dictionary_service.json in Resources */ = {isa = PBXBuildFile; fileRef = D4EE67592B39968900F062DE /* dictionary_service.json */; }; D4F0BBDF279AF1AF0071253C /* ArchiveUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F0BBDE279AF1AF0071253C /* ArchiveUtil.swift */; }; D4F0BBE1279AF8B30071253C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F0BBE0279AF8B30071253C /* AppDelegate.swift */; }; D4F0BBE4279B08900071253C /* BundleTranslocate.m in Sources */ = {isa = PBXBuildFile; fileRef = D4F0BBE3279B08900071253C /* BundleTranslocate.m */; }; @@ -209,6 +211,7 @@ D4E569DA27A34CC100AC2CEF /* KeyHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KeyHandler.h; sourceTree = ""; }; D4E569DB27A34CC100AC2CEF /* KeyHandler.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = KeyHandler.mm; sourceTree = ""; }; D4EE67572B39685F00F062DE /* DictionaryServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryServiceTests.swift; sourceTree = ""; }; + D4EE67592B39968900F062DE /* dictionary_service.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = dictionary_service.json; sourceTree = ""; }; D4F0BBDE279AF1AF0071253C /* ArchiveUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveUtil.swift; sourceTree = ""; }; D4F0BBE0279AF8B30071253C /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; D4F0BBE2279B08900071253C /* BundleTranslocate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BundleTranslocate.h; sourceTree = ""; }; @@ -364,6 +367,7 @@ B0781B372ACA2655003D9F75 /* ServicesMenu.strings */, 6A187E2816004C5900466B2E /* MainMenu.xib */, 6A0D4F4E15FC0EE100ABF4B3 /* preferences.xib */, + D4EE67592B39968900F062DE /* dictionary_service.json */, ); name = Resources; sourceTree = ""; @@ -605,6 +609,7 @@ 6A38BC1515FC117A00A8A51F /* data.txt in Resources */, 6A6ED16B2797650A0012872E /* template-phrases-replacement.txt in Resources */, 6AFF97F2253B299E007F1C49 /* NonModalAlertWindowController.xib in Resources */, + D4EE675A2B39968900F062DE /* dictionary_service.json in Resources */, 6AE210B215FC63CC003659FE /* PlainBopomofo.tiff in Resources */, D4F0BBE7279B14C20071253C /* Credits.rtf in Resources */, D476CB492827DB6300F48552 /* add-phrase-hook.sh in Resources */, @@ -635,6 +640,7 @@ buildActionMask = 2147483647; files = ( D4E569E527A414CB00AC2CEF /* data.txt in Resources */, + D4EE675B2B399D0200F062DE /* dictionary_service.json in Resources */, D4E569E427A414CB00AC2CEF /* data-plain-bpmf.txt in Resources */, D47D73A927A6C84F00255A50 /* associated-phrases.txt in Resources */, ); diff --git a/McBopomofoTests/DictionaryServiceTests.swift b/McBopomofoTests/DictionaryServiceTests.swift index 6a9dcf65..a10b6971 100644 --- a/McBopomofoTests/DictionaryServiceTests.swift +++ b/McBopomofoTests/DictionaryServiceTests.swift @@ -4,14 +4,14 @@ import XCTest final class DictionaryServiceTests: XCTestCase { func testSpeak() { - let result = DictionaryServices.shared.lookUp(phrase: "你", serviceIndex: 0) + let result = DictionaryServices.shared.lookUp(phrase: "你", withServiceAtIndex: 0) XCTAssertTrue(result) } func testDictionaryService() { let count = DictionaryServices.shared.services.count for index in 0.. String } -extension DictionaryService { - func textForMenu(selectedString: String) -> String { - String(format: NSLocalizedString("Look up \"%1$@\" in %2$@", comment: ""), - selectedString, name) - } -} - fileprivate struct Speak: DictionaryService { let speechSynthesizer: NSSpeechSynthesizer? = { let voices = NSSpeechSynthesizer.availableVoices @@ -44,205 +37,69 @@ fileprivate struct Speak: DictionaryService { } } -fileprivate struct MacOSBuiltInDictionary: DictionaryService { - var name: String { - return NSLocalizedString("Dictionary app", comment: "") - } - - func lookUp(phrase: String) -> Bool { - guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return false - } - if let url = URL(string: "dict://\(encoded)") { - return NSWorkspace.shared.open(url) - } - return false - } -} +fileprivate struct HttpBasedDictionary: DictionaryService, Codable { + private (set) var name: String + private (set) var urlTemplate: String -fileprivate struct MoeDictionary: DictionaryService { - var name: String { - return NSLocalizedString("MOE Dict", comment: "") + init(name: String, urlTemplate: String) { + self.name = name + self.urlTemplate = urlTemplate } func lookUp(phrase: String) -> Bool { guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { return false } - if let url = URL(string: "https://www.moedict.tw/\(encoded)") { + if let url = URL(string: urlTemplate.replacingOccurrences(of: "(encoded)", with: encoded)) { return NSWorkspace.shared.open(url) } return false } -} - -fileprivate struct MoeDicHoloDictionary: DictionaryService { - var name: String { - return NSLocalizedString("MOE Dict (Holo)", comment: "") - } - func lookUp(phrase: String) -> Bool { - guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return false - } - if let url = URL(string: "https://www.moedict.tw/'\(encoded)") { - return NSWorkspace.shared.open(url) - } - return false + private enum CodingKeys : String, CodingKey { + case name, urlTemplate = "url_template" } -} -fileprivate struct MoeDicHakkaDictionary: DictionaryService { - var name: String { - return NSLocalizedString("MOE Dict (Hakka)", comment: "") - } - - func lookUp(phrase: String) -> Bool { - guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return false - } - if let url = URL(string: "https://www.moedict.tw/:\(encoded)") { - return NSWorkspace.shared.open(url) - } - return false - } -} - -fileprivate struct GoogleSearch: DictionaryService { - var name: String { - return NSLocalizedString("Google", comment: "") + func textForMenu(selectedString: String) -> String { + String(format: NSLocalizedString("Look up \"%1$@\" in %2$@", comment: ""), + selectedString, name) } - func lookUp(phrase: String) -> Bool { - guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return false - } - if let url = URL(string: "https://www.google.com/search?q=\(encoded)") { - return NSWorkspace.shared.open(url) - } - return false - } } -fileprivate struct MoeRevisedDictionary: DictionaryService { - var name: String { - return NSLocalizedString("教育部重編國語詞典修訂本", comment: "") - } - func lookUp(phrase: String) -> Bool { - guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return false - } - if let url = URL(string: "https://dict.revised.moe.edu.tw/search.jsp?md=1&word=\(encoded)") { - return NSWorkspace.shared.open(url) - } - return false - } -} - -fileprivate struct MoeConcisedDictionary: DictionaryService { - var name: String { - return NSLocalizedString("教育部國語詞典簡編本", comment: "") - } - - func lookUp(phrase: String) -> Bool { - guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return false - } - if let url = URL(string: "https://dict.concised.moe.edu.tw/search.jsp?md=1&word=\(encoded)") { - return NSWorkspace.shared.open(url) - } - return false - } -} - -fileprivate struct MoeIdiomsDictionary: DictionaryService { - var name: String { - return NSLocalizedString("教育部成語典", comment: "") - } +class DictionaryServices: NSObject { - func lookUp(phrase: String) -> Bool { - guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return false - } - if let url = URL(string: "https://dict.idioms.moe.edu.tw/idiomList.jsp?idiom=\(encoded)&qMd=0&qTp=1&qTp=2") { - return NSWorkspace.shared.open(url) - } - return false + private struct ServiceWrapper: Codable { + var services: [HttpBasedDictionary] } -} -fileprivate struct MoeVariantsDictionary: DictionaryService { - var name: String { - return NSLocalizedString("教育部異體字字典", comment: "") - } + @objc static var shared = DictionaryServices() - func lookUp(phrase: String) -> Bool { - guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return false - } - if let url = URL(string: "https://dict.variants.moe.edu.tw/variants/rbt/query_result.do?from=standard&search_text=\(encoded)") { - return NSWorkspace.shared.open(url) - } - return false - } -} + var services: [DictionaryService] -fileprivate struct KangXiictionary: DictionaryService { - var name: String { - return NSLocalizedString("康熙字典網上版", comment: "") - } + override init() { + var services: [DictionaryService] = [] + services.append(Speak()) - func lookUp(phrase: String) -> Bool { - guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return false - } - if let url = URL(string: "https://www.kangxizidian.com/search/index.php?stype=Word&sword=\(encoded)&detail=n") { - return NSWorkspace.shared.open(url) + let bundle = Bundle(for: DictionaryServices.self) + if let jsonPath = bundle.url(forResource: "dictionary_service", withExtension: "json"), + let data = try? Data(contentsOf: jsonPath) { + let decoder = JSONDecoder() + if let httpServices = try? decoder.decode(ServiceWrapper.self, from: data) { + services.append(contentsOf: httpServices.services) + } } - return false - } -} -fileprivate struct UnihanDatabase: DictionaryService { - var name: String { - return NSLocalizedString("Unihan Database", comment: "") + self.services = services } - func lookUp(phrase: String) -> Bool { - guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { - return false - } - if let url = URL(string: "https://www.unicode.org/cgi-bin/GetUnihanData.pl?codepoint=\(encoded)") { - return NSWorkspace.shared.open(url) - } - return false - } -} - -class DictionaryServices: NSObject { - @objc static var shared = DictionaryServices() - var services: [DictionaryService] = [ - Speak(), - MacOSBuiltInDictionary(), - MoeDictionary(), - MoeDicHoloDictionary(), - MoeDicHakkaDictionary(), - GoogleSearch(), - MoeRevisedDictionary(), - MoeConcisedDictionary(), - MoeIdiomsDictionary(), - MoeVariantsDictionary(), - KangXiictionary(), - UnihanDatabase(), - ] - - func lookUp(phrase: String, serviceIndex: Int) -> Bool { - if serviceIndex >= services.count { + func lookUp(phrase: String, withServiceAtIndex index: Int) -> Bool { + if index >= services.count { return false } - let service = services[serviceIndex] + let service = services[index] return service.lookUp(phrase: phrase) } } diff --git a/Source/InputState.swift b/Source/InputState.swift index a207730b..5eabfdba 100644 --- a/Source/InputState.swift +++ b/Source/InputState.swift @@ -371,7 +371,7 @@ class InputState: NSObject { } func lookUp(usingServiceAtIndex index: Int) { - _ = DictionaryServices.shared.lookUp(phrase: selectedPhrase, serviceIndex: index) + _ = DictionaryServices.shared.lookUp(phrase: selectedPhrase, withServiceAtIndex: index) } } } diff --git a/Source/dictionary_service.json b/Source/dictionary_service.json new file mode 100644 index 00000000..6e0ac6f1 --- /dev/null +++ b/Source/dictionary_service.json @@ -0,0 +1,64 @@ +{ + "services": [ + { + "name": "Dictionary app", + "url_template": "dict://(encoded)" + }, + { + "name": "MOE Dict", + "url_template": "https://www.moedict.tw/(encoded)" + }, + { + "name": "MOE Dict (Holo)", + "url_template": "https://www.moedict.tw/'(encoded)" + }, + { + "name": "MOE Dict (Hakka)", + "url_template": "https://www.moedict.tw/:(encoded)" + }, + { + "name": "Google", + "url_template": "https://www.google.com/search?q=(encoded)" + }, + { + "name": "教育部重編國語詞典修訂本", + "url_template": "https://dict.revised.moe.edu.tw/search.jsp?md=1&word=(encoded)" + }, + { + "name": "教育部國語詞典簡編本", + "url_template": "https://dict.concised.moe.edu.tw/search.jsp?md=1&word=(encoded)" + }, + { + "name": "教育部成語典", + "url_template": "https://dict.idioms.moe.edu.tw/idiomList.jsp?idiom=(encoded)&qMd=0&qTp=1&qTp=2" + }, + { + "name": "教育部異體字字典", + "url_template": "https://dict.variants.moe.edu.tw/variants/rbt/query_result.do?from=standard&search_text=(encoded)" + }, + { + "name": "教育部國字標準字體筆順學習網", + "url_template": "https://stroke-order.learningweb.moe.edu.tw/charactersQueryResult.do?words=(encoded)&lang=zh_TW&csrfPreventionSalt=null" + }, + { + "name": "教育部臺灣閩南語常用詞辭典", + "url_template": "https://sutian.moe.edu.tw/zh-hant/tshiau/?lui=tai_su&tsha=(encoded)" + }, + { + "name": "教育部臺灣客家語常用詞辭典", + "url_template": "https://hakkadict.moe.edu.tw/cgi-bin/gs32/gsweb.cgi/ccd=nsbgkx/search?qs0=(encoded)" + }, + { + "name": "Wiktionary", + "url_template": "https://zh.wiktionary.org/wiki/Special:Search?search=(encoded)" + }, + { + "name": "康熙字典網上版", + "url_template": "https://www.kangxizidian.com/search/index.php?stype=Word&sword=(encoded)&detail=n" + }, + { + "name": "Unihan Database", + "url_template": "https://www.unicode.org/cgi-bin/GetUnihanData.pl?codepoint=(encoded)" + } + ] +} diff --git a/Source/en.lproj/Localizable.strings b/Source/en.lproj/Localizable.strings index 0f96d7a1..5dcef6fa 100644 --- a/Source/en.lproj/Localizable.strings +++ b/Source/en.lproj/Localizable.strings @@ -106,3 +106,6 @@ "MOE Dict (Holo)" = "MOE Dict (Holo)"; "Speak \"%@\"…" = "Speak \"%@\"…"; + +"Wiktionary" = "Wiktionary"; + diff --git a/Source/zh-Hant.lproj/Localizable.strings b/Source/zh-Hant.lproj/Localizable.strings index f3e87acd..36aaf13d 100644 --- a/Source/zh-Hant.lproj/Localizable.strings +++ b/Source/zh-Hant.lproj/Localizable.strings @@ -109,3 +109,4 @@ "Speak \"%@\"…" = "朗讀「%@」…"; +"Wiktionary" = "維基詞典"; From 5a07cdac6b5577101c39febf7eb74d814070d9b4 Mon Sep 17 00:00:00 2001 From: zonble Date: Mon, 25 Dec 2023 21:04:34 +0800 Subject: [PATCH 10/18] Remaps the key to do dictionary look up to "?". --- Source/KeyHandler.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/KeyHandler.mm b/Source/KeyHandler.mm index 40809a8b..17051c2e 100644 --- a/Source/KeyHandler.mm +++ b/Source/KeyHandler.mm @@ -1036,7 +1036,7 @@ - (BOOL)_handleCandidateState:(InputState *)state UniChar charCode = input.charCode; VTCandidateController *gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; - if ([input isTab]) { + if ([[input inputText] isEqualToString:@"?"]) { if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { InputStateSelectingDictionaryService *current = (InputStateSelectingDictionaryService *)state; NSInteger selectedIndex = current.selectedIndex; From 9fd2070e0e234e3449e9d0cecaee3b84e4a8b799 Mon Sep 17 00:00:00 2001 From: zonble Date: Mon, 25 Dec 2023 21:18:12 +0800 Subject: [PATCH 11/18] Fixes tests. --- McBopomofoTests/KeyHandlerBopomofoTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/McBopomofoTests/KeyHandlerBopomofoTests.swift b/McBopomofoTests/KeyHandlerBopomofoTests.swift index 509f6d31..903635c6 100644 --- a/McBopomofoTests/KeyHandlerBopomofoTests.swift +++ b/McBopomofoTests/KeyHandlerBopomofoTests.swift @@ -1599,7 +1599,7 @@ class KeyHandlerBopomofoTests: XCTestCase { } errorCallback: { } - input = KeyHandlerInput(inputText: " ", keyCode: KeyCode.tab.rawValue, charCode: 0, flags: [], isVerticalMode: false) + input = KeyHandlerInput(inputText: "?", keyCode: 0, charCode: 0, flags: [], isVerticalMode: false) handler.handle(input: input, state: state) { newState in state = newState } errorCallback: { @@ -1607,7 +1607,7 @@ class KeyHandlerBopomofoTests: XCTestCase { XCTAssert(state is InputState.SelectingDictionaryService) - input = KeyHandlerInput(inputText: " ", keyCode: KeyCode.tab.rawValue, charCode: 0, flags: [], isVerticalMode: false) + input = KeyHandlerInput(inputText: "?", keyCode: 0, charCode: 0, flags: [], isVerticalMode: false) handler.handle(input: input, state: state) { newState in state = newState } errorCallback: { @@ -1634,7 +1634,7 @@ class KeyHandlerBopomofoTests: XCTestCase { } errorCallback: { } - input = KeyHandlerInput(inputText: " ", keyCode: KeyCode.tab.rawValue, charCode: 0, flags: [], isVerticalMode: false) + input = KeyHandlerInput(inputText: "?", keyCode: 0, charCode: 0, flags: [], isVerticalMode: false) handler.handle(input: input, state: state) { newState in state = newState } errorCallback: { From d17d75bd196aa49ee6c544d93711dff4dbe0a225 Mon Sep 17 00:00:00 2001 From: zonble Date: Tue, 26 Dec 2023 00:25:20 +0800 Subject: [PATCH 12/18] Adds a menu to display character information. --- Source/Base.lproj/Localizable.strings | 2 + Source/Base.lproj/MainMenu.xib | 4 +- Source/Base.lproj/preferences.xib | 2 +- Source/DictionaryService.swift | 35 +++++-- Source/InputMacro.swift | 6 +- Source/InputMethodController.swift | 44 ++++++++- Source/InputState.swift | 111 +++++++++++++++++++---- Source/KeyHandler.mm | 47 ++++++---- Source/NonModalAlertWindowController.xib | 8 +- Source/dictionary_service.json | 4 - Source/en.lproj/Localizable.strings | 2 + Source/zh-Hant.lproj/Localizable.strings | 3 + Source/zh-Hant.lproj/MainMenu.xib | 7 +- Source/zh-Hant.lproj/preferences.xib | 2 +- 14 files changed, 211 insertions(+), 66 deletions(-) diff --git a/Source/Base.lproj/Localizable.strings b/Source/Base.lproj/Localizable.strings index b77f12e9..61097605 100644 --- a/Source/Base.lproj/Localizable.strings +++ b/Source/Base.lproj/Localizable.strings @@ -108,3 +108,5 @@ "Speak \"%@\"…" = "Speak \"%@\"…"; "Wiktionary" = "Wiktionary"; + +"%@ has been copied." = "%@ has been copied."; diff --git a/Source/Base.lproj/MainMenu.xib b/Source/Base.lproj/MainMenu.xib index 6a12278f..7cd50ebc 100644 --- a/Source/Base.lproj/MainMenu.xib +++ b/Source/Base.lproj/MainMenu.xib @@ -1,8 +1,8 @@ - + - + diff --git a/Source/Base.lproj/preferences.xib b/Source/Base.lproj/preferences.xib index 337b0e2f..520569e0 100644 --- a/Source/Base.lproj/preferences.xib +++ b/Source/Base.lproj/preferences.xib @@ -467,7 +467,7 @@ - + diff --git a/Source/DictionaryService.swift b/Source/DictionaryService.swift index 7b2034d7..17f215c4 100644 --- a/Source/DictionaryService.swift +++ b/Source/DictionaryService.swift @@ -3,7 +3,7 @@ import Cocoa protocol DictionaryService { var name: String { get } - func lookUp(phrase: String) -> Bool + func lookUp(phrase: String, state: InputState, serviceIndex: Int, stateCallback: (InputState) -> ()) -> Bool func textForMenu(selectedString: String) -> String } @@ -23,7 +23,7 @@ fileprivate struct Speak: DictionaryService { return NSLocalizedString("Speak", comment: "") } - func lookUp(phrase: String) -> Bool { + func lookUp(phrase: String, state: InputState, serviceIndex: Int, stateCallback: (InputState) -> ()) -> Bool { guard let speechSynthesizer else { return false } @@ -37,6 +37,26 @@ fileprivate struct Speak: DictionaryService { } } +fileprivate struct CharacterInfo: DictionaryService { + var name: String { + NSLocalizedString("Character Information", comment: "") + } + + func lookUp(phrase: String, state: InputState, serviceIndex: Int, stateCallback: (InputState) -> ()) -> Bool { + guard let state = state as? InputState.SelectingDictionaryService else { + return false + } + let newState = InputState.ShowingCharInfo(previousState: state, selectedString: state.selectedPhrase, selectedIndex: serviceIndex) + stateCallback(newState) + return false + } + + func textForMenu(selectedString: String) -> String { + NSLocalizedString("Character Information", comment: "") + } + +} + fileprivate struct HttpBasedDictionary: DictionaryService, Codable { private (set) var name: String private (set) var urlTemplate: String @@ -46,7 +66,7 @@ fileprivate struct HttpBasedDictionary: DictionaryService, Codable { self.urlTemplate = urlTemplate } - func lookUp(phrase: String) -> Bool { + func lookUp(phrase: String, state: InputState, serviceIndex: Int, stateCallback: (InputState) -> ()) -> Bool { guard let encoded = phrase.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { return false } @@ -56,13 +76,13 @@ fileprivate struct HttpBasedDictionary: DictionaryService, Codable { return false } - private enum CodingKeys : String, CodingKey { + private enum CodingKeys: String, CodingKey { case name, urlTemplate = "url_template" } func textForMenu(selectedString: String) -> String { String(format: NSLocalizedString("Look up \"%1$@\" in %2$@", comment: ""), - selectedString, name) + selectedString, name) } } @@ -81,6 +101,7 @@ class DictionaryServices: NSObject { override init() { var services: [DictionaryService] = [] services.append(Speak()) + services.append(CharacterInfo()) let bundle = Bundle(for: DictionaryServices.self) if let jsonPath = bundle.url(forResource: "dictionary_service", withExtension: "json"), @@ -95,11 +116,11 @@ class DictionaryServices: NSObject { } - func lookUp(phrase: String, withServiceAtIndex index: Int) -> Bool { + func lookUp(phrase: String, withServiceAtIndex index: Int, state: InputState, stateCallback: (InputState) -> ()) -> Bool { if index >= services.count { return false } let service = services[index] - return service.lookUp(phrase: phrase) + return service.lookUp(phrase: phrase, state: state, serviceIndex: index, stateCallback: stateCallback) } } diff --git a/Source/InputMacro.swift b/Source/InputMacro.swift index 33f0b5f7..709fe415 100644 --- a/Source/InputMacro.swift +++ b/Source/InputMacro.swift @@ -422,7 +422,7 @@ class InputMacroController: NSObject { @objc static let shared = InputMacroController() - private var macros: [String:InputMacro] = { + private var macros: [String: InputMacro] = { let macros: [InputMacro] = [ InputMacroThisYear(), InputMacroThisYearROC(), @@ -459,12 +459,12 @@ class InputMacroController: NSObject { InputMacroLastYearChineseZodiac(), InputMacroNextYearChineseZodiac(), ] - var map:[String:InputMacro] = [:] + var map: [String: InputMacro] = [:] macros.forEach { macro in map[macro.name] = macro } return map - } () + }() @objc diff --git a/Source/InputMethodController.swift b/Source/InputMethodController.swift index a2baacaf..62fb8f4a 100644 --- a/Source/InputMethodController.swift +++ b/Source/InputMethodController.swift @@ -291,6 +291,8 @@ extension McBopomofoInputMethodController { handle(state: newState, previous: previous, client: client) case let newState as InputState.SelectingDictionaryService: handle(state: newState, previous: previous, client: client) + case let newState as InputState.ShowingCharInfo: + handle(state: newState, previous: previous, client: client) default: break } @@ -459,6 +461,20 @@ extension McBopomofoInputMethodController { client.setMarkedText(candidateDate.attributedString, selectionRange: NSMakeRange(Int(candidateDate.cursorIndex), 0), replacementRange: NSMakeRange(NSNotFound, NSNotFound)) show(candidateWindowWith: state, client: client) } + + private func handle(state: InputState.ShowingCharInfo, previous: InputState, client: Any?) { + + hideTooltip() + guard let client = client as? IMKTextInput else { + gCurrentCandidateController?.visible = false + return + } + let candidateDate = state.previousState.previousState + // the selection range is where the cursor is, with the length being 0 and replacement range NSNotFound, + // i.e. the client app needs to take care of where to put this composing buffer + client.setMarkedText(candidateDate.attributedString, selectionRange: NSMakeRange(Int(candidateDate.cursorIndex), 0), replacementRange: NSMakeRange(NSNotFound, NSNotFound)) + show(candidateWindowWith: state, client: client) + } } // MARK: - @@ -480,6 +496,8 @@ extension McBopomofoInputMethodController { } case _ as InputState.SelectingDictionaryService: return true + case _ as InputState.ShowingCharInfo: + return true default: break } @@ -655,6 +673,8 @@ extension McBopomofoInputMethodController: CandidateControllerDelegate { UInt(state.candidates.count) case let state as InputState.SelectingDictionaryService: UInt(state.menu.count) + case let state as InputState.ShowingCharInfo: + UInt(state.menu.count) default: 0 } @@ -668,6 +688,8 @@ extension McBopomofoInputMethodController: CandidateControllerDelegate { state.candidates[Int(index)] case let state as InputState.SelectingDictionaryService: state.menu[Int(index)] + case let state as InputState.ShowingCharInfo: + state.menu[Int(index)] default: "" } @@ -711,9 +733,25 @@ extension McBopomofoInputMethodController: CandidateControllerDelegate { handle(state: .Empty(), client: client) } case let state as InputState.SelectingDictionaryService: - state.lookUp(usingServiceAtIndex: Int(index)) - let previous = state.previousState - let candidateIndex = state.selectedIndex + let handled = state.lookUp(usingServiceAtIndex: Int(index), state: state) { state in + handle(state: state, client: client) + } + if handled { + let previous = state.previousState + let candidateIndex = state.selectedIndex + handle(state: previous, client: client) + if candidateIndex > 0 { + gCurrentCandidateController?.selectedCandidateIndex = UInt(candidateIndex) + } + } + case let state as InputState.ShowingCharInfo: + let text = state.menuTitleValueMapping[Int(index)].1 + NSPasteboard.general.declareTypes([.string], owner: nil) + NSPasteboard.general.setString(text, forType: .string) + NotifierController.notify(message:String(format:NSLocalizedString("%@ has been copied.", comment: ""), text)) + + let previous = state.previousState.previousState + let candidateIndex = state.previousState.selectedIndex handle(state: previous, client: client) if candidateIndex > 0 { gCurrentCandidateController?.selectedCandidateIndex = UInt(candidateIndex) diff --git a/Source/InputState.swift b/Source/InputState.swift index 5eabfdba..d3ee038e 100644 --- a/Source/InputState.swift +++ b/Source/InputState.swift @@ -58,7 +58,7 @@ import NSStringUtils class InputState: NSObject { /// Represents that the input controller is deactivated. - @objc (InputStateDeactivated) + @objc(InputStateDeactivated) class Deactivated: InputState { override var description: String { "" @@ -68,7 +68,7 @@ class InputState: NSObject { // MARK: - /// Represents that the composing buffer is empty. - @objc (InputStateEmpty) + @objc(InputStateEmpty) class Empty: InputState { @objc var composingBuffer: String { "" @@ -82,7 +82,7 @@ class InputState: NSObject { // MARK: - /// Represents that the composing buffer is empty. - @objc (InputStateEmptyIgnoringPreviousState) + @objc(InputStateEmptyIgnoringPreviousState) class EmptyIgnoringPreviousState: InputState { @objc var composingBuffer: String { "" @@ -95,7 +95,7 @@ class InputState: NSObject { // MARK: - /// Represents that the input controller is committing text into client app. - @objc (InputStateCommitting) + @objc(InputStateCommitting) class Committing: InputState { @objc private(set) var poppedText: String = "" @@ -114,6 +114,7 @@ class InputState: NSObject { @objc(InputStateBig5) class Big5: InputState { @objc private(set) var code: String + @objc init(code: String) { self.code = code } @@ -130,7 +131,7 @@ class InputState: NSObject { // MARK: - /// Represents that the composing buffer is not empty. - @objc (InputStateNotEmpty) + @objc(InputStateNotEmpty) class NotEmpty: InputState { @objc private(set) var composingBuffer: String @objc private(set) var cursorIndex: UInt @@ -148,7 +149,7 @@ class InputState: NSObject { // MARK: - /// Represents that the user is inputting text. - @objc (InputStateInputting) + @objc(InputStateInputting) class Inputting: NotEmpty { @objc var tooltip: String = "" @@ -175,7 +176,7 @@ class InputState: NSObject { private let kMaxMarkRangeLength = 6 /// Represents that the user is marking a range in the composing buffer. - @objc (InputStateMarking) + @objc(InputStateMarking) class Marking: NotEmpty { @objc private(set) var markerIndex: UInt @@ -243,7 +244,7 @@ class InputState: NSObject { .underlineStyle: NSUnderlineStyle.single.rawValue, .markedClauseSegment: 2 ], range: NSRange(location: end, - length: (composingBuffer as NSString).length - end)) + length: (composingBuffer as NSString).length - end)) return attributedSting } @@ -265,7 +266,7 @@ class InputState: NSObject { if composingBuffer.count != readings.count { return false } - if markedRange.length < kMinMarkRangeLength { + if markedRange.length < kMinMarkRangeLength { return false } if markedRange.length > kMaxMarkRangeLength { @@ -295,11 +296,12 @@ class InputState: NSObject { // MARK: - - @objc (InputStateCandidate) + @objc(InputStateCandidate) class Candidate: NSObject { @objc private(set) var reading: String @objc private(set) var value: String @objc private(set) var displayText: String + @objc init(reading: String, value: String, displayText: String) { self.reading = reading self.value = value @@ -308,7 +310,7 @@ class InputState: NSObject { } /// Represents that the user is choosing in a candidates list. - @objc (InputStateChoosingCandidate) + @objc(InputStateChoosingCandidate) class ChoosingCandidate: NotEmpty { @objc private(set) var candidates: [Candidate] @objc private(set) var useVerticalMode: Bool @@ -336,10 +338,11 @@ class InputState: NSObject { /// Represents that the user is choosing in a candidates list /// in the associated phrases mode. - @objc (InputStateAssociatedPhrases) + @objc(InputStateAssociatedPhrases) class AssociatedPhrases: InputState { @objc private(set) var candidates: [String] = [] @objc private(set) var useVerticalMode: Bool = false + @objc init(candidates: [String], useVerticalMode: Bool) { self.candidates = candidates self.useVerticalMode = useVerticalMode @@ -351,27 +354,97 @@ class InputState: NSObject { } } - @objc (InputStateSelectingDictionaryService) + @objc(InputStateSelectingDictionaryService) class SelectingDictionaryService: NotEmpty { - @objc private (set) var previousState: ChoosingCandidate + @objc private(set) var previousState: ChoosingCandidate @objc private(set) var selectedPhrase: String = "" @objc private(set) var selectedIndex: Int = 0 - @objc var menu: [String] + @objc private(set) var menu: [String] @objc init(previousState: ChoosingCandidate, selectedString: String, selectedIndex: Int) { self.previousState = previousState self.selectedPhrase = selectedString self.selectedIndex = selectedIndex - self.menu = DictionaryServices.shared.services.map - { service in + self.menu = DictionaryServices.shared.services.map { service in service.textForMenu(selectedString: selectedString) } super.init(composingBuffer: previousState.composingBuffer, cursorIndex: previousState.cursorIndex) } - func lookUp(usingServiceAtIndex index: Int) { - _ = DictionaryServices.shared.lookUp(phrase: selectedPhrase, withServiceAtIndex: index) + func lookUp(usingServiceAtIndex index: Int, state: InputState, stateCallback: (InputState) -> ()) -> Bool { + DictionaryServices.shared.lookUp(phrase: selectedPhrase, withServiceAtIndex: index, state: state, stateCallback: stateCallback) + } + + override var description: String { + "" + } + } + + @objc(InputStateShowingCharInfo) + class ShowingCharInfo: NotEmpty { + + @objc private(set) var previousState: SelectingDictionaryService + @objc private(set) var selectedPhrase: String = "" + @objc private(set) var selectedIndex: Int = 0 + @objc private(set) var menu: [String] + private(set) var menuTitleValueMapping: [(String, String)] + + @objc + init(previousState: SelectingDictionaryService, selectedString: String, selectedIndex: Int) { + self.previousState = previousState + self.selectedPhrase = selectedString + self.selectedIndex = selectedIndex + + func buildItem(prefix: String, selectedString: String, builder: (String) -> String) -> (String, String) { + let result = builder(selectedString) + return ("\(prefix): \(result)", result) + } + + func getCharCode(string: String, encoding: UInt32) -> String { + return string.map { c in + let swiftString = "\(c)" + let cfString: CFString = swiftString as CFString + var cStringBuffer = [CChar](repeating: 0, count: 4) + CFStringGetCString(cfString, &cStringBuffer, 4, encoding) + let data = Data(bytes: cStringBuffer, count: strlen(cStringBuffer)) + if data.count >= 2 { + return "0x" + String(format: "%02x%02x", data[0], data[1]).uppercased() + } + return "N/A" + }.joined(separator: " ") + } + + self.menuTitleValueMapping = [ + buildItem(prefix: "UTF-8 HEX", selectedString: selectedPhrase, builder: { string in + string.map { c in + "0x" + c.utf8.map { String(format: "%02x", $0).uppercased() }.joined() + }.joined(separator: " ") + }), + buildItem(prefix: "UTF-16 HEX", selectedString: selectedPhrase, builder: { string in + string.map { c in + "0x" + c.utf16.map { String(format: "%02x", $0).uppercased() }.joined() + }.joined(separator: " ") + }), + buildItem(prefix: "URL Escape", selectedString: selectedPhrase, builder: { string in + string.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + }), + buildItem(prefix: "Big5", selectedString: selectedPhrase, builder: { string in + getCharCode(string: string, encoding: 0x0A06) + }), + buildItem(prefix: "GB2312", selectedString: selectedPhrase, builder: { string in + getCharCode(string: string, encoding: 0x0631) + }), + buildItem(prefix: "Shift JIS", selectedString: selectedPhrase, builder: { string in + getCharCode(string: string, encoding: 0x0A01) + }), + ] + self.menu = menuTitleValueMapping.map { $0.0 } + super.init(composingBuffer: previousState.composingBuffer, cursorIndex: previousState.cursorIndex) + } + + override var description: String { + "" } } } diff --git a/Source/KeyHandler.mm b/Source/KeyHandler.mm index 17051c2e..fd88368a 100644 --- a/Source/KeyHandler.mm +++ b/Source/KeyHandler.mm @@ -298,6 +298,12 @@ - (BOOL)handleInput:(KeyHandlerInput *)input state:(InputState *)inState stateCa return [self _handleCandidateState:state input:input stateCallback:stateCallback errorCallback:errorCallback]; } + // MARK: Handle Showing Character Information + if ([state isKindOfClass:[InputStateShowingCharInfo class]]) { + return [self _handleCandidateState:state input:input stateCallback:stateCallback errorCallback:errorCallback]; + } + + // MARK: Handle Marking if ([state isKindOfClass:[InputStateMarking class]]) { InputStateMarking *marking = (InputStateMarking *)state; @@ -1036,15 +1042,13 @@ - (BOOL)_handleCandidateState:(InputState *)state UniChar charCode = input.charCode; VTCandidateController *gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; + BOOL cancelCandidateKey = (charCode == 27) || (charCode == 8) || [input isDelete]; + if ([[input inputText] isEqualToString:@"?"]) { - if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { - InputStateSelectingDictionaryService *current = (InputStateSelectingDictionaryService *)state; - NSInteger selectedIndex = current.selectedIndex; - InputStateChoosingCandidate *newState = [current previousState]; - stateCallback(newState); - gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; - gCurrentCandidateController.selectedCandidateIndex = selectedIndex; - return YES; + if ([state isKindOfClass:[InputStateShowingCharInfo class]]) { + cancelCandidateKey = YES; + } else if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { + cancelCandidateKey = YES; } else if ([state isKindOfClass:[InputStateChoosingCandidate class]]) { InputStateChoosingCandidate *currentState = (InputStateChoosingCandidate *)state; NSInteger index = gCurrentCandidateController.selectedCandidateIndex; @@ -1055,13 +1059,18 @@ - (BOOL)_handleCandidateState:(InputState *)state } } - BOOL cancelCandidateKey = (charCode == 27) || (charCode == 8) || [input isDelete]; - if (cancelCandidateKey) { - if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { + if ([state isKindOfClass:[InputStateShowingCharInfo class]]) { + InputStateShowingCharInfo *current = (InputStateShowingCharInfo *)state; + NSInteger selectedIndex = current.previousState.selectedIndex; + InputStateChoosingCandidate *newState = current.previousState.previousState; + stateCallback(newState); + gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; + gCurrentCandidateController.selectedCandidateIndex = selectedIndex; + } else if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { InputStateSelectingDictionaryService *current = (InputStateSelectingDictionaryService *)state; NSInteger selectedIndex = current.selectedIndex; - InputStateChoosingCandidate *newState = [current previousState]; + InputStateChoosingCandidate *newState = current.previousState; stateCallback(newState); gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; gCurrentCandidateController.selectedCandidateIndex = selectedIndex; @@ -1315,13 +1324,13 @@ - (BOOL)_handleBig5State:(InputState *)state if ((charCode >= '0' && charCode <= '9') || (charCode >= 'a' && charCode <= 'f')) { - NSString *appneded = [NSString stringWithFormat:@"%@%c", bigs.code, toupper(charCode)]; - if (appneded.length == 4) { - long big5Code = (long)strtol(appneded.UTF8String, NULL, 16); + NSString *appended = [NSString stringWithFormat:@"%@%c", bigs.code, toupper(charCode)]; + if (appended.length == 4) { + long big5Code = (long)strtol(appended.UTF8String, NULL, 16); char bytes[3] = {0}; bytes[0] = (big5Code >> CHAR_BIT) & 0xff; bytes[1] = big5Code & 0xff; - CFStringRef string = CFStringCreateWithCString(NULL, bytes, kCFStringEncodingBig5); + CFStringRef string = CFStringCreateWithCString(NULL, bytes, kCFStringEncodingBig5_HKSCS_1999); if (string == NULL) { errorCallback(); InputStateEmpty *empty = [[InputStateEmpty alloc] init]; @@ -1329,12 +1338,12 @@ - (BOOL)_handleBig5State:(InputState *)state return YES; } - InputStateCommitting *commiting = [[InputStateCommitting alloc] initWithPoppedText:(__bridge NSString *)string]; - stateCallback(commiting); + InputStateCommitting *committing = [[InputStateCommitting alloc] initWithPoppedText:(__bridge NSString *)string]; + stateCallback(committing); InputStateEmpty *empty = [[InputStateEmpty alloc] init]; stateCallback(empty); } else { - InputStateBig5 *newState = [[InputStateBig5 alloc] initWithCode:appneded]; + InputStateBig5 *newState = [[InputStateBig5 alloc] initWithCode:appended]; stateCallback(newState); } return YES; diff --git a/Source/NonModalAlertWindowController.xib b/Source/NonModalAlertWindowController.xib index 8c378007..c4396340 100644 --- a/Source/NonModalAlertWindowController.xib +++ b/Source/NonModalAlertWindowController.xib @@ -1,8 +1,8 @@ - + - + @@ -53,7 +53,7 @@ - + @@ -62,7 +62,7 @@ - + diff --git a/Source/dictionary_service.json b/Source/dictionary_service.json index 6e0ac6f1..793da288 100644 --- a/Source/dictionary_service.json +++ b/Source/dictionary_service.json @@ -44,10 +44,6 @@ "name": "教育部臺灣閩南語常用詞辭典", "url_template": "https://sutian.moe.edu.tw/zh-hant/tshiau/?lui=tai_su&tsha=(encoded)" }, - { - "name": "教育部臺灣客家語常用詞辭典", - "url_template": "https://hakkadict.moe.edu.tw/cgi-bin/gs32/gsweb.cgi/ccd=nsbgkx/search?qs0=(encoded)" - }, { "name": "Wiktionary", "url_template": "https://zh.wiktionary.org/wiki/Special:Search?search=(encoded)" diff --git a/Source/en.lproj/Localizable.strings b/Source/en.lproj/Localizable.strings index 5dcef6fa..57accfb1 100644 --- a/Source/en.lproj/Localizable.strings +++ b/Source/en.lproj/Localizable.strings @@ -109,3 +109,5 @@ "Wiktionary" = "Wiktionary"; +"%@ has been copied." = "%@ has been copied."; + diff --git a/Source/zh-Hant.lproj/Localizable.strings b/Source/zh-Hant.lproj/Localizable.strings index 36aaf13d..ed923a2c 100644 --- a/Source/zh-Hant.lproj/Localizable.strings +++ b/Source/zh-Hant.lproj/Localizable.strings @@ -110,3 +110,6 @@ "Speak \"%@\"…" = "朗讀「%@」…"; "Wiktionary" = "維基詞典"; + +"%@ has been copied." = "已成功複製 %@"; + diff --git a/Source/zh-Hant.lproj/MainMenu.xib b/Source/zh-Hant.lproj/MainMenu.xib index 5f7bcf12..d29962b7 100644 --- a/Source/zh-Hant.lproj/MainMenu.xib +++ b/Source/zh-Hant.lproj/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -11,7 +11,7 @@ - + @@ -288,6 +288,7 @@ + diff --git a/Source/zh-Hant.lproj/preferences.xib b/Source/zh-Hant.lproj/preferences.xib index f9b7fb3f..2efc9514 100644 --- a/Source/zh-Hant.lproj/preferences.xib +++ b/Source/zh-Hant.lproj/preferences.xib @@ -434,7 +434,7 @@ - + From 88339a9ecc15b272efbc0f629e91727a66091943 Mon Sep 17 00:00:00 2001 From: zonble Date: Tue, 26 Dec 2023 00:38:18 +0800 Subject: [PATCH 13/18] Fixes tests. --- McBopomofoTests/DictionaryServiceTests.swift | 15 ++++++-- Source/zh-Hant.lproj/preferences.xib | 36 ++++++++++---------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/McBopomofoTests/DictionaryServiceTests.swift b/McBopomofoTests/DictionaryServiceTests.swift index a10b6971..a76aef31 100644 --- a/McBopomofoTests/DictionaryServiceTests.swift +++ b/McBopomofoTests/DictionaryServiceTests.swift @@ -4,15 +4,24 @@ import XCTest final class DictionaryServiceTests: XCTestCase { func testSpeak() { - let result = DictionaryServices.shared.lookUp(phrase: "你", withServiceAtIndex: 0) + let result = DictionaryServices.shared.lookUp(phrase: "你", withServiceAtIndex: 0, state: InputState.Empty()) { _ in + + } XCTAssertTrue(result) } func testDictionaryService() { let count = DictionaryServices.shared.services.count for index in 0.. - + @@ -81,7 +81,7 @@ - + @@ -89,7 +89,7 @@ - + @@ -97,7 +97,7 @@ - + @@ -162,7 +162,7 @@ - + @@ -178,7 +178,7 @@ - + @@ -197,7 +197,7 @@ - + @@ -232,7 +232,7 @@ - + @@ -265,7 +265,7 @@ - + @@ -360,7 +360,7 @@ - + @@ -393,7 +393,7 @@ - + @@ -462,7 +462,7 @@ - + @@ -488,7 +488,7 @@ - + @@ -500,7 +500,7 @@ - + @@ -521,7 +521,7 @@ - + @@ -539,7 +539,7 @@ - + @@ -553,7 +553,7 @@ - + @@ -575,7 +575,7 @@ - + From e85a7f34a93c5644bc080a36069458243527c225 Mon Sep 17 00:00:00 2001 From: zonble Date: Tue, 26 Dec 2023 00:42:26 +0800 Subject: [PATCH 14/18] Applies the key to look up candidates only in Bopomofo mode. --- Source/KeyHandler.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/KeyHandler.mm b/Source/KeyHandler.mm index fd88368a..84ae85b9 100644 --- a/Source/KeyHandler.mm +++ b/Source/KeyHandler.mm @@ -1044,7 +1044,7 @@ - (BOOL)_handleCandidateState:(InputState *)state BOOL cancelCandidateKey = (charCode == 27) || (charCode == 8) || [input isDelete]; - if ([[input inputText] isEqualToString:@"?"]) { + if (_inputMode == InputModeBopomofo && [[input inputText] isEqualToString:@"?"]) { if ([state isKindOfClass:[InputStateShowingCharInfo class]]) { cancelCandidateKey = YES; } else if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { From ae0f210e9f7b17b2c2b8415c666f054cef5d80a0 Mon Sep 17 00:00:00 2001 From: zonble Date: Tue, 26 Dec 2023 11:25:28 +0800 Subject: [PATCH 15/18] Allows user to look up current marked text. --- Source/InputMethodController.swift | 18 ++++++++++++++---- Source/InputState.swift | 4 ++-- Source/KeyHandler.mm | 13 +++++++++++-- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/Source/InputMethodController.swift b/Source/InputMethodController.swift index 62fb8f4a..ac5a38ea 100644 --- a/Source/InputMethodController.swift +++ b/Source/InputMethodController.swift @@ -455,10 +455,16 @@ extension McBopomofoInputMethodController { gCurrentCandidateController?.visible = false return } - let candidateDate = state.previousState + let previousState = state.previousState // the selection range is where the cursor is, with the length being 0 and replacement range NSNotFound, // i.e. the client app needs to take care of where to put this composing buffer - client.setMarkedText(candidateDate.attributedString, selectionRange: NSMakeRange(Int(candidateDate.cursorIndex), 0), replacementRange: NSMakeRange(NSNotFound, NSNotFound)) + + if let candidateDate = previousState as? InputState.ChoosingCandidate { + client.setMarkedText(candidateDate.attributedString, selectionRange: NSMakeRange(Int(candidateDate.cursorIndex), 0), replacementRange: NSMakeRange(NSNotFound, NSNotFound)) + } else if let candidateDate = previousState as? InputState.Marking { + client.setMarkedText(candidateDate.attributedString, selectionRange: NSMakeRange(Int(candidateDate.cursorIndex), 0), replacementRange: NSMakeRange(NSNotFound, NSNotFound)) + } + show(candidateWindowWith: state, client: client) } @@ -469,10 +475,14 @@ extension McBopomofoInputMethodController { gCurrentCandidateController?.visible = false return } - let candidateDate = state.previousState.previousState + let previousState = state.previousState.previousState // the selection range is where the cursor is, with the length being 0 and replacement range NSNotFound, // i.e. the client app needs to take care of where to put this composing buffer - client.setMarkedText(candidateDate.attributedString, selectionRange: NSMakeRange(Int(candidateDate.cursorIndex), 0), replacementRange: NSMakeRange(NSNotFound, NSNotFound)) + if let candidateDate = previousState as? InputState.ChoosingCandidate { + client.setMarkedText(candidateDate.attributedString, selectionRange: NSMakeRange(Int(candidateDate.cursorIndex), 0), replacementRange: NSMakeRange(NSNotFound, NSNotFound)) + } else if let candidateDate = previousState as? InputState.Marking { + client.setMarkedText(candidateDate.attributedString, selectionRange: NSMakeRange(Int(candidateDate.cursorIndex), 0), replacementRange: NSMakeRange(NSNotFound, NSNotFound)) + } show(candidateWindowWith: state, client: client) } } diff --git a/Source/InputState.swift b/Source/InputState.swift index d3ee038e..d542ed73 100644 --- a/Source/InputState.swift +++ b/Source/InputState.swift @@ -356,13 +356,13 @@ class InputState: NSObject { @objc(InputStateSelectingDictionaryService) class SelectingDictionaryService: NotEmpty { - @objc private(set) var previousState: ChoosingCandidate + @objc private(set) var previousState: NotEmpty @objc private(set) var selectedPhrase: String = "" @objc private(set) var selectedIndex: Int = 0 @objc private(set) var menu: [String] @objc - init(previousState: ChoosingCandidate, selectedString: String, selectedIndex: Int) { + init(previousState: NotEmpty, selectedString: String, selectedIndex: Int) { self.previousState = previousState self.selectedPhrase = selectedString self.selectedIndex = selectedIndex diff --git a/Source/KeyHandler.mm b/Source/KeyHandler.mm index 84ae85b9..f51ee507 100644 --- a/Source/KeyHandler.mm +++ b/Source/KeyHandler.mm @@ -988,6 +988,15 @@ - (BOOL)_handleMarkingState:(InputStateMarking *)state return YES; } + // Dictionary look up + if ([input.inputText isEqualToString:@"?"]) { + if (state.markedRange.length > 0) { + InputStateSelectingDictionaryService *newState = [[InputStateSelectingDictionaryService alloc] initWithPreviousState:state selectedString:state.selectedText selectedIndex:0]; + stateCallback(newState); + return YES; + } + } + // Shift + left if (([input isCursorBackward] || input.emacsKey == McBopomofoEmacsKeyBackward) && ([input isShiftHold])) { @@ -1063,14 +1072,14 @@ - (BOOL)_handleCandidateState:(InputState *)state if ([state isKindOfClass:[InputStateShowingCharInfo class]]) { InputStateShowingCharInfo *current = (InputStateShowingCharInfo *)state; NSInteger selectedIndex = current.previousState.selectedIndex; - InputStateChoosingCandidate *newState = current.previousState.previousState; + InputStateNotEmpty *newState = current.previousState.previousState; stateCallback(newState); gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; gCurrentCandidateController.selectedCandidateIndex = selectedIndex; } else if ([state isKindOfClass:[InputStateSelectingDictionaryService class]]) { InputStateSelectingDictionaryService *current = (InputStateSelectingDictionaryService *)state; NSInteger selectedIndex = current.selectedIndex; - InputStateChoosingCandidate *newState = current.previousState; + InputStateNotEmpty *newState = current.previousState; stateCallback(newState); gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; gCurrentCandidateController.selectedCandidateIndex = selectedIndex; From caa706574dab0c0bbc6fa323148dfd5d8125a643 Mon Sep 17 00:00:00 2001 From: zonble Date: Wed, 27 Dec 2023 09:53:22 +0800 Subject: [PATCH 16/18] Fixes tests. --- McBopomofoTests/InputMacroTests.swift | 34 ++++++++++++++++++++------- Source/InputMacro.swift | 11 +++++++-- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/McBopomofoTests/InputMacroTests.swift b/McBopomofoTests/InputMacroTests.swift index 9b31e96f..b6070398 100644 --- a/McBopomofoTests/InputMacroTests.swift +++ b/McBopomofoTests/InputMacroTests.swift @@ -32,8 +32,14 @@ class InputMacroTests: XCTestCase { XCTAssertTrue(macro == output) } + func testThisYearPlain() { + let macro = "MACRO@THIS_YEAR_PLAIN" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + func testThisYear() { - let macro = "MACRO@THIS_YEAR" + let macro = "MACRO@THIS_YEAR_PLAIN_WITH_ERA" let output = InputMacroController.shared.handle(macro) XCTAssertTrue(output.starts(with: "西元")) XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") @@ -52,8 +58,15 @@ class InputMacroTests: XCTestCase { XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") } + func testLastYearPlain() { + let macro = "MACRO@LAST_YEAR_PLAIN" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + + func testLastYear() { - let macro = "MACRO@LAST_YEAR" + let macro = "MACRO@LAST_YEAR_PLAIN_WITH_ERA" let output = InputMacroController.shared.handle(macro) XCTAssertTrue(output.starts(with: "西元")) XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") @@ -72,8 +85,14 @@ class InputMacroTests: XCTestCase { XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") } + func testNextYearPlain() { + let macro = "MACRO@NEXT_YEAR_PLAIN" + let output = InputMacroController.shared.handle(macro) + XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") + } + func testNextYear() { - let macro = "MACRO@NEXT_YEAR" + let macro = "MACRO@NEXT_YEAR_PLAIN_WITH_ERA" let output = InputMacroController.shared.handle(macro) XCTAssertTrue(output.starts(with: "西元")) XCTAssertTrue(output[output.index(output.endIndex, offsetBy: -1)] == "年") @@ -207,30 +226,27 @@ class InputMacroTests: XCTestCase { } func testTodayLongJapanese() { - let macro = "MACRO@DATE_TODAY_FULL_JAPANESE" + let macro = "MACRO@DATE_TODAY_MEDIUM_JAPANESE" let output = InputMacroController.shared.handle(macro) XCTAssert(output.contains("年")) XCTAssert(output.contains("月")) XCTAssert(output.contains("日")) - XCTAssert(output.contains("曜日")) } func testYesterdayLongJapanese() { - let macro = "MACRO@DATE_YESTERDAY_FULL_JAPANESE" + let macro = "MACRO@DATE_YESTERDAY_MEDIUM_JAPANESE" let output = InputMacroController.shared.handle(macro) XCTAssert(output.contains("年")) XCTAssert(output.contains("月")) XCTAssert(output.contains("日")) - XCTAssert(output.contains("曜日")) } func testTomorrowLongJapanese() { - let macro = "MACRO@DATE_TOMORROW_FULL_JAPANESE" + let macro = "MACRO@DATE_TOMORROW_MEDIUM_JAPANESE" let output = InputMacroController.shared.handle(macro) XCTAssert(output.contains("年")) XCTAssert(output.contains("月")) XCTAssert(output.contains("日")) - XCTAssert(output.contains("曜日")) } func testTimeNowShort() { diff --git a/Source/InputMacro.swift b/Source/InputMacro.swift index c1f36192..3b057b9b 100644 --- a/Source/InputMacro.swift +++ b/Source/InputMacro.swift @@ -1,4 +1,5 @@ // Copyright (c) 2022 and onwards The McBopomofo Authors. +// Copyright (c) 2022 and onwards The McBopomofo Authors. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -256,7 +257,7 @@ struct InputMacroDateYesterdayMediumJapanese: InputMacroDate { struct InputMacroDateTomorrowShort: InputMacroDate { var calendarName: Calendar.Identifier { .gregorian } var dayOffset: Int { 1 } - var style: DateFormatter.Style { .medium } + var style: DateFormatter.Style { .short } var name: String { "MACRO@DATE_TOMORROW_SHORT" } } @@ -364,7 +365,7 @@ struct InputMacroNextYearRoc: InputMacroYear { var calendarName: Calendar.Identifier { .republicOfChina } var yearOffset: Int { 1 } var pattern: String { "Gy" } - var name: String { "MACRO@NEXT_YEAR_PLAIN_WITH_ERA" } + var name: String { "MACRO@NEXT_YEAR_ROC" } } struct InputMacroNextYearJapanese: InputMacroYear { @@ -604,5 +605,11 @@ class InputMacroController: NSObject { return input } + @objc var availableMacros: [String] { + macros.map { k, v in + k + } + } + } From c63ee834b644733d9faa97de4e95ee1022cf26d6 Mon Sep 17 00:00:00 2001 From: zonble Date: Wed, 27 Dec 2023 10:02:57 +0800 Subject: [PATCH 17/18] Cleans up the list of the dictionary services. --- Source/Base.lproj/Localizable.strings | 6 ------ Source/dictionary_service.json | 6 +++--- Source/en.lproj/Localizable.strings | 4 ---- Source/zh-Hant.lproj/Localizable.strings | 9 --------- 4 files changed, 3 insertions(+), 22 deletions(-) diff --git a/Source/Base.lproj/Localizable.strings b/Source/Base.lproj/Localizable.strings index 61097605..f52b0cc8 100644 --- a/Source/Base.lproj/Localizable.strings +++ b/Source/Base.lproj/Localizable.strings @@ -101,12 +101,6 @@ "Dictionary app" = "Dictionary app"; -"MOE Dict" = "MOE Dict"; - -"MOE Dict (Holo)" = "MOE Dict (Holo)"; - "Speak \"%@\"…" = "Speak \"%@\"…"; -"Wiktionary" = "Wiktionary"; - "%@ has been copied." = "%@ has been copied."; diff --git a/Source/dictionary_service.json b/Source/dictionary_service.json index 793da288..c983687b 100644 --- a/Source/dictionary_service.json +++ b/Source/dictionary_service.json @@ -5,15 +5,15 @@ "url_template": "dict://(encoded)" }, { - "name": "MOE Dict", + "name": "萌典", "url_template": "https://www.moedict.tw/(encoded)" }, { - "name": "MOE Dict (Holo)", + "name": "萌典 (台語)", "url_template": "https://www.moedict.tw/'(encoded)" }, { - "name": "MOE Dict (Hakka)", + "name": "萌典 (客語)", "url_template": "https://www.moedict.tw/:(encoded)" }, { diff --git a/Source/en.lproj/Localizable.strings b/Source/en.lproj/Localizable.strings index 57accfb1..1584644c 100644 --- a/Source/en.lproj/Localizable.strings +++ b/Source/en.lproj/Localizable.strings @@ -101,10 +101,6 @@ "Dictionary app" = "Dictionary app"; -"MOE Dict" = "MOE Dict"; - -"MOE Dict (Holo)" = "MOE Dict (Holo)"; - "Speak \"%@\"…" = "Speak \"%@\"…"; "Wiktionary" = "Wiktionary"; diff --git a/Source/zh-Hant.lproj/Localizable.strings b/Source/zh-Hant.lproj/Localizable.strings index ed923a2c..9cff1cd9 100644 --- a/Source/zh-Hant.lproj/Localizable.strings +++ b/Source/zh-Hant.lproj/Localizable.strings @@ -101,15 +101,6 @@ "Dictionary app" = "字典App"; -"MOE Dict" = "萌典"; - -"MOE Dict (Holo)" = "台語萌典"; - -"MOE Dict (Holo)" = "客語萌典"; - "Speak \"%@\"…" = "朗讀「%@」…"; -"Wiktionary" = "維基詞典"; - "%@ has been copied." = "已成功複製 %@"; - From e628dffdbafbe7b408c380673e49683cdbeb22f7 Mon Sep 17 00:00:00 2001 From: zonble Date: Wed, 27 Dec 2023 10:06:50 +0800 Subject: [PATCH 18/18] Fixes a potentiaal crash in VerticalCandidateController. --- .../Sources/CandidateUI/VerticalCandidateController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/CandidateUI/Sources/CandidateUI/VerticalCandidateController.swift b/Packages/CandidateUI/Sources/CandidateUI/VerticalCandidateController.swift index a5efa107..8c332d1a 100644 --- a/Packages/CandidateUI/Sources/CandidateUI/VerticalCandidateController.swift +++ b/Packages/CandidateUI/Sources/CandidateUI/VerticalCandidateController.swift @@ -294,7 +294,7 @@ public class VerticalCandidateController: CandidateController { // no need to handle the backward case: (newIndex < selectedRow && selectedRow - newIndex > 1) } - if itemCount > labelCount { + if itemCount > labelCount && lastVisibleRow < Int.max { tableView.scrollRowToVisible(Int(lastVisibleRow)) } tableView.selectRowIndexes(IndexSet(integer: Int(newIndex)), byExtendingSelection: false)