Shows a tooltip when then cursor in a phrase whose length and count of readings do not match.

McBopomofo allows users to input pheases with a different length of the
characters and Bopomofo readings, for example, users can input ∴ with ㄙㄨㄛˇ-ㄧˇ.
When the cursor if between ㄙㄨㄛˇ and ㄧˇ, the users have no clue where
the cursor exactly is. The tooltip is to tell the users the cursor is
now betwen ㄙㄨㄛˇ and ㄧˇ.
This commit is contained in:
zonble 2022-02-02 01:04:49 +08:00
parent 82dbed7815
commit 5afc5defdd
8 changed files with 169 additions and 73 deletions

View File

@ -46,6 +46,7 @@
D44FB74A2791B829003C80A6 /* VXHanConvert in Frameworks */ = {isa = PBXBuildFile; productRef = D44FB7492791B829003C80A6 /* VXHanConvert */; };
D44FB74D2792189A003C80A6 /* PhraseReplacementMap.cpp in Sources */ = {isa = PBXBuildFile; fileRef = D44FB74B2792189A003C80A6 /* PhraseReplacementMap.cpp */; };
D456576E279E4F7B00DF6BC9 /* KeyHandlerInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = D456576D279E4F7B00DF6BC9 /* KeyHandlerInput.swift */; };
D45EB5C127A9894C00E28B17 /* StringUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = D45EB5BF27A9890C00E28B17 /* StringUtils.swift */; };
D461B792279DAC010070E734 /* InputState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D461B791279DAC010070E734 /* InputState.swift */; };
D47B92C027972AD100458394 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = D47B92BF27972AC800458394 /* main.swift */; };
D47D73A427A5D43900255A50 /* KeyHandlerBopomofoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D47D73A327A5D43900255A50 /* KeyHandlerBopomofoTests.swift */; };
@ -200,6 +201,7 @@
D44FB74B2792189A003C80A6 /* PhraseReplacementMap.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = PhraseReplacementMap.cpp; sourceTree = "<group>"; };
D44FB74C2792189A003C80A6 /* PhraseReplacementMap.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PhraseReplacementMap.h; sourceTree = "<group>"; };
D456576D279E4F7B00DF6BC9 /* KeyHandlerInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyHandlerInput.swift; sourceTree = "<group>"; };
D45EB5BF27A9890C00E28B17 /* StringUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringUtils.swift; sourceTree = "<group>"; };
D461B791279DAC010070E734 /* InputState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputState.swift; sourceTree = "<group>"; };
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>"; };
@ -302,6 +304,7 @@
D4E569DB27A34CC100AC2CEF /* KeyHandler.mm */,
D456576D279E4F7B00DF6BC9 /* KeyHandlerInput.swift */,
D461B791279DAC010070E734 /* InputState.swift */,
D45EB5BF27A9890C00E28B17 /* StringUtils.swift */,
D427F76B278CA1BA004A2160 /* AppDelegate.swift */,
D44FB74427915555003C80A6 /* Preferences.swift */,
D47F7DCF278C0897002F9DD7 /* NonModalAlertWindowController.swift */,
@ -730,6 +733,7 @@
D47F7DD3278C1263002F9DD7 /* UserOverrideModel.cpp in Sources */,
6A0D4F4515FC0EB100ABF4B3 /* Mandarin.cpp in Sources */,
6ACC3D452793701600F1B140 /* ParselessLM.cpp in Sources */,
D45EB5C127A9894C00E28B17 /* StringUtils.swift in Sources */,
D41355DE278EA3ED005E5CBD /* UserPhrasesLM.cpp in Sources */,
6ACC3D3F27914F2400F1B140 /* KeyValueBlobReader.cpp in Sources */,
D41355D8278D74B5005E5CBD /* LanguageModelManager.mm in Sources */,

View File

@ -84,3 +84,9 @@
"Associated Phrases" = "Associated Phrases";
"There are special phrases in your text. We don't support adding new phrases in this case." = "There are special phrases in your text. We don't support adding new phrases in this case.";
"Cursor is before \"%@\"." = "Cursor is before \"%@\".";
"Cursor is after \"%@\"." = "Cursor is after \"%@\".";
"Cursor is between \"%@\" and \"%@\"." = "Cursor is between \"%@\" and \"%@\".";

