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.
This commit is contained in:
zonble 2022-01-31 02:33:28 +08:00
parent 2dd398f6ca
commit 2ebc789030
19 changed files with 547 additions and 57 deletions

View File

@ -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 = "<group>"; };
D47D73A327A5D43900255A50 /* KeyHandlerBopomofoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyHandlerBopomofoTests.swift; sourceTree = "<group>"; };
D47D73C027A71FFA00255A50 /* FSEventStreamHelper */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FSEventStreamHelper; path = Packages/FSEventStreamHelper; sourceTree = "<group>"; };
D47D73A727A6C84F00255A50 /* associated-phrases.cin */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "associated-phrases.cin"; sourceTree = "<group>"; };
D47D73AA27A6CAE600255A50 /* AssociatedPhrases.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = AssociatedPhrases.cpp; sourceTree = "<group>"; };
D47D73AB27A6CAE600255A50 /* AssociatedPhrases.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AssociatedPhrases.h; sourceTree = "<group>"; };
D47F7DCD278BFB57002F9DD7 /* PreferencesWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesWindowController.swift; sourceTree = "<group>"; };
D47F7DCF278C0897002F9DD7 /* NonModalAlertWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonModalAlertWindowController.swift; sourceTree = "<group>"; };
D47F7DD1278C1263002F9DD7 /* UserOverrideModel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UserOverrideModel.h; sourceTree = "<group>"; };
@ -337,6 +343,8 @@
D41355DA278E6D17005E5CBD /* McBopomofoLM.h */,
D47F7DD2278C1263002F9DD7 /* UserOverrideModel.cpp */,
D47F7DD1278C1263002F9DD7 /* UserOverrideModel.h */,
D47D73AA27A6CAE600255A50 /* AssociatedPhrases.cpp */,
D47D73AB27A6CAE600255A50 /* AssociatedPhrases.h */,
);
path = Engine;
sourceTree = "<group>";
@ -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 */,

View File

@ -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() {
}

View File

