From 65c151b33cfe0698bdc45ae7fc976a1ff83262d5 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Wed, 4 Feb 2026 14:40:43 -0500 Subject: [PATCH 1/4] fix(mac): improved adherence to backspace rule introduces an alternative to approach to backspace for compliant apps by using the insertText API to replace a character to be deleted and the preceding character from the context with only the character from the context Fixes: #15543 --- .../Keyman4MacIM/KMInputMethodEventHandler.m | 96 ++++++++++++++++--- .../Keyman4MacIM/TextApiCompliance.h | 1 + 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index dd9a8a3ceea..20409a3a091 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -8,6 +8,7 @@ #import "KMInputMethodEventHandler.h" #import #import /* For kVK_ constants. */ +#import #import "KeySender.h" #import "TextApiCompliance.h" #import "KMSettingsRepository.h" @@ -370,7 +371,7 @@ -(NSString*)readContext:(NSEvent *)event forClient:(id) client { contextString = attributedString.string; //only uncomment for testing as we do not want to write context in logs - //os_log_debug([KMLogs testLog], " length: %lu result: %{public}@", contextString.length, contextString); + //os_log_debug([KMLogs keyTraceLog], " length: %lu result: %{public}@", contextString.length, contextString); } } } @@ -405,18 +406,7 @@ -(BOOL)applyKeyOutputToTextInputClient:(CoreKeyOutput*)output keyDownEvent:(nonn [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; [self insertAndReplaceTextForOutput:output client:client]; } else if (output.isDeleteOnlyScenario) { - if ((event.keyCode == kVK_Delete) && output.codePointsToDeleteBeforeInsert == 1) { - // let the delete pass through in the original event rather than sending a new delete - NSString *message = @"applyOutputToTextInputClient, delete only scenario with passthrough"; - os_log_debug([KMLogs keyTraceLog], "%@", message); - [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; - handledEvent = NO; - } else { - NSString *message = @"applyOutputToTextInputClient, delete only scenario"; - os_log_debug([KMLogs keyTraceLog], "%@", message); - [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; - [self sendEvents:event forOutput:output]; - } + handledEvent = [self handleDeleteOnlyScenario:output keyDownEvent:event client:client]; } else if (output.isDeleteAndInsertScenario) { // TODO: fix issue #10246 /* @@ -511,6 +501,86 @@ -(void)insertAndReplaceText:(NSString *)text deleteCount:(int) replacementCount } } +/** + * Handles deleting without an associated insert in one of three methods: + * 1. delete via replace: do the delete by replacing two (or more) characters with one. + * 2. backspace passthrough : if the original keydown event was a backspace, pass it through unhandled + * 3. generate event: generate keydown backspace events as necessary + */ +-(BOOL)handleDeleteOnlyScenario:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client { + + // attempt to delete by replacing -- for compliant apps only + if ([self handleDeleteWithReplacement:output keyDownEvent:event client:client]) { + return YES; + } + + // pass through if this was a backspace keydown event + if ((event.keyCode == kVK_Delete) && output.codePointsToDeleteBeforeInsert == 1) { + // let the delete pass through in the original event rather than sending a new delete + NSString *message = @"handleDeleteOnlyForOutput, delete only scenario with passthrough"; + os_log_debug([KMLogs keyTraceLog], "%@", message); + [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; + + // instruct system to handle the event + return NO; + } + // otherwise generate a backspace + else { + NSString *message = @"handleDeleteOnlyForOutput, send backspace event"; + os_log_debug([KMLogs keyTraceLog], "%@", message); + [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; + [self sendEvents:event forOutput:output]; + + return YES; + } +} + +/** + * For compliant apps only. + * This an attempt to do a more precise delete. When generating a backspace event or allowing a backspace + * to pass through, it may delete a combining diacritic and the preceding codepoint that it combines with. + * Instead, we can delete the combining diacritic alone by using the insertText API to replace two code points with one. + * This method only works for compliant apps because non-compliant apps do not support the insertText API. + */ +-(BOOL)handleDeleteWithReplacement:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client { + BOOL handledEvent = NO; + NSString *context = [self readContext:event forClient:client]; + int codePointsToDelete = (int) output.codePointsToDeleteBeforeInsert; + + if ((self.apiCompliance.canReplaceText) && ([context length] > codePointsToDelete)) { + int codePointsToReplace = codePointsToDelete + 1; + NSRange replacementStringRange = NSMakeRange([context length] - codePointsToReplace, 1); + NSString *replacementString = [context substringWithRange:replacementStringRange]; + + // replace only works for non-control characters + // if replacementString contains control characters, then return without handling event + NSString *message = nil; + if ([self containsControlCharacter:replacementString]) { + message = @"handleDeleteByReplace, replacementString contains control characters, cannot delete with replace"; + os_log_debug([KMLogs keyTraceLog], "%@", message); + [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; + return NO; + } else { + message = @"handleDeleteByReplace, canReplaceText == true"; + os_log_debug([KMLogs keyTraceLog], "%@", message); + [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; + handledEvent = YES; + } + + NSRange replacementRange = NSMakeRange(replacementStringRange.location, codePointsToReplace); + [client insertText:replacementString replacementRange:replacementRange]; + } + + return handledEvent; +} + +-(BOOL) containsControlCharacter:(NSString*)text { + NSCharacterSet *controlSet = [NSCharacterSet controlCharacterSet]; + NSRange range = [text rangeOfCharacterFromSet:controlSet]; + + return (range.location != NSNotFound); +} + /** * Calculates the range where text will be inserted and replace existing text. * Returning {NSNotFound, NSNotFound} for range signifies to insert at current location without replacement. diff --git a/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.h b/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.h index 1f5b55f17fa..40c516a8dbc 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.h +++ b/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.h @@ -22,6 +22,7 @@ NS_ASSUME_NONNULL_BEGIN -(void) checkComplianceAfterInsert:(NSString *)insertedText deleted:(NSString *)deletedText; -(BOOL)isComplianceUncertain; -(BOOL)canReadText; +-(BOOL)canReplaceText; -(BOOL)mustBackspaceUsingEvents; @end From c2a667635a389c45ff169ca20e3878c40b1f91aa Mon Sep 17 00:00:00 2001 From: Shawn Schantz <89134789+sgschantz@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:53:05 -0500 Subject: [PATCH 2/4] Update mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m Co-authored-by: Marc Durdin --- .../Keyman4MacIM/KMInputMethodEventHandler.m | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 20409a3a091..659d723c017 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -537,10 +537,33 @@ -(BOOL)handleDeleteOnlyScenario:(CoreKeyOutput*)output keyDownEvent:(nonnull NSE /** * For compliant apps only. - * This an attempt to do a more precise delete. When generating a backspace event or allowing a backspace - * to pass through, it may delete a combining diacritic and the preceding codepoint that it combines with. - * Instead, we can delete the combining diacritic alone by using the insertText API to replace two code points with one. - * This method only works for compliant apps because non-compliant apps do not support the insertText API. + * + * This an attempt to make sure that deletion removes only the expected codepoints. + * When handling a transform which only deletes a character, or when allowing a + * backspace to pass through, the OS or application may not use the same rules + * around deletion as Keyman -- especially when deleting clusters such as letter + + * combining diacritic (e.g. `U+0062 U+0301`), where some applications may delete + * both together as they represent a single 'grapheme cluster'. + * + * (Note, the question of whether it is appropriate for backspace to delete a + * cluster rather than a codepoint from an end-user perspective is not relevant + * here, because what is important is that we match the rules that the keyboard has + * provided, which means we need a method of deleting a precise number of + * codepoints. The keyboard author can and should include rules for cluster + * deletion that meet end-user expectations.) + * + * The `insertText` API takes two parameters: a string to insert, and a range to + * replace with that string. However, we cannot simply pass through a zero-length + * insertion string along with the range to delete, because the `insertText` API + * treats this as an invalid call and ignores it. + * + * Instead, we can delete the desired number of codepoints only by using the + * `insertText` API to replace e.g. two codepoints with one. + * + * This method only works for compliant apps because non-compliant apps do not + * support the `insertText` API. + * + * Ref: https://developer.apple.com/documentation/appkit/nstextinputclient/inserttext(_:replacementrange:) */ -(BOOL)handleDeleteWithReplacement:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client { BOOL handledEvent = NO; From 553a2e557184ba5d943f103087fc4863cddbfa57 Mon Sep 17 00:00:00 2001 From: Shawn Schantz <89134789+sgschantz@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:54:00 -0500 Subject: [PATCH 3/4] Apply suggestions from code review Co-authored-by: Marc Durdin --- mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 659d723c017..828daec2f92 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -515,7 +515,7 @@ -(BOOL)handleDeleteOnlyScenario:(CoreKeyOutput*)output keyDownEvent:(nonnull NSE } // pass through if this was a backspace keydown event - if ((event.keyCode == kVK_Delete) && output.codePointsToDeleteBeforeInsert == 1) { + if (event.keyCode == kVK_Delete && output.codePointsToDeleteBeforeInsert == 1) { // let the delete pass through in the original event rather than sending a new delete NSString *message = @"handleDeleteOnlyForOutput, delete only scenario with passthrough"; os_log_debug([KMLogs keyTraceLog], "%@", message); @@ -524,8 +524,8 @@ -(BOOL)handleDeleteOnlyScenario:(CoreKeyOutput*)output keyDownEvent:(nonnull NSE // instruct system to handle the event return NO; } - // otherwise generate a backspace else { + // otherwise generate a backspace NSString *message = @"handleDeleteOnlyForOutput, send backspace event"; os_log_debug([KMLogs keyTraceLog], "%@", message); [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; From f8e9f7221023254cc4536a2a60435f7b9a290d12 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Sat, 14 Feb 2026 16:02:53 -0500 Subject: [PATCH 4/4] fix(mac): improved adherence to backspace rule deals with preceding character being a surrogate pair when a delete occurs Fixes: #15543 --- .../Keyman4MacIM/KMInputMethodEventHandler.m | 133 +++++++++++++++--- 1 file changed, 114 insertions(+), 19 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 828daec2f92..3dfbd8593b9 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -568,33 +568,128 @@ -(BOOL)handleDeleteOnlyScenario:(CoreKeyOutput*)output keyDownEvent:(nonnull NSE -(BOOL)handleDeleteWithReplacement:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client { BOOL handledEvent = NO; NSString *context = [self readContext:event forClient:client]; - int codePointsToDelete = (int) output.codePointsToDeleteBeforeInsert; - if ((self.apiCompliance.canReplaceText) && ([context length] > codePointsToDelete)) { - int codePointsToReplace = codePointsToDelete + 1; - NSRange replacementStringRange = NSMakeRange([context length] - codePointsToReplace, 1); - NSString *replacementString = [context substringWithRange:replacementStringRange]; + // 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"); + 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 + } + + NSUInteger deletionTargetLength = output.textToDelete.length; + NSUInteger deletionTargetLocation = context.length-deletionTargetLength; + NSUInteger precedingCharacterLocation = deletionTargetLocation - 1; + + if ([self deletionWillReplacePartOfCluster: deletionTargetLocation precedingCharacterLocation:precedingCharacterLocation context:context]) { + handledEvent = [self deleteByReplacingWithPrecedingCharacter:precedingCharacterLocation deleteLength:deletionTargetLength context:context client:client]; + } else { + handledEvent = [self deleteByReplacingWithPrecedingCluster:precedingCharacterLocation deleteLength:deletionTargetLength context:context client:client]; + } + + return handledEvent; +} + +/** + * Check whether the string to be deleted is found at the tail end of the current context. + */ +-(BOOL) stringToDeleteMatchesContextSuffix:(NSString*)textToDelete context:(NSString*) context { + BOOL doesMatch = NO; + + // get length of string to delete and compare to end of context + NSUInteger deleteLength = textToDelete.length; + NSUInteger locationOfDeletionTarget = context.length-deleteLength; + NSUInteger locationOfPrecedingCharacter = locationOfDeletionTarget - 1; + NSString *contextSuffix = [context substringFromIndex:context.length-deleteLength]; + + os_log_debug([KMLogs keyTraceLog], "stringToDeleteMatchesSuffix, textToDelete: '%{public}@', contextSuffix: '%{public}@', locationOfDeletionTarget: %u, locationOfprecedingCharacter: %u", textToDelete, contextSuffix, (int)locationOfDeletionTarget, (int)locationOfPrecedingCharacter); + + doesMatch = [textToDelete isEqualToString:contextSuffix]; + os_log_debug([KMLogs keyTraceLog], "stringToDeleteMatchesSuffix: %{public}@", doesMatch?@"YES":@"NO"); + return doesMatch; +} + +/** + * Check whether the string to be deleted is part of the same cluster as the character in the context that precedes it. + */ +-(BOOL) deletionWillReplacePartOfCluster: (NSUInteger)deletionLocation precedingCharacterLocation: (NSUInteger)precedingLocation context:(NSString*) context { + // NSString objects hold UTF-16 characters, so a single unicode composed character + // or grapheme cluster may occupy a range of NSString indices instead of a single character. + // This includes base and combining characters potentially composed of surrogate pairs. + NSRange firstDeletionTargetClusterRange = [context rangeOfComposedCharacterSequenceAtIndex: deletionLocation]; + + // get range of the preceding cluster in the context + NSRange precedingClusterRange = [context rangeOfComposedCharacterSequenceAtIndex: precedingLocation]; + + NSString *firstFullCharacterToDelete = [context substringWithRange:firstDeletionTargetClusterRange]; + NSString *precedingFullCharacter = [context substringWithRange:precedingClusterRange]; + os_log_debug([KMLogs keyTraceLog], "firstDeletionTargetCharacterRange: %{public}@, deletionCharacter: %{public}@, precedingCharacterRange %{public}@, precedingCharacter: %{public}@", NSStringFromRange(firstDeletionTargetClusterRange), firstFullCharacterToDelete, NSStringFromRange(precedingClusterRange), precedingFullCharacter); + + // true when the first character to delete and the preceding character + // from the context are part of the same grapheme cluster + return NSEqualRanges(firstDeletionTargetClusterRange, precedingClusterRange); +} + +/** + * 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 { + + os_log_debug([KMLogs keyTraceLog], "deleteByReplacingWithPrecedingCharacter, deletion target is part of the same grapheme cluster as the character that precedes it"); + // get the preceding character + NSRange precedingCharacterRange = NSMakeRange(precedingCharacterLocation, 1); + NSString *replacementString = [context substringWithRange:precedingCharacterRange]; + + // 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; + } + + // perform the replacement + NSUInteger replacementLength = [replacementString length] + deleteLength; + NSRange replacementRange = NSMakeRange(precedingCharacterLocation, replacementLength); + os_log_debug([KMLogs keyTraceLog], "replacementRange: %{public}@", NSStringFromRange(replacementRange)); + [client insertText:replacementString replacementRange:replacementRange]; + + return YES; +} + +/** + * Replace both the text to delete and the cluster preceding it solely with the cluster that precedes it. + * The 'cluster' may be just one character, but if contains surrogate pairs, this ensures that they stay together. + * Returns YES if executing the replace/delete and NO otherwise. + */ + -(BOOL) deleteByReplacingWithPrecedingCluster:(NSUInteger)precedingCharacterLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client { + + os_log_debug([KMLogs keyTraceLog], "deleteByReplacingWithPrecedingCluster, deletion target is independent of the grapheme cluster that precedes it"); + + // get range of the preceding cluster and the substring from the context + NSRange precedingClusterRange = [context rangeOfComposedCharacterSequenceAtIndex: precedingCharacterLocation]; + NSString *replacementString = [context substringWithRange:precedingClusterRange]; - // replace only works for non-control characters - // if replacementString contains control characters, then return without handling event - NSString *message = nil; + // guard: if preceding cluster contains control characters, return NO if ([self containsControlCharacter:replacementString]) { - message = @"handleDeleteByReplace, replacementString contains control characters, cannot delete with replace"; + NSString *message = @"replacementString contains control characters, cannot delete with replace"; os_log_debug([KMLogs keyTraceLog], "%@", message); [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; return NO; - } else { - message = @"handleDeleteByReplace, canReplaceText == true"; - os_log_debug([KMLogs keyTraceLog], "%@", message); - [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; - handledEvent = YES; } - - NSRange replacementRange = NSMakeRange(replacementStringRange.location, codePointsToReplace); + + // perform the replacement + NSUInteger replacementLength = [replacementString length] + deleteLength; + NSRange replacementRange = NSMakeRange([context length] - replacementLength, replacementLength); + os_log_debug([KMLogs keyTraceLog], "replacementRange: %{public}@", NSStringFromRange(replacementRange)); [client insertText:replacementString replacementRange:replacementRange]; - } - - return handledEvent; + + return YES; } -(BOOL) containsControlCharacter:(NSString*)text {