Merge pull request #16634 from keymanapp/fix/mac/erratic-backspace

fix(mac): apply backspace to correct location 🍎
This commit is contained in:
Marc Durdin 2026-10-06 06:17:20 +02:00 • committed by GitHub
commit 21e1e2e8b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 305 additions and 84 deletions

View file

@ -1 +1 @@
github "erikdoe/ocmock" "v3.9.1"
github "erikdoe/ocmock" "v3.9.4"

View file

@ -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 */ = {

View file

@ -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:

View file

@ -12,6 +12,14 @@
#import <Foundation/Foundation.h>
#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;

View file

@ -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:&currentSelection];
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<NSTextInputClient> 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 {

View file

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

View file

@ -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,

View file

@ -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 */ = {

View file

@ -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 {