From 2ebc789030e933f35b2a1d8b8790e0bc09f2b371 Mon Sep 17 00:00:00 2001 From: zonble Date: Mon, 31 Jan 2022 02:33:28 +0800 Subject: [PATCH] Implements the associated phrases function. Since we use states manage the input flow in McBopomofo, implementing this function becomes easy. What I did is to create a new state, Associated Phrases state, and let the key handler to emit such a state just after emitting a Committing state. When the input method controller is under Associated Phrase state, it shows the candidate window with a tooltip, and only accept candidate keys with the shift key. The key handler uses the characters without modifiers in an NSEvent object to find if there is any matching candidate label, so I added a new member "inputTextIgnoringModifiers" to KeyHandlerInput. I use KeyValueBlobReader to read the associated phrases. I use the cin file from OpenVanilla project but removed the head and tail of the file to pass KeyValueBlobReader's validation. --- McBopomofo.xcodeproj/project.pbxproj | 12 ++ .../CandidateUI/CandidateController.swift | 2 + .../HorizontalCandidateController.swift | 37 ++++- .../VerticalCandidateController.swift | 39 +++++- Source/Engine/AssociatedPhrases.cpp | 127 ++++++++++++++++++ Source/Engine/AssociatedPhrases.h | 66 +++++++++ Source/Engine/McBopomofoLM.cpp | 34 ++++- Source/Engine/McBopomofoLM.h | 17 ++- Source/Engine/UserPhrasesLM.cpp | 8 ++ Source/Engine/UserPhrasesLM.h | 3 +- Source/InputMethodController.swift | 96 ++++++++++--- Source/InputState.swift | 23 +++- Source/KeyHandler.h | 1 + Source/KeyHandler.mm | 101 ++++++++++++-- Source/KeyHandlerInput.swift | 7 +- Source/LanguageModelManager.mm | 14 ++ Source/Preferences.swift | 13 ++ Source/en.lproj/Localizable.strings | 2 + Source/zh-Hant.lproj/Localizable.strings | 2 + 19 files changed, 547 insertions(+), 57 deletions(-) create mode 100644 Source/Engine/AssociatedPhrases.cpp create mode 100644 Source/Engine/AssociatedPhrases.h 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" = "聯想詞";