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.
This commit is contained in:
ShikiSuen 2022-02-01 14:35:23 +08:00
parent a2792f6279
commit 3e561d215c
3 changed files with 320 additions and 217 deletions

View File

@ -40,31 +40,41 @@ 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.
class InputStateDeactivated: InputState {
/// Represents that the input controller is deactivated.
@objc (InputStateDeactivated)
class Deactivated: InputState {
override var description: String {
"<InputStateDeactivated>"
"<InputState.Deactivated>"
}
}
}
/// Represents that the composing buffer is empty.
class InputStateEmpty: InputState {
/// Represents that the composing buffer is empty.
@objc (InputStateEmpty)
class Empty: InputState {
@objc var composingBuffer: String {
""
}
}
/// Represents that the composing buffer is empty.
class InputStateEmptyIgnoringPreviousState: InputState {
override var description: String {
"<InputState.Empty>"
}
}
/// Represents that the composing buffer is empty.
@objc (InputStateEmptyIgnoringPreviousState)
class EmptyIgnoringPreviousState: InputState {
@objc var composingBuffer: String {
""
}
}
override var description: String {
"<InputState.EmptyIgnoringPreviousState>"
}
}
/// Represents that the input controller is committing text into client app.
class InputStateCommitting: InputState {
/// 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) {
@ -73,33 +83,34 @@ class InputStateCommitting: InputState {
}
override var description: String {
"<InputStateCommitting poppedText:\(poppedText)>"
"<InputState.Committing poppedText:\(poppedText)>"
}
}
}
/// 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]
/// 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) {
@objc init(composingBuffer: String, cursorIndex: UInt, phrases: [InputPhrase]) {
self.composingBuffer = composingBuffer
self.cursorIndex = cursorIndex
self.phrases = phrases
}
override var description: String {
"<InputStateNotEmpty, composingBuffer:\(composingBuffer), cursorIndex:\(cursorIndex)>"
"<InputState.NotEmpty, composingBuffer:\(composingBuffer), cursorIndex:\(cursorIndex), phrases:\(phrases)>"
}
}
}
/// Represents that the user is inputting text.
class InputStateInputting: InputStateNotEmpty {
@objc var bpmfReading: String = ""
@objc var bpmfReadingCursorIndex: UInt8 = 0
/// Represents that the user is inputting text.
@objc (InputStateInputting)
class Inputting: NotEmpty {
@objc var poppedText: String = ""
@objc override init(composingBuffer: String, cursorIndex: UInt) {
super.init(composingBuffer: composingBuffer, cursorIndex: cursorIndex)
@objc override init(composingBuffer: String, cursorIndex: UInt, phrases: [InputPhrase]) {
super.init(composingBuffer: composingBuffer, cursorIndex: cursorIndex, phrases: phrases)
}
@objc var attributedString: NSAttributedString {
@ -111,15 +122,17 @@ class InputStateInputting: InputStateNotEmpty {
}
override var description: String {
"<InputStateInputting, composingBuffer:\(composingBuffer), cursorIndex:\(cursorIndex), poppedText:\(poppedText)>"
"<InputState.Inputting, composingBuffer:\(composingBuffer), cursorIndex:\(cursorIndex), phrases:\(phrases)>, poppedText:\(poppedText)>"
}
}
}
private let kMinMarkRangeLength = 2
private let kMaxMarkRangeLength = Preferences.maxCandidateLength
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 {
/// 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 {
@ -141,15 +154,12 @@ class InputStateMarking: InputStateNotEmpty {
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]) {
@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))
self.readings = readings
super.init(composingBuffer: composingBuffer, cursorIndex: cursorIndex)
super.init(composingBuffer: composingBuffer, cursorIndex: cursorIndex, phrases: phrases)
}
@objc var attributedString: NSAttributedString {
@ -168,16 +178,16 @@ class InputStateMarking: InputStateNotEmpty {
.underlineStyle: NSUnderlineStyle.single.rawValue,
.markedClauseSegment: 2
], range: NSRange(location: end,
length: composingBuffer.count - end))
length: (composingBuffer as NSString).length - end))
return attributedSting
}
override var description: String {
"<InputStateMarking, composingBuffer:\(composingBuffer), cursorIndex:\(cursorIndex), markedRange:\(markedRange), readings:\(readings)>"
"<InputState.Marking, composingBuffer:\(composingBuffer), cursorIndex:\(cursorIndex), markedRange:\(markedRange), phrases:\(phrases)>"
}
@objc func convertToInputting() -> InputStateInputting {
let state = InputStateInputting(composingBuffer: composingBuffer, cursorIndex: cursorIndex)
@objc func convertToInputting() -> Inputting {
let state = Inputting(composingBuffer: composingBuffer, cursorIndex: cursorIndex, phrases: phrases)
return state
}
@ -188,21 +198,34 @@ class InputStateMarking: InputStateNotEmpty {
@objc var userPhrase: String {
let text = (composingBuffer as NSString).substring(with: markedRange)
let end = markedRange.location + markedRange.length
let readings = readings[markedRange.location..<end]
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.
class InputStateChoosingCandidate: InputStateNotEmpty {
@objc private(set) var candidates: [String] = []
@objc private(set) var useVerticalMode: Bool = false
/// 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], 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)
super.init(composingBuffer: composingBuffer, cursorIndex: cursorIndex, phrases: phrases)
}
@objc var attributedString: NSAttributedString {
@ -214,6 +237,70 @@ class InputStateChoosingCandidate: InputStateNotEmpty {
}
override var description: String {
"<InputStateChoosingCandidate, candidates:\(candidates), useVerticalMode:\(useVerticalMode), composingBuffer:\(composingBuffer), cursorIndex:\(cursorIndex)>"
"<InputState.ChoosingCandidate, candidates:\(candidates), useVerticalMode:\(useVerticalMode), phrases:\(phrases), composingBuffer:\(composingBuffer), cursorIndex:\(cursorIndex)>"
}
}
/// 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)>"
}
}
}
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

@ -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();
@ -1056,6 +1058,8 @@ static NSString *const kGraphVizOutputfile = @"/tmp/vChewing-visualization.dot";
size_t readingCursorIndex = 0;
size_t builderCursorIndex = _builder->cursorIndex();
NSMutableArray <InputPhrase *> *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;
}

View File

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