diff --git a/mac/Cartfile.resolved b/mac/Cartfile.resolved index c743c88996..7a3ac4d400 100644 --- a/mac/Cartfile.resolved +++ b/mac/Cartfile.resolved @@ -1 +1 @@ -github "erikdoe/ocmock" "v3.9.1" +github "erikdoe/ocmock" "v3.9.4" diff --git a/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj b/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj index 80cb2a72ba..57ae8c510e 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj +++ b/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj @@ -731,7 +731,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# check that our PRODUCT_VERSION matches this git branch's KEYMAN_VERSION from ../../VERSION.md\n(cd .. ; ./is_same_version.sh Keyman4MacIM || exit 1 ; cd -)\n"; + shellScript = "# check that our PRODUCT_VERSION matches this git branch's KEYMAN_VERSION from ../../VERSION.md\n# TODO: use xcode-wrap.sh (follow-up in #16712)\nexport PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\necho \"BASH version inside Xcode is: $BASH_VERSION\"\n(cd .. ; ./is_same_version.sh Keyman4MacIM || exit 1 ; cd -)\n"; showEnvVarsInLog = 0; }; D2FF67BB6C749BA6861F5E25 /* [CP] Check Pods Manifest.lock */ = { diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m index a4751e31ac..2d5ab6aa51 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m @@ -334,7 +334,7 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef NSEvent* sysEvent = [NSEvent eventWithCGEvent:event]; // Too many of these to be useful for most debugging sessions, but we'll keep this around to be // un-commented when needed. - os_log_debug([KMLogs keyTraceLog], "System Event: %{public}@", sysEvent); +// os_log_debug([KMLogs keyTraceLog], "System Event: %{public}@", sysEvent); switch (type) { case kCGEventFlagsChanged: diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.h b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.h index d836551191..0729b6ce5b 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.h +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.h @@ -12,6 +12,14 @@ #import #import "KMInputMethodAppDelegate.h" +// used to perform delete by inserting with replacement +typedef struct { + BOOL canDeleteWithReplacement; + NSUInteger replacementLocation; + NSUInteger replacementLength; + NSString *replacementString; +} ReplacementInfo; + @interface KMInputMethodEventHandler : NSObject - (instancetype)initWithClient:(NSString *)clientAppId client:(id)sender; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 9cf29e5b89..50ee8313cf 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -37,7 +37,9 @@ CGKeyCode _keyCodeOfOriginalEvent; CGEventSourceRef _sourceFromOriginalEvent = nil; CGEventSourceRef _sourceForGeneratedEvent = nil; -// This is the public initializer. +/** + * The public initializer + */ - (instancetype)initWithClient:(NSString *)clientAppId client:(id) sender { self = [super init]; if(self) { @@ -109,7 +111,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; } output = [self processEventWithKeymanEngine:event in:sender]; - os_log_debug([KMLogs keyTraceLog], "keyman engine output: %{public}@", output); + os_log_debug([KMLogs keyTraceLog], "πŸ…°οΈ keyman engine output: %{public}@", output); if (output == nil) { return NO; @@ -331,52 +333,69 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; // if we can read the text, then get the context and send it to Core // we do this whether the context has changed or not if (self.apiCompliance.canReadText) { - contextString = [self readContext:event forClient:client]; - os_log_debug([KMLogs keyTraceLog], "reportContext, setting new context for compliant app (if needed)"); + contextString = [self readContext:client outSelection:NULL]; [self.kme setCoreContextIfNeeded:contextString]; - } else if (self.contextChanged) { + + // the context is now current, reset the flag + self.contextChanged = NO; + return; + } + + if (self.contextChanged) { // we cannot read the text but know the context has changed, so we must clear it os_log_debug([KMLogs keyTraceLog], "reportContext, clearing context for non-compliant app"); [self.kme clearCoreContext]; + + // the context is now current, reset the flag + self.contextChanged = NO; } - - // the context is now current, reset the flag - self.contextChanged = NO; } --(NSString*)readContext:(NSEvent *)event forClient:(id) client { +/** + * Returns the current context string up to kMaxContext characters + * Also returns the results of `[client selectedRange]` in the`selection` pointer + */ + +-(NSString*)readContext:(id) client outSelection:(NSRangePointer) outSelection { NSString *contextString = @""; NSAttributedString *attributedString = nil; + NSRange selectionRange = {0}; + if (!self.apiCompliance.canReadText) { + os_log_debug([KMLogs keyTraceLog], "InputMethodEventHandler readContext, unable to read context: non-compliant app"); + return contextString; + } + // if we can read the text, then get the context for up to kMaxContext characters - if (self.apiCompliance.canReadText) { - NSRange selectionRange = [client selectedRange]; - os_log_debug([KMLogs eventsLog], "InputMethodEventHandler readContext, canReadText: true selectionRange %{public}@: ", NSStringFromRange(selectionRange)); - - NSUInteger contextLength = MIN(kMaxContext, selectionRange.location); - NSUInteger contextStart = selectionRange.location - contextLength; - - if (contextLength > 0) { - NSRange contextRange = NSMakeRange(contextStart, contextLength); - os_log_debug([KMLogs eventsLog], " contextRange %{public}@: client: %p", NSStringFromRange(contextRange), client); - attributedString = [client attributedSubstringFromRange:contextRange]; + selectionRange = [client selectedRange]; + os_log_debug([KMLogs keyTraceLog], "InputMethodEventHandler readContext, selectionRange %{public}@: ", NSStringFromRange(selectionRange)); - // adjust string in case that we receive half of a surrogate pair at context start - // the API appears to always return a full code point, but this could vary by app - if (attributedString.length > 0) { - if (CFStringIsSurrogateLowCharacter([attributedString.string characterAtIndex:0])) { - os_log_debug([KMLogs eventsLog], " first char is low surrogate, reducing context by one character"); - contextString = [attributedString.string substringFromIndex:1]; - } else { - contextString = attributedString.string; - - //only uncomment for testing as we do not want to write context in logs - //os_log_debug([KMLogs keyTraceLog], " length: %lu result: %{public}@", contextString.length, contextString); - } + NSUInteger contextLength = MIN(kMaxContext, selectionRange.location); + NSUInteger contextStart = selectionRange.location - contextLength; + + if (contextLength > 0) { + NSRange contextRange = NSMakeRange(contextStart, contextLength); + attributedString = [client attributedSubstringFromRange:contextRange]; + + // adjust string in case that we receive half of a surrogate pair at context start + // the API appears to always return a full code point, but this could vary by app + if (attributedString.length > 0) { + if (CFStringIsSurrogateLowCharacter([attributedString.string characterAtIndex:0])) { + os_log_debug([KMLogs keyTraceLog], " first char is low surrogate, reducing context by one character"); + contextString = [attributedString.string substringFromIndex:1]; + } else { + contextString = attributedString.string; + + //only uncomment for testing as we do not want to write context in logs + //os_log_debug([KMLogs keyTraceLog], " length: %lu result: %{public}@", contextString.length, contextString); } } } + // return the range of the current selection + if (outSelection != NULL) { + *outSelection = selectionRange; + } return contextString; } @@ -508,6 +527,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; * 3. generate event: generate keydown backspace events as necessary */ -(BOOL)handleDeleteOnlyScenario:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client { + os_log_debug([KMLogs keyTraceLog], "πŸ”™ handleDeleteOnlyScenario"); // attempt to delete by replacing -- for compliant apps only if ([self handleDeleteWithReplacement:output keyDownEvent:event client:client]) { @@ -566,35 +586,51 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; * Ref: https://developer.apple.com/documentation/appkit/nstextinputclient/inserttext(_:replacementrange:) */ -(BOOL)handleDeleteWithReplacement:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client { - BOOL handledEvent = NO; - NSString *context = [self readContext:event forClient:client]; - - // guard: only for compliant apps with sufficient context - if (!(self.apiCompliance.canReplaceText) || ([context length] <= output.textToDelete.length)) { - os_log_debug([KMLogs keyTraceLog], "cannot replace text, non-compliant or insufficient context"); + // guard: only for compliant apps + if (!self.apiCompliance.canReadText) { + os_log_debug([KMLogs keyTraceLog], "cannot replace text, non-compliant"); return NO; // return without deleting/replacing } - + + NSRange currentSelection = {0}; + NSString *context = [self readContext:client outSelection:¤tSelection]; + os_log_debug([KMLogs keyTraceLog], "handleDeleteWithReplacement, currentSelection: %{public}@: ", NSStringFromRange(currentSelection)); + + // guard: if text is currently selected, then accept default delete behavior of removing selected text + if (currentSelection.length > 0) { + os_log_debug([KMLogs keyTraceLog], "text is currently selected, accept default system handling to delete it"); + return NO; // return without deleting/replacing + } + + // guard: must have sufficient context + if ([context length] <= output.textToDelete.length) { + os_log_debug([KMLogs keyTraceLog], "cannot replace text, insufficient context"); + return NO; // return without deleting/replacing + } + // guard: the logic of this method depends on locating textToDelete in the context if (![self stringToDeleteMatchesContextSuffix:output.textToDelete context:context]) { os_log_debug([KMLogs keyTraceLog], "cannot replace text, textToDelete not found at end of context"); return NO; // return without deleting/replacing } + // the length of the text we are to delete NSUInteger deletionTargetLength = output.textToDelete.length; - NSUInteger deletionTargetLocation = context.length-deletionTargetLength; - NSUInteger precedingCharacterLocation = deletionTargetLocation - 1; + // the location of the text we are to delete + NSUInteger locationOfDeletionTarget = context.length-deletionTargetLength; - // if the preceding character is the trailing half of a surrogate pair - // then delete by replacing with the entire surrogate pair - if ([self precededBySurrogatePair:precedingCharacterLocation context:context]) { - handledEvent = [self deleteByReplacingWithPrecedingSurrogate:precedingCharacterLocation deleteLength:deletionTargetLength context:context client:client]; - } else { - // otherwise replace with only the preceding character - handledEvent = [self deleteByReplacingWithPrecedingCharacter:precedingCharacterLocation deleteLength:deletionTargetLength context:context client:client]; + ReplacementInfo replacementInfo = [self evaluateForReplaceability: context textStoreLocation:currentSelection.location deleteLength:deletionTargetLength locationOfDeletionTarget:locationOfDeletionTarget]; + + if (replacementInfo.canDeleteWithReplacement) { + id textInputClient = (id)client; + NSRange replacementRange = NSMakeRange(replacementInfo.replacementLocation, replacementInfo.replacementLength); + + // perform the delete with replacement + os_log_debug([KMLogs keyTraceLog], "insertText with replacementString: %{public}@, replacementRange.location: %lu, replacementRange.length: %lu", replacementInfo.replacementString, replacementRange.location, replacementRange.length); + [textInputClient insertText:replacementInfo.replacementString replacementRange:replacementRange]; } - - return handledEvent; + + return replacementInfo.canDeleteWithReplacement; } /** @@ -616,6 +652,35 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; return doesMatch; } +/** + * Evaluate whether the target can be deleted with a replacement, and, if it can, include the information + * that describes how to perform the replacement. + */ +- (ReplacementInfo)evaluateForReplaceability:(NSString*) context textStoreLocation:(NSUInteger)textStoreLocation deleteLength:(NSUInteger)deleteLength locationOfDeletionTarget:(NSUInteger)deletionLocation { + + ReplacementInfo replacementInfo; + + NSUInteger precedingLocation = deletionLocation - 1; + os_log_debug([KMLogs keyTraceLog], "evaluateForReplaceability of deleteLength: %lu at precedingLocation: %lu", (unsigned long)deleteLength, (unsigned long)precedingLocation); + + // if the preceding character is the trailing half of a surrogate pair + // then delete by replacing with the entire surrogate pair + if ([self precededBySurrogatePair:precedingLocation context:context]) { + replacementInfo = [self determineReplacementInfoWithPrecedingSurrogate:precedingLocation textStoreLocation:textStoreLocation deleteLength:deleteLength context:context]; + } else { + // otherwise replace with only the preceding character + replacementInfo = [self determineReplacementInfoWithPrecedingCharacter:precedingLocation textStoreLocation:textStoreLocation deleteLength:deleteLength context:context]; + } + + if (replacementInfo.canDeleteWithReplacement) { + os_log_debug([KMLogs keyTraceLog], "⭐️ evaluateForReplaceability, canDeleteWithReplacement = YES, replacementString = %{public}@, replacementLength: %lu, replacementLocation: %lu", replacementInfo.replacementString, replacementInfo.replacementLength, replacementInfo.replacementLocation); + } else { + os_log_debug([KMLogs keyTraceLog], "evaluateForReplaceability, canDeleteWithReplacement = NO"); + } + + return replacementInfo; +} + /** * Check whether the preceding character, which is to be used for the replacement, * is part of a surrogate pair that is distinct from the character being deleted. @@ -640,49 +705,53 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; } /** - * Replace both the text to delete and the character preceding it solely with the character that precedes it. - * Returns YES if executing the replace/delete and NO otherwise. + * Determine the replacement info using the character preceding the string to be deleted. */ --(BOOL) deleteByReplacingWithPrecedingCharacter:(NSUInteger)precedingCharacterLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client { - - os_log_debug([KMLogs keyTraceLog], "deleteByReplacingWithPrecedingCharacter"); - // get the preceding character +-(ReplacementInfo) determineReplacementInfoWithPrecedingCharacter:(NSUInteger)precedingCharacterLocation textStoreLocation:(NSUInteger)textStoreLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context { + ReplacementInfo replacementInfo = {NO, 0, 0, NULL}; + + // get the preceding character from the context NSRange precedingCharacterRange = NSMakeRange(precedingCharacterLocation, 1); NSString *replacementString = [context substringWithRange:precedingCharacterRange]; - + + os_log_debug([KMLogs keyTraceLog], "deleteByReplacingWithPrecedingCharacter, precedingCharacterLocation = %lu, textStoreLocation = %lu, replacementString = %{public}@", precedingCharacterLocation, textStoreLocation, replacementString); + // guard: if preceding character is a control character, return NO if ([self containsControlCharacter:replacementString]) { NSString *message = @"replacementString contains control characters, cannot delete with replace"; os_log_debug([KMLogs keyTraceLog], "%@", message); [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; - return NO; + return replacementInfo; } - // perform the replacement + // the length of the text to replace = the length of the replacement string + length of the string to delete NSUInteger replacementLength = [replacementString length] + deleteLength; - NSRange replacementRange = NSMakeRange(precedingCharacterLocation, replacementLength); - os_log_debug([KMLogs keyTraceLog], "replacementRange: %{public}@", NSStringFromRange(replacementRange)); - [client insertText:replacementString replacementRange:replacementRange]; + // the location of the replacement is the current location in the text store + // minus the length of the text we are deleting + // minus the length of the preceding character (the one that makes this a replacement) + NSUInteger replacementLocation = textStoreLocation - replacementLength; - return YES; + replacementInfo.canDeleteWithReplacement = YES; + replacementInfo.replacementString = replacementString; + replacementInfo.replacementLength = replacementLength; + replacementInfo.replacementLocation = replacementLocation; + return replacementInfo; } /** - * Replace both the text to delete and the surrogate pair preceding it with the surrogate pair preceding it. - * Returns YES if executing the replace/delete and NO otherwise. + * Determine the replacement info using the surrogate pair preceding the string to be deleted. */ --(BOOL) deleteByReplacingWithPrecedingSurrogate:(NSUInteger)precedingCharacterLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client { - - os_log_debug([KMLogs keyTraceLog], "deleteByReplacingWithPrecedingSurrogate"); +-(ReplacementInfo) determineReplacementInfoWithPrecedingSurrogate:(NSUInteger)precedingCharacterLocation textStoreLocation:(NSUInteger)textStoreLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context { + ReplacementInfo replacementInfo = {NO, 0, 0, NULL}; // guard: return NO if there is no character before the precedingCharacterLocation if (precedingCharacterLocation <= 0) { NSString *message = @"no characters exist before precedingCharacterLocation, so it cannot be a surrogate pair"; os_log_debug([KMLogs keyTraceLog], "%@", message); - return NO; + return replacementInfo; } - // get the preceding character + // get the preceding character from the context NSRange precedingCharacterRange = NSMakeRange(precedingCharacterLocation - 1, 2); NSString *replacementString = [context substringWithRange:precedingCharacterRange]; @@ -694,18 +763,24 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; // found preceding surrogate as expected } else { NSString *message = [NSString stringWithFormat:@"Preceding characters of string do not comprise a surrogate pair: 0x%02x, 0x%02x", (unsigned int)highCharacter, (unsigned int)lowCharacter]; - os_log_debug([KMLogs keyTraceLog], "%@", message); + os_log_error([KMLogs keyTraceLog], "%@", message); [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; - return NO; + return replacementInfo; } - // perform the replacement + // the length of the text to replace = the length of the replacement string + length of the string to delete NSUInteger replacementLength = [replacementString length] + deleteLength; - NSRange replacementRange = NSMakeRange(precedingCharacterRange.location, replacementLength); - os_log_debug([KMLogs keyTraceLog], "replacementRange: %{public}@", NSStringFromRange(replacementRange)); - [client insertText:replacementString replacementRange:replacementRange]; + // the location of the replacement is the current location in the text store + // minus the length of the text we are deleting + // minus the length of the preceding character (the one that makes this a replacement) + NSUInteger replacementLocation = textStoreLocation - replacementLength; - return YES; + replacementInfo.canDeleteWithReplacement = YES; + replacementInfo.replacementString = replacementString; + replacementInfo.replacementLength = replacementLength; + replacementInfo.replacementLocation = replacementLocation; + + return replacementInfo; } -(BOOL) containsControlCharacter:(NSString*)text { diff --git a/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.m b/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.m index a5e04e6e09..34a0edecbb 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.m @@ -377,8 +377,12 @@ NSString *const kKMLegacyApps = @"KMLegacyApps"; [clientAppId isEqual: @"com.google.Chrome"] || // 2023-12-13[sgs]: must hard-code for Chrome because Google Docs returns relative but incorrect location so no way to auto-detect [clientAppId hasPrefix: @"net.java"] || - [clientAppId isEqual: @"com.Keyman.test.legacyInput"] + [clientAppId isEqual: @"com.Keyman.test.legacyInput"] || /*||[clientAppId isEqual: @"ro.sync.exml.Oxygen"] - Oxygen has worse problems */ + [clientAppId isEqual: @"com.tinyspeck.slackmacgap"] || + // #16731 2026-09-24[sgs]: Slack does double backspace when inserting with replacement + [clientAppId isEqual: @"com.microsoft.VSCode"] + // #16731 2026-09-24[sgs]: VSCode does double backspace when inserting with replacement ); os_log_debug([KMLogs complianceLog], "containedInHardCodedNoncompliantAppList: for app %{public}@: %{public}@", clientAppId, isAppNonCompliant?@"yes":@"no"); diff --git a/mac/Keyman4MacIM/KeymanTests/InputMethodTests.m b/mac/Keyman4MacIM/KeymanTests/InputMethodTests.m index 1f599e51c6..70ea63db2a 100644 --- a/mac/Keyman4MacIM/KeymanTests/InputMethodTests.m +++ b/mac/Keyman4MacIM/KeymanTests/InputMethodTests.m @@ -23,6 +23,7 @@ id testClient = nil; @interface InputMethodTests : XCTestCase @end +// redeclare private properties and functions to expose for testing @interface KMInputMethodEventHandler (Testing) @property (nonatomic, retain) TextApiCompliance* apiCompliance; @property (nonatomic, retain) NSString* clientApplicationId; @@ -30,7 +31,10 @@ id testClient = nil; - (instancetype)initWithClient:(NSString *)clientAppId client:(id) sender; - (NSRange) calculateInsertRangeForDeletedText:(NSString*)textToDelete selectionRange:(NSRange) selection; - (void)checkTextApiCompliance:(id)client; - +- (ReplacementInfo)evaluateForReplaceability:(NSString*) context + textStoreLocation:(NSUInteger)textStoreLocation + deleteLength:(NSUInteger)deleteLength + locationOfDeletionTarget:(NSUInteger)deletionLocation; @end @implementation InputMethodTests @@ -92,6 +96,134 @@ id testClient = nil; XCTAssertTrue(correctResult, @"insert or replacement range expected to be {1,2}"); } +- (void)testEvaluateReplacement_precededByControlCharacter_canReplaceFalse { + NSString *context = @"test\ta"; + NSUInteger storeLocation = 100; + NSUInteger deletionLength = 1; + NSUInteger locationOfDeletion = 5; + + ReplacementInfo replacementInfo = [testEventHandler evaluateForReplaceability: context textStoreLocation:storeLocation deleteLength:deletionLength locationOfDeletionTarget:locationOfDeletion]; + XCTAssertFalse(replacementInfo.canDeleteWithReplacement, @"target preceded by control character, canDelete should be false"); +} + +- (void)testEvaluateReplacement_precededByLatinCharacter_canReplaceTrue { + NSString *context = @"testxa"; + NSUInteger storeLocation = 100; + NSUInteger deletionLength = 1; + NSUInteger locationOfDeletion = 5; + + ReplacementInfo replacementInfo = [testEventHandler evaluateForReplaceability: context textStoreLocation:storeLocation deleteLength:deletionLength locationOfDeletionTarget:locationOfDeletion]; + + XCTAssertTrue([replacementInfo.replacementString isEqualToString:@"x"], @"replacementString should be == 'x'"); + XCTAssertTrue(replacementInfo.replacementLength == 2, @"replacementLength should be 2"); +} + +- (void)testEvaluateReplacement_lowStoreLocation_correctReplacementLocation { + NSString *context = @"testxa"; + NSUInteger storeLocation = 10; + NSUInteger deletionLength = 1; + NSUInteger locationOfDeletion = 5; + + ReplacementInfo replacementInfo = [testEventHandler evaluateForReplaceability: context textStoreLocation:storeLocation deleteLength:deletionLength locationOfDeletionTarget:locationOfDeletion]; + + XCTAssertTrue(replacementInfo.replacementLocation==8, @"replacementLocation should be 8"); +} + +- (void)testEvaluateReplacement_highStoreLocation_correctReplacementLocation { + NSString *context = @"testxa"; + NSUInteger storeLocation = 1000; + NSUInteger deletionLength = 1; + NSUInteger locationOfDeletion = 5; + + ReplacementInfo replacementInfo = [testEventHandler evaluateForReplaceability: context textStoreLocation:storeLocation deleteLength:deletionLength locationOfDeletionTarget:locationOfDeletion]; + + XCTAssertTrue(replacementInfo.replacementLocation==998, @"replacementLocation should be 998"); +} + +// Uses Malar Tirhuta script +- (void)testEvaluateReplacement_latinCharacterPrecededBySurrogatePair_replacementLengthThree { + NSString *context = @"test.𑒏a"; // 𑒏 : U+1148F : TIRHUTA LETTER + NSString *stringToDelete = @"a"; + + NSRange range = [context rangeOfString:stringToDelete]; + + NSUInteger storeLocation = 100; + NSUInteger deletionLength = stringToDelete.length; + NSUInteger locationOfDeletion = range.location; + + ReplacementInfo replacementInfo = [testEventHandler evaluateForReplaceability: context textStoreLocation:storeLocation deleteLength:deletionLength locationOfDeletionTarget:locationOfDeletion]; + + XCTAssertTrue([replacementInfo.replacementString isEqualToString:@"𑒏"], @"replacementString is incorrect"); + XCTAssertTrue(replacementInfo.replacementLength == 3, @"replacementLength should == 3"); +} + +- (void)testEvaluateReplacement_surrogatePairPrecededByLatinCharacter_replacementLengthThree { + NSString *context = @"test.a𑒏"; // 𑒏 : U+1148F : TIRHUTA LETTER + NSString *stringToDelete = @"𑒏"; + + NSRange range = [context rangeOfString:stringToDelete]; + + NSUInteger storeLocation = 100; + NSUInteger deletionLength = stringToDelete.length; + NSUInteger locationOfDeletion = range.location; + + ReplacementInfo replacementInfo = [testEventHandler evaluateForReplaceability: context textStoreLocation:storeLocation deleteLength:deletionLength locationOfDeletionTarget:locationOfDeletion]; + + XCTAssertTrue([replacementInfo.replacementString isEqualToString:@"a"], @"replacementString is incorrect"); + XCTAssertTrue(replacementInfo.replacementLength == 3, @"replacementLength should == 3"); +} + +- (void)testEvaluateReplacement_latinCharacterPrecededByEmoji_replacementLengthThree { + NSString *context = @"test🏁a"; + NSString *stringToDelete = @"a"; + + NSRange range = [context rangeOfString:stringToDelete]; + + NSUInteger storeLocation = 100; + NSUInteger deletionLength = stringToDelete.length; + NSUInteger locationOfDeletion = range.location; + + ReplacementInfo replacementInfo = [testEventHandler evaluateForReplaceability: context textStoreLocation:storeLocation deleteLength:deletionLength locationOfDeletionTarget:locationOfDeletion]; + + XCTAssertTrue([replacementInfo.replacementString isEqualToString:@"🏁"], @"replacementString should be == '🏁'"); + XCTAssertTrue(replacementInfo.replacementLength == 3, @"replacementLength should == 3"); +} + +- (void)testEvaluateReplacement_emojiPrecededByLatinCharacter_replacementLengthThree { + NSString *context = @"testa🏁"; + NSString *stringToDelete = @"🏁"; + + NSRange range = [context rangeOfString:stringToDelete]; + + NSUInteger storeLocation = 100; + NSUInteger deletionLength = stringToDelete.length; + NSUInteger locationOfDeletion = range.location; + + ReplacementInfo replacementInfo = [testEventHandler evaluateForReplaceability: context textStoreLocation:storeLocation deleteLength:deletionLength locationOfDeletionTarget:locationOfDeletion]; + + XCTAssertTrue([replacementInfo.replacementString isEqualToString:@"a"], @"replacementString should be == 'a'"); + XCTAssertTrue(replacementInfo.replacementLength == 3, @"replacementLength should == 3"); +} + +// Note: though this test checks the calculation, deleting the jolly roger emoji will currently not behave as expected. +// Keyman core instructs only part of the emoji to be deleted. The same happens with emoji with skin tones. +// See #4956 for context +- (void)testEvaluateReplacement_jollyRogerPrecededByLatinCharacter_replacementLengthSix { + NSString *context = @"testaπŸ΄β€β˜ οΈ"; + NSString *stringToDelete = @"πŸ΄β€β˜ οΈ"; + + NSRange range = [context rangeOfString:stringToDelete]; + + NSUInteger storeLocation = 100; + NSUInteger deletionLength = stringToDelete.length; + NSUInteger locationOfDeletion = range.location; + + ReplacementInfo replacementInfo = [testEventHandler evaluateForReplaceability: context textStoreLocation:storeLocation deleteLength:deletionLength locationOfDeletionTarget:locationOfDeletion]; + + XCTAssertTrue([replacementInfo.replacementString isEqualToString:@"a"], @"replacementString should be == 'a'"); + XCTAssertTrue(replacementInfo.replacementLength == 6, @"replacementLength should == 6"); +} + /** * test compliance check of a KMInputMethodEventHandler with a nil client application ID * not sure if this can ever happen, but the lifecycle of the input method is not 100% clear, diff --git a/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj b/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj index 98997bb8ac..a071786c8f 100644 --- a/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj +++ b/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj @@ -473,7 +473,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# check that our PRODUCT_VERSION matches this git branch's KEYMAN_VERSION from ../../VERSION.md\n(cd .. ; ./is_same_version.sh KeymanEngine4Mac || exit 1 ; cd -)\n"; + shellScript = "# check that our PRODUCT_VERSION matches this git branch's KEYMAN_VERSION from ../../VERSION.md\n# TODO: use xcode-wrap.sh (follow-up in #16712)\nexport PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\necho \"BASH version inside Xcode is: $BASH_VERSION\"\n(cd .. ; ./is_same_version.sh KeymanEngine4Mac || exit 1 ; cd -)\n"; showEnvVarsInLog = 0; }; E2588F2F1F2647A50061C9C4 /* ShellScript */ = { diff --git a/mac/KeymanEngine4Mac/KeymanEngine4Mac/CoreWrapper/CoreWrapper.m b/mac/KeymanEngine4Mac/KeymanEngine4Mac/CoreWrapper/CoreWrapper.m index ed2227f318..fde762b2ac 100644 --- a/mac/KeymanEngine4Mac/KeymanEngine4Mac/CoreWrapper/CoreWrapper.m +++ b/mac/KeymanEngine4Mac/KeymanEngine4Mac/CoreWrapper/CoreWrapper.m @@ -275,7 +275,9 @@ const int CORE_ENVIRONMENT_ARRAY_LENGTH = 6; -(void)setContextIfNeeded:(NSString*)context { unichar const * unicharContext = [self.coreHelper createUnicharStringFromNSString:context]; km_core_status result = km_core_state_context_set_if_needed(self.coreState, unicharContext); - os_log_debug([KMELogs coreLog], "setContextIfNeeded, context=%{public}@, km_core_state_context_set_if_needed result=%d", context, result); + + // only uncomment for debugging to avoid logging context + //os_log_debug([KMELogs coreLog], "setContextIfNeeded, context=%{public}@, km_core_state_context_set_if_needed result=%d", context, result); } -(NSString*)contextDebug {