From 3e561d215ced846eb2e03041ef56a33c73811264 Mon Sep 17 00:00:00 2001 From: ShikiSuen Date: Tue, 1 Feb 2022 14:35:23 +0800 Subject: [PATCH] Zonble: UserPhrases // UTF8 Surrogate Pair Fix - phase 1 - This already fixed the issue, but we need another commit to prevent disastrous results by similar issues. --- .../Engine/ControllerModules/InputState.swift | 431 +++++++++++------- Source/Engine/ControllerModules/KeyHandler.mm | 98 ++-- Source/InputMethodController.mm | 8 + 3 files changed, 320 insertions(+), 217 deletions(-) diff --git a/Source/Engine/ControllerModules/InputState.swift b/Source/Engine/ControllerModules/InputState.swift index 81d0af54..835c4a4c 100644 --- a/Source/Engine/ControllerModules/InputState.swift +++ b/Source/Engine/ControllerModules/InputState.swift @@ -40,180 +40,267 @@ import Cocoa /// - Choosing Candidate: The candidate window is open to let the user to choose /// one among the candidates. class InputState: NSObject { + + /// Represents that the input controller is deactivated. + @objc (InputStateDeactivated) + class Deactivated: InputState { + override var description: String { + "" + } + } + + /// Represents that the composing buffer is empty. + @objc (InputStateEmpty) + class Empty: InputState { + @objc var composingBuffer: String { + "" + } + + override var description: String { + "" + } + } + + /// Represents that the composing buffer is empty. + @objc (InputStateEmptyIgnoringPreviousState) + class EmptyIgnoringPreviousState: InputState { + @objc var composingBuffer: String { + "" + } + override var description: String { + "" + } + } + + /// Represents that the input controller is committing text into client app. + @objc (InputStateCommitting) + class Committing: InputState { + @objc private(set) var poppedText: String = "" + + @objc convenience init(poppedText: String) { + self.init() + self.poppedText = poppedText + } + + override var description: String { + "" + } + } + /// Represents that the composing buffer is not empty. + @objc (InputStateNotEmpty) + class NotEmpty: InputState { + @objc private(set) var composingBuffer: String + @objc private(set) var cursorIndex: UInt + @objc private(set) var phrases: [InputPhrase] + + @objc init(composingBuffer: String, cursorIndex: UInt, phrases: [InputPhrase]) { + self.composingBuffer = composingBuffer + self.cursorIndex = cursorIndex + self.phrases = phrases + } + + override var description: String { + "" + } + } + + /// Represents that the user is inputting text. + @objc (InputStateInputting) + class Inputting: NotEmpty { + @objc var poppedText: String = "" + + @objc override init(composingBuffer: String, cursorIndex: UInt, phrases: [InputPhrase]) { + super.init(composingBuffer: composingBuffer, cursorIndex: cursorIndex, phrases: phrases) + } + + @objc var attributedString: NSAttributedString { + let attributedSting = NSAttributedString(string: composingBuffer, attributes: [ + .underlineStyle: NSUnderlineStyle.single.rawValue, + .markedClauseSegment: 0 + ]) + return attributedSting + } + + override var description: String { + ", poppedText:\(poppedText)>" + } + } + + private let kMinMarkRangeLength = 2 + private let kMaxMarkRangeLength = 6 + + /// Represents that the user is marking a range in the composing buffer. + @objc (InputStateMarking) + class Marking: NotEmpty { + + @objc private(set) var markerIndex: UInt + @objc private(set) var markedRange: NSRange + @objc var tooltip: String { + + if Preferences.phraseReplacementEnabled { + return NSLocalizedString("⚠︎ Phrase replacement mode enabled, interfering user phrase entry.", comment: "") + } + + if markedRange.length == 0 { + return "" + } + + let text = (composingBuffer as NSString).substring(with: markedRange) + if markedRange.length < kMinMarkRangeLength { + return String(format: NSLocalizedString("\"%@\" length must ≥ 2 for a user phrase.", comment: ""), text) + } else if (markedRange.length > kMaxMarkRangeLength) { + return String(format: NSLocalizedString("\"%@\" length should ≤ %d for a user phrase.", comment: ""), text, kMaxMarkRangeLength) + } + return String(format: NSLocalizedString("\"%@\" selected. ENTER to add user phrase.", comment: ""), text) + } + + @objc init(composingBuffer: String, cursorIndex: UInt, markerIndex: UInt, phrases: [InputPhrase]) { + self.markerIndex = markerIndex + let begin = min(cursorIndex, markerIndex) + let end = max(cursorIndex, markerIndex) + markedRange = NSMakeRange(Int(begin), Int(end - begin)) + super.init(composingBuffer: composingBuffer, cursorIndex: cursorIndex, phrases: phrases) + } + + @objc var attributedString: NSAttributedString { + let attributedSting = NSMutableAttributedString(string: composingBuffer) + let end = markedRange.location + markedRange.length + + attributedSting.setAttributes([ + .underlineStyle: NSUnderlineStyle.single.rawValue, + .markedClauseSegment: 0 + ], range: NSRange(location: 0, length: markedRange.location)) + attributedSting.setAttributes([ + .underlineStyle: NSUnderlineStyle.thick.rawValue, + .markedClauseSegment: 1 + ], range: markedRange) + attributedSting.setAttributes([ + .underlineStyle: NSUnderlineStyle.single.rawValue, + .markedClauseSegment: 2 + ], range: NSRange(location: end, + length: (composingBuffer as NSString).length - end)) + return attributedSting + } + + override var description: String { + "" + } + + @objc func convertToInputting() -> Inputting { + let state = Inputting(composingBuffer: composingBuffer, cursorIndex: cursorIndex, phrases: phrases) + return state + } + + @objc var validToWrite: Bool { + markedRange.length >= kMinMarkRangeLength && markedRange.length <= kMaxMarkRangeLength + } + + @objc var userPhrase: String { + let text = (composingBuffer as NSString).substring(with: markedRange) + let end = markedRange.location + markedRange.length + var selectedPhrases = [InputPhrase]() + var length = 0 + for component in self.phrases { + if length >= end { + break + } + if length >= markedRange.location { + selectedPhrases.append(component) + } + length += (component.text as NSString).length + } + + let readings = selectedPhrases.map { $0.reading } + let joined = readings.joined(separator: "-") + return "\(text) \(joined)" + } + } + + /// 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 + + @objc init(composingBuffer: String, cursorIndex: UInt, candidates: [String], phrases: [InputPhrase], useVerticalMode: Bool) { + self.candidates = candidates + self.useVerticalMode = useVerticalMode + super.init(composingBuffer: composingBuffer, cursorIndex: cursorIndex, phrases: phrases) + } + + @objc var attributedString: NSAttributedString { + let attributedSting = NSAttributedString(string: composingBuffer, attributes: [ + .underlineStyle: NSUnderlineStyle.single.rawValue, + .markedClauseSegment: 0 + ]) + return attributedSting + } + + override var description: String { + "" + } + } + + /// 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 { + "" + } + } + } -/// Represents that the input controller is deactivated. -class InputStateDeactivated: InputState { - override var description: String { - "" - } +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() + } } -/// Represents that the composing buffer is empty. -class InputStateEmpty: InputState { - @objc var composingBuffer: String { - "" - } -} - -/// Represents that the composing buffer is empty. -class InputStateEmptyIgnoringPreviousState: InputState { - @objc var composingBuffer: String { - "" - } -} - -/// Represents that the input controller is committing text into client app. -class InputStateCommitting: InputState { - @objc private(set) var poppedText: String = "" - - @objc convenience init(poppedText: String) { - self.init() - self.poppedText = poppedText - } - - override var description: String { - "" - } -} - -/// Represents that the composing buffer is not empty. -class InputStateNotEmpty: InputState { - @objc private(set) var composingBuffer: String = "" - @objc private(set) var cursorIndex: UInt = 0 - - @objc init(composingBuffer: String, cursorIndex: UInt) { - self.composingBuffer = composingBuffer - self.cursorIndex = cursorIndex - } - - override var description: String { - "" - } -} - -/// Represents that the user is inputting text. -class InputStateInputting: InputStateNotEmpty { - @objc var bpmfReading: String = "" - @objc var bpmfReadingCursorIndex: UInt8 = 0 - @objc var poppedText: String = "" - - @objc override init(composingBuffer: String, cursorIndex: UInt) { - super.init(composingBuffer: composingBuffer, cursorIndex: cursorIndex) - } - - @objc var attributedString: NSAttributedString { - let attributedSting = NSAttributedString(string: composingBuffer, attributes: [ - .underlineStyle: NSUnderlineStyle.single.rawValue, - .markedClauseSegment: 0 - ]) - return attributedSting - } - - override var description: String { - "" - } -} - -private let kMinMarkRangeLength = 2 -private let kMaxMarkRangeLength = Preferences.maxCandidateLength - -/// Represents that the user is marking a range in the composing buffer. -class InputStateMarking: InputStateNotEmpty { - @objc private(set) var markerIndex: UInt - @objc private(set) var markedRange: NSRange - @objc var tooltip: String { - - if Preferences.phraseReplacementEnabled { - return NSLocalizedString("⚠︎ Phrase replacement mode enabled, interfering user phrase entry.", comment: "") - } - - if markedRange.length == 0 { - return "" - } - - let text = (composingBuffer as NSString).substring(with: markedRange) - if markedRange.length < kMinMarkRangeLength { - return String(format: NSLocalizedString("\"%@\" length must ≥ 2 for a user phrase.", comment: ""), text) - } else if (markedRange.length > kMaxMarkRangeLength) { - return String(format: NSLocalizedString("\"%@\" length should ≤ %d for a user phrase.", comment: ""), text, kMaxMarkRangeLength) - } - return String(format: NSLocalizedString("\"%@\" selected. ENTER to add user phrase.", comment: ""), text) - } - - @objc private(set) var readings: [String] = [] - - @objc init(composingBuffer: String, cursorIndex: UInt, markerIndex: UInt, readings: [String]) { - self.markerIndex = markerIndex - let begin = min(cursorIndex, markerIndex) - let end = max(cursorIndex, markerIndex) - markedRange = NSMakeRange(Int(begin), Int(end - begin)) - self.readings = readings - super.init(composingBuffer: composingBuffer, cursorIndex: cursorIndex) - } - - @objc var attributedString: NSAttributedString { - let attributedSting = NSMutableAttributedString(string: composingBuffer) - let end = markedRange.location + markedRange.length - - attributedSting.setAttributes([ - .underlineStyle: NSUnderlineStyle.single.rawValue, - .markedClauseSegment: 0 - ], range: NSRange(location: 0, length: markedRange.location)) - attributedSting.setAttributes([ - .underlineStyle: NSUnderlineStyle.thick.rawValue, - .markedClauseSegment: 1 - ], range: markedRange) - attributedSting.setAttributes([ - .underlineStyle: NSUnderlineStyle.single.rawValue, - .markedClauseSegment: 2 - ], range: NSRange(location: end, - length: composingBuffer.count - end)) - return attributedSting - } - - override var description: String { - "" - } - - @objc func convertToInputting() -> InputStateInputting { - let state = InputStateInputting(composingBuffer: composingBuffer, cursorIndex: cursorIndex) - return state - } - - @objc var validToWrite: Bool { - markedRange.length >= kMinMarkRangeLength && markedRange.length <= kMaxMarkRangeLength - } - - @objc var userPhrase: String { - let text = (composingBuffer as NSString).substring(with: markedRange) - let end = markedRange.location + markedRange.length - let readings = readings[markedRange.location.." - } +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[..setPhraseReplacementEnabled(Preferences.phraseReplacementEnabled); - _languageModel->setCNSEnabled(Preferences.cns11643Enabled); - _userOverrideModel = [LanguageModelManager userOverrideModelCHT]; - - _builder = new BlockReadingBuilder(_languageModel); + // create the lattice builder + _languageModel = [LanguageModelManager languageModelCoreCHT]; + _languageModel->setPhraseReplacementEnabled(Preferences.phraseReplacementEnabled); + _languageModel->setCNSEnabled(Preferences.cns11643Enabled); + _userOverrideModel = [LanguageModelManager userOverrideModelCHT]; + + _builder = new BlockReadingBuilder(_languageModel); // each Mandarin syllable is separated by a hyphen _builder->setJoinSeparator("-"); @@ -220,7 +220,7 @@ static NSString *const kGraphVizOutputfile = @"/tmp/vChewing-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 isOptionHold] || [input isNumericPad]) || [input isControlHotKey]; + BOOL isFunctionKey = ([input isCommandHold] || [input isOptionHold] || [input isNumericPad]) || [input isControlHotKey]; if (![state isKindOfClass:[InputStateNotEmpty class]] && isFunctionKey) { return NO; } @@ -282,7 +282,7 @@ static NSString *const kGraphVizOutputfile = @"/tmp/vChewing-visualization.dot"; // MARK: Handle BPMF Keys // see if it's valid BPMF reading - if (![input isControlHold] && _bpmfReadingBuffer->isValidKey((char) charCode)) { + if (![input isControlHold] && _bpmfReadingBuffer->isValidKey((char) charCode)) { _bpmfReadingBuffer->combineKey((char) charCode); // if we have a tone marker, we have to insert the reading to the @@ -364,14 +364,14 @@ static NSString *const kGraphVizOutputfile = @"/tmp/vChewing-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); - } - } - [self clear]; + if ([state isKindOfClass:[InputStateNotEmpty class]]) { + NSString *composingBuffer = [(InputStateNotEmpty *)state composingBuffer]; + if ([composingBuffer length]) { + InputStateCommitting *committing = [[InputStateCommitting alloc] initWithPoppedText:composingBuffer]; + stateCallback(committing); + } + } + [self clear]; InputStateCommitting *committing = [[InputStateCommitting alloc] initWithPoppedText:@" "]; stateCallback(committing); InputStateEmpty *empty = [[InputStateEmpty alloc] init]; @@ -457,15 +457,15 @@ static NSString *const kGraphVizOutputfile = @"/tmp/vChewing-visualization.dot"; // MARK: Punctuation // if nothing is matched, see if it's a punctuation key for current layout. - string punctuationNamePrefix; - if ([input isControlHold]) { - punctuationNamePrefix = string("_ctrl_punctuation_"); - } else if (Preferences.halfWidthPunctuationEnabled) { - punctuationNamePrefix = string("_half_punctuation_"); - } else { - punctuationNamePrefix = string("_punctuation_"); - } - string layout = [self _currentLayout]; + string punctuationNamePrefix; + if ([input isControlHold]) { + punctuationNamePrefix = string("_ctrl_punctuation_"); + } else if (Preferences.halfWidthPunctuationEnabled) { + punctuationNamePrefix = string("_half_punctuation_"); + } else { + punctuationNamePrefix = string("_punctuation_"); + } + string layout = [self _currentLayout]; string customPunctuation = punctuationNamePrefix + layout + string(1, (char) charCode); if ([self _handlePunctuation:customPunctuation state:state usingVerticalMode:input.useVerticalMode stateCallback:stateCallback errorCallback:errorCallback]) { return YES; @@ -543,8 +543,9 @@ static NSString *const kGraphVizOutputfile = @"/tmp/vChewing-visualization.dot"; if ([input isShiftHold]) { // Shift + left - if (_builder->cursorIndex() > 0) { - InputStateMarking *marking = [[InputStateMarking alloc] initWithComposingBuffer:currentState.composingBuffer cursorIndex:currentState.cursorIndex markerIndex:currentState.cursorIndex - 1 readings: [self _currentReadings]]; + 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 phrases:currentState.phrases]; stateCallback(marking); } else { errorCallback(); @@ -579,8 +580,9 @@ static NSString *const kGraphVizOutputfile = @"/tmp/vChewing-visualization.dot"; if ([input isShiftHold]) { // Shift + Right - if (_builder->cursorIndex() < _builder->length()) { - InputStateMarking *marking = [[InputStateMarking alloc] initWithComposingBuffer:currentState.composingBuffer cursorIndex:currentState.cursorIndex markerIndex:currentState.cursorIndex + 1 readings: [self _currentReadings]]; + 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 phrases:currentState.phrases]; stateCallback(marking); } else { errorCallback(); @@ -806,8 +808,8 @@ static NSString *const kGraphVizOutputfile = @"/tmp/vChewing-visualization.dot"; && ([input isShiftHold])) { NSUInteger index = state.markerIndex; if (index > 0) { - index -= 1; - InputStateMarking *marking = [[InputStateMarking alloc] initWithComposingBuffer:state.composingBuffer cursorIndex:state.cursorIndex markerIndex:index readings:state.readings]; + index = [StringUtils previousUtf16PositionForIndex:index in:state.composingBuffer]; + InputStateMarking *marking = [[InputStateMarking alloc] initWithComposingBuffer:state.composingBuffer cursorIndex:state.cursorIndex markerIndex:index phrases:state.phrases]; stateCallback(marking); } else { errorCallback(); @@ -821,8 +823,8 @@ static NSString *const kGraphVizOutputfile = @"/tmp/vChewing-visualization.dot"; && ([input isShiftHold])) { NSUInteger index = state.markerIndex; if (index < state.composingBuffer.length) { - index += 1; - InputStateMarking *marking = [[InputStateMarking alloc] initWithComposingBuffer:state.composingBuffer cursorIndex:state.cursorIndex markerIndex:index readings:state.readings]; + index = [StringUtils nextUtf16PositionForIndex:index in:state.composingBuffer]; + InputStateMarking *marking = [[InputStateMarking alloc] initWithComposingBuffer:state.composingBuffer cursorIndex:state.cursorIndex markerIndex:index phrases:state.phrases]; stateCallback(marking); } else { errorCallback(); @@ -844,7 +846,7 @@ static NSString *const kGraphVizOutputfile = @"/tmp/vChewing-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 (Preferences.useWinNT351BPMF) { @@ -1005,14 +1007,14 @@ static NSString *const kGraphVizOutputfile = @"/tmp/vChewing-visualization.dot"; if (Preferences.useWinNT351BPMF) { string layout = [self _currentLayout]; - string punctuationNamePrefix; - if ([input isControlHold]) { - punctuationNamePrefix = string("_ctrl_punctuation_"); - } else if (Preferences.halfWidthPunctuationEnabled) { - punctuationNamePrefix = string("_half_punctuation_"); - } else { - punctuationNamePrefix = string("_punctuation_"); - } + string punctuationNamePrefix; + if ([input isControlHold]) { + punctuationNamePrefix = string("_ctrl_punctuation_"); + } else if (Preferences.halfWidthPunctuationEnabled) { + punctuationNamePrefix = string("_half_punctuation_"); + } else { + punctuationNamePrefix = string("_punctuation_"); + } string customPunctuation = punctuationNamePrefix + layout + string(1, (char) charCode); string punctuation = punctuationNamePrefix + string(1, (char) charCode); @@ -1056,6 +1058,8 @@ static NSString *const kGraphVizOutputfile = @"/tmp/vChewing-visualization.dot"; size_t readingCursorIndex = 0; size_t builderCursorIndex = _builder->cursorIndex(); + NSMutableArray *phrases = [[NSMutableArray alloc] init]; + // 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 // locations @@ -1068,6 +1072,10 @@ static NSString *const kGraphVizOutputfile = @"/tmp/vChewing-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" @@ -1096,7 +1104,7 @@ static NSString *const kGraphVizOutputfile = @"/tmp/vChewing-visualization.dot"; NSString *composedText = [head stringByAppendingString:[reading stringByAppendingString:tail]]; NSInteger cursorIndex = composedStringCursorIndex + [reading length]; - InputStateInputting *newState = [[InputStateInputting alloc] initWithComposingBuffer:composedText cursorIndex:cursorIndex]; + InputStateInputting *newState = [[InputStateInputting alloc] initWithComposingBuffer:composedText cursorIndex:cursorIndex phrases:phrases]; return newState; } @@ -1166,7 +1174,7 @@ static NSString *const kGraphVizOutputfile = @"/tmp/vChewing-visualization.dot"; } } - InputStateChoosingCandidate *state = [[InputStateChoosingCandidate alloc] initWithComposingBuffer:currentState.composingBuffer cursorIndex:currentState.cursorIndex candidates:candidatesArray useVerticalMode:useVerticalMode]; + InputStateChoosingCandidate *state = [[InputStateChoosingCandidate alloc] initWithComposingBuffer:currentState.composingBuffer cursorIndex:currentState.cursorIndex candidates:candidatesArray phrases:currentState.phrases useVerticalMode:useVerticalMode]; return state; } diff --git a/Source/InputMethodController.mm b/Source/InputMethodController.mm index 53bb1143..8eb8fd6b 100644 --- a/Source/InputMethodController.mm +++ b/Source/InputMethodController.mm @@ -443,6 +443,10 @@ static inline NSString *LocalizationNotNeeded(NSString *s) { // some apps (e.g. Twitter for Mac's search bar) handle this call incorrectly, hence the try-catch @try { [client attributesForCharacterIndex:cursor lineHeightRectangle:&lineHeightRect]; + if ((lineHeightRect.origin.x == 0) && (lineHeightRect.origin.y == 0) && (cursor > 0)) { + cursor -= 1; // Zonble's UPR fix: "Corrects the selection range while using Shift + Arrow keys to add new phrases." + [client attributesForCharacterIndex:cursor lineHeightRectangle:&lineHeightRect]; + } } @catch (NSException *exception) { NSLog(@"lineHeightRectangle %@", exception); @@ -688,6 +692,10 @@ static inline NSString *LocalizationNotNeeded(NSString *s) { // some apps (e.g. Twitter for Mac's search bar) handle this call incorrectly, hence the try-catch @try { [client attributesForCharacterIndex:cursor lineHeightRectangle:&lineHeightRect]; + if ((lineHeightRect.origin.x == 0) && (lineHeightRect.origin.y == 0) && (cursor > 0)) { + cursor -= 1; // Zonble's UPR fix: "Corrects the selection range while using Shift + Arrow keys to add new phrases." + [client attributesForCharacterIndex:cursor lineHeightRectangle:&lineHeightRect]; + } } @catch (NSException *exception) { NSLog(@"%@", exception);