diff --git a/McBopomofo.xcodeproj/project.pbxproj b/McBopomofo.xcodeproj/project.pbxproj index 629c3053..ec38f092 100644 --- a/McBopomofo.xcodeproj/project.pbxproj +++ b/McBopomofo.xcodeproj/project.pbxproj @@ -52,6 +52,9 @@ D47B92C027972AD100458394 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = D47B92BF27972AC800458394 /* main.swift */; }; D47D73A427A5D43900255A50 /* KeyHandlerBopomofoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D47D73A327A5D43900255A50 /* KeyHandlerBopomofoTests.swift */; }; D47D73C327A7200500255A50 /* FSEventStreamHelper in Frameworks */ = {isa = PBXBuildFile; productRef = D47D73C227A7200500255A50 /* FSEventStreamHelper */; }; + D47D73A827A6C84F00255A50 /* associated-phrases.cin in Resources */ = {isa = PBXBuildFile; fileRef = D47D73A727A6C84F00255A50 /* associated-phrases.cin */; }; + D47D73A927A6C84F00255A50 /* associated-phrases.cin in Resources */ = {isa = PBXBuildFile; fileRef = D47D73A727A6C84F00255A50 /* associated-phrases.cin */; }; + D47D73AC27A6CAE600255A50 /* AssociatedPhrases.cpp in Sources */ = {isa = PBXBuildFile; fileRef = D47D73AA27A6CAE600255A50 /* AssociatedPhrases.cpp */; }; D47F7DCE278BFB57002F9DD7 /* PreferencesWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D47F7DCD278BFB57002F9DD7 /* PreferencesWindowController.swift */; }; D47F7DD0278C0897002F9DD7 /* NonModalAlertWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D47F7DCF278C0897002F9DD7 /* NonModalAlertWindowController.swift */; }; D47F7DD3278C1263002F9DD7 /* UserOverrideModel.cpp in Sources */ = {isa = PBXBuildFile; fileRef = D47F7DD2278C1263002F9DD7 /* UserOverrideModel.cpp */; }; @@ -205,6 +208,9 @@ D47B92BF27972AC800458394 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; D47D73A327A5D43900255A50 /* KeyHandlerBopomofoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyHandlerBopomofoTests.swift; sourceTree = ""; }; D47D73C027A71FFA00255A50 /* FSEventStreamHelper */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FSEventStreamHelper; path = Packages/FSEventStreamHelper; sourceTree = ""; }; + D47D73A727A6C84F00255A50 /* associated-phrases.cin */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "associated-phrases.cin"; sourceTree = ""; }; + D47D73AA27A6CAE600255A50 /* AssociatedPhrases.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = AssociatedPhrases.cpp; sourceTree = ""; }; + D47D73AB27A6CAE600255A50 /* AssociatedPhrases.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AssociatedPhrases.h; sourceTree = ""; }; D47F7DCD278BFB57002F9DD7 /* PreferencesWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesWindowController.swift; sourceTree = ""; }; D47F7DCF278C0897002F9DD7 /* NonModalAlertWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonModalAlertWindowController.swift; sourceTree = ""; }; D47F7DD1278C1263002F9DD7 /* UserOverrideModel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UserOverrideModel.h; sourceTree = ""; }; @@ -337,6 +343,8 @@ D41355DA278E6D17005E5CBD /* McBopomofoLM.h */, D47F7DD2278C1263002F9DD7 /* UserOverrideModel.cpp */, D47F7DD1278C1263002F9DD7 /* UserOverrideModel.h */, + D47D73AA27A6CAE600255A50 /* AssociatedPhrases.cpp */, + D47D73AB27A6CAE600255A50 /* AssociatedPhrases.h */, ); path = Engine; sourceTree = ""; @@ -426,6 +434,7 @@ 6A38BBDD15FC115800A8A51F /* Data */ = { isa = PBXGroup; children = ( + D47D73A727A6C84F00255A50 /* associated-phrases.cin */, 6A38BBF615FC117A00A8A51F /* data.txt */, 6AD7CBC715FE555000691B5B /* data-plain-bpmf.txt */, ); @@ -648,6 +657,7 @@ 6AE210B315FC63CC003659FE /* PlainBopomofo@2x.tiff in Resources */, 6AD7CBC815FE555000691B5B /* data-plain-bpmf.txt in Resources */, 6A187E2616004C5900466B2E /* MainMenu.xib in Resources */, + D47D73A827A6C84F00255A50 /* associated-phrases.cin in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -671,6 +681,7 @@ files = ( D4E569E527A414CB00AC2CEF /* data.txt in Resources */, D4E569E427A414CB00AC2CEF /* data-plain-bpmf.txt in Resources */, + D47D73A927A6C84F00255A50 /* associated-phrases.cin in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -712,6 +723,7 @@ D47F7DD0278C0897002F9DD7 /* NonModalAlertWindowController.swift in Sources */, D456576E279E4F7B00DF6BC9 /* KeyHandlerInput.swift in Sources */, D47F7DCE278BFB57002F9DD7 /* PreferencesWindowController.swift in Sources */, + D47D73AC27A6CAE600255A50 /* AssociatedPhrases.cpp in Sources */, D41355DB278E6D17005E5CBD /* McBopomofoLM.cpp in Sources */, D47F7DD3278C1263002F9DD7 /* UserOverrideModel.cpp in Sources */, 6A0D4F4515FC0EB100ABF4B3 /* Mandarin.cpp in Sources */, diff --git a/Packages/CandidateUI/Sources/CandidateUI/CandidateController.swift b/Packages/CandidateUI/Sources/CandidateUI/CandidateController.swift index 5ed1fa75..36eb27b9 100644 --- a/Packages/CandidateUI/Sources/CandidateUI/CandidateController.swift +++ b/Packages/CandidateUI/Sources/CandidateUI/CandidateController.swift @@ -40,6 +40,7 @@ public class CandidateController: NSWindowController { @objc public var selectedCandidateIndex: UInt = UInt.max @objc public var visible: Bool = false { didSet { + NSObject.cancelPreviousPerformRequests(withTarget: self) if visible { window?.perform(#selector(NSWindow.orderFront(_:)), with: self, afterDelay: 0.0) } else { @@ -64,6 +65,7 @@ public class CandidateController: NSWindowController { @objc public var keyLabels: [String] = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] @objc public var keyLabelFont: NSFont = NSFont.systemFont(ofSize: 14) @objc public var candidateFont: NSFont = NSFont.systemFont(ofSize: 18) + @objc public var tooltip: String = "" @objc public func reloadData() { } diff --git a/Packages/CandidateUI/Sources/CandidateUI/HorizontalCandidateController.swift b/Packages/CandidateUI/Sources/CandidateUI/HorizontalCandidateController.swift index 44e18c07..37ad07ea 100644 --- a/Packages/CandidateUI/Sources/CandidateUI/HorizontalCandidateController.swift +++ b/Packages/CandidateUI/Sources/CandidateUI/HorizontalCandidateController.swift @@ -38,6 +38,23 @@ fileprivate class HorizontalCandidateView: NSView { private var elementWidths: [CGFloat] = [] private var trackingHighlightedIndex: UInt = UInt.max + private let tooltipPadding: CGFloat = 2.0 + private var tooltipSize: NSSize = NSSize.zero + + override var toolTip: String? { + didSet { + if let toolTip = toolTip, !toolTip.isEmpty { + let baseSize = NSSize(width: 10240.0, height: 10240.0) + var tooltipRect = (toolTip as NSString).boundingRect(with: baseSize, options: .usesLineFragmentOrigin, attributes: keyLabelAttrDict) + tooltipRect.size.height += tooltipPadding * 2 + tooltipRect.size.width += tooltipPadding * 2 + self.tooltipSize = tooltipRect.size + } else { + self.tooltipSize = NSSize.zero + } + } + } + override var isFlipped: Bool { true } @@ -50,6 +67,9 @@ fileprivate class HorizontalCandidateView: NSView { result.width += CGFloat(elementWidths.count) result.height = keyLabelHeight + candidateTextHeight + 1.0 } + + result.height += tooltipSize.height + result.width = max(tooltipSize.width, result.width) return result } @@ -64,7 +84,7 @@ fileprivate class HorizontalCandidateView: NSView { for index in 0.. +#include +#include +#include +#include + +#include "KeyValueBlobReader.h" + +namespace McBopomofo { + +AssociatedPhrases::AssociatedPhrases() +: fd(-1) +, data(0) +, length(0) +{ +} + +AssociatedPhrases::~AssociatedPhrases() +{ + if (data) { + close(); + } +} + +const bool AssociatedPhrases::isLoaded() +{ + if (data) { + return true; + } + return false; +} + +bool AssociatedPhrases::open(const char *path) +{ + if (data) { + return false; + } + + fd = ::open(path, O_RDONLY); + if (fd == -1) { + printf("open:: file not exist"); + return false; + } + + struct stat sb; + if (fstat(fd, &sb) == -1) { + printf("open:: cannot open file"); + return false; + } + + length = (size_t)sb.st_size; + + data = mmap(NULL, length, PROT_READ, MAP_SHARED, fd, 0); + if (!data) { + ::close(fd); + return false; + } + + KeyValueBlobReader reader(static_cast(data), length); + KeyValueBlobReader::KeyValue keyValue; + KeyValueBlobReader::State state; + while ((state = reader.Next(&keyValue)) == KeyValueBlobReader::State::HAS_PAIR) { + keyRowMap[keyValue.key].emplace_back(keyValue.key, keyValue.value); + } + return true; +} + +void AssociatedPhrases::close() +{ + if (data) { + munmap(data, length); + ::close(fd); + data = 0; + } + + keyRowMap.clear(); +} + +const std::vector AssociatedPhrases::valuesForKey(const std::string& key) +{ + std::vector v; + auto iter = keyRowMap.find(key); + if (iter != keyRowMap.end()) { + const std::vector& rows = iter->second; + for (const auto& row : rows) { + std::string_view value = row.value; + v.push_back({value.data(), value.size()}); + } + } + return v; +} + +const bool AssociatedPhrases::hasValuesForKey(const std::string& key) +{ + return keyRowMap.find(key) != keyRowMap.end(); +} + +}; // namespace McBopomofo diff --git a/Source/Engine/AssociatedPhrases.h b/Source/Engine/AssociatedPhrases.h new file mode 100644 index 00000000..17827b71 --- /dev/null +++ b/Source/Engine/AssociatedPhrases.h @@ -0,0 +1,66 @@ +// +// AssociatedPhrases.h +// +// Copyright (c) 2017 The McBopomofo Project. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#ifndef ASSOCIATEDPHRASES_H +#define ASSOCIATEDPHRASES_H + +#include +#include +#include +#include + +namespace McBopomofo { + +class AssociatedPhrases +{ +public: + AssociatedPhrases(); + ~AssociatedPhrases(); + + const bool isLoaded(); + bool open(const char *path); + void close(); + const std::vector valuesForKey(const std::string& key); + const bool hasValuesForKey(const std::string& key); + +protected: + struct Row { + Row(std::string_view& k, std::string_view& v) : key(k), value(v) {} + std::string_view key; + std::string_view value; + }; + + std::map> keyRowMap; + + int fd; + void *data; + size_t length; +}; + +} + +#endif /* AssociatedPhrases_hpp */ diff --git a/Source/Engine/McBopomofoLM.cpp b/Source/Engine/McBopomofoLM.cpp index 6e5cb650..a28ecce3 100644 --- a/Source/Engine/McBopomofoLM.cpp +++ b/Source/Engine/McBopomofoLM.cpp @@ -37,11 +37,7 @@ McBopomofoLM::~McBopomofoLM() m_userPhrases.close(); m_excludedPhrases.close(); m_phraseReplacement.close(); -} - -bool McBopomofoLM::isDataModelLoaded() -{ - return m_languageModel.isLoaded(); + m_associatedPhrases.close(); } void McBopomofoLM::loadLanguageModel(const char* languageModelDataPath) @@ -52,6 +48,24 @@ void McBopomofoLM::loadLanguageModel(const char* languageModelDataPath) } } +bool McBopomofoLM::isDataModelLoaded() +{ + return m_languageModel.isLoaded(); +} + +void McBopomofoLM::loadAssociatedPhrases(const char* associatedPhrasesPath) +{ + if (associatedPhrasesPath) { + m_associatedPhrases.close(); + m_associatedPhrases.open(associatedPhrasesPath); + } +} + +bool McBopomofoLM::isAssociatedPhrasesLoaded() +{ + return m_associatedPhrases.isLoaded(); +} + void McBopomofoLM::loadUserPhrases(const char* userPhrasesDataPath, const char* excludedPhrasesDataPath) { @@ -189,3 +203,13 @@ const vector McBopomofoLM::filterAndTransformUnigrams(const vector McBopomofoLM::associatedPhrasesForKey(const string& key) +{ + return m_associatedPhrases.valuesForKey(key); +} + +bool McBopomofoLM::hasAssociatedPhrasesForKey(const string& key) +{ + return m_associatedPhrases.hasValuesForKey(key); +} diff --git a/Source/Engine/McBopomofoLM.h b/Source/Engine/McBopomofoLM.h index 1636c55d..0e531863 100644 --- a/Source/Engine/McBopomofoLM.h +++ b/Source/Engine/McBopomofoLM.h @@ -28,6 +28,7 @@ #include "UserPhrasesLM.h" #include "ParselessLM.h" #include "PhraseReplacementMap.h" +#include "AssociatedPhrases.h" #include namespace McBopomofo { @@ -61,11 +62,18 @@ public: McBopomofoLM(); ~McBopomofoLM(); - /// Asks to load the primary language model a the given path. - /// @param languageModelPath Thw path of the language model. + /// Asks to load the primary language model at the given path. + /// @param languageModelPath The path of the language model. void loadLanguageModel(const char* languageModelPath); /// If the data model is already loaded. bool isDataModelLoaded(); + + /// Asks to load the associated phrases at the given path. + /// @param associatedPhrasesPath The path of the associated phrases. + void loadAssociatedPhrases(const char* associatedPhrasesPath); + /// If the associated phrases already loaded. + bool isAssociatedPhrasesLoaded(); + /// Asks to load the user phrases and excluded phrases at the given path. /// @param userPhrasesPath The path of user phrases. /// @param excludedPhrasesPath The path of excluded phrases. @@ -96,6 +104,10 @@ public: /// Sets a lambda to let the values of unigrams could be converted by it. void setExternalConverter(std::function externalConverter); + const vector associatedPhrasesForKey(const string& key); + bool hasAssociatedPhrasesForKey(const string& key); + + protected: /// Filters and converts the input unigrams and return a new list of unigrams. /// @@ -112,6 +124,7 @@ protected: UserPhrasesLM m_userPhrases; UserPhrasesLM m_excludedPhrases; PhraseReplacementMap m_phraseReplacement; + AssociatedPhrases m_associatedPhrases; bool m_phraseReplacementEnabled; bool m_externalConverterEnabled; std::function m_externalConverter; diff --git a/Source/Engine/UserPhrasesLM.cpp b/Source/Engine/UserPhrasesLM.cpp index 31f2f1e7..1564a6c7 100644 --- a/Source/Engine/UserPhrasesLM.cpp +++ b/Source/Engine/UserPhrasesLM.cpp @@ -47,6 +47,14 @@ UserPhrasesLM::~UserPhrasesLM() } } +bool UserPhrasesLM::isLoaded() +{ + if (data) { + return true; + } + return false; +} + bool UserPhrasesLM::open(const char *path) { if (data) { diff --git a/Source/Engine/UserPhrasesLM.h b/Source/Engine/UserPhrasesLM.h index 7fdb37e9..01d41d75 100644 --- a/Source/Engine/UserPhrasesLM.h +++ b/Source/Engine/UserPhrasesLM.h @@ -36,7 +36,8 @@ class UserPhrasesLM : public Formosa::Gramambular::LanguageModel public: UserPhrasesLM(); ~UserPhrasesLM(); - + + bool isLoaded(); bool open(const char *path); void close(); void dump(); diff --git a/Source/InputMethodController.swift b/Source/InputMethodController.swift index d7e80c8b..f8b149a0 100644 --- a/Source/InputMethodController.swift +++ b/Source/InputMethodController.swift @@ -76,6 +76,11 @@ class McBopomofoInputMethodController: IMKInputController { let inputMode = keyHandler.inputMode let optionKeyPressed = NSEvent.modifierFlags.contains(.option) + if inputMode == .plainBopomofo { + let associatedPhrasesItem = menu.addItem(withTitle: NSLocalizedString("Associated Phrases", comment: ""), action: #selector(toggleAssociatedPhrasesEnabled(_:)), keyEquivalent: "") + associatedPhrasesItem.state = Preferences.associatedPhrasesEnabled.state + } + if inputMode == .bopomofo && optionKeyPressed { let phaseReplacementItem = menu.addItem(withTitle: NSLocalizedString("Use Phrase Replacement", comment: ""), action: #selector(togglePhraseReplacement(_:)), keyEquivalent: "") phaseReplacementItem.state = Preferences.phraseReplacementEnabled.state @@ -200,6 +205,10 @@ class McBopomofoInputMethodController: IMKInputController { NotifierController.notify(message: enabled ? NSLocalizedString("Half-width punctuation on", comment: "") : NSLocalizedString("Half-width punctuation off", comment: "")) } + @objc func toggleAssociatedPhrasesEnabled(_ sender: Any?) { + _ = Preferences.toggleAssociatedPhrasesEnabled() + } + @objc func togglePhraseReplacement(_ sender: Any?) { let enabled = Preferences.togglePhraseReplacementEnabled() LanguageModelManager.phraseReplacementEnabled = enabled @@ -276,6 +285,8 @@ extension McBopomofoInputMethodController { handle(state: newState, previous: previous, client: client) } else if let newState = newState as? InputState.ChoosingCandidate { handle(state: newState, previous: previous, client: client) + } else if let newState = newState as? InputState.AssociatedPhrases { + handle(state: newState, previous: previous, client: client) } } @@ -407,9 +418,17 @@ extension McBopomofoInputMethodController { // 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(state.attributedString, selectionRange: NSMakeRange(Int(state.cursorIndex), 0), replacementRange: NSMakeRange(NSNotFound, NSNotFound)) - if previous is InputState.ChoosingCandidate == false { - show(candidateWindowWith: state, client: client) + show(candidateWindowWith: state, client: client) + } + + private func handle(state: InputState.AssociatedPhrases, previous: InputState, client: Any?) { + hideTooltip() + guard let client = client as? IMKTextInput else { + gCurrentCandidateController?.visible = false + return } + client.setMarkedText("", selectionRange: NSMakeRange(0, 0), replacementRange: NSMakeRange(NSNotFound, NSNotFound)) + show(candidateWindowWith: state, client: client) } } @@ -417,8 +436,18 @@ extension McBopomofoInputMethodController { extension McBopomofoInputMethodController { - private func show(candidateWindowWith state: InputState.ChoosingCandidate, client: Any!) { - if state.useVerticalMode { + private func show(candidateWindowWith state: InputState, client: Any!) { + let useVerticalMode:Bool = { + if let state = state as? InputState.ChoosingCandidate { + return state.useVerticalMode + } + else if let state = state as? InputState.AssociatedPhrases { + return state.useVerticalMode + } + return false + }() + + if useVerticalMode { gCurrentCandidateController = McBopomofoInputMethodController.verticalCandidateController } else if Preferences.useHorizontalCandidateList { gCurrentCandidateController = McBopomofoInputMethodController.horizontalCandidateController @@ -426,6 +455,12 @@ extension McBopomofoInputMethodController { gCurrentCandidateController = McBopomofoInputMethodController.verticalCandidateController } + if state is InputState.AssociatedPhrases { + gCurrentCandidateController?.tooltip = NSLocalizedString("Associated Phrases", comment: "") + } else { + gCurrentCandidateController?.tooltip = "" + } + // set the attributes for the candidate panel (which uses NSAttributedString) let textSize = Preferences.candidateListTextSize let keyLabelSize = max(textSize / 2, kMinKeyLabelSize) @@ -453,14 +488,18 @@ extension McBopomofoInputMethodController { gCurrentCandidateController?.visible = true var lineHeightRect = NSMakeRect(0.0, 0.0, 16.0, 16.0) - var cursor = state.cursorIndex - if cursor == state.composingBuffer.count && cursor != 0 { - cursor -= 1 + var cursor:UInt = 0 + + if let state = state as? InputState.ChoosingCandidate { + cursor = state.cursorIndex + if cursor == state.composingBuffer.count && cursor != 0 { + cursor -= 1 + } } (client as? IMKTextInput)?.attributes(forCharacterIndex: Int(cursor), lineHeightRectangle: &lineHeightRect) - if state.useVerticalMode { + if useVerticalMode { gCurrentCandidateController?.set(windowTopLeftPoint: NSMakePoint(lineHeightRect.origin.x + lineHeightRect.size.width + 4.0, lineHeightRect.origin.y - 4.0), bottomOutOfScreenAdjustmentHeight: lineHeightRect.size.height + 4.0) } else { gCurrentCandidateController?.set(windowTopLeftPoint: NSMakePoint(lineHeightRect.origin.x, lineHeightRect.origin.y - 4.0), bottomOutOfScreenAdjustmentHeight: lineHeightRect.size.height + 4.0) @@ -513,6 +552,8 @@ 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 0 } @@ -520,28 +561,39 @@ extension McBopomofoInputMethodController: CandidateControllerDelegate { func candidateController(_ controller: CandidateController, candidateAtIndex index: UInt) -> String { if let state = state as? InputState.ChoosingCandidate { return state.candidates[Int(index)] + } else if let state = state as? InputState.AssociatedPhrases { + return state.candidates[Int(index)] } return "" } func candidateController(_ controller: CandidateController, didSelectCandidateAtIndex index: UInt) { - gCurrentCandidateController?.visible = false - guard let state = state as? InputState.ChoosingCandidate else { - return - } - let selectedValue = state.candidates[Int(index)] - keyHandler.fixNode(withValue: selectedValue) - guard let inputting = keyHandler.buildInputtingState() as? InputState.Inputting else { - return - } + if let state = state as? InputState.ChoosingCandidate { + let selectedValue = state.candidates[Int(index)] + keyHandler.fixNode(withValue: selectedValue) - if keyHandler.inputMode == .plainBopomofo { - keyHandler.clear() - handle(state: .Committing(poppedText: inputting.composingBuffer), client: currentCandidateClient) + guard let inputting = keyHandler.buildInputtingState() as? InputState.Inputting else { + return + } + + if keyHandler.inputMode == .plainBopomofo { + keyHandler.clear() + let text = inputting.composingBuffer + handle(state: .Committing(poppedText: text), client: currentCandidateClient) + if Preferences.associatedPhrasesEnabled, + let associatePhrases = keyHandler.buildAssociatePhraseState(withKey: text, useVerticalMode: state.useVerticalMode) as? InputState.AssociatedPhrases { + self.handle(state: associatePhrases, client: self.currentCandidateClient) + } else { + handle(state: .Empty(), client: currentDeferredClient) + } + } else { + handle(state: inputting, client: currentCandidateClient) + } + } else if let state = state as? InputState.AssociatedPhrases { + let selectedValue = state.candidates[Int(index)] + handle(state: .Committing(poppedText: selectedValue), client: currentCandidateClient) handle(state: .Empty(), client: currentDeferredClient) - } else { - handle(state: inputting, client: currentCandidateClient) } } } diff --git a/Source/InputState.swift b/Source/InputState.swift index 0338c995..6483c626 100644 --- a/Source/InputState.swift +++ b/Source/InputState.swift @@ -170,7 +170,7 @@ class InputState: NSObject { return String(format: NSLocalizedString("You are now selecting \"%@\". Press enter to add a new phrase.", comment: ""), text) } - @objc private(set) var readings: [String] = [] + @objc private(set) var readings: [String] @objc init(composingBuffer: String, cursorIndex: UInt, markerIndex: UInt, readings: [String]) { self.markerIndex = markerIndex @@ -226,8 +226,8 @@ class InputState: NSObject { /// Represents that the user is choosing in a candidates list. @objc (InputStateChoosingCandidate) class ChoosingCandidate: NotEmpty { - @objc private(set) var candidates: [String] = [] - @objc private(set) var useVerticalMode: Bool = false + @objc private(set) var candidates: [String] + @objc private(set) var useVerticalMode: Bool @objc init(composingBuffer: String, cursorIndex: UInt, candidates: [String], useVerticalMode: Bool) { self.candidates = candidates @@ -248,4 +248,21 @@ class InputState: NSObject { } } + /// Represents that the user is choosing in a candidates list + /// in the associated phrases mode. + @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 + super.init() + } + + override var description: String { + "" + } + } + } diff --git a/Source/KeyHandler.h b/Source/KeyHandler.h index 7613785c..4c0093e0 100644 --- a/Source/KeyHandler.h +++ b/Source/KeyHandler.h @@ -53,6 +53,7 @@ candidateSelectionCallback:(void (^)(void))candidateSelectionCallback - (void)clear; - (InputState *)buildInputtingState; +- (nullable InputState *)buildAssociatePhraseStateWithKey:(NSString *)key useVerticalMode:(BOOL)useVerticalMode; @property (strong, nonatomic) InputMode inputMode; @property (weak, nonatomic) id delegate; diff --git a/Source/KeyHandler.mm b/Source/KeyHandler.mm index 6a1fcc6f..d777d197 100644 --- a/Source/KeyHandler.mm +++ b/Source/KeyHandler.mm @@ -232,7 +232,9 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot // if the composing buffer is empty and there's no reading, and there is some function key combination, we ignore it BOOL isFunctionKey = ([input isCommandHold] || [input isControlHold] || [input isOptionHold] || [input isNumericPad]); - if (![state isKindOfClass:[InputStateNotEmpty class]] && isFunctionKey) { + if (![state isKindOfClass:[InputStateNotEmpty class]] && + ![state isKindOfClass:[InputStateAssociatedPhrases class]] && + isFunctionKey) { return NO; } @@ -276,7 +278,17 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot // MARK: Handle Candidates if ([state isKindOfClass:[InputStateChoosingCandidate class]]) { - return [self _handleCandidateState:(InputStateChoosingCandidate *) state input:input stateCallback:stateCallback candidateSelectionCallback:candidateSelectionCallback errorCallback:errorCallback]; + return [self _handleCandidateState:state input:input stateCallback:stateCallback candidateSelectionCallback:candidateSelectionCallback errorCallback:errorCallback]; + } + + // MARK: Handle Associated Phrases + if ([state isKindOfClass:[InputStateAssociatedPhrases class]]) { + BOOL result = [self _handleCandidateState:state input:input stateCallback:stateCallback candidateSelectionCallback:candidateSelectionCallback errorCallback:errorCallback]; + if (result) { + return YES; + } + state = [[InputStateEmpty alloc] init]; + stateCallback(state); } // MARK: Handle Marking @@ -350,10 +362,23 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot InputStateChoosingCandidate *choosingCandidates = [self _buildCandidateState:inputting useVerticalMode:input.useVerticalMode]; if (choosingCandidates.candidates.count == 1) { [self clear]; - InputStateCommitting *committing = [[InputStateCommitting alloc] initWithPoppedText:choosingCandidates.candidates.firstObject]; + NSString *text = choosingCandidates.candidates.firstObject; + InputStateCommitting *committing = [[InputStateCommitting alloc] initWithPoppedText:text]; stateCallback(committing); - InputStateEmpty *empty = [[InputStateEmpty alloc] init]; - stateCallback(empty); + + if (!Preferences.associatedPhrasesEnabled) { + InputStateEmpty *empty = [[InputStateEmpty alloc] init]; + stateCallback(empty); + } + else { + InputStateAssociatedPhrases *associatedPhrases = (InputStateAssociatedPhrases *)[self buildAssociatePhraseStateWithKey:text useVerticalMode:input.useVerticalMode]; + if (associatedPhrases) { + stateCallback(associatedPhrases); + } else { + InputStateEmpty *empty = [[InputStateEmpty alloc] init]; + stateCallback(empty); + } + } } else { stateCallback(choosingCandidates); } @@ -835,7 +860,7 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot } -- (BOOL)_handleCandidateState:(InputStateChoosingCandidate *)state +- (BOOL)_handleCandidateState:(InputState *)state input:(KeyHandlerInput *)input stateCallback:(void (^)(InputState *))stateCallback candidateSelectionCallback:(void (^)(void))candidateSelectionCallback @@ -845,10 +870,15 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot UniChar charCode = input.charCode; VTCandidateController *gCurrentCandidateController = [self.delegate candidateControllerForKeyHandler:self]; - BOOL cancelCandidateKey = (charCode == 27) || (charCode == 8) || [input isDelete]; + BOOL cancelCandidateKey = (charCode == 27) || (charCode == 8) || [input isDelete]; if (cancelCandidateKey) { - if (_inputMode == InputModePlainBopomofo) { + if ([state isKindOfClass: [InputStateAssociatedPhrases class]]) { + [self clear]; + InputStateEmptyIgnoringPreviousState *empty = [[InputStateEmptyIgnoringPreviousState alloc] init]; + stateCallback(empty); + } + else if (_inputMode == InputModePlainBopomofo) { [self clear]; InputStateEmptyIgnoringPreviousState *empty = [[InputStateEmptyIgnoringPreviousState alloc] init]; stateCallback(empty); @@ -860,6 +890,9 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot } if (charCode == 13 || [input isEnter]) { + if ([state isKindOfClass: [InputStateAssociatedPhrases class]] && ![input isShiftHold]) { + return NO; + } [self.delegate keyHandler:self didSelectCandidateAtIndex:gCurrentCandidateController.selectedCandidateIndex candidateController:gCurrentCandidateController]; return YES; } @@ -975,27 +1008,47 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot return YES; } - if (([input isEnd] || input.emacsKey == McBopomofoEmacsKeyEnd) && [state.candidates count] > 0) { - if (gCurrentCandidateController.selectedCandidateIndex == [state.candidates count] - 1) { + NSArray *candidates; + + if ([state isKindOfClass: [InputStateChoosingCandidate class]]) { + candidates = [(InputStateChoosingCandidate *)state candidates]; + } + + if ([state isKindOfClass: [InputStateAssociatedPhrases class]]) { + candidates = [(InputStateAssociatedPhrases *)state candidates]; + } + + if (([input isEnd] || input.emacsKey == McBopomofoEmacsKeyEnd) && candidates.count > 0) { + if (gCurrentCandidateController.selectedCandidateIndex == candidates.count - 1) { errorCallback(); } else { - gCurrentCandidateController.selectedCandidateIndex = [state.candidates count] - 1; + gCurrentCandidateController.selectedCandidateIndex = candidates.count - 1; } candidateSelectionCallback(); return YES; } + if ([state isKindOfClass: [InputStateAssociatedPhrases class]]) { + if (![input isShiftHold]) { + return NO; + } + } + NSInteger index = NSNotFound; + NSString *match = inputText; + + if ([state isKindOfClass: [InputStateAssociatedPhrases class]]) { + match = input.inputTextIgnoringModifiers; + } + for (NSUInteger j = 0, c = [gCurrentCandidateController.keyLabels count]; j < c; j++) { - if ([inputText compare:[gCurrentCandidateController.keyLabels objectAtIndex:j] options:NSCaseInsensitiveSearch] == NSOrderedSame) { + if ([match compare:[gCurrentCandidateController.keyLabels objectAtIndex:j] options:NSCaseInsensitiveSearch] == NSOrderedSame) { index = j; break; } } - [gCurrentCandidateController.keyLabels indexOfObject:inputText]; - if (index != NSNotFound) { NSUInteger candidateIndex = [gCurrentCandidateController candidateIndexAtKeyLabelIndex:index]; if (candidateIndex != NSUIntegerMax) { @@ -1004,6 +1057,10 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot } } + if ([state isKindOfClass: [InputStateAssociatedPhrases class]]) { + return NO; + } + if (_inputMode == InputModePlainBopomofo) { string layout = [self _currentLayout]; string punctuationNamePrefix = Preferences.halfWidthPunctuationEnabled ? string("_half_punctuation_") : string("_punctuation_"); @@ -1191,4 +1248,20 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot return readingsArray; } +- (nullable InputState *)buildAssociatePhraseStateWithKey:(NSString *)key useVerticalMode:(BOOL)useVerticalMode +{ + string cppKey = string(key.UTF8String); + if (_languageModel->hasAssociatedPhrasesForKey(cppKey)) { + vector phrases = _languageModel->associatedPhrasesForKey(cppKey); + NSMutableArray *array = [NSMutableArray array]; + for (auto phrase: phrases) { + NSString *item = [[NSString alloc] initWithUTF8String:phrase.c_str()]; + [array addObject:item]; + } + InputStateAssociatedPhrases *associatedPhrases = [[InputStateAssociatedPhrases alloc] initWithCandidates:array useVerticalMode:useVerticalMode]; + return associatedPhrases; + } + return nil; +} + @end diff --git a/Source/KeyHandlerInput.swift b/Source/KeyHandlerInput.swift index e0c3ea61..ad86c1b2 100644 --- a/Source/KeyHandlerInput.swift +++ b/Source/KeyHandlerInput.swift @@ -40,8 +40,9 @@ enum KeyCode: UInt16 { class KeyHandlerInput: NSObject { @objc private (set) var useVerticalMode: Bool @objc private (set) var inputText: String? + @objc private (set) var inputTextIgnoringModifiers: String? @objc private (set) var charCode: UInt16 - private var keyCode: UInt16 + @objc private (set) var keyCode: UInt16 private var flags: NSEvent.ModifierFlags private var cursorForwardKey: KeyCode private var cursorBackwardKey: KeyCode @@ -50,8 +51,9 @@ class KeyHandlerInput: NSObject { private var verticalModeOnlyChooseCandidateKey: KeyCode @objc private (set) var emacsKey: McBopomofoEmacsKey - @objc init(inputText: String?, keyCode: UInt16, charCode: UInt16, flags: NSEvent.ModifierFlags, isVerticalMode: Bool) { + @objc init(inputText: String?, keyCode: UInt16, charCode: UInt16, flags: NSEvent.ModifierFlags, isVerticalMode: Bool, inputTextIgnoringModifiers: String? = nil) { self.inputText = inputText + self.inputTextIgnoringModifiers = inputTextIgnoringModifiers ?? inputText self.keyCode = keyCode self.charCode = charCode self.flags = flags @@ -67,6 +69,7 @@ class KeyHandlerInput: NSObject { @objc init(event: NSEvent, isVerticalMode: Bool) { inputText = event.characters + inputTextIgnoringModifiers = event.charactersIgnoringModifiers keyCode = event.keyCode flags = event.modifierFlags useVerticalMode = isVerticalMode diff --git a/Source/LanguageModelManager.mm b/Source/LanguageModelManager.mm index 6c74c0d8..df6b4019 100644 --- a/Source/LanguageModelManager.mm +++ b/Source/LanguageModelManager.mm @@ -53,6 +53,13 @@ static void LTLoadLanguageModelFile(NSString *filenameWithoutExtension, McBopomo lm.loadLanguageModel([dataPath UTF8String]); } +static void LTLoadAssociatedPhrases(McBopomofoLM &lm) +{ + Class cls = NSClassFromString(@"McBopomofoInputMethodController"); + NSString *dataPath = [[NSBundle bundleForClass:cls] pathForResource:@"associated-phrases" ofType:@"cin"]; + lm.loadAssociatedPhrases([dataPath UTF8String]); +} + + (void)loadDataModels { if (!gLanguageModelMcBopomofo.isDataModelLoaded()) { @@ -61,6 +68,9 @@ static void LTLoadLanguageModelFile(NSString *filenameWithoutExtension, McBopomo if (!gLanguageModelPlainBopomofo.isDataModelLoaded()) { LTLoadLanguageModelFile(@"data-plain-bpmf", gLanguageModelPlainBopomofo); } + if (!gLanguageModelPlainBopomofo.isAssociatedPhrasesLoaded()) { + LTLoadAssociatedPhrases(gLanguageModelPlainBopomofo); + } } + (void)loadDataModel:(InputMode)mode @@ -70,10 +80,14 @@ static void LTLoadLanguageModelFile(NSString *filenameWithoutExtension, McBopomo LTLoadLanguageModelFile(@"data", gLanguageModelMcBopomofo); } } + if ([mode isEqualToString:InputModePlainBopomofo]) { if (!gLanguageModelPlainBopomofo.isDataModelLoaded()) { LTLoadLanguageModelFile(@"data-plain-bpmf", gLanguageModelPlainBopomofo); } + if (!gLanguageModelPlainBopomofo.isAssociatedPhrasesLoaded()) { + LTLoadAssociatedPhrases(gLanguageModelPlainBopomofo); + } } } diff --git a/Source/Preferences.swift b/Source/Preferences.swift index 632a1670..287f7dba 100644 --- a/Source/Preferences.swift +++ b/Source/Preferences.swift @@ -42,6 +42,8 @@ private let kCandidateKeys = "CandidateKeys" private let kPhraseReplacementEnabledKey = "PhraseReplacementEnabled" private let kChineseConversionEngineKey = "ChineseConversionEngine" private let kChineseConversionStyle = "ChineseConversionStyle" +private let kAssociatedPhrasesEnabledKey = "AssociatedPhrasesEnabled" +//private let kAssociatedPhrasesKeys = "AssociatedPhrasesKeys" private let kDefaultCandidateListTextSize: CGFloat = 16 private let kMinCandidateListTextSize: CGFloat = 12 @@ -57,6 +59,7 @@ private let kMinComposingBufferSize = 4 private let kMaxComposingBufferSize = 20 private let kDefaultKeys = "123456789" +private let kDefaultAssociatedPhrasesKeys = "!@#$%^&*(" // MARK: Property wrappers @@ -215,6 +218,8 @@ class Preferences: NSObject { defaults.removeObject(forKey: kPhraseReplacementEnabledKey) defaults.removeObject(forKey: kChineseConversionEngineKey) defaults.removeObject(forKey: kChineseConversionStyle) + defaults.removeObject(forKey: kAssociatedPhrasesEnabledKey) +// defaults.removeObject(forKey: kAssociatedPhrasesKeys) } @UserDefault(key: kKeyboardLayoutPreferenceKey, defaultValue: 0) @@ -365,4 +370,12 @@ class Preferences: NSObject { ChineseConversionStyle(rawValue: chineseConversionStyle)?.name } + @UserDefault(key: kAssociatedPhrasesEnabledKey, defaultValue: false) + @objc static var associatedPhrasesEnabled: Bool + + @objc static func toggleAssociatedPhrasesEnabled() -> Bool { + associatedPhrasesEnabled = !associatedPhrasesEnabled + return associatedPhrasesEnabled + } + } diff --git a/Source/en.lproj/Localizable.strings b/Source/en.lproj/Localizable.strings index 3bf5af15..a75f8f25 100644 --- a/Source/en.lproj/Localizable.strings +++ b/Source/en.lproj/Localizable.strings @@ -98,3 +98,5 @@ "Half-width punctuation on" = "Half-width punctuation on"; "Half-width punctuation off" = "Half-width punctuation off"; + +"Associated Phrases" = "Associated Phrases"; diff --git a/Source/zh-Hant.lproj/Localizable.strings b/Source/zh-Hant.lproj/Localizable.strings index 97da1229..e2501806 100644 --- a/Source/zh-Hant.lproj/Localizable.strings +++ b/Source/zh-Hant.lproj/Localizable.strings @@ -98,3 +98,5 @@ "Half-width punctuation on" = "已經切換到半型標點模式"; "Half-width punctuation off" = "已經切回到全型標點模式"; + +"Associated Phrases" = "聯想詞";