spiegel-keyman/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m
2024-02-28 13:25:03 +07:00

506 lines
21 KiB
Objective-C

//
// KMInputMethodEventHandler.m
// Keyman4MacIM
//
// Created by Tom Bogle on 11/22/17.
// Copyright © 2017-2018 SIL International. All rights reserved.
//
#import "KMInputMethodEventHandler.h"
#import "KMInputMethodEventHandlerProtected.h"
#import <KeymanEngine4Mac/KeymanEngine4Mac.h>
#import <Carbon/Carbon.h> /* For kVK_ constants. */
#import "KeySender.h"
#import "TextApiCompliance.h"
@import Sentry;
@interface KMInputMethodEventHandler ()
@property (nonatomic, retain) KeySender* keySender;
@property (nonatomic, retain) TextApiCompliance* apiCompliance;
@property NSUInteger generatedBackspaceCount;
@property NSUInteger lowLevelBackspaceCount;
@property (nonatomic, retain) NSString* clientApplicationId;
@property NSString *queuedText;
@property BOOL contextChanged;
@end
@implementation KMInputMethodEventHandler
const CGKeyCode kProcessPendingBuffer = 0xFF;
const NSUInteger kMaxContext = 80;
const NSTimeInterval kQueuedTextEventDelayInSeconds = 0.1;
CGKeyCode _keyCodeOfOriginalEvent;
CGEventSourceRef _sourceFromOriginalEvent = nil;
CGEventSourceRef _sourceForGeneratedEvent = nil;
NSMutableString* _easterEggForSentry = nil;
NSString* const kEasterEggText = @"Sentry force now";
NSString* const kEasterEggKmxName = @"EnglishSpanish.kmx";
// This is the public initializer.
- (instancetype)initWithClient:(NSString *)clientAppId client:(id) sender {
_keySender = [[KeySender alloc] init];
_clientApplicationId = clientAppId;
_generatedBackspaceCount = 0;
_lowLevelBackspaceCount = 0;
_queuedText = nil;
// In Xcode, if Keyman is the active IM and is in "debugMode" and "English plus Spanish" is
// the current keyboard and you type "Sentry force now", it will force a simulated crash to
// test reporting to sentry.keyman.com
if ([self.appDelegate debugMode] && [clientAppId isEqual: @"com.apple.dt.Xcode"]) {
NSLog(@"Sentry - Preparing to detect Easter egg.");
_easterEggForSentry = [[NSMutableString alloc] init];
}
else
_easterEggForSentry = nil;
return self;
}
- (void)deactivate {
if (_generatedBackspaceCount > 0 || (_queuedText != nil && _queuedText.length > 0) ||
_keyCodeOfOriginalEvent != 0 || _sourceFromOriginalEvent != nil)
{
if ([self.appDelegate debugMode]) {
NSLog(@"ERROR: new app activated before previous app finished processing pending events!");
NSLog(@" _generatedBackspaceCount = %lu", _generatedBackspaceCount);
NSLog(@" _queuedText = \"%@\"", _queuedText == nil ? @"(NIL)" : (NSString*)[self queuedText]);
NSLog(@" _keyCodeOfOriginalEvent = %hu", _keyCodeOfOriginalEvent);
}
_keyCodeOfOriginalEvent = 0;
_generatedBackspaceCount = 0;
_queuedText = nil;
}
}
- (void)dealloc {
if (_sourceFromOriginalEvent != nil) {
CFRelease(_sourceFromOriginalEvent);
_sourceFromOriginalEvent = nil;
}
if (_sourceForGeneratedEvent != nil) {
CFRelease(_sourceForGeneratedEvent);
_sourceForGeneratedEvent = nil;
}
}
- (void)handleCommand:(NSEvent *)event {
// There are a bunch of common navigation/selection command-key combinations, but individual
// apps may implement specific commands that also change the selection. There is probably no
// situation where a user could reasonably hope that any dead-keys typed before using a
// command shortcut would be remembered, so other than an insignificant performance penalty,
// the only downside to treating all commands as having the potential to change the selection
// is that some non-compliant apps can't get their context at all. This can be overridden so that
// non-compliant apps can mitigate this problem as appropriate.
[self.appDelegate logDebugMessage:@"KMInputMethodEventHandler handleCommand, event type=%@, must refresh context", event.type];
self.contextChanged = YES;
}
- (KMInputMethodAppDelegate *)appDelegate {
return (KMInputMethodAppDelegate *)[NSApp delegate];
}
- (KMEngine *)kme {
return self.appDelegate.kme;
}
- (BOOL) handleEventWithKeymanEngine:(NSEvent *)event in:(id) sender {
[self.appDelegate logDebugMessage:@"handleEventWithKeymanEngine, event = %@", event];
CoreKeyOutput *output = nil;
output = [self processEventWithKeymanEngine:event in:sender];
if (output == nil) {
[self checkEventForSentryEasterEgg:event];
return NO;
}
return [self applyKeymanCoreActions:output event:event client:sender];
}
- (CoreKeyOutput*) processEventWithKeymanEngine:(NSEvent *)event in:(id) sender {
CoreKeyOutput* coreKeyOutput = nil;
if (self.appDelegate.lowLevelEventTap != nil) {
NSEvent *eventWithOriginalModifierFlags = [NSEvent keyEventWithType:event.type location:event.locationInWindow modifierFlags:self.appDelegate.currentModifierFlags timestamp:event.timestamp windowNumber:event.windowNumber context:event.context characters:event.characters charactersIgnoringModifiers:event.charactersIgnoringModifiers isARepeat:event.isARepeat keyCode:event.keyCode];
coreKeyOutput = [self.kme processEvent:eventWithOriginalModifierFlags];
[self.appDelegate logDebugMessage:@"processEventWithKeymanEngine, using AppDelegate.currentModifierFlags %lu, instead of event.modifiers = %lu", (unsigned long)self.appDelegate.currentModifierFlags, (unsigned long)event.modifierFlags];
}
else {
// Depending on the client app and the keyboard, using the passed-in event as it is should work okay.
// Keyboards that depend on chirality support will not work. And command-key actions that change the
// context might go undetected in some apps, resulting in errant behavior for subsequent typing.
coreKeyOutput = [self.kme processEvent:event];
}
[self.appDelegate logDebugMessage:@"processEventWithKeymanEngine, coreKeyOutput = %@", coreKeyOutput];
return coreKeyOutput;
}
- (void)checkEventForSentryEasterEgg:(NSEvent *)event {
if (_easterEggForSentry != nil) {
NSString * kmxName = [[self.kme.kmx filePath] lastPathComponent];
NSLog(@"Sentry - KMX name: %@", kmxName);
if ([kmxName isEqualToString:kEasterEggKmxName]) {
NSUInteger len = [_easterEggForSentry length];
NSLog(@"Sentry - Processing character(s): %@", [event characters]);
if ([[event characters] characterAtIndex:0] == [kEasterEggText characterAtIndex:len]) {
NSString *characterToAdd = [kEasterEggText substringWithRange:NSMakeRange(len, 1)];
NSLog(@"Sentry - Adding character to Easter Egg code string: %@", characterToAdd);
[_easterEggForSentry appendString:characterToAdd];
if ([_easterEggForSentry isEqualToString:kEasterEggText]) {
NSLog(@"Sentry - Forcing crash now");
[SentrySDK crash];
}
}
else if (len > 0) {
NSLog(@"Sentry - Clearing Easter Egg code string.");
[_easterEggForSentry setString:@""];
}
}
}
}
/**
handleBackspace: handles generated backspaces for a subset of non-compliant apps (such as Adobe apps) that do
not support replacing text with the insertText API but also intercept events so that we do not see them in handleEvent.
Other apps, such as Word, show the event both in the low
level EventTap and in the normal IM handleEvent. Thus, we set a flag after
processing a backspace here so that we do not process it again in handleEvent.
Note that a backspace that we handle here should never be passed on to Keyman
Core for processing, as it is already the result of processing.
*/
- (void)handleBackspace:(NSEvent *)event {
[self.appDelegate logDebugMessage:@"KMInputMethodEventHandler handleBackspace, event = %@", event];
if (self.generatedBackspaceCount > 0) {
self.generatedBackspaceCount--;
self.lowLevelBackspaceCount++;
// if we just encountered the last backspace, then send event to insert queued text
if ((self.generatedBackspaceCount == 0) && (self.queuedText.length > 0)) {
[self performSelector:@selector(triggerInsertQueuedText:) withObject:event afterDelay:kQueuedTextEventDelayInSeconds];
}
}
}
- (void)triggerInsertQueuedText:(NSEvent *)event {
[self.appDelegate logDebugMessage:@"KMInputMethodEventHandler triggerInsertQueuedText"];
[self.keySender sendKeymanKeyCodeForEvent:event];
}
//MARK: Core-related key processing
- (void)checkTextApiCompliance:(id)client {
// If the TextApiCompliance object is nil or the app or the context has changed,
// then create a new object for the current application and context.
// The text api compliance may vary from one text field to another of the same app.
if ((self.apiCompliance == nil) ||
(![self.apiCompliance.clientApplicationId isEqualTo:self.clientApplicationId]) ||
self.contextChanged) {
self.apiCompliance = [[TextApiCompliance alloc]initWithClient:client applicationId:self.clientApplicationId];
[self.appDelegate logDebugMessage:@"KMInputMethodHandler initWithClient checkTextApiCompliance: %@", _apiCompliance];
} else if (self.apiCompliance.isComplianceUncertain) {
// We have a valid TextApiCompliance object, but compliance is uncertain, so test it
[self.apiCompliance testCompliance:client];
}
}
- (void) handleContextChangedByLowLevelEvent {
if (self.appDelegate.contextChangedByLowLevelEvent) {
if (!self.contextChanged) {
[self.appDelegate logDebugMessage:@"Low-level event has changed the context."];
self.contextChanged = YES;
}
self.appDelegate.contextChangedByLowLevelEvent = NO;
}
}
// handleEvent implementation for core event processing
- (BOOL)handleEvent:(NSEvent *)event client:(id)sender {
BOOL handled = NO;
[self.appDelegate logDebugMessage:@"handleEvent event = %@", event];
[self checkTextApiCompliance:sender];
// mouse movement requires that the context be invalidated
[self handleContextChangedByLowLevelEvent];
if (event.type == NSKeyDown) {
// indicates that our generated backspace event(s) are consumed
// and we can insert text that followed the backspace(s)
if (event.keyCode == kKeymanEventKeyCode) {
[self.appDelegate logDebugMessage:@"handleEvent, handling kKeymanEventKeyCode"];
[self insertQueuedText: event client:sender];
return YES;
}
// for some apps, handleEvent will not be called for the generated backspace
// but if it is, we need to let it pass through rather than process again in core
if((event.keyCode == kVK_Delete) && (self.lowLevelBackspaceCount > 0)) {
[self.appDelegate logDebugMessage:@"handleEvent, allowing generated backspace to pass through"];
self.lowLevelBackspaceCount--;
// return NO to pass through to client app
return NO;
}
}
if (event.type == NSEventTypeFlagsChanged) {
[self handleFlagsChangedEvent:[event keyCode]];
return NO;
}
if ((event.modifierFlags & NSEventModifierFlagCommand) == NSEventModifierFlagCommand) {
[self handleCommand:event];
return NO; // let the client app handle all Command-key events.
}
if (event.type == NSKeyDown) {
[self reportContext:event forClient:sender];
handled = [self handleEventWithKeymanEngine:event in: sender];
}
[self.appDelegate logDebugMessage:@"event, keycode: %u, characters='%@' handled = %@", event.keyCode, event.characters, handled?@"yes":@"no"];
return handled;
}
-(void)handleFlagsChangedEvent:(short)keyCode {
// Mark the context as out of date for the command keys
switch(keyCode) {
case kVK_RightCommand:
case kVK_Command:
self.contextChanged = YES;
break;
case kVK_Shift:
case kVK_RightShift:
case kVK_CapsLock:
case kVK_Option:
case kVK_RightOption:
case kVK_Control:
case kVK_RightControl:
break;
}
}
/**
* Called for every keystroke.
* If we can read the context, send it to Keyman Core.
* For non-compliant apps, we cannot read the context.
* In the non-compliant app case, if the context has changed then clear it in Core.
*/
-(void)reportContext:(NSEvent *)event forClient:(id) client {
NSString *contextString = 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];
[self.appDelegate logDebugMessage:@"reportContext, setting new context='%@' for compliant app (if needed)", contextString];
[self.kme setCoreContextIfNeeded:contextString];
} else if (self.contextChanged) {
// we cannot read the text but know the context has changed, so we must clear it
[self.appDelegate logDebugMessage:@"reportContext, clearing context for non-compliant app"];
[self.kme clearCoreContext];
}
// the context is now current, reset the flag
self.contextChanged = NO;
}
-(NSString*)readContext:(NSEvent *)event forClient:(id) client {
NSString *contextString = nil;
NSAttributedString *attributedString = nil;
// if we can read the text, then get the context
if (self.apiCompliance.canReadText) {
NSRange selectionRange = [client selectedRange];
NSRange contextRange = NSMakeRange(0, selectionRange.location);
attributedString = [client attributedSubstringFromRange:contextRange];
}
if(attributedString == nil) {
contextString = @"";
} else {
contextString = attributedString.string;
}
return contextString;
}
/**
* Returns NO if we have not applied an event to the client or if Keyman Core determines that we should emit the keystroke
*/
-(BOOL)applyKeymanCoreActions:(CoreKeyOutput*)output event: (NSEvent*)event client:(id) client {
[self.appDelegate logDebugMessage:@" *** InputMethodEventHandler applyKeymanCoreActions: output = %@ ", output];
BOOL handledEvent = [self applyKeyOutputToTextInputClient:output keyDownEvent:event client:client];
[self applyNonTextualOutput:output];
// if output from Keyman Core indicates to emit the keystroke,
// then return NO, so that the OS still handles the event
if (handledEvent && output.emitKeystroke) {
[self.appDelegate logDebugMessage:@" *** InputMethodEventHandler applyKeymanCoreActions: emit keystroke true, returning false for handledEvent"];
handledEvent = NO;
}
return handledEvent;
}
-(BOOL)applyKeyOutputToTextInputClient:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client {
BOOL handledEvent = YES;
if (output.isInsertOnlyScenario) {
[self.appDelegate logDebugMessage:@"KXMInputMethodHandler applyOutputToTextInputClient, insert only scenario"];
[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
[self.appDelegate logDebugMessage:@"KXMInputMethodHandler applyOutputToTextInputClient, delete only scenario with passthrough"];
handledEvent = NO;
} else {
[self.appDelegate logDebugMessage:@"KXMInputMethodHandler applyOutputToTextInputClient, delete only scenario"];
[self sendEvents:event forOutput:output];
}
} else if (output.isDeleteAndInsertScenario) {
/*
// needs further testing to fix backspace bug in google docs (without breaking other apps)
// assume that all non-compliant apps which require backspaces apply an extra backspace if the original event is a backspace
if ((self.apiCompliance.mustBackspaceUsingEvents) && (event.keyCode == kVK_Delete)) {
output.codePointsToDeleteBeforeInsert--;
os_log_with_type(keymanLog, OS_LOG_TYPE_INFO, "isDeleteAndInsertScenario, after delete pressed subtracting one backspace to reach %d and insert text '%{public}@'", output.codePointsToDeleteBeforeInsert, output.textToInsert);
if (output.codePointsToDeleteBeforeInsert == 0) {
// no backspace events needed
[self insertAndReplaceTextForOutput:output client:client];
} else {
// still need at least one backspace event
[self sendEvents:event forOutput:output];
}
}
*/
if (self.apiCompliance.mustBackspaceUsingEvents) {
[self.appDelegate logDebugMessage:@"KXMInputMethodHandler applyOutputToTextInputClient, delete and insert scenario with events"];
[self sendEvents:event forOutput:output];
} else {
[self.appDelegate logDebugMessage:@"KXMInputMethodHandler applyOutputToTextInputClient, delete and insert scenario with insert API"];
// directly insert text and handle backspaces by using replace
[self insertAndReplaceTextForOutput:output client:client];
}
}
return handledEvent;
}
-(void)applyNonTextualOutput:(CoreKeyOutput*)output {
[self persistOptions:output.optionsToPersist];
[self applyCapsLock:output.capsLockState];
if (output.alert) {
NSBeep();
}
}
-(void) persistOptions:(NSDictionary*)options{
for(NSString *key in options) {
NSString *value = [options objectForKey:key];
if(key && value) {
[self.appDelegate logDebugMessage:@"applyNonTextualOutput calling writePersistedOptions, key: %@, value: %@", key, value];
[self.appDelegate writePersistedOptions:key withValue:value];
}
else {
[self.appDelegate logDebugMessage:@"applyNonTextualOutput, invalid values in optionsToPersist, not writing to UserDefaults, key: %@, value: %@", key, value];
}
}
}
-(void) applyCapsLock:(CapsLockState)capsLockState {
switch(capsLockState) {
case On:
//TODO: handle this, Issue #10244
break;
case Off:
//TODO: handle this, Issue #10244
break;
case Unchanged:
// do nothing
break;
}
}
-(void)insertAndReplaceTextForOutput:(CoreKeyOutput*)output client:(id) client {
[self insertAndReplaceText:output.textToInsert deleteCount:(int)output.codePointsToDeleteBeforeInsert client:client];
}
/**
* This directly inserts text and applies backspaces for the operation by replacing existing text with the new text.
* Because this method depends on the selectedRange API which is not implemented correctly for some client applications,
* this method can only be used if approved by TextApiCompliance
*/
-(void)insertAndReplaceText:(NSString *)text deleteCount:(int) replacementCount client:(id) client {
[self.appDelegate logDebugMessage:@"KXMInputMethodHandler insertAndReplaceText: %@ replacementCount: %d", text, replacementCount];
int actualReplacementCount = 0;
NSRange replacementRange;
NSRange notFoundRange = NSMakeRange(NSNotFound, NSNotFound);
if (replacementCount > 0) {
NSRange selectionRange = [client selectedRange];
// make sure we have room to apply backspaces from current location
actualReplacementCount = MIN(replacementCount, (int)selectionRange.location);
// log this: it is a sign that we are out of sync
if (actualReplacementCount < replacementCount) {
[self.appDelegate logDebugMessage:@"KXMInputMethodHandler insertAndReplaceText, replacement of %d characters limited by start of context to actual count: %d", replacementCount, actualReplacementCount];
}
if (actualReplacementCount > 0) {
[self.appDelegate logDebugMessage:@"KXMInputMethodHandler insertAndReplaceText, actualReplacementCount=%d, selectionRange.location=%lu", actualReplacementCount, selectionRange.location];
replacementRange = NSMakeRange(selectionRange.location-actualReplacementCount, replacementCount);
[self.appDelegate logDebugMessage:@"KXMInputMethodHandler insertAndReplaceText, insertText %@ in replacementRange.start=%lu, replacementRange.length=%lu", text, replacementRange.location, replacementRange.length];
} else {
replacementRange = notFoundRange;
}
} else {
replacementRange = notFoundRange;
}
[client insertText:text replacementRange:replacementRange];
if (self.apiCompliance.isComplianceUncertain) {
[self.apiCompliance testComplianceAfterInsert:client];
}
}
-(void)sendEvents:(NSEvent *)event forOutput:(CoreKeyOutput*)output {
[self.appDelegate logDebugMessage:@"sendEvents called, output = %@", output];
_sourceForGeneratedEvent = CGEventCreateSourceFromEvent([event CGEvent]);
self.generatedBackspaceCount = output.codePointsToDeleteBeforeInsert;
if (output.hasCodePointsToDelete) {
for (int i = 0; i < output.codePointsToDeleteBeforeInsert; i++) {
[self.appDelegate logDebugMessage:@"sendEvents sending backspace key event"];
[self.keySender sendBackspaceforEventSource:_sourceForGeneratedEvent];
}
}
if (output.hasTextToInsert) {
[self.appDelegate logDebugMessage:@"sendEvents, queueing text to insert: %@", output.textToInsert];
self.queuedText = output.textToInsert;
}
}
-(void)insertQueuedText: (NSEvent *)event client:(id) client {
if (self.queuedText.length> 0) {
[self.appDelegate logDebugMessage:@"insertQueuedText, inserting %@", self.queuedText];
[self insertAndReplaceText:self.queuedText deleteCount:0 client:client];
self.queuedText = nil;
} else {
[self.appDelegate logDebugMessage:@"insertQueuedText called but no text to insert"];
}
}
@end