From 5afc5defddae53e56fa27dbe5a6d9c36a7f414b4 Mon Sep 17 00:00:00 2001 From: zonble Date: Wed, 2 Feb 2022 01:04:49 +0800 Subject: [PATCH] Shows a tooltip when then cursor in a phrase whose length and count of readings do not match. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ㄧˇ. --- McBopomofo.xcodeproj/project.pbxproj | 4 ++ Source/Base.lproj/Localizable.strings | 6 ++ Source/InputMethodController.swift | 19 +++--- Source/InputState.swift | 74 +++++++++--------------- Source/KeyHandler.mm | 54 +++++++++++------ Source/StringUtils.swift | 73 +++++++++++++++++++++++ Source/en.lproj/Localizable.strings | 6 ++ Source/zh-Hant.lproj/Localizable.strings | 6 ++ 8 files changed, 169 insertions(+), 73 deletions(-) create mode 100644 Source/StringUtils.swift diff --git a/McBopomofo.xcodeproj/project.pbxproj b/McBopomofo.xcodeproj/project.pbxproj index 38d6e4a9..5f4ba050 100644 --- a/McBopomofo.xcodeproj/project.pbxproj +++ b/McBopomofo.xcodeproj/project.pbxproj @@ -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 = ""; }; D44FB74C2792189A003C80A6 /* PhraseReplacementMap.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PhraseReplacementMap.h; sourceTree = ""; }; D456576D279E4F7B00DF6BC9 /* KeyHandlerInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyHandlerInput.swift; sourceTree = ""; }; + D45EB5BF27A9890C00E28B17 /* StringUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringUtils.swift; sourceTree = ""; }; D461B791279DAC010070E734 /* InputState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputState.swift; sourceTree = ""; }; D47B92BF27972AC800458394 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; D47D73A327A5D43900255A50 /* KeyHandlerBopomofoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyHandlerBopomofoTests.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Source/Base.lproj/Localizable.strings b/Source/Base.lproj/Localizable.strings index d9bc1ea9..3f906806 100644 --- a/Source/Base.lproj/Localizable.strings +++ b/Source/Base.lproj/Localizable.strings @@ -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 \"%@\"."; diff --git a/Source/InputMethodController.swift b/Source/InputMethodController.swift index aaf247c5..d8d3591a 100644 --- a/Source/InputMethodController.swift +++ b/Source/InputMethodController.swift @@ -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) } diff --git a/Source/InputState.swift b/Source/InputState.swift index 3e7e0a47..8dd6780e 100644 --- a/Source/InputState.swift +++ b/Source/InputState.swift @@ -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 { "" } } + + // 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 { "" } } - -} - -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[.. Int { - var index = convertToCharIndex(from: index, in: string) - if index > 0 { - index -= 1 - } - let count = string[.. nodes = _builder->grid().nodesCrossingOrEndingAt(cursorIndex); double highestScore = FindHighestScore(nodes, kEpsilon); - _builder->grid().overrideNodeScoreForSelectedCandidate(cursorIndex, overrideValue, highestScore); + _builder->grid().overrideNodeScoreForSelectedCandidate(cursorIndex, overrideValue, static_cast(highestScore)); } // then update the text @@ -397,12 +397,10 @@ 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]) { - InputStateCommitting *committing = [[InputStateCommitting alloc] initWithPoppedText:composingBuffer]; - stateCallback(committing); - } + NSString *composingBuffer = [(InputStateNotEmpty*) state composingBuffer]; + if (composingBuffer.length) { + InputStateCommitting *committing = [[InputStateCommitting alloc] initWithPoppedText:composingBuffer]; + stateCallback (committing); } [self clear]; InputStateCommitting *committing = [[InputStateCommitting alloc] initWithPoppedText:@" "]; @@ -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 *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,9 +1158,30 @@ static NSString *const kGraphVizOutputfile = @"/tmp/McBopomofo-visualization.dot composedStringCursorIndex += [valueString length]; readingCursorIndex += spanningLength; } else { - for (size_t i = 0; i < codepointCount && readingCursorIndex < builderCursorIndex; i++) { - composedStringCursorIndex += [[NSString stringWithUTF8String:codepoints[i].c_str()] length]; - readingCursorIndex++; + 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; } diff --git a/Source/StringUtils.swift b/Source/StringUtils.swift new file mode 100644 index 00000000..db5067a8 --- /dev/null +++ b/Source/StringUtils.swift @@ -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[.. Int { + var index = convertToCharIndex(from: index, in: string) + if index > 0 { + index -= 1 + } + let count = string[..