From cc054ccb0ec6315860e8be1dead643b79c0476bc Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Tue, 22 Sep 2026 09:12:08 -0400 Subject: [PATCH 1/8] fix(mac): apply insert with replace to correct location --- .../Keyman4MacIM/KMInputMethodEventHandler.m | 116 +++++++++++++----- 1 file changed, 83 insertions(+), 33 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 9cf29e5b89..263416f341 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -327,11 +327,12 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; */ -(void)reportContext:(NSEvent *)event forClient:(id) client { NSString *contextString = nil; + NSRange currentSelection; // 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]; + contextString = [self readContext:client at:¤tSelection]; os_log_debug([KMLogs keyTraceLog], "reportContext, setting new context for compliant app (if needed)"); [self.kme setCoreContextIfNeeded:contextString]; } else if (self.contextChanged) { @@ -344,28 +345,36 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; 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 at:(NSRange *) selectionRange { NSString *contextString = @""; NSAttributedString *attributedString = nil; // 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)); + if (selectionRange != NULL) { + *selectionRange = [client selectedRange]; + } - NSUInteger contextLength = MIN(kMaxContext, selectionRange.location); - NSUInteger contextStart = selectionRange.location - contextLength; + os_log_debug([KMLogs keyTraceLog], "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); + os_log_debug([KMLogs keyTraceLog], " contextRange %{public}@: client: %p", NSStringFromRange(contextRange), client); 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 eventsLog], " first char is low surrogate, reducing context by one character"); + os_log_debug([KMLogs keyTraceLog], " first char is low surrogate, reducing context by one character"); contextString = [attributedString.string substringFromIndex:1]; } else { contextString = attributedString.string; @@ -567,31 +576,55 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; */ -(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"); + NSRange currentSelection; + NSString *context = [self readContext:client at:¤tSelection]; + + os_log_debug([KMLogs keyTraceLog], "๐ŸšฆhandleDeleteWithReplacement, currentSelection: %{public}@: ", NSStringFromRange(currentSelection)); + + // guard: only for compliant apps + if (!self.apiCompliance.canReplaceText) { + os_log_debug([KMLogs keyTraceLog], "cannot replace text, non-compliant"); 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: if text is 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 of deleting it"); + 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 deletion is the length of textToDelete provided by core +// NSUInteger deletionTargetLength = output.codePointsToDeleteBeforeInsert; +// +// // for the preceding character location, subtract from the end of the context +// NSUInteger precedingCharacterLocation = context.length - deletionTargetLength - 1; + NSUInteger deletionTargetLength = output.textToDelete.length; - NSUInteger deletionTargetLocation = context.length-deletionTargetLength; - NSUInteger precedingCharacterLocation = deletionTargetLocation - 1; + NSUInteger locationOfDeletionTarget = context.length-deletionTargetLength; + NSUInteger precedingCharacterLocation = locationOfDeletionTarget - 1; + + os_log_debug([KMLogs keyTraceLog], "๐Ÿ handleDeleteWithReplacement of deletionTargetLength: %lu at precedingCharacterLocation: %lu", (unsigned long)deletionTargetLength, (unsigned long)precedingCharacterLocation); // 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]; + handledEvent = [self deleteByReplacingWithPrecedingSurrogate:precedingCharacterLocation textStoreLocation:currentSelection.location deleteLength:deletionTargetLength context:context client:client]; } else { // otherwise replace with only the preceding character - handledEvent = [self deleteByReplacingWithPrecedingCharacter:precedingCharacterLocation deleteLength:deletionTargetLength context:context client:client]; + handledEvent = [self deleteByReplacingWithPrecedingCharacter:precedingCharacterLocation textStoreSelection:currentSelection deleteLength:deletionTargetLength context:context client:client]; } return handledEvent; @@ -643,13 +676,17 @@ 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. */ --(BOOL) deleteByReplacingWithPrecedingCharacter:(NSUInteger)precedingCharacterLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client { +-(BOOL) deleteByReplacingWithPrecedingCharacter:(NSUInteger)precedingCharacterLocation textStoreSelection:(NSRange)textStoreSelection deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client { - os_log_debug([KMLogs keyTraceLog], "deleteByReplacingWithPrecedingCharacter"); - // get the preceding character + id textInputClient = (id)client; + + // 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", precedingCharacterLocation, textStoreSelection.location); + os_log_debug([KMLogs keyTraceLog], "๐Ÿดโ€โ˜ ๏ธ deleteByReplacingWithPrecedingCharacter, replacementString = %{public}@", 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"; @@ -658,11 +695,15 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; return NO; } - // 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 = textStoreSelection.location - replacementLength; + os_log_debug([KMLogs keyTraceLog], "๐Ÿดโ€โ˜ ๏ธ deleteByReplacingWithPrecedingCharacter, replacementLength: %lu, replacementLocation: %lu", replacementLength, replacementLocation); + NSRange replacementRange = NSMakeRange(replacementLocation, replacementLength); + [textInputClient insertText:replacementString replacementRange:replacementRange]; return YES; } @@ -671,9 +712,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; * 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. */ --(BOOL) deleteByReplacingWithPrecedingSurrogate:(NSUInteger)precedingCharacterLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client { - - os_log_debug([KMLogs keyTraceLog], "deleteByReplacingWithPrecedingSurrogate"); +-(BOOL) deleteByReplacingWithPrecedingSurrogate:(NSUInteger)precedingCharacterLocation textStoreLocation:(NSUInteger)textStoreLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client { // guard: return NO if there is no character before the precedingCharacterLocation if (precedingCharacterLocation <= 0) { @@ -682,10 +721,12 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; return NO; } - // get the preceding character + // get the preceding character from the context NSRange precedingCharacterRange = NSMakeRange(precedingCharacterLocation - 1, 2); NSString *replacementString = [context substringWithRange:precedingCharacterRange]; + os_log_debug([KMLogs keyTraceLog], "๐Ÿดโ€โ˜ ๏ธ deleteByReplacingWithPrecedingSurrogate, replacementString = %{public}@", replacementString); + unichar highCharacter = [replacementString characterAtIndex:0]; unichar lowCharacter = [replacementString characterAtIndex:1]; @@ -699,10 +740,19 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; return NO; } - // 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)); + // 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; + os_log_debug([KMLogs keyTraceLog], "๐Ÿดโ€โ˜ ๏ธ deleteByReplacingWithPrecedingSurrogate, replacementLength: %lu, replacementLocation: %lu", replacementLength, replacementLocation); + + // perform the replacement +// NSUInteger replacementLength = [replacementString length] + deleteLength; +// NSUInteger replacementLocation = textStoreLocation - deleteLength; +// + NSRange replacementRange = NSMakeRange(replacementLocation, replacementLength); [client insertText:replacementString replacementRange:replacementRange]; return YES; From b696b841c29d6dc1ba20941befafc72e46c97732 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Tue, 22 Sep 2026 21:07:40 -0400 Subject: [PATCH 2/8] fix(mac): separate textInputClient code from replacement logic --- .../Keyman4MacIM/KMInputMethodAppDelegate.m | 2 +- .../Keyman4MacIM/KMInputMethodEventHandler.m | 150 ++++++++++-------- .../CoreWrapper/CoreWrapper.m | 4 +- 3 files changed, 92 insertions(+), 64 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m index 878033e6b7..194a781ad8 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m @@ -330,7 +330,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.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 263416f341..b65d2f14ba 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -37,7 +37,17 @@ CGKeyCode _keyCodeOfOriginalEvent; CGEventSourceRef _sourceFromOriginalEvent = nil; CGEventSourceRef _sourceForGeneratedEvent = nil; -// This is the public initializer. +// used to perform delete by inserting with replacement +typedef struct { + BOOL canDeleteWithReplacement; + NSUInteger replacementLocation; + NSUInteger replacementLength; + NSString *replacementString; +} ReplacementInfo; + +/** + * The public initializer + */ - (instancetype)initWithClient:(NSString *)clientAppId client:(id) sender { self = [super init]; if(self) { @@ -109,7 +119,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; @@ -333,7 +343,6 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; // we do this whether the context has changed or not if (self.apiCompliance.canReadText) { contextString = [self readContext:client at:¤tSelection]; - os_log_debug([KMLogs keyTraceLog], "reportContext, setting new context for compliant app (if needed)"); [self.kme setCoreContextIfNeeded:contextString]; } else if (self.contextChanged) { // we cannot read the text but know the context has changed, so we must clear it @@ -358,16 +367,14 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; if (self.apiCompliance.canReadText) { if (selectionRange != NULL) { *selectionRange = [client selectedRange]; + os_log_debug([KMLogs keyTraceLog], "InputMethodEventHandler readContext, selectionRange %{public}@: ", NSStringFromRange(*selectionRange)); } - - os_log_debug([KMLogs keyTraceLog], "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 keyTraceLog], " contextRange %{public}@: client: %p", NSStringFromRange(contextRange), client); attributedString = [client attributedSubstringFromRange:contextRange]; // adjust string in case that we receive half of a surrogate pair at context start @@ -517,6 +524,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]) { @@ -579,7 +587,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; NSRange currentSelection; NSString *context = [self readContext:client at:¤tSelection]; - os_log_debug([KMLogs keyTraceLog], "๐ŸšฆhandleDeleteWithReplacement, currentSelection: %{public}@: ", NSStringFromRange(currentSelection)); + os_log_debug([KMLogs keyTraceLog], "handleDeleteWithReplacement, currentSelection: %{public}@: ", NSStringFromRange(currentSelection)); // guard: only for compliant apps if (!self.apiCompliance.canReplaceText) { @@ -587,47 +595,40 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; return NO; // return without deleting/replacing } + // 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 of deleting 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: if text is 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 of deleting it"); - 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 deletion is the length of textToDelete provided by core -// NSUInteger deletionTargetLength = output.codePointsToDeleteBeforeInsert; -// -// // for the preceding character location, subtract from the end of the context -// NSUInteger precedingCharacterLocation = context.length - deletionTargetLength - 1; - + // the length of the text we are to delete NSUInteger deletionTargetLength = output.textToDelete.length; + // the location of the text we are to delete NSUInteger locationOfDeletionTarget = context.length-deletionTargetLength; - NSUInteger precedingCharacterLocation = locationOfDeletionTarget - 1; - os_log_debug([KMLogs keyTraceLog], "๐Ÿ handleDeleteWithReplacement of deletionTargetLength: %lu at precedingCharacterLocation: %lu", (unsigned long)deletionTargetLength, (unsigned long)precedingCharacterLocation); + 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); - // 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 textStoreLocation:currentSelection.location deleteLength:deletionTargetLength context:context client:client]; - } else { - // otherwise replace with only the preceding character - handledEvent = [self deleteByReplacingWithPrecedingCharacter:precedingCharacterLocation textStoreSelection:currentSelection deleteLength:deletionTargetLength context:context client:client]; + // perform the delete with replacement + [textInputClient insertText:replacementInfo.replacementString replacementRange:replacementRange]; } - - return handledEvent; + + return replacementInfo.canDeleteWithReplacement; } /** @@ -649,6 +650,36 @@ 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 { + BOOL canReplace = NO; + + 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. @@ -673,26 +704,24 @@ 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 textStoreSelection:(NSRange)textStoreSelection deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client { - - id textInputClient = (id)client; - +-(ReplacementInfo) determineReplacementInfoWithPrecedingCharacter:(NSUInteger)precedingCharacterLocation textStoreLocation:(NSUInteger)textStoreLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context { + ReplacementInfo replacementInfo; + replacementInfo.canDeleteWithReplacement = NO; + // 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", precedingCharacterLocation, textStoreSelection.location); - os_log_debug([KMLogs keyTraceLog], "๐Ÿดโ€โ˜ ๏ธ deleteByReplacingWithPrecedingCharacter, replacementString = %{public}@", replacementString); + 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; } // the length of the text to replace = the length of the replacement string + length of the string to delete @@ -700,33 +729,33 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; // 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 = textStoreSelection.location - replacementLength; - os_log_debug([KMLogs keyTraceLog], "๐Ÿดโ€โ˜ ๏ธ deleteByReplacingWithPrecedingCharacter, replacementLength: %lu, replacementLocation: %lu", replacementLength, replacementLocation); - NSRange replacementRange = NSMakeRange(replacementLocation, replacementLength); - [textInputClient insertText:replacementString replacementRange:replacementRange]; + 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 textStoreLocation:(NSUInteger)textStoreLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client { +-(ReplacementInfo) determineReplacementInfoWithPrecedingSurrogate:(NSUInteger)precedingCharacterLocation textStoreLocation:(NSUInteger)textStoreLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context { + ReplacementInfo replacementInfo; + replacementInfo.canDeleteWithReplacement = NO; // 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 from the context NSRange precedingCharacterRange = NSMakeRange(precedingCharacterLocation - 1, 2); NSString *replacementString = [context substringWithRange:precedingCharacterRange]; - os_log_debug([KMLogs keyTraceLog], "๐Ÿดโ€โ˜ ๏ธ deleteByReplacingWithPrecedingSurrogate, replacementString = %{public}@", replacementString); - unichar highCharacter = [replacementString characterAtIndex:0]; unichar lowCharacter = [replacementString characterAtIndex:1]; @@ -737,7 +766,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; 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); [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; - return NO; + return replacementInfo; } // the length of the text to replace = the length of the replacement string + length of the string to delete @@ -746,16 +775,13 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; // 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; - os_log_debug([KMLogs keyTraceLog], "๐Ÿดโ€โ˜ ๏ธ deleteByReplacingWithPrecedingSurrogate, replacementLength: %lu, replacementLocation: %lu", replacementLength, replacementLocation); - // perform the replacement -// NSUInteger replacementLength = [replacementString length] + deleteLength; -// NSUInteger replacementLocation = textStoreLocation - deleteLength; -// - NSRange replacementRange = NSMakeRange(replacementLocation, replacementLength); - [client insertText:replacementString replacementRange:replacementRange]; - - return YES; + replacementInfo.canDeleteWithReplacement = YES; + replacementInfo.replacementString = replacementString; + replacementInfo.replacementLength = replacementLength; + replacementInfo.replacementLocation = replacementLocation; + + return replacementInfo; } -(BOOL) containsControlCharacter:(NSString*)text { 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 { From 5bb4dc201f42b2efd958ca4204c14dca5dc72134 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Wed, 23 Sep 2026 20:18:30 -0400 Subject: [PATCH 3/8] fix(mac): local xcode builds uses bash from opt/homebrew addresses 'declare: -g: invalid option' error encountered in builder.inc.sh due to finding the old version of bash 3.x pre-installed with mac --- mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj | 2 +- .../KeymanEngine4Mac.xcodeproj/project.pbxproj | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj b/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj index 4a612a060d..8d006b81d1 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj +++ b/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj @@ -842,7 +842,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\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/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj b/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj index 98997bb8ac..c29ef39bd7 100644 --- a/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj +++ b/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj @@ -472,8 +472,8 @@ outputPaths = ( ); 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"; + shellPath = /opt/homebrew/bin/bash; + shellScript = "# check that our PRODUCT_VERSION matches this git branch's KEYMAN_VERSION from ../../VERSION.md\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 */ = { From af462ae564477dd8c67ede755362d0c7086db920 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Wed, 23 Sep 2026 21:31:06 -0400 Subject: [PATCH 4/8] fix(mac): added unit test for preceding control character scenario --- .../Keyman4MacIM/KMInputMethodEventHandler.h | 8 ++++++++ .../Keyman4MacIM/KMInputMethodEventHandler.m | 10 ---------- mac/Keyman4MacIM/KeymanTests/InputMethodTests.m | 16 +++++++++++++++- 3 files changed, 23 insertions(+), 11 deletions(-) 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 b65d2f14ba..0a1309dc6d 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -37,14 +37,6 @@ CGKeyCode _keyCodeOfOriginalEvent; CGEventSourceRef _sourceFromOriginalEvent = nil; CGEventSourceRef _sourceForGeneratedEvent = nil; -// used to perform delete by inserting with replacement -typedef struct { - BOOL canDeleteWithReplacement; - NSUInteger replacementLocation; - NSUInteger replacementLength; - NSString *replacementString; -} ReplacementInfo; - /** * The public initializer */ @@ -583,7 +575,6 @@ typedef struct { * Ref: https://developer.apple.com/documentation/appkit/nstextinputclient/inserttext(_:replacementrange:) */ -(BOOL)handleDeleteWithReplacement:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client { - BOOL handledEvent = NO; NSRange currentSelection; NSString *context = [self readContext:client at:¤tSelection]; @@ -655,7 +646,6 @@ typedef struct { * that describes how to perform the replacement. */ - (ReplacementInfo)evaluateForReplaceability:(NSString*) context textStoreLocation:(NSUInteger)textStoreLocation deleteLength:(NSUInteger)deleteLength locationOfDeletionTarget:(NSUInteger)deletionLocation { - BOOL canReplace = NO; ReplacementInfo replacementInfo; diff --git a/mac/Keyman4MacIM/KeymanTests/InputMethodTests.m b/mac/Keyman4MacIM/KeymanTests/InputMethodTests.m index 1f599e51c6..ff7a9ed76b 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,16 @@ id testClient = nil; XCTAssertTrue(correctResult, @"insert or replacement range expected to be {1,2}"); } +- (void)testEvaluateReplacement_precededByControlCharacter_canReplaceFalse { + NSString *context = @"test\ta"; + NSUInteger storeLocation = 10; + 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"); +} + /** * 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, From c14a36baa961529e080900462aa77e67ae9134e0 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Thu, 24 Sep 2026 13:56:35 -0400 Subject: [PATCH 5/8] fix(mac): added vscode and slack to non-compliant list added unit tests for delete with insert/replace --- .../Keyman4MacIM/KMInputMethodEventHandler.m | 14 +-- .../Keyman4MacIM/TextApiCompliance.m | 6 +- .../KeymanTests/InputMethodTests.m | 98 ++++++++++++++++++- 3 files changed, 109 insertions(+), 9 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 0a1309dc6d..ba04fe23e4 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -575,20 +575,19 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; * Ref: https://developer.apple.com/documentation/appkit/nstextinputclient/inserttext(_:replacementrange:) */ -(BOOL)handleDeleteWithReplacement:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client { - NSRange currentSelection; - NSString *context = [self readContext:client at:¤tSelection]; - - os_log_debug([KMLogs keyTraceLog], "handleDeleteWithReplacement, currentSelection: %{public}@: ", NSStringFromRange(currentSelection)); - // guard: only for compliant apps - if (!self.apiCompliance.canReplaceText) { + if (!self.apiCompliance.canReadText) { os_log_debug([KMLogs keyTraceLog], "cannot replace text, non-compliant"); return NO; // return without deleting/replacing } + NSRange currentSelection; + NSString *context = [self readContext:client at:¤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 of deleting it"); + os_log_debug([KMLogs keyTraceLog], "text is currently selected, accept default system handling to delete it"); return NO; // return without deleting/replacing } @@ -616,6 +615,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; 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]; } diff --git a/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.m b/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.m index a5e04e6e09..d77ffa015a 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"] || + // 2026-09-24[sgs]: Slack does double backspace when inserting with replacement + [clientAppId isEqual: @"com.microsoft.VSCode"] + // 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 ff7a9ed76b..2f2da19805 100644 --- a/mac/Keyman4MacIM/KeymanTests/InputMethodTests.m +++ b/mac/Keyman4MacIM/KeymanTests/InputMethodTests.m @@ -98,7 +98,7 @@ id testClient = nil; - (void)testEvaluateReplacement_precededByControlCharacter_canReplaceFalse { NSString *context = @"test\ta"; - NSUInteger storeLocation = 10; + NSUInteger storeLocation = 100; NSUInteger deletionLength = 1; NSUInteger locationOfDeletion = 5; @@ -106,6 +106,102 @@ id testClient = nil; 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_latinCharacterPrecededBySurrogatePair_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 is incorrect"); + XCTAssertTrue(replacementInfo.replacementLength == 3, @"replacementLength should == 3"); +} + +- (void)testEvaluateReplacement_surrogatePairPrecededByLatinCharacter_replacementLengthThree { + NSString *context = @"test.a๐‘’"; + 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_latinCharacterPrecededByCheckeredFlag_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_checkeredFlagPrecededByLatinCharacter_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. +- (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, From 25ffd9e5ef6167781c4d83a7faf33fb0f50a7dab Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Thu, 24 Sep 2026 14:17:17 -0400 Subject: [PATCH 6/8] fix(mac): reset shell path to /bin/sh --- mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj b/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj index c29ef39bd7..05e64972f6 100644 --- a/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj +++ b/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj @@ -472,7 +472,7 @@ outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; - shellPath = /opt/homebrew/bin/bash; + shellPath = /bin/sh; shellScript = "# check that our PRODUCT_VERSION matches this git branch's KEYMAN_VERSION from ../../VERSION.md\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; }; From eedb763e9e88f987c7cf4b952194ffe560e91a45 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Mon, 5 Oct 2026 14:12:40 -0400 Subject: [PATCH 7/8] fix(mac): respond to review comments --- .../Keyman4MacIM.xcodeproj/project.pbxproj | 2 +- .../Keyman4MacIM/KMInputMethodEventHandler.m | 88 +++++++++++-------- .../Keyman4MacIM/TextApiCompliance.m | 4 +- .../KeymanTests/InputMethodTests.m | 12 +-- .../project.pbxproj | 2 +- 5 files changed, 59 insertions(+), 49 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj b/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj index 0f4bc1e393..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\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"; + 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/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index ba04fe23e4..640c839abd 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -329,21 +329,26 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; */ -(void)reportContext:(NSEvent *)event forClient:(id) client { NSString *contextString = nil; - NSRange currentSelection; // 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:client at:¤tSelection]; + 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; } /** @@ -351,40 +356,47 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; * Also returns the results of `[client selectedRange]` in the`selection` pointer */ --(NSString*)readContext:(id) client at:(NSRange *) selectionRange { +-(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"); + *outSelection = selectionRange; + return contextString; + } + // if we can read the text, then get the context for up to kMaxContext characters - if (self.apiCompliance.canReadText) { - if (selectionRange != NULL) { - *selectionRange = [client selectedRange]; - os_log_debug([KMLogs keyTraceLog], "InputMethodEventHandler readContext, selectionRange %{public}@: ", NSStringFromRange(*selectionRange)); - } - - NSUInteger contextLength = MIN(kMaxContext, selectionRange->location); - NSUInteger contextStart = selectionRange->location - contextLength; - - if (contextLength > 0) { - NSRange contextRange = NSMakeRange(contextStart, contextLength); - 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 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); - } + 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; } @@ -581,8 +593,8 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; return NO; // return without deleting/replacing } - NSRange currentSelection; - NSString *context = [self readContext:client at:¤tSelection]; + 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 @@ -593,7 +605,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; // guard: must have sufficient context if ([context length] <= output.textToDelete.length) { - os_log_debug([KMLogs keyTraceLog], "cannot replace text, insufficient context"); + os_log_error([KMLogs keyTraceLog], "cannot replace text, insufficient context"); return NO; // return without deleting/replacing } @@ -697,8 +709,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; * Determine the replacement info using the character preceding the string to be deleted. */ -(ReplacementInfo) determineReplacementInfoWithPrecedingCharacter:(NSUInteger)precedingCharacterLocation textStoreLocation:(NSUInteger)textStoreLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context { - ReplacementInfo replacementInfo; - replacementInfo.canDeleteWithReplacement = NO; + ReplacementInfo replacementInfo = {NO, 0, 0, NULL}; // get the preceding character from the context NSRange precedingCharacterRange = NSMakeRange(precedingCharacterLocation, 1); @@ -732,8 +743,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; * Determine the replacement info using the surrogate pair preceding the string to be deleted. */ -(ReplacementInfo) determineReplacementInfoWithPrecedingSurrogate:(NSUInteger)precedingCharacterLocation textStoreLocation:(NSUInteger)textStoreLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context { - ReplacementInfo replacementInfo; - replacementInfo.canDeleteWithReplacement = NO; + ReplacementInfo replacementInfo = {NO, 0, 0, NULL}; // guard: return NO if there is no character before the precedingCharacterLocation if (precedingCharacterLocation <= 0) { @@ -754,7 +764,7 @@ 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 replacementInfo; } diff --git a/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.m b/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.m index d77ffa015a..34a0edecbb 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.m @@ -380,9 +380,9 @@ NSString *const kKMLegacyApps = @"KMLegacyApps"; [clientAppId isEqual: @"com.Keyman.test.legacyInput"] || /*||[clientAppId isEqual: @"ro.sync.exml.Oxygen"] - Oxygen has worse problems */ [clientAppId isEqual: @"com.tinyspeck.slackmacgap"] || - // 2026-09-24[sgs]: Slack does double backspace when inserting with replacement + // #16731 2026-09-24[sgs]: Slack does double backspace when inserting with replacement [clientAppId isEqual: @"com.microsoft.VSCode"] - // 2026-09-24[sgs]: VSCode does double backspace when inserting with replacement + // #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 2f2da19805..667b9cce49 100644 --- a/mac/Keyman4MacIM/KeymanTests/InputMethodTests.m +++ b/mac/Keyman4MacIM/KeymanTests/InputMethodTests.m @@ -118,10 +118,9 @@ id testClient = nil; XCTAssertTrue(replacementInfo.replacementLength == 2, @"replacementLength should be 2"); } -//๐‘’๐‘’น - +// Uses Malar Tirhuta script - (void)testEvaluateReplacement_latinCharacterPrecededBySurrogatePair_replacementLengthThree { - NSString *context = @"test.๐‘’a"; + NSString *context = @"test.๐‘’a"; // ๐‘’ : U+1148F : TIRHUTA LETTER NSString *stringToDelete = @"a"; NSRange range = [context rangeOfString:stringToDelete]; @@ -137,7 +136,7 @@ id testClient = nil; } - (void)testEvaluateReplacement_surrogatePairPrecededByLatinCharacter_replacementLengthThree { - NSString *context = @"test.a๐‘’"; + NSString *context = @"test.a๐‘’"; // ๐‘’ : U+1148F : TIRHUTA LETTER NSString *stringToDelete = @"๐‘’"; NSRange range = [context rangeOfString:stringToDelete]; @@ -152,7 +151,7 @@ id testClient = nil; XCTAssertTrue(replacementInfo.replacementLength == 3, @"replacementLength should == 3"); } -- (void)testEvaluateReplacement_latinCharacterPrecededByCheckeredFlag_replacementLengthThree { +- (void)testEvaluateReplacement_latinCharacterPrecededByEmoji_replacementLengthThree { NSString *context = @"test๐Ÿa"; NSString *stringToDelete = @"a"; @@ -168,7 +167,7 @@ id testClient = nil; XCTAssertTrue(replacementInfo.replacementLength == 3, @"replacementLength should == 3"); } -- (void)testEvaluateReplacement_checkeredFlagPrecededByLatinCharacter_replacementLengthThree { +- (void)testEvaluateReplacement_emojiPrecededByLatinCharacter_replacementLengthThree { NSString *context = @"testa๐Ÿ"; NSString *stringToDelete = @"๐Ÿ"; @@ -186,6 +185,7 @@ id testClient = nil; // 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 = @"๐Ÿดโ€โ˜ ๏ธ"; diff --git a/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj b/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj index 05e64972f6..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\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"; + 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 */ = { From 9dd9e580e04f83aca9a6ed99aae5ea60132af6e5 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Mon, 5 Oct 2026 14:44:26 -0400 Subject: [PATCH 8/8] fix(mac): added two unit tests --- mac/Cartfile.resolved | 2 +- .../Keyman4MacIM/KMInputMethodEventHandler.m | 3 +-- .../KeymanTests/InputMethodTests.m | 22 +++++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) 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/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 640c839abd..50ee8313cf 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -363,7 +363,6 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; if (!self.apiCompliance.canReadText) { os_log_debug([KMLogs keyTraceLog], "InputMethodEventHandler readContext, unable to read context: non-compliant app"); - *outSelection = selectionRange; return contextString; } @@ -605,7 +604,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; // guard: must have sufficient context if ([context length] <= output.textToDelete.length) { - os_log_error([KMLogs keyTraceLog], "cannot replace text, insufficient context"); + os_log_debug([KMLogs keyTraceLog], "cannot replace text, insufficient context"); return NO; // return without deleting/replacing } diff --git a/mac/Keyman4MacIM/KeymanTests/InputMethodTests.m b/mac/Keyman4MacIM/KeymanTests/InputMethodTests.m index 667b9cce49..70ea63db2a 100644 --- a/mac/Keyman4MacIM/KeymanTests/InputMethodTests.m +++ b/mac/Keyman4MacIM/KeymanTests/InputMethodTests.m @@ -118,6 +118,28 @@ id testClient = nil; 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