View File

@ -388,6 +388,9 @@ 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 !state.tooltip.isEmpty {
show(tooltip: state.tooltip, composingBuffer: state.composingBuffer, cursorIndex: state.cursorIndex, client: client)
}
}
private func handle(state: InputState.Marking, previous: InputState, client: Any?) {
@ -482,19 +485,18 @@ extension McBopomofoInputMethodController {
gCurrentCandidateController?.visible = true
var lineHeightRect = NSMakeRect(0.0, 0.0, 16.0, 16.0)
var cursor: UInt = 0
var cursor: Int = 0
if let state = state as? InputState.ChoosingCandidate {
cursor = state.cursorIndex
cursor = Int(state.cursorIndex)
if cursor == state.composingBuffer.count && cursor != 0 {
cursor -= 1
}
}
(client as? IMKTextInput)?.attributes(forCharacterIndex: Int(cursor), lineHeightRectangle: &lineHeightRect)
if lineHeightRect.origin.x == 0 && lineHeightRect.origin.y == 0 && cursor > 0 {
while lineHeightRect.origin.x == 0 && lineHeightRect.origin.y == 0 && cursor >= 0 {
(client as? IMKTextInput)?.attributes(forCharacterIndex: cursor, lineHeightRectangle: &lineHeightRect)
cursor -= 1
(client as? IMKTextInput)?.attributes(forCharacterIndex: Int(cursor), lineHeightRectangle: &lineHeightRect)
}
if useVerticalMode {
@ -506,14 +508,13 @@ extension McBopomofoInputMethodController {
private func show(tooltip: String, composingBuffer: String, cursorIndex: UInt, client: Any!) {
var lineHeightRect = NSMakeRect(0.0, 0.0, 16.0, 16.0)
var cursor = cursorIndex
var cursor: Int = Int(cursorIndex)
if cursor == composingBuffer.count && cursor != 0 {
cursor -= 1
}
(client as? IMKTextInput)?.attributes(forCharacterIndex: Int(cursor), lineHeightRectangle: &lineHeightRect)
if lineHeightRect.origin.x == 0 && lineHeightRect.origin.y == 0 && cursor > 0 {
while lineHeightRect.origin.x == 0 && lineHeightRect.origin.y == 0 && cursor >= 0 {
(client as? IMKTextInput)?.attributes(forCharacterIndex: cursor, lineHeightRectangle: &lineHeightRect)
cursor -= 1
(client as? IMKTextInput)?.attributes(forCharacterIndex: Int(cursor), lineHeightRectangle: &lineHeightRect)
}
McBopomofoInputMethodController.tooltipController.show(tooltip: tooltip, at: lineHeightRect.origin)
}

View File

@ -64,6 +64,8 @@ class InputState: NSObject {
}
}
// MARK: -
/// Represents that the composing buffer is empty.
@objc (InputStateEmpty)
class Empty: InputState {
@ -76,6 +78,8 @@ class InputState: NSObject {
}
}
// MARK: -
/// Represents that the composing buffer is empty.
@objc (InputStateEmptyIgnoringPreviousState)
class EmptyIgnoringPreviousState: InputState {
@ -87,6 +91,8 @@ class InputState: NSObject {
}
}
// MARK: -
/// Represents that the input controller is committing text into client app.
@objc (InputStateCommitting)
class Committing: InputState {
@ -101,6 +107,9 @@ class InputState: NSObject {
"<InputState.Committing poppedText:\(poppedText)>"
}
}
// MARK: -
/// Represents that the composing buffer is not empty.
@objc (InputStateNotEmpty)
class NotEmpty: InputState {
@ -117,10 +126,13 @@ class InputState: NSObject {
}
}
// MARK: -
/// Represents that the user is inputting text.
@objc (InputStateInputting)
class Inputting: NotEmpty {
@objc var poppedText: String = ""
@objc var tooltip: String = ""
@objc override init(composingBuffer: String, cursorIndex: UInt) {
super.init(composingBuffer: composingBuffer, cursorIndex: cursorIndex)
@ -139,6 +151,8 @@ class InputState: NSObject {
}
}
// MARK: -
private let kMinMarkRangeLength = 2
private let kMaxMarkRangeLength = 6
@ -173,6 +187,7 @@ class InputState: NSObject {
return String(format: NSLocalizedString("You are now selecting \"%@\". Press enter to add a new phrase.", comment: ""), text)
}
@objc var tooltipForInputting: String = ""
@objc private(set) var readings: [String]
@objc init(composingBuffer: String, cursorIndex: UInt, markerIndex: UInt, readings: [String]) {
@ -210,14 +225,21 @@ class InputState: NSObject {
@objc func convertToInputting() -> Inputting {
let state = Inputting(composingBuffer: composingBuffer, cursorIndex: cursorIndex)
state.tooltip = tooltipForInputting
return state
}
@objc var validToWrite: Bool {
/// McBopomofo allows users to input a string whose length differs
/// from the amount of Bopomofo readings. In this case, the range
/// in the composing buffer and the readings could not match, so
/// we disable the function to write user phrases in this case.
if composingBuffer.count != readings.count {
return false
}
return markedRange.length >= kMinMarkRangeLength && markedRange.length <= kMaxMarkRangeLength
return markedRange.length >= kMinMarkRangeLength &&
markedRange.length <= kMaxMarkRangeLength
}
@objc var userPhrase: String {
@ -230,6 +252,8 @@ class InputState: NSObject {
}
}
// MARK: -
/// Represents that the user is choosing in a candidates list.
@objc (InputStateChoosingCandidate)
class ChoosingCandidate: NotEmpty {
@ -255,6 +279,8 @@ class InputState: NSObject {
}
}
// MARK: -
/// Represents that the user is choosing in a candidates list
/// in the associated phrases mode.
@objc (InputStateAssociatedPhrases)
@ -271,50 +297,4 @@ class InputState: NSObject {
"<InputState.AssociatedPhrases, candidates:\(candidates), useVerticalMode:\(useVerticalMode)>"
}
}
}
class InputPhrase: NSObject {
@objc private (set) var text: String
@objc private (set) var reading: String
@objc init(text: String, reading: String) {
self.text = text
self.reading = reading
super.init()
}
}
class StringUtils: NSObject {
static func convertToCharIndex(from utf16Index: Int, in string: String) -> Int {
var length = 0
for (i, c) in string.enumerated() {
if length >= utf16Index {
return i
}
length += c.utf16.count
}
return string.count
}
@objc (nextUtf16PositionForIndex:in:)
static func nextUtf16Position(for index: Int, in string: String) -> Int {
var index = convertToCharIndex(from: index, in: string)
if index < string.count {
index += 1
}
let count = string[..<string.index(string.startIndex, offsetBy: index)].utf16.count
return count
}
@objc (previousUtf16PositionForIndex:in:)
static func previousUtf16Position(for index: Int, in string: String) -> Int {
var index = convertToCharIndex(from: index, in: string)
if index > 0 {
index -= 1
}
let count = string[..<string.index(string.startIndex, offsetBy: index)].utf16.count
return count
}
}

View File

@ -226,7 +226,7 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot
McBopomofoEmacsKey emacsKey = input.emacsKey;
// if the inputText is empty, it's a function key combination, we ignore it
if (![input.inputText length]) {
if (!input.inputText.length) {
return NO;
}
@ -348,7 +348,7 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot
size_t cursorIndex = [self _actualCandidateCursorIndex];
vector<NodeAnchor> nodes = _builder->grid().nodesCrossingOrEndingAt(cursorIndex);
double highestScore = FindHighestScore(nodes, kEpsilon);
_builder->grid().overrideNodeScoreForSelectedCandidate(cursorIndex, overrideValue, highestScore);
_builder->grid().overrideNodeScoreForSelectedCandidate(cursorIndex, overrideValue, static_cast<float>(highestScore));
}
// then update the text
@ -397,13 +397,11 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot
// if the spacebar is NOT set to be a selection key
if ([input isShiftHold] || !Preferences.chooseCandidateUsingSpace) {
if (_builder->cursorIndex() >= _builder->length()) {
if ([state isKindOfClass:[InputStateNotEmpty class]]) {
NSString *composingBuffer = [(InputStateNotEmpty*) state composingBuffer];
if ([composingBuffer length]) {
if (composingBuffer.length) {
InputStateCommitting *committing = [[InputStateCommitting alloc] initWithPoppedText:composingBuffer];
stateCallback (committing);
}
}
[self clear];
InputStateCommitting *committing = [[InputStateCommitting alloc] initWithPoppedText:@" "];
stateCallback(committing);
@ -548,7 +546,7 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot
// other platforms
if (_bpmfReadingBuffer->isEmpty()) {
// no nee to beep since the event is deliberately triggered by user
// no need to beep since the event is deliberately triggered by user
if (![state isKindOfClass:[InputStateInputting class]]) {
return NO;
}
@ -580,6 +578,7 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot
if (currentState.cursorIndex > 0) {
NSInteger previousPosition = [StringUtils previousUtf16PositionForIndex:currentState.cursorIndex in:currentState.composingBuffer];
InputStateMarking *marking = [[InputStateMarking alloc] initWithComposingBuffer:currentState.composingBuffer cursorIndex:currentState.cursorIndex markerIndex:previousPosition readings:[self _currentReadings]];
marking.tooltipForInputting = currentState.tooltip;
stateCallback(marking);
} else {
errorCallback();
@ -617,6 +616,7 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot
if (currentState.cursorIndex < currentState.composingBuffer.length) {
NSInteger nextPosition = [StringUtils nextUtf16PositionForIndex:currentState.cursorIndex in:currentState.composingBuffer];
InputStateMarking *marking = [[InputStateMarking alloc] initWithComposingBuffer:currentState.composingBuffer cursorIndex:currentState.cursorIndex markerIndex:nextPosition readings:[self _currentReadings]];
marking.tooltipForInputting = currentState.tooltip;
stateCallback(marking);
} else {
errorCallback();
@ -844,6 +844,7 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot
if (index > 0) {
index = [StringUtils previousUtf16PositionForIndex:index in:state.composingBuffer];
InputStateMarking *marking = [[InputStateMarking alloc] initWithComposingBuffer:state.composingBuffer cursorIndex:state.cursorIndex markerIndex:index readings:state.readings];
marking.tooltipForInputting = state.tooltipForInputting;
stateCallback(marking);
} else {
errorCallback();
@ -859,6 +860,7 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot
if (index < state.composingBuffer.length) {
index = [StringUtils nextUtf16PositionForIndex:index in:state.composingBuffer];
InputStateMarking *marking = [[InputStateMarking alloc] initWithComposingBuffer:state.composingBuffer cursorIndex:state.cursorIndex markerIndex:index readings:state.readings];
marking.tooltipForInputting = state.tooltipForInputting;
stateCallback(marking);
} else {
errorCallback();
@ -1131,7 +1133,7 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot
size_t readingCursorIndex = 0;
size_t builderCursorIndex = _builder->cursorIndex();
NSMutableArray <InputPhrase *> *phrases = [[NSMutableArray alloc] init];
NSString *tooltip = @"";
// we must do some Unicode codepoint counting to find the actual cursor location for the client
// i.e. we need to take UTF-16 into consideration, for which a surrogate pair takes 2 UniChars
@ -1145,10 +1147,6 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot
NSString *valueString = [NSString stringWithUTF8String:nodeStr.c_str()];
[composingBuffer appendString:valueString];
NSString *readingString = [NSString stringWithUTF8String:(*wi).node->currentKeyValue().key.c_str()];
InputPhrase *phrase = [[InputPhrase alloc] initWithText:valueString reading:readingString];
[phrases addObject:phrase];
// this re-aligns the cursor index in the composed string
// (the actual cursor on the screen) with the builder's logical
// cursor (reading) cursor; each built node has a "spanning length"
@ -1160,10 +1158,31 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot
composedStringCursorIndex += [valueString length];
readingCursorIndex += spanningLength;
} else {
if (codepointCount == spanningLength) {
for (size_t i = 0; i < codepointCount && readingCursorIndex < builderCursorIndex; i++) {
composedStringCursorIndex += [[NSString stringWithUTF8String:codepoints[i].c_str()] length];
readingCursorIndex++;
}
} else {
if (readingCursorIndex < builderCursorIndex) {
composedStringCursorIndex += [valueString length];
readingCursorIndex += spanningLength;
if (readingCursorIndex > builderCursorIndex) {
readingCursorIndex = builderCursorIndex;
}
if (builderCursorIndex == 0) {
tooltip = [NSString stringWithFormat:NSLocalizedString(@"Cursor is before \"%@\".", @""),
[NSString stringWithUTF8String:_builder->readings()[builderCursorIndex].c_str()]];
} else if (builderCursorIndex >= _builder->readings().size()) {
tooltip = [NSString stringWithFormat:NSLocalizedString(@"Cursor is after \"%@\".", @""),
[NSString stringWithUTF8String:_builder->readings()[_builder->readings().size() - 1].c_str()]];
} else {
tooltip = [NSString stringWithFormat:NSLocalizedString(@"Cursor is between \"%@\" and \"%@\".", @""),
[NSString stringWithUTF8String:_builder->readings()[builderCursorIndex - 1].c_str()],
[NSString stringWithUTF8String:_builder->readings()[builderCursorIndex].c_str()]];
}
}
}
}
}
}
@ -1178,6 +1197,7 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot
NSInteger cursorIndex = composedStringCursorIndex + [reading length];
InputStateInputting *newState = [[InputStateInputting alloc] initWithComposingBuffer:composedText cursorIndex:cursorIndex];
newState.tooltip = tooltip;
return newState;
}

73
Source/StringUtils.swift Normal file
View File

@ -0,0 +1,73 @@
// Copyright (c) 2022 and onwards The McBopomofo Authors.
//
// Permission is hereby granted, free of charge, to any person
// obtaining a copy of this software and associated documentation
// 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.
import Foundation
/// Utilities to convert the length of an NSString and a Swift string.
class StringUtils: NSObject {
/// Converts the index in an NSString to the index in a Swift string.
///
/// An Emoji might be compose by more than one UTF-16 code points, however
/// the length of an NSString is only the sum of the UTF-16 code points. It
/// causes that the NSString and Swift string representation of the same
/// string have different lengths once the string contains such Emoji. The
/// method helps to find the index in a Swift string by passing the index
/// in an NSString.
static func convertToCharIndex(from utf16Index: Int, in string: String) -> Int {
var length = 0
for (i, character) in string.enumerated() {
if length >= utf16Index {
return i
}
length += character.utf16.count
}
return string.count
}
@objc (nextUtf16PositionForIndex:in:)
static func nextUtf16Position(for index: Int, in string: String) -> Int {
var index = convertToCharIndex(from: index, in: string)
if index < string.count {
index += 1
}
let count = string[..<string.index(string.startIndex, offsetBy: index)].utf16.count
return count
}
@objc (previousUtf16PositionForIndex:in:)
static func previousUtf16Position(for index: Int, in string: String) -> Int {
var index = convertToCharIndex(from: index, in: string)
if index > 0 {
index -= 1
}
let count = string[..<string.index(string.startIndex, offsetBy: index)].utf16.count
return count
}
}
extension NSString {
@objc var count: Int {
(self as String).count
}
}

View File

@ -84,3 +84,9 @@
"Associated Phrases" = "Associated Phrases";
"There are special phrases in your text. We don't support adding new phrases in this case." = "There are special phrases in your text. We don't support adding new phrases in this case.";
"Cursor is before \"%@\"." = "Cursor is before \"%@\".";
"Cursor is after \"%@\"." = "Cursor is after \"%@\".";
"Cursor is between \"%@\" and \"%@\"." = "Cursor is between \"%@\" and \"%@\".";

View File

@ -84,3 +84,9 @@
"Associated Phrases" = "聯想詞";
"There are special phrases in your text. We don't support adding new phrases in this case." = "您輸入了特殊符號,我們還無法支援在這種狀況下手動加詞。";
"Cursor is before \"%@\"." = "游標正在「%@」前方";
"Cursor is after \"%@\"." = "游標正在「%@」後方";
"Cursor is between \"%@\" and \"%@\"." = "游標正在「%@」與「%@」之間";