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[..