@ -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..<count {
let labelRect = (keyLabels[index] as NSString).boundingRect(with: baseSize, options: .usesLineFragmentOrigin, attributes: keyLabelAttrDict)
let candidateRect = (displayedCandidates[index] as NSString).boundingRect(with: baseSize, options: .usesLineFragmentOrigin, attributes: candidateAttrDict)
let cellWidth = max(keyLabelHeight * 2,
let cellWidth = max(candidateTextHeight,
max(labelRect.size.width, candidateRect.size.width)) + cellPadding;
newWidths.append(cellWidth)
}
@ -108,13 +128,21 @@ fileprivate class HorizontalCandidateView: NSView {
NSColor.darkGray.setStroke()
}
NSBezierPath.strokeLine(from: NSPoint(x: bounds.size.width, y: 0.0), to: NSPoint(x: bounds.size.width, y: bounds.size.height))
if let toolTip = toolTip {
lightGray.setFill()
NSBezierPath.fill(NSMakeRect(0, 0, bounds.width, tooltipSize.height))
let tooltipFrame = NSRect(x: 0, y: 0, width: tooltipSize.width, height: tooltipSize.height)
(toolTip as NSString).draw(in: tooltipFrame, withAttributes: keyLabelAttrDict)
NSBezierPath.strokeLine(from: NSPoint(x: 0, y: tooltipSize.height - 2), to: NSPoint(x: bounds.width, y: tooltipSize.height - 2))
}
NSBezierPath.strokeLine(from: NSPoint(x: bounds.width, y: 0), to: NSPoint(x: bounds.width, y: bounds.height))
var accuWidth: CGFloat = 0
for index in 0..<elementWidths.count {
let currentWidth = elementWidths[index]
let labelRect = NSRect(x: accuWidth, y: 0.0, width: currentWidth, height: keyLabelHeight)
let candidateRect = NSRect(x: accuWidth, y: keyLabelHeight + 1.0, width: currentWidth, height: candidateTextHeight)
let labelRect = NSRect(x: accuWidth, y: tooltipSize.height, width: currentWidth, height: keyLabelHeight)
let candidateRect = NSRect(x: accuWidth, y: tooltipSize.height + keyLabelHeight + 1.0, width: currentWidth, height: candidateTextHeight)
(index == highlightedIndex ? darkGray : lightGray).setFill()
NSBezierPath.fill(labelRect)
(keyLabels[index] as NSString).draw(in: labelRect, withAttributes: keyLabelAttrDict)
@ -347,6 +375,7 @@ extension HorizontalCandidateController {
candidates.append(candidate)
}
candidateView.set(keyLabels: keyLabels, displayedCandidates: candidates)
candidateView.toolTip = tooltip
var newSize = candidateView.sizeForView
var frameRect = candidateView.frame
frameRect.size = newSize

View File

@ -85,6 +85,12 @@ private let kCandidateTextLeftMargin: CGFloat = 8.0
private let kCandidateTextPaddingWithMandatedTableViewPadding: CGFloat = 18.0
private let kCandidateTextLeftMarginWithMandatedTableViewPadding: CGFloat = 0.0
private class BackgroundView: NSView {
override func draw(_ dirtyRect: NSRect) {
NSColor.windowBackgroundColor.setFill()
NSBezierPath.fill(self.bounds)
}
}
@objc(VTVerticalCandidateController)
public class VerticalCandidateController: CandidateController {
@ -95,6 +101,8 @@ public class VerticalCandidateController: CandidateController {
private var candidateTextPadding: CGFloat = kCandidateTextPadding
private var candidateTextLeftMargin: CGFloat = kCandidateTextLeftMargin
private var maxCandidateAttrStringWidth: CGFloat = 0
private let tooltipPadding: CGFloat = 2.0
private var tooltipView: NSTextField
public init() {
var contentRect = NSRect(x: 128.0, y: 128.0, width: 0.0, height: 0.0)
@ -102,6 +110,14 @@ public class VerticalCandidateController: CandidateController {
let panel = NSPanel(contentRect: contentRect, styleMask: styleMask, backing: .buffered, defer: false)
panel.level = NSWindow.Level(Int(kCGPopUpMenuWindowLevel) + 1)
panel.hasShadow = true
panel.contentView = BackgroundView()
tooltipView = NSTextField(frame: NSRect.zero)
tooltipView.isEditable = false
tooltipView.isSelectable = false
tooltipView.isBezeled = false
tooltipView.drawsBackground = true
tooltipView.lineBreakMode = .byTruncatingTail
contentRect.origin = NSPoint.zero
var stripRect = contentRect
@ -400,6 +416,20 @@ extension VerticalCandidateController: NSTableViewDataSource, NSTableViewDelegat
return
}
var tooltipHeight: CGFloat = 0
var tooltipWidth: CGFloat = 0
if !tooltip.isEmpty {
tooltipView.stringValue = tooltip
let size = tooltipView.intrinsicContentSize
tooltipWidth = size.width + tooltipPadding * 2
tooltipHeight = size.height + tooltipPadding * 2
self.window?.contentView?.addSubview(tooltipView)
} else {
tooltipView.removeFromSuperview()
}
let candidateFontSize = ceil(candidateFont.pointSize)
let keyLabelFontSize = ceil(keyLabelFont.pointSize)
let fontSize = max(candidateFontSize, keyLabelFontSize)
@ -438,8 +468,8 @@ extension VerticalCandidateController: NSTableViewDataSource, NSTableViewDelegat
let rowSpacing = tableView.intercellSpacing.height
let stripWidth = ceil(maxKeyLabelWidth * 1.20)
let tableViewStartWidth = ceil(maxCandidateAttrStringWidth + scrollerWidth)
let windowWidth = stripWidth + 1.0 + tableViewStartWidth
let windowHeight = CGFloat(keyLabelCount) * (rowHeight + rowSpacing)
let windowWidth = max(stripWidth + 1.0 + tableViewStartWidth, tooltipWidth)
let windowHeight = CGFloat(keyLabelCount) * (rowHeight + rowSpacing) + tooltipHeight
var frameRect = self.window?.frame ?? NSRect.zero
let topLeftPoint = NSMakePoint(frameRect.origin.x, frameRect.origin.y + frameRect.size.height)
@ -447,8 +477,9 @@ extension VerticalCandidateController: NSTableViewDataSource, NSTableViewDelegat
frameRect.size = NSMakeSize(windowWidth, windowHeight)
frameRect.origin = NSMakePoint(topLeftPoint.x, topLeftPoint.y - frameRect.size.height)
keyLabelStripView.frame = NSRect(x: 0.0, y: 0.0, width: stripWidth, height: windowHeight)
scrollView.frame = NSRect(x: stripWidth + 1.0, y: 0.0, width: tableViewStartWidth, height: windowHeight)
keyLabelStripView.frame = NSRect(x: 0.0, y: 0, width: stripWidth, height: windowHeight - tooltipHeight)
scrollView.frame = NSRect(x: stripWidth + 1.0, y: 0, width: (windowWidth - stripWidth - 1), height: windowHeight - tooltipHeight)
tooltipView.frame = NSRect(x: tooltipPadding, y: windowHeight - tooltipHeight + tooltipPadding, width: windowWidth, height: tooltipHeight)
self.window?.setFrame(frameRect, display: false)
}
}

View File

@ -0,0 +1,127 @@
//
// AssociatedPhrases.cpp
//
// 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.
//
#include "AssociatedPhrases.h"
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <fstream>
#include <unistd.h>
#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<char*>(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<std::string> AssociatedPhrases::valuesForKey(const std::string& key)
{
std::vector<std::string> v;
auto iter = keyRowMap.find(key);
if (iter != keyRowMap.end()) {
const std::vector<Row>& 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

View File

@ -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 <string>
#include <map>
#include <iostream>
#include <vector>
namespace McBopomofo {
class AssociatedPhrases
{
public:
AssociatedPhrases();
~AssociatedPhrases();
const bool isLoaded();
bool open(const char *path);
void close();
const std::vector<std::string> 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<std::string_view, std::vector<Row>> keyRowMap;
int fd;
void *data;
size_t length;
};
}
#endif /* AssociatedPhrases_hpp */

View File

@ -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<Unigram> McBopomofoLM::filterAndTransformUnigrams(const vector<Unig
}
return results;
}
const vector<std::string> McBopomofoLM::associatedPhrasesForKey(const string& key)
{
return m_associatedPhrases.valuesForKey(key);
}
bool McBopomofoLM::hasAssociatedPhrasesForKey(const string& key)
{
return m_associatedPhrases.hasValuesForKey(key);
}

View File

@ -28,6 +28,7 @@
#include "UserPhrasesLM.h"
#include "ParselessLM.h"
#include "PhraseReplacementMap.h"
#include "AssociatedPhrases.h"
#include <unordered_set>
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<string(string)> externalConverter);
const vector<std::string> 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<string(string)> m_externalConverter;

View File

@ -47,6 +47,14 @@ UserPhrasesLM::~UserPhrasesLM()
}
}
bool UserPhrasesLM::isLoaded()
{
if (data) {
return true;
}
return false;
}
bool UserPhrasesLM::open(const char *path)
{
if (data) {

View File

@ -36,7 +36,8 @@ class UserPhrasesLM : public Formosa::Gramambular::LanguageModel
public:
UserPhrasesLM();
~UserPhrasesLM();
bool isLoaded();
bool open(const char *path);
void close();
void dump();

View File

@ -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)
}
}
}

View File

@ -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 {
"<InputState.AssociatedPhrases, candidates:\(candidates), useVerticalMode:\(useVerticalMode)>"
}
}
}

View File

@ -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 <KeyHandlerDelegate> delegate;

View File

@ -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<string> phrases = _languageModel->associatedPhrasesForKey(cppKey);
NSMutableArray <NSString *> *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

View File

@ -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

View File

@ -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);
}
}
}

View File

@ -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
}
}

View File

@ -98,3 +98,5 @@
"Half-width punctuation on" = "Half-width punctuation on";
"Half-width punctuation off" = "Half-width punctuation off";
"Associated Phrases" = "Associated Phrases";

View File

@ -98,3 +98,5 @@
"Half-width punctuation on" = "已經切換到半型標點模式";
"Half-width punctuation off" = "已經切回到全型標點模式";
"Associated Phrases" = "聯想詞";