diff --git a/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj b/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj index 054ba75f52..0579836d62 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj +++ b/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj @@ -50,6 +50,10 @@ 98FE105E1B4DE8F400525F54 /* NSWindow+SuppMethods.m in Sources */ = {isa = PBXBuildFile; fileRef = 98FE105D1B4DE8F400525F54 /* NSWindow+SuppMethods.m */; }; 98FE10631B4DEE5600525F54 /* KMInfoWindowController.m in Sources */ = {isa = PBXBuildFile; fileRef = 98FE10611B4DEE5600525F54 /* KMInfoWindowController.m */; }; 98FE10641B4DEE5600525F54 /* KMInfoWindowController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98FE10621B4DEE5600525F54 /* KMInfoWindowController.xib */; }; + E21799051FC5B7BC00F2D66A /* KMInputMethodEventHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = E21799041FC5B7BC00F2D66A /* KMInputMethodEventHandler.m */; }; + E21799061FC5B7BC00F2D66A /* KMInputMethodEventHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = E21799041FC5B7BC00F2D66A /* KMInputMethodEventHandler.m */; }; + E22020F6200506F300B74FAC /* KMInputMethodSafariClientEventHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = E22020F5200506F300B74FAC /* KMInputMethodSafariClientEventHandler.m */; }; + E24C79EA1FFFCC7500D8E46F /* KMInputMethodBrowserClientEventHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = E24C79E91FFFCC7500D8E46F /* KMInputMethodBrowserClientEventHandler.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -145,6 +149,14 @@ 98FE10601B4DEE5600525F54 /* KMInfoWindowController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = KMInfoWindowController.h; path = Keyman4MacIM/KMInfoWindow/KMInfoWindowController.h; sourceTree = ""; }; 98FE10611B4DEE5600525F54 /* KMInfoWindowController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = KMInfoWindowController.m; path = Keyman4MacIM/KMInfoWindow/KMInfoWindowController.m; sourceTree = ""; }; 98FE10621B4DEE5600525F54 /* KMInfoWindowController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; name = KMInfoWindowController.xib; path = Keyman4MacIM/KMInfoWindow/KMInfoWindowController.xib; sourceTree = ""; }; + E21799031FC5B74D00F2D66A /* KMInputMethodEventHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KMInputMethodEventHandler.h; sourceTree = ""; }; + E21799041FC5B7BC00F2D66A /* KMInputMethodEventHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KMInputMethodEventHandler.m; sourceTree = ""; }; + E22020F3200505EF00B74FAC /* KMInputMethodSafariClientEventHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KMInputMethodSafariClientEventHandler.h; sourceTree = ""; }; + E22020F5200506F300B74FAC /* KMInputMethodSafariClientEventHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = KMInputMethodSafariClientEventHandler.m; sourceTree = ""; }; + E22020F920050D6300B74FAC /* KMInputMethodBrowserClientEventHandlerProtected.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = KMInputMethodBrowserClientEventHandlerProtected.h; sourceTree = ""; }; + E24C79E91FFFCC7500D8E46F /* KMInputMethodBrowserClientEventHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KMInputMethodBrowserClientEventHandler.m; sourceTree = ""; }; + E24C79EB1FFFCE3000D8E46F /* KMInputMethodBrowserClientEventHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KMInputMethodBrowserClientEventHandler.h; sourceTree = ""; }; + E24C79EC1FFFEA6B00D8E46F /* KMInputMethodEventHandlerProtected.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KMInputMethodEventHandlerProtected.h; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -266,6 +278,7 @@ 98D6DA791A799EE700B09822 /* Frameworks */, 989C9C121A7876DE00A20425 /* Images.xcassets */, 98BDD3681BC3511200FAC7C4 /* Keyman4MacIM.entitlements */, + 98BF92401BF01CBE0002126A /* KMAboutWindow */, 983246EC1A9E8E860010B90C /* KMConfiguration */, 9897BD8E1B099A2400C3F58A /* KMDownloadKeyboard */, 98FE105F1B4DEE1100525F54 /* KMInfoWindow */, @@ -273,8 +286,15 @@ 98D6DA7D1A799FF400B09822 /* KMInputController.m */, 98A778C21A8C53BF00CF809D /* KMInputMethodAppDelegate.h */, 98A778C31A8C53BF00CF809D /* KMInputMethodAppDelegate.m */, + E21799031FC5B74D00F2D66A /* KMInputMethodEventHandler.h */, + E24C79EC1FFFEA6B00D8E46F /* KMInputMethodEventHandlerProtected.h */, + E22020F920050D6300B74FAC /* KMInputMethodBrowserClientEventHandlerProtected.h */, + E21799041FC5B7BC00F2D66A /* KMInputMethodEventHandler.m */, + E24C79EB1FFFCE3000D8E46F /* KMInputMethodBrowserClientEventHandler.h */, + E22020F3200505EF00B74FAC /* KMInputMethodSafariClientEventHandler.h */, + E24C79E91FFFCC7500D8E46F /* KMInputMethodBrowserClientEventHandler.m */, + E22020F5200506F300B74FAC /* KMInputMethodSafariClientEventHandler.m */, 98E672961B532DE000DBDE2F /* KMKeyboardHelpWindow */, - 98BF92401BF01CBE0002126A /* KMAboutWindow */, 989C9C141A7876DE00A20425 /* MainMenu.xib */, 984B8F3E1AF1C2F400E096A8 /* OnScreenKeyboard */, 989C9C0B1A7876DE00A20425 /* Supporting Files */, @@ -521,6 +541,7 @@ 984B8F441AF1C3D900E096A8 /* OSKWindowController.m in Sources */, 9836B3711AE5F11D00780482 /* mztools.c in Sources */, 9836B3701AE5F11D00780482 /* ioapi.c in Sources */, + E21799051FC5B7BC00F2D66A /* KMInputMethodEventHandler.m in Sources */, 98E6729F1B532F5E00DBDE2F /* KMDownloadKBWindowController.m in Sources */, 98E672A41B532F9900DBDE2F /* KMKeyboardHelpWindowController.m in Sources */, 98A778C41A8C53BF00CF809D /* KMInputMethodAppDelegate.m in Sources */, @@ -529,6 +550,8 @@ 98FE10631B4DEE5600525F54 /* KMInfoWindowController.m in Sources */, 9836B3721AE5F11D00780482 /* unzip.c in Sources */, 9832472E1A9EABC70010B90C /* KMConfigColumn1CellView.m in Sources */, + E22020F6200506F300B74FAC /* KMInputMethodSafariClientEventHandler.m in Sources */, + E24C79EA1FFFCC7500D8E46F /* KMInputMethodBrowserClientEventHandler.m in Sources */, 98FE105E1B4DE8F400525F54 /* NSWindow+SuppMethods.m in Sources */, 9836B3741AE5F11D00780482 /* ZipArchive.mm in Sources */, 983247B21A9EB9690010B90C /* KMConfigColumn3CellView.m in Sources */, @@ -543,6 +566,7 @@ buildActionMask = 2147483647; files = ( 989C9C221A7876DE00A20425 /* Keyman4MacIMTests.m in Sources */, + E21799061FC5B7BC00F2D66A /* KMInputMethodEventHandler.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputController.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputController.m index 2e6bb9bbc3..85cb61637d 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputController.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputController.m @@ -7,48 +7,14 @@ // #import "KMInputController.h" +#import "KMInputMethodEventHandler.h" +#import "KMInputMethodBrowserClientEventHandler.h" +#import "KMInputMethodSafariClientEventHandler.h" #include /* For kVK_ constants. */ -@interface KMInputController () -@property (nonatomic, strong) NSMutableDictionary *kbData; -//@property (nonatomic, strong) NSDictionary *kmModes; -@property (assign) BOOL willDeleteNullChar; -@end - @implementation KMInputController -const CGKeyCode kProcessPendingBuffer = 0xFF; - -//_pendingBuffer contains text that is ready to be sent to the client when all delete-backs are finished. -NSMutableString* _pendingBuffer; -NSUInteger _numberOfPostedDeletesToExpect = 0; -CGKeyCode _keyCodeOfOriginalEvent; -CGEventSourceRef _sourceFromOriginalEvent = nil; -BOOL _contextOutOfDate = YES; - -// This flag indicates which mode is being used for replacing already typed text when composing characters -// (i.e., when not using deadkeys). Some apps (and some javascript-based websites, such as Google Docs) do -// not properly deal with calls to insertText calls that replace a range of characters. So for these "legacy" -// apps, we post one or more deletes (i.e., backspace), followed by a special code that tells us we're now -// ready to insert the composed text. -BOOL _legacyMode = NO; -// Some clients (e.g. Chrome) handle the mouse down events before we get a crack at them. For such apps that -// are able to report their current selection location (LibreOffice can't even do that!), we can do some -// checking at the start of the event processing to see if we're probably still in the same place where we -// left off previously. -BOOL _clientSelectionCanChangeUnexpectedly = YES; // REVIEW: Maybe we can get notification from these clients by handling mouseDownOnCharacterIndex. -BOOL _insertCharactersIndividually = NO; -// Because Google Docs can't report its context in any of the browsers (Safari, Chrome, Firefox), we want to -// try to detect it and: -// in Safari, switch to legacy mode -// in Chrome, NOT assume that it needs to re-get the context every time around (which means that if the user -// does mouse-click somewhere else, it could lead to bad behaviour). -// in Firefox, we're already in legacy mode and we do get mouse clicks, so we're already doing the best we can. -NSUInteger _failuresToRetrieveExpectedContext = NSUIntegerMax; -BOOL _forceRemoveSelectionInGoogleDocs = NO; -BOOL _explicitlyDeleteExistingSelectionBeforeInserting = NO; -BOOL _cannnotTrustSelectionLength = NO; -NSRange _previousSelRange; +KMInputMethodEventHandler* _eventHandler; - (KMInputMethodAppDelegate *)AppDelegate { return (KMInputMethodAppDelegate *)[NSApp delegate]; @@ -74,648 +40,34 @@ NSRange _previousSelRange; if ([self.AppDelegate debugMode]) NSLog(@"Event = %@", event); - if (event == nil || sender == nil || self.kmx == nil) + if (event == nil || sender == nil || self.kmx == nil || _eventHandler == nil) return NO; // Not sure this can ever happen. - // OSK key feedback from hardware keyboard is disabled - /*if (event.type == NSKeyDown) - [self.AppDelegate handleKeyEvent:event];*/ - - if (event.type == NSLeftMouseDown || event.type == NSLeftMouseUp ) { -// if (_clientSelectionCanChangeUnexpectedly) { -// if ([self.AppDelegate debugMode]) -// NSLog(@"WARNING: We are dealing with an app/context where we THINK we shouldn't be getting mouse events, but we just got one!"); -// _clientSelectionCanChangeUnexpectedly = NO; -// } - _contextOutOfDate = YES; - return NO; - } - else if (event.type != NSKeyDown) - return NO; // We ignore NSLeftMouseDragged events (because we'll eventually get a mouse-up). - else if ((event.modifierFlags & NSEventModifierFlagCommand) == NSEventModifierFlagCommand) { - // There are a bunch of common navigation/selection command-key combinations, but individual - // apps may implement specifc commands that also chnage 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 "legacy" apps can't get their context at all. - if ([self.AppDelegate debugMode]) - NSLog(@"Command key - context needs to be re-gotten."); - _contextOutOfDate = YES; - return NO; // We let the client app handle all Command-key events. - } - - if (_legacyMode && event.keyCode == kProcessPendingBuffer) - { - if ([self.AppDelegate debugMode]) - NSLog(@"Processing the special %hu code", kProcessPendingBuffer); - - NSUInteger length = [self pendingBuffer].length; - if (length > 0) { - if ([self.AppDelegate debugMode]) { - for (NSUInteger ich = 0; ich < length; ich++) - NSLog(@"Char %li: '%x'", ich, [[self pendingBuffer] characterAtIndex:ich]); - } - NSString* text; - if (length > 1 && _insertCharactersIndividually) { - if ([self.AppDelegate debugMode]) { - NSLog(@"Using special Google Docs in Chrome logic"); - } - unichar chars[1]; - chars[0] = [[self pendingBuffer] characterAtIndex:0]; - text = [[NSString alloc] initWithCharacters:chars length:1]; - } - else { - text = [self pendingBuffer]; - } - - if ([self.AppDelegate debugMode]) - NSLog(@"Inserting text from pending buffer: \"%@\"", text); - - [sender insertText:text replacementRange:NSMakeRange(NSNotFound, NSNotFound)]; - _previousSelRange.location += text.length; - _previousSelRange.length = 0; - - if (length > 1 && _insertCharactersIndividually) { - // Come back for more... - [_pendingBuffer deleteCharactersInRange:NSMakeRange(0, 1)]; - [self performSelector:@selector(initiatePendingBufferProcessing:) withObject:sender afterDelay:0.1]; - } - else { - [self setPendingBuffer:@""]; - } - } - else { - if ([self.AppDelegate debugMode]) - NSLog(@"Error - expected text in pending buffer!"); - } - return YES; - } - - if (!self.willDeleteNullChar && !_contextOutOfDate && _failuresToRetrieveExpectedContext < 3) { - if ([self AppDelegate].debugMode) { - NSLog(@"Checking to see if we're in Google Docs (or some other site that can't give context)."); - } - NSUInteger bufferLength = self.contextBuffer.length; - if (bufferLength) { - NSUInteger location = [sender selectedRange].location; - - if (location != NSNotFound && location > 0) { - NSString *clientContext = [[sender attributedSubstringFromRange:NSMakeRange(0, location)] string]; - if (clientContext == nil || !clientContext.length || - [clientContext characterAtIndex:clientContext.length - 1] != - [self.contextBuffer characterAtIndex:bufferLength - 1]) { - _failuresToRetrieveExpectedContext++; - } - else { - if ([self AppDelegate].debugMode) { - NSLog(@"We got what we were expecting from the client. We can stop checking."); - } - _failuresToRetrieveExpectedContext = NSUIntegerMax; - } - } - else { - _failuresToRetrieveExpectedContext++; - } - - if (_failuresToRetrieveExpectedContext == 3) - { - if ([self AppDelegate].debugMode) { - NSLog(@"Detected Google Docs or some other editor that can't provide context. Using legacy mode."); - } - _failuresToRetrieveExpectedContext = NSUIntegerMax; - _legacyMode = YES; - if (_clientSelectionCanChangeUnexpectedly) { - _cannnotTrustSelectionLength = YES; - _clientSelectionCanChangeUnexpectedly = NO; // This isn't true (it can change unexpectedly), but we can't get the context, so we pretend/hope it won't. - // Google docs in Chrome allows only a single character at a time :-( - _insertCharactersIndividually = YES; - } - } - } - } - else if ((event.modifierFlags & NSEventModifierFlagCommand) == NSEventModifierFlagCommand) - return NO; // We ignore any Command-key events. - - if ([self.AppDelegate debugMode]) { - if (_clientSelectionCanChangeUnexpectedly) - NSLog(@"_clientSelectionCanChangeUnexpectedly = YES"); - else - NSLog(@"_clientSelectionCanChangeUnexpectedly = NO"); - } - // The following is needed because some clients (e.g. Chrome & Terminal) handle mouse down events and we - // never get a crack at them: - // At the start of the loop before asking the Engine to process the event, unless we still have - // pending work to do based on posted deletes or the special kProcessPendingBuffer code, check to see if - // the client still reports a selection that matches the position we would expect based on the length of - // the context buffer. If not, then reset the context buffer. - if (_clientSelectionCanChangeUnexpectedly && _numberOfPostedDeletesToExpect == 0 && - (_pendingBuffer == nil || _pendingBuffer.length == 0)) { - NSRange currentSelRange = [sender selectedRange]; - - if (currentSelRange.location == NSNotFound) { - _clientSelectionCanChangeUnexpectedly = NO; - } - else if ((_previousSelRange.location != currentSelRange.location || _cannnotTrustSelectionLength || _previousSelRange.length != currentSelRange.length)) { - if ([self.AppDelegate debugMode]) { - NSLog(@"Client selection may have changed since context was set. Resetting context..."); - NSLog(@" _previousSelRange.location = %lu", _previousSelRange.location); - NSLog(@" _previousSelRange.length = %lu", _previousSelRange.length); - NSLog(@" currentSelRange.location = %lu", currentSelRange.location); - if (_cannnotTrustSelectionLength) - NSLog(@"The following cannot be trusted and will be ignored:"); - NSLog(@" currentSelRange.length = %lu", currentSelRange.length); - } - [self updateContextBuffer:sender]; - } - } - - if (_contextOutOfDate) - [self updateContextBuffer:sender]; - - BOOL handled = NO; - BOOL deleteBackPosted = NO; - NSArray *actions = nil; - if (!self.willDeleteNullChar) { - actions = [self.kme processEvent:event]; - } - - if ([self.AppDelegate debugMode]) { - NSLog(@"sender type = %@", NSStringFromClass([sender class])); - NSLog(@"sender selection range location = %lu", [sender selectedRange].location); - NSLog(@"actions = %@", actions); - } - for (NSDictionary *action in actions) { - NSString *actionType = [[action allKeys] objectAtIndex:0]; - if ([actionType isEqualToString:Q_STR]) { - if ([self.AppDelegate debugMode]) - NSLog(@"About to start handling Q_STR action..."); - - NSString *output = [action objectForKey:actionType]; - if ([self.AppDelegate debugMode]) - NSLog(@"output = %@", output); - if (deleteBackPosted) - { - [self appendPendingBuffer:output]; - if ([self.AppDelegate debugMode]) - NSLog(@"pendingBuffer = %@", [self pendingBuffer]); - } - else { - NSUInteger nc = [self.contextBuffer deleteLastNullChars]; - // Each null in the context buffer presumably corresponds to a space we inserted when - // processing the Q_BACK, which we now need to replace with the text we're inserting. - if (nc > 0) { - if ([self.AppDelegate debugMode]) - NSLog(@"nc = %lu", nc); - NSRange selRange = [sender selectedRange]; - NSUInteger pos = selRange.location; - if (pos >= nc && pos != NSNotFound) { - if ([self.AppDelegate debugMode]) { - NSLog(@"Replacement index = %lu", pos - nc); - NSLog(@"Replacement length = %lu", nc); - } - [sender insertText:output replacementRange:NSMakeRange(pos - nc, nc)]; - _previousSelRange.location += output.length - nc; - _previousSelRange.length = 0; - } - } - else { - // The following commented out code is the code that would make it possible to type over an - // existing selection in Word in Safari, but I don't know of a way to distinguish that case - // from other contexts in Safari (wherein this code inserts an extra leading space). -// if (_explicitlyDeleteExistingSelectionBeforeInserting) { -// NSUInteger selLength = [sender selectedRange].length; -// if (selLength > 0 && selLength != NSNotFound) { -// if ([self.AppDelegate debugMode]) { -// NSLog(@"Attempting to delete existing selection of length = %lu by replacing it with a space (which should not actually appear in the text).", selLength); -// } -// -// [sender insertText:@" " replacementRange:NSMakeRange(NSNotFound, NSNotFound)]; -// -// if ([self.AppDelegate debugMode]) -// NSLog(@"Re-posting original (unhandled) code: %d", (int)_keyCodeOfOriginalEvent); -// -// CGEventSourceRef sourceFromEvent = CGEventCreateSourceFromEvent([event CGEvent]); -// [self postKeyPressToFrontProcess:event.keyCode from:sourceFromEvent]; -// CFRelease(sourceFromEvent); -// return YES; -// } -// } - [sender insertText:output replacementRange:NSMakeRange(NSNotFound, NSNotFound)]; - _previousSelRange.location += output.length; - _previousSelRange.length = 0; - - // In Google Docs in Safari when there is an existing selection, the inserted characters - // stays selected. The following clears the selection. - if (_legacyMode && _forceRemoveSelectionInGoogleDocs) { - if ([self.AppDelegate debugMode]) - NSLog(@"Sending Command-Shift-A to clear selection in Google Docs"); - ProcessSerialNumber psn; - GetFrontProcess(&psn); - - CGEventRef event = CGEventCreateKeyboardEvent(NULL, kVK_ANSI_A, true); - //set shift and command keys down for above event - CGEventSetFlags(event, kCGEventFlagMaskShift | kCGEventFlagMaskCommand); - CGEventPostToPSN(&psn, event); - CFRelease(event); - - event = CGEventCreateKeyboardEvent(NULL, kVK_ANSI_A, false); - CGEventSetFlags(event, kCGEventFlagMaskShift | kCGEventFlagMaskCommand); - CGEventPostToPSN(&psn, event); - CFRelease(event); - } - } - } - - // Even if the characters to insert are pending, we want to append them to the context buffer now. - // Waiting until they are inserted would probably be safe, but on the off-chance that the engine - // generates additional actions beyond this current one, we want to be sure that the context reflects - // the state as it *will* be when all the posted/pending events have been processed. - [self.contextBuffer appendString:output]; - } - else if ([actionType isEqualToString:Q_BACK]) { - if ([self.AppDelegate debugMode]) - { - NSLog(@"About to start handling Q_BACK action..."); - NSLog(@"contextBuffer = \"%@\"", self.contextBuffer.length?[self.contextBuffer codeString]:@"{empty}"); - } - [self.contextBuffer deleteLastNullChars]; - NSUInteger dk = [self.contextBuffer deleteLastDeadkeys]; - NSInteger n = [[action objectForKey:actionType] integerValue] - dk; - NSUInteger dc = [[self.contextBuffer lastNChars:n] deadKeyCount]; - // n is now the number of characters to delete from the context buffer - // (which could include deadkeys the client doesn't know about). - [self.contextBuffer deleteLastNChars:n]; - n -= dc; - - // n is now the number of characters to delete from the client. - if (n > 0) { - if ([self.AppDelegate debugMode]) - { - NSLog(@"Q_BACK"); - NSLog(@"dk = %lu", dk); - NSLog(@"n = %li", n); - NSLog(@"dc = %lu", dc); - if (_legacyMode) - NSLog(@"Using Legacy mode."); - else - NSLog(@"Using Apple IM-compliant mode."); - } - NSRange selectedRange = [sender selectedRange]; - NSInteger pos = selectedRange.location; - if (_legacyMode && self.contextBuffer != nil && (pos == 0 || pos == NSNotFound)) { - pos = self.contextBuffer.length + n; - } - if ([self.AppDelegate debugMode]) - NSLog(@"pos = %lu", pos); - - if (!_legacyMode && pos >= n && pos != NSNotFound) { - NSInteger preCharPos = pos - (n+1); - if ((preCharPos) >= 0) { - NSUInteger nbrOfPreCharacters; - NSString *preChar = nil; - - // This regex will look back through the context until it finds a *known* base - // character because some (non-legacy) apps (e.g., Mail) do not properly handle sending - // comining marks on there own via insertText. One potentially negative implication - // of this is that if the script should happen to contain characters whose class is - // not known, it will skip over them and keep looking, so it could end up using a - // longer string of characters than otherwise necessary. This could result in a - // mildly jarring visual experience for the user if the app refreshes the diplay - // between the time the characters are removed and re-inserted. But presumbly this - // algorithm will eventually find either a known base character or get all the way back - // to the start of the context, so if it doesn't find a known base character, it will - // fall back to just attempting the insert with whatever it does find. I believe this - // will always work and should at least work as reliably as the old version of the code, - // which always used just a single character regardless of its class. - NSError *error = NULL; - NSRegularExpression *regexNonCombiningMark = [NSRegularExpression regularExpressionWithPattern:@"\\P{M}" options:NSRegularExpressionCaseInsensitive error:&error]; - - for (nbrOfPreCharacters = 1; YES; nbrOfPreCharacters++, preCharPos--) { - preChar = [[sender attributedSubstringFromRange:NSMakeRange(preCharPos, nbrOfPreCharacters)] string]; - if (!preChar) { - if ([self.AppDelegate debugMode]) { - NSLog(@"Client apparently doesn't implement attributedSubstringFromRange. Attempting to get preChar from context..."); - } - if (self.contextBuffer != nil && preCharPos < self.contextBuffer.length) { - preChar = [self.contextBuffer substringWithRange:NSMakeRange(preCharPos, 1)]; - } - if (!preChar) - break; - } - if ([self.AppDelegate debugMode]) - NSLog(@"Testing preChar: %@", preChar); - - if ([regexNonCombiningMark numberOfMatchesInString:preChar options:NSMatchingAnchored range:NSMakeRange(0, 1)] > 0) - break; - if (preCharPos == 0) { - if ([self.AppDelegate debugMode]) { - NSLog(@"Failed to find a base character!"); - } - break; - } - if ([self.AppDelegate debugMode]) { - NSLog(@"Have not yet found a base character. nbrOfPreCharacters = %lu", nbrOfPreCharacters); - } - } - if (preChar) { - if ([self.AppDelegate debugMode]) { - NSLog(@"preChar (to insert at %lu) = \"%@\"", preCharPos, preChar); - } - [sender insertText:preChar replacementRange:NSMakeRange(preCharPos, n+nbrOfPreCharacters)]; - } - else { - if ([self.AppDelegate debugMode]) { - NSLog(@"Switching to legacy mode - client apparently doesn't implement attributedSubstringFromRange and no previous character in context buffer."); - } - _legacyMode = YES; // client apparently doesn't implement attributedSubstringFromRange. - } - } - else { - if ([self.AppDelegate debugMode]) - NSLog(@"No previous character to use for replacement - replacing range with space"); - [sender insertText:@" " replacementRange:NSMakeRange(pos - n, n)]; - [self.contextBuffer appendNullChar]; - } - } - if (_legacyMode && pos >= n) { - // n is now the number of delete-backs we need to post (plus one more if there is selected text) - if ([self.AppDelegate debugMode]) { - NSLog(@"Legacy mode: calling deleteBack"); - if (_cannnotTrustSelectionLength) - NSLog(@"Cannot trust client to report accurate selection length - assuming no selection."); - } - - // Note: If pos is "not found", most likely the client can't accurately report the location. This might be - // dangerous, but for now let's go ahead and attempt to delete the characters we think should be there. - if (_pendingBuffer != nil && [[self pendingBuffer] length] > 0) { - NSException* exception = [NSException - exceptionWithName:@"InvalidOperationException" - reason:@"Cannot process subsequent Q_BACK after Q_STR" - userInfo:nil]; - @throw exception; - } - - if (!_cannnotTrustSelectionLength && selectedRange.length > 0) - n++; // First delete-back will delete the existing selection. - [self deleteBack:n for:event]; - deleteBackPosted = YES; - - CFRelease(_sourceFromOriginalEvent); - _sourceFromOriginalEvent = nil; - } - } - } - else if ([actionType isEqualToString:Q_DEADKEY]) { - [self.contextBuffer deleteLastNullChars]; - NSUInteger x = [[action objectForKey:actionType] unsignedIntegerValue]; - [self.contextBuffer appendDeadkey:x]; - } - else if ([actionType isEqualToString:Q_NUL]) { - continue; - } - else if ([actionType isEqualToString:Q_RETURN]) { -// if (_legacyMode) -// _previousSelRange = [sender selectedRange]; - return YES; - } - else if ([actionType isEqualToString:Q_BEEP]) { - [[NSSound soundNamed:@"Tink"] play]; - } - - handled = YES; - } - - // Apply context changes if not handled - if (!handled) { - NSUInteger nc = [self.contextBuffer deleteLastNullChars]; - if (nc > 0) { - if ([self.AppDelegate debugMode]) - NSLog(@"Deleted %li null characters from context buffer", nc); - // This can presumably only happen if a previous event resulted in a chain of - // actions that had a Q_BACK not followed by a Q_STR (assuming that can happen). - // REVIEW: Need to test this scenario in Atom and Googe Docs in Safari - self.willDeleteNullChar = YES; - [self deleteBack:nc for:event]; - _keyCodeOfOriginalEvent = event.keyCode; - - return YES; - } - - NSString* charactersToAppend = nil; - BOOL updateEngineContext = YES; - unsigned short keyCode = event.keyCode; - switch (keyCode) { - case kVK_Delete: - if ([self.AppDelegate debugMode]) { - NSLog(@"Processing an unhandled delete-back..."); - NSLog(@"_numberOfPostedDeletesToExpect = %lu", _numberOfPostedDeletesToExpect); - } - - // If we have pending characters to insert following the delete-back, then - // the context buffer has already been properly set to reflect the deletions. - if ((_legacyMode && (_pendingBuffer == nil || _pendingBuffer.length == 0)) || - (!_legacyMode && (!self.willDeleteNullChar))) - { - // Backspace clears last "real" character from buffer, plus any surrounding deadkeys - [self.contextBuffer deleteLastDeadkeys]; - [self.contextBuffer deleteLastNChars:1]; - if (_legacyMode) { - _previousSelRange.location -= 1; - _previousSelRange.length = 0; - } - [self.contextBuffer deleteLastDeadkeys]; - } - if (_numberOfPostedDeletesToExpect > 0) { - if (--_numberOfPostedDeletesToExpect == 0) { - if ([self.AppDelegate debugMode]) - NSLog(@"Processing final posted delete-back..."); - - self.willDeleteNullChar = NO; - if (_legacyMode && _pendingBuffer != nil && _pendingBuffer.length > 0) { - if ([self.AppDelegate debugMode]) - NSLog(@"Posting special code to tell IM to insert characters from pending buffer."); - [self performSelector:@selector(initiatePendingBufferProcessing:) withObject:sender afterDelay:0.1]; - } - else { - if ([self.AppDelegate debugMode]) { - NSLog(@"Re-posting original (unhandled) code: %d", (int)_keyCodeOfOriginalEvent); - } - [self postKeyPressToFrontProcess:_keyCodeOfOriginalEvent from:_sourceFromOriginalEvent]; - _keyCodeOfOriginalEvent = 0; - CFRelease(_sourceFromOriginalEvent); - _sourceFromOriginalEvent = nil; - } - updateEngineContext = NO; - } - } - else { - self.willDeleteNullChar = NO; - } - break; - - case kVK_LeftArrow: - // Marc has suggested that it's better to just let it be dumb. Too many potential pitfalls - // trying to guess where in the context we ended up after a left arrow. -// if (_legacyMode && self.contextBuffer.length) { -// NSUInteger flags = [event modifierFlags] & NSEventModifierFlagDeviceIndependentFlagsMask; -// if ([self.AppDelegate debugMode]) -// NSLog(@"Legacy app Left arrow. flags = 0x%02x", (int)flags); -// if ((flags ^ NSEventModifierFlagShift) == 0) { -// [self.contextBuffer deleteLastNChars:1]; -// if ([self.AppDelegate debugMode]) -// NSLog(@"Removed one character from context."); -// break; -// } -// } - /* FALLTHROUGH */ - case kVK_RightArrow: - case kVK_UpArrow: - case kVK_DownArrow: - case kVK_Home: - case kVK_End: - case kVK_PageUp: - case kVK_PageDown: - _contextOutOfDate = YES; - updateEngineContext = NO; - break; - - case kVK_Return: - case kVK_ANSI_KeypadEnter: - charactersToAppend = @"\n"; - break; - - default: - { - unichar ch = [event.characters characterAtIndex:0]; - // REVIEW: Is ch ever != keyCode? Is there ever more than one character in event.characters? - if (keyCode < 0x33 || (ch >= 0x2A && ch <= 0x39)) { // Main keys, Numpad char range, normal punctuation - charactersToAppend = event.characters; - } - else { - // Other keys - } - } - break; - } - if (charactersToAppend != nil) { - if ([self.AppDelegate debugMode]) { - NSLog(@"Adding \"%@\" to context buffer", charactersToAppend); - } - [self.contextBuffer appendString:charactersToAppend]; - if (_legacyMode) { - _previousSelRange.location += charactersToAppend.length; - _previousSelRange.length = 0; - } - } - - if (updateEngineContext) { - [self.kme setContextBuffer:self.contextBuffer]; - } - } - - if ([self.AppDelegate debugMode]) { - NSLog(@"handledEvent: %@", handled?@"YES":@"NO"); - if (_contextOutOfDate) - NSLog(@"Context now out of date."); - else - NSLog(@"contextBuffer = \"%@\"", self.contextBuffer.length?[self.contextBuffer codeString]:@"{empty}"); - NSLog(@"kme.contextBuffer = \"%@\"", self.kme.contextBuffer.length?[self.kme.contextBuffer codeString]:@"{empty}"); - NSRange range = [sender markedRange]; - NSLog(@"sender.markedRange.location = %lu", range.location); - NSLog(@"sender.markedRange.length = %lu", range.length); - range = [sender selectedRange]; - NSLog(@"sender.selectedRange.location = %lu", range.location); - if (_cannnotTrustSelectionLength) - NSLog(@"The following cannot be trusted and will be ignored:"); - NSLog(@"sender.selectedRange.length = %lu", range.length); - NSLog(@"***"); - } - -// Seems we can't do it this way because (at least for "legacy mode" apps, the selection range doesn't get updated -// until after we return from this method. -// if (_legacyMode) -// _previousSelRange = [sender selectedRange]; - return handled; + return [_eventHandler handleEvent:event client:sender]; } - (void)activateServer:(id)sender { [sender overrideKeyboardWithKeyboardNamed:@"com.apple.keylayout.US"]; - + + if (_eventHandler != nil) { + [_eventHandler deactivate]; + } + NSRunningApplication *currApp = [[NSWorkspace sharedWorkspace] frontmostApplication]; NSString *clientAppId = [currApp bundleIdentifier]; if ([self.AppDelegate debugMode]) NSLog(@"New active app %@", clientAppId); - _previousSelRange = NSMakeRange(NSNotFound, NSNotFound); - _clientSelectionCanChangeUnexpectedly = YES; - _forceRemoveSelectionInGoogleDocs = NO; - _explicitlyDeleteExistingSelectionBeforeInserting = NO; - _cannnotTrustSelectionLength = NO; - _insertCharactersIndividually = NO; - // REVIEW: Should this list be in a info.plist file - // Use a table of known apps to decide whether or not to operate in legacy mode - // and whether or not to follow calls to setMarkedText with calls to insertText. - if ([clientAppId isEqual: @"com.google.Chrome"] || - [clientAppId isEqual: @"org.mozilla.firefox"] || - [clientAppId isEqual: @"com.github.atom"] || - [clientAppId isEqual: @"com.collabora.libreoffice-free"] || - [clientAppId isEqual: @"com.axosoft.gitkraken"] || - [clientAppId isEqual: @"org.sil.app.builder.scripture.ScriptureAppBuilder"] || - [clientAppId isEqual: @"org.sil.app.builder.reading.ReadingAppBuilder"] || - [clientAppId isEqual: @"org.sil.app.builder.dictionary.DictionaryAppBuilder"] || - [clientAppId isEqual: @"com.microsoft.Word"] - /*||[clientAppId isEqual: @"ro.sync.exml.Oxygen"] - Oxygen has worse problems */) { - _legacyMode = YES; - if ([self.AppDelegate debugMode]) - NSLog(@"Using legacy mode for this app."); - } - else { - _legacyMode = NO; - } - - // if ([clientAppId isEqual: @"com.google.Chrome"] || - // [clientAppId isEqual: @"com.apple.Terminal"] || - // [clientAppId isEqual: @"com.apple.dt.Xcode"]) { - // _clientSelectionCanChangeUnexpectedly = YES; - // } - - if ([clientAppId isEqual: @"com.github.atom"]) { - // This isn't true (the context can change unexpectedly), but we can't get the context, - // so we pretend/hope it won't. - _clientSelectionCanChangeUnexpectedly = NO; - } // Most things in Safari work well using the normal way, but Google Docs doesn't. - if ([clientAppId isEqual: @"com.google.Chrome"] || - [clientAppId isEqual: @"com.apple.Safari"] || - [clientAppId isEqual: @"org.mozilla.firefox"]) { - _failuresToRetrieveExpectedContext = 0; - _forceRemoveSelectionInGoogleDocs = [clientAppId isEqual: @"com.apple.Safari"]; - //_explicitlyDeleteExistingSelectionBeforeInserting = [clientAppId isEqual: @"com.apple.Safari"]; + if ([clientAppId isEqual: @"com.apple.Safari"]) { + _eventHandler = [[KMInputMethodSafariClientEventHandler alloc] init]; } - else { - _failuresToRetrieveExpectedContext = NSUIntegerMax; + else if ([clientAppId isEqual: @"org.mozilla.firefox"] || + [clientAppId isEqual: @"com.google.Chrome"]) { + _eventHandler = [[KMInputMethodBrowserClientEventHandler alloc] init]; } - - if (_numberOfPostedDeletesToExpect > 0 || (_pendingBuffer != nil && _pendingBuffer.length > 0) || - _keyCodeOfOriginalEvent != 0 || _sourceFromOriginalEvent != nil) - { - if ([self.AppDelegate debugMode]) { - NSLog(@"ERROR: new app activated before previous app finished processing pending events!"); - NSLog(@" _numberOfPostedDeletesToExpect = %lu", _numberOfPostedDeletesToExpect); - NSLog(@" pendingBuffer = \"%@\"", _pendingBuffer == nil ? @"(NIL)" : (NSString*)[self pendingBuffer]); - NSLog(@" _keyCodeOfOriginalEvent = %hu", _keyCodeOfOriginalEvent); - } - _numberOfPostedDeletesToExpect = 0; - _pendingBuffer = nil; - _keyCodeOfOriginalEvent = 0; - if (_sourceFromOriginalEvent != nil) { - CFRelease(_sourceFromOriginalEvent); - _sourceFromOriginalEvent = nil; - } - } - - _contextOutOfDate = YES; + else + _eventHandler = [[KMInputMethodEventHandler alloc] initWithClient:clientAppId]; } - (void)deactivateServer:(id)sender { @@ -724,6 +76,13 @@ NSRange _previousSelRange; NSLog(@"sender: %@", sender); NSLog(@"***"); } + // Seems like we ought to do this, but it appears there is a timing issue that + // sometimes causes the deactivate to happen AFTER the new activateServer event, + // thereby clobbering the newly created event handler. +// if (_eventHandler != nil) { +// [_eventHandler deactivate]; +// _eventHandler = nil; +// } } /* @@ -769,68 +128,10 @@ NSRange _previousSelRange; return self.AppDelegate.menu; } -- (KMEngine *)kme { - return self.AppDelegate.kme; -} - - (KMXFile *)kmx { return self.AppDelegate.kmx; } -- (NSMutableString *)contextBuffer { - if (_contextOutOfDate) { - NSException* exception = [NSException - exceptionWithName:@"InvalidOperationException" - reason:@"Attempt to access out-of-date context." - userInfo:nil]; - @throw exception; - } - return self.AppDelegate.contextBuffer; -} - -- (void)updateContextBuffer:(id)sender { - if ([self.AppDelegate debugMode]) { - NSLog(@"*** updateContextBuffer ***"); - NSLog(@"sender: %@", sender); - } - - NSRange selRange = [sender selectedRange]; - NSUInteger len = [sender length]; - if ([self.AppDelegate debugMode]) { - NSLog(@"selRange.location: %lu", (unsigned long)selRange.location); - NSLog(@"sender length: %lu", len); - } - - if (selRange.location == NSNotFound) { - // REVIEW: For now, if the client always reports its selectedRange as "not found", we're stuck assuming that any - // previous context we've built up is indeed current. This may be totally untrue, but if the client can't report - // its location, there's no point trying to check to see if it changed unexpectedly. - _clientSelectionCanChangeUnexpectedly = NO; - } - else { - len = selRange.location; - } - - NSString *preBuffer = [[sender attributedSubstringFromRange:NSMakeRange(0, len)] string]; - if ([self.AppDelegate debugMode]) { - NSLog(@"preBuffer = \"%@\"", preBuffer); - if (preBuffer.length) - NSLog(@"First character: '%x'", [preBuffer characterAtIndex:0]); - else - NSLog(@"preBuffer has a length of 0"); - } - // REVIEW: If there is ever a situation where preBuffer gets some text but the client reports its - // selectedRange as not found, we probably can't reliably assume that the current location is really - // at the end of the "preBuffer", so maybe we just need to assume no context. - [self.AppDelegate setContextBuffer:preBuffer.length?[NSMutableString stringWithString:preBuffer]:nil]; - _contextOutOfDate = NO; - if ([self.AppDelegate debugMode]) { - NSLog(@"contextBuffer = \"%@\"", self.contextBuffer.length?[self.contextBuffer codeString]:@"{empty}"); - NSLog(@"***"); - } - _previousSelRange = selRange; -} - - (void)menuAction:(id)sender { NSMenuItem *mItem = [sender objectForKey:kIMKCommandMenuItemName]; NSInteger itag = mItem.tag; @@ -874,82 +175,4 @@ NSRange _previousSelRange; [self.AppDelegate showOSK]; } } - -// Return the pending buffer. If it is NIL create it. --(NSMutableString*)pendingBuffer; -{ - if ( _pendingBuffer == nil ) { - _pendingBuffer = [[NSMutableString alloc] init]; - } - return _pendingBuffer; -} - -// Change the pending buffer. --(void)setPendingBuffer:(NSString*)string -{ - NSMutableString* buffer = [self pendingBuffer]; - [buffer setString:string]; -} - -// Append to (creating if necessary) the pending buffer. --(void)appendPendingBuffer:(NSString*)string -{ - NSMutableString* buffer = [self pendingBuffer]; - [buffer appendString:string]; -} - -- (void)sendEvent:(NSEvent *)event { - ProcessSerialNumber psn; - GetFrontProcess(&psn); - - CGEventSourceRef source = CGEventCreateSourceFromEvent([event CGEvent]); - CGEventRef keyDownEvent = CGEventCreateKeyboardEvent(source, event.keyCode, true); - CGEventRef keyUpEvent = CGEventCreateKeyboardEvent(source, event.keyCode, false); - - CGEventPostToPSN(&psn, keyDownEvent); - CGEventPostToPSN(&psn, keyUpEvent); - - CFRelease(source); - CFRelease(keyDownEvent); - CFRelease(keyUpEvent); -} - -- (void)deleteBack:(NSUInteger)count for:(NSEvent *) event { - _numberOfPostedDeletesToExpect = count; - CGEventRef ev; - _sourceFromOriginalEvent = CGEventCreateSourceFromEvent([event CGEvent]); - - for (int db = 0; db < count; db++) - { - if ([self.AppDelegate debugMode]) - NSLog(@"Posting a delete (down/up) at kCGHIDEventTap."); - - ev = CGEventCreateKeyboardEvent (_sourceFromOriginalEvent, kVK_Delete, true);//delete-back down - CGEventPost(kCGHIDEventTap, ev); - CFRelease(ev); - ev = CGEventCreateKeyboardEvent (_sourceFromOriginalEvent, kVK_Delete, false); //delete-back up - CGEventPost(kCGHIDEventTap, ev); - CFRelease(ev); - } -} - -- (void)initiatePendingBufferProcessing:(id)sender { - [self postKeyPressToFrontProcess:kProcessPendingBuffer from:NULL]; -} - -- (void)postKeyPressToFrontProcess:(CGKeyCode)code from:(CGEventSourceRef) source { - ProcessSerialNumber psn; - GetFrontProcess(&psn); - - CGEventRef event = CGEventCreateKeyboardEvent(source, code, true); - CGEventPostToPSN(&psn, event); - CFRelease(event); - - if (code != kProcessPendingBuffer) { // special 0xFF code is not a real key-press, so no "up" is needed - event = CGEventCreateKeyboardEvent(source, code, false); - CGEventPostToPSN(&psn, event); - CFRelease(event); - } -} - @end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m index b95643bd95..042e6874e8 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m @@ -63,7 +63,7 @@ typedef enum { - (id)init { self = [super init]; if (self) { - _debugMode = YES; // Disable before release + _debugMode = NO; // Disable before release [[NSAppleEventManager sharedAppleEventManager] setEventHandler:self andSelector:@selector(handleURLEvent:withReplyEvent:) forEventClass:kInternetEventClass diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodBrowserClientEventHandler.h b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodBrowserClientEventHandler.h new file mode 100644 index 0000000000..f04233322b --- /dev/null +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodBrowserClientEventHandler.h @@ -0,0 +1,20 @@ +// +// KMInputMethodBrowserClientEventHandler.h +// Keyman4MacIM +// This class is used for browser clients because certain javascript-based websites, such as Google Docs) do +// not properly deal with calls to insertText calls that replace a range of characters. So for these "legacy" +// apps, we post one or more deletes (i.e., backspace), followed by a special code that tells us we're now +// ready to insert the composed text. +// +// Created by tom on 1/5/18. +// Copyright © 2018 SIL International. All rights reserved. +// + +#ifndef KMInputMethodBrowserClientEventHandler_h +#define KMInputMethodBrowserClientEventHandler_h +#import "KMInputMethodEventHandler.h" + +@interface KMInputMethodBrowserClientEventHandler : KMInputMethodEventHandler +@end + +#endif /* KMInputMethodBrowserClientEventHandler_h */ diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodBrowserClientEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodBrowserClientEventHandler.m new file mode 100644 index 0000000000..ed18aee28e --- /dev/null +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodBrowserClientEventHandler.m @@ -0,0 +1,110 @@ +// +// KMInputMethodBrowserClientEventHandler.m +// Keyman +// +// Created by tom on 1/5/18. +// Copyright © 2018 SIL International. All rights reserved. +// + +#import "KMInputMethodBrowserClientEventHandler.h" +#import "KMInputMethodEventHandlerProtected.h" +#import "KMInputMethodBrowserClientEventHandlerProtected.h" + +@implementation KMInputMethodBrowserClientEventHandler + +// Because Google Docs can't report its context in any of the browsers (Safari, Chrome, Firefox) and Word sometimes +// has trouble doing this in Chrome, we want to try to detect it so each browser can respond appropriately. +NSUInteger _failuresToRetrieveExpectedContext; + +- (instancetype)init { + return [self initWithLegacyMode:YES clientSelectionCanChangeUnexpectedly:YES]; +} + +- (instancetype)initWithLegacyMode:(BOOL)legacy clientSelectionCanChangeUnexpectedly:(BOOL) flagClientSelectionCanChangeUnexpectedly { + self = [super initWithLegacyMode:legacy clientSelectionCanChangeUnexpectedly: flagClientSelectionCanChangeUnexpectedly]; + if (self) { + _failuresToRetrieveExpectedContext = 0; + } + return self; +} + +- (void)checkContextIn:(id) client { + if (!self.willDeleteNullChar && !self.contextOutOfDate && _failuresToRetrieveExpectedContext < 3) { + if (self.AppDelegate.debugMode) { + NSLog(@"Checking to see if we're in Google Docs (or some other site that can't give context)."); + } + NSUInteger bufferLength = self.contextBuffer.length; + if (bufferLength) { + NSUInteger location = [client selectedRange].location; + + if (location != NSNotFound && location > 0) { + if ([self AppDelegate].debugMode) + NSLog(@"Trying to get context up to location %lu", location); + NSString* clientContext = nil; + if ([client respondsToSelector:@selector(attributedSubstringFromRange:)]) + clientContext = [[client attributedSubstringFromRange:NSMakeRange(0, location)] string]; + if (clientContext == nil) + { + // Client is failing to provide useful response to attributedSubstringFromRange. + // Word (in MS Live) occasionally does this. + [self setInSiteThatDoesNotGiveContext]; + } + else if (!clientContext.length) { + if ([self AppDelegate].debugMode) { + NSLog(@"Expected context = '%@'", self.contextBuffer); + NSLog(@"Actual clientContext was empty"); + } + _failuresToRetrieveExpectedContext++; + } + else + { + unichar lastCodepointExpected = [self.contextBuffer characterAtIndex:bufferLength - 1]; + unichar lastCodepointInClient = [clientContext characterAtIndex:clientContext.length - 1]; + if (lastCodepointExpected != lastCodepointInClient) { + if ([self AppDelegate].debugMode) { + NSLog(@"Expected context = '%@'", self.contextBuffer); + NSLog(@"Actual clientContext = '%@'", (clientContext == nil ? @"{nil}" : clientContext)); + NSLog(@"Last character expected (in contextBuffer) = '%lu'", (unsigned long)lastCodepointExpected); + NSLog(@"Last character in clientContext = '%lu'", (unsigned long)lastCodepointInClient); + } + // MS Word converts/reports plain spaces (32) as non-breaking spaces (160). If we + // get this kind of mismatch, we don't want to count this as a definite match, but + // we also don't want to count it as a failure to match. So just wait for a more + // reliable character to test. + if (lastCodepointInClient != 160 || lastCodepointExpected != 32) { + _failuresToRetrieveExpectedContext++; + } + } + else { + if ([self AppDelegate].debugMode) { + NSLog(@"We got what we were expecting from the client. We can stop checking."); + } + _failuresToRetrieveExpectedContext = NSUIntegerMax; + } + } + } + else { + if ([self AppDelegate].debugMode) { + NSLog(@"bufferLength is %lu, but location was %lu.", bufferLength, location); + } + _failuresToRetrieveExpectedContext++; + } + + if (_failuresToRetrieveExpectedContext == 3) + [self setInSiteThatDoesNotGiveContext]; + } + } +} + +- (void)setInSiteThatDoesNotGiveContext { + if ([self AppDelegate].debugMode) { + NSLog(@"Detected some editor (e.g., Google Docs) that can't provide context."); + } + _failuresToRetrieveExpectedContext = NSUIntegerMax; + + if (self.clientSelectionCanChangeUnexpectedly) { + self.cannnotTrustSelectionLength = YES; + self.clientSelectionCanChangeUnexpectedly = NO; // This isn't true (it can change unexpectedly), but we can't get the context, so we pretend/hope it won't. + } +} +@end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodBrowserClientEventHandlerProtected.h b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodBrowserClientEventHandlerProtected.h new file mode 100644 index 0000000000..faaf606546 --- /dev/null +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodBrowserClientEventHandlerProtected.h @@ -0,0 +1,18 @@ +// +// KMInputMethodBrowserClientEventHandlerProtected.h +// Keyman4MacIM +// +// Created by tom on 1/9/18. +// Copyright © 2018 SIL International. All rights reserved. +// + +#ifndef KMInputMethodEventBrowserClientHandlerProtected_h +#define KMInputMethodEventBrowserClientHandlerProtected_h + +@interface KMInputMethodBrowserClientEventHandler () + +- (void)setInSiteThatDoesNotGiveContext; + +@end + +#endif /* KMInputMethodEventBrowserClientHandlerProtected_h */ diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.h b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.h new file mode 100644 index 0000000000..790fcde534 --- /dev/null +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.h @@ -0,0 +1,23 @@ +// +// KMInputMethodEventHandler.h +// Keyman4MacIM +// +// Created by Tom Bogle on 11/22/17. +// Copyright © 2017 SIL International. All rights reserved. +// + +#import +#import "KMInputMethodAppDelegate.h" + +#ifndef KMInputMethodEventHandler_h +#define KMInputMethodEventHandler_h + +@interface KMInputMethodEventHandler : NSObject + +- (instancetype)initWithClient:(NSString *)clientAppId; +- (BOOL)handleEvent:(NSEvent *)event client:(id)sender; +- (void)deactivate; + +@end + +#endif /* KMInputMethodEventHandler_h */ diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m new file mode 100644 index 0000000000..46f5a2e216 --- /dev/null +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -0,0 +1,728 @@ +// +// 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" +#include /* For kVK_ constants. */ + +@implementation KMInputMethodEventHandler + +const CGKeyCode kProcessPendingBuffer = 0xFF; + +//_pendingBuffer contains text that is ready to be sent to the client when all delete-backs are finished. +NSMutableString* _pendingBuffer; +NSUInteger _numberOfPostedDeletesToExpect = 0; +CGKeyCode _keyCodeOfOriginalEvent; +CGEventSourceRef _sourceFromOriginalEvent = nil; + +NSRange _previousSelRange; + +// Protected initializer for use by subclasses +- (instancetype)initWithLegacyMode:(BOOL)legacy clientSelectionCanChangeUnexpectedly:(BOOL) flagClientSelectionCanChangeUnexpectedly { + self = [super init]; + if (self) { + _previousSelRange = NSMakeRange(NSNotFound, NSNotFound); + _clientSelectionCanChangeUnexpectedly = flagClientSelectionCanChangeUnexpectedly; + _cannnotTrustSelectionLength = NO; + _legacyMode = NO; + _contextOutOfDate = YES; + if (legacy) { + [self switchToLegacyMode]; + } + } + return self; +} + +// This is the public initializer. +- (instancetype)initWithClient:(NSString *)clientAppId { + BOOL legacy = ([clientAppId isEqual: @"com.github.atom"] || + [clientAppId isEqual: @"com.collabora.libreoffice-free"] || + [clientAppId isEqual: @"com.axosoft.gitkraken"] || + [clientAppId isEqual: @"org.sil.app.builder.scripture.ScriptureAppBuilder"] || + [clientAppId isEqual: @"org.sil.app.builder.reading.ReadingAppBuilder"] || + [clientAppId isEqual: @"org.sil.app.builder.dictionary.DictionaryAppBuilder"] || + [clientAppId isEqual: @"com.microsoft.Word"] + /*||[clientAppId isEqual: @"ro.sync.exml.Oxygen"] - Oxygen has worse problems */); + + // We used to default to NO, so these were the obvious exceptions. But then we realized that + // in any app, command keys can change the selection, so now we default to YES, and only have + // a few situations where we pretend it can't. This flag should probably be renamed to something + // like "disregardPossibleSelectionChanges". + // if ([clientAppId isEqual: @"com.google.Chrome"] || + // [clientAppId isEqual: @"com.apple.Terminal"] || + // [clientAppId isEqual: @"com.apple.dt.Xcode"]) { + // _clientSelectionCanChangeUnexpectedly = YES; + // } + + // For the Atom editor, this isn't really true (the context CAN change unexpectedly), but we can't get + // the context, so we pretend/hope it won't. + BOOL selectionCanChangeUnexpectedly = (![clientAppId isEqual: @"com.github.atom"]); + return [self initWithLegacyMode:legacy clientSelectionCanChangeUnexpectedly:selectionCanChangeUnexpectedly]; +} + +- (void)switchToLegacyMode { + _legacyMode = YES; + if ([self.AppDelegate debugMode]) + NSLog(@"Using legacy mode for this app."); +} + +- (void)deactivate { + if (_numberOfPostedDeletesToExpect > 0 || (_pendingBuffer != nil && _pendingBuffer.length > 0) || + _keyCodeOfOriginalEvent != 0 || _sourceFromOriginalEvent != nil) + { + if ([self.AppDelegate debugMode]) { + NSLog(@"ERROR: new app activated before previous app finished processing pending events!"); + NSLog(@" _numberOfPostedDeletesToExpect = %lu", _numberOfPostedDeletesToExpect); + NSLog(@" pendingBuffer = \"%@\"", _pendingBuffer == nil ? @"(NIL)" : (NSString*)[self pendingBuffer]); + NSLog(@" _keyCodeOfOriginalEvent = %hu", _keyCodeOfOriginalEvent); + } + _numberOfPostedDeletesToExpect = 0; + _pendingBuffer = nil; + _keyCodeOfOriginalEvent = 0; + } +} + +- (void)dealloc { + if (_sourceFromOriginalEvent != nil) { + CFRelease(_sourceFromOriginalEvent); + _sourceFromOriginalEvent = 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 "legacy" apps can't get their context at all. This can be overridden so that + // legacy apps can mitigate this problem as appropriate. + if ([self.AppDelegate debugMode]) + NSLog(@"Command key - context needs to be re-gotten."); + _contextOutOfDate = YES; +} + +- (void)checkContextIn:(id)client { + // Base implementation is no-op +} + +- (void)replaceExistingSelectionIn:(id)client with:(NSString *) text { + [client insertText:text replacementRange:NSMakeRange(NSNotFound, NSNotFound)]; + _previousSelRange.location += text.length; + _previousSelRange.length = 0; +} + +- (void)insertPendingBufferTextIn:(id)client { + NSUInteger length = [self pendingBuffer].length; + if (!length) { + if ([self.AppDelegate debugMode]) + NSLog(@"Error - expected text in pending buffer!"); + return; + } + NSString* text = [self pendingBuffer]; + + if ([self.AppDelegate debugMode]) + NSLog(@"Inserting text from pending buffer: \"%@\"", text); + + [client insertText:text replacementRange:NSMakeRange(NSNotFound, NSNotFound)]; + _previousSelRange.location += text.length; + _previousSelRange.length = 0; + + [self setPendingBuffer:@""]; +} + +- (KMInputMethodAppDelegate *)AppDelegate { + return (KMInputMethodAppDelegate *)[NSApp delegate]; +} + +- (KMEngine *)kme { + return self.AppDelegate.kme; +} + +- (NSMutableString *)contextBuffer { + if (_contextOutOfDate) { + NSException* exception = [NSException + exceptionWithName:@"InvalidOperationException" + reason:@"Attempt to access out-of-date context." + userInfo:nil]; + @throw exception; + } + return self.AppDelegate.contextBuffer; +} + +- (void)updateContextBuffer:(id)sender { + if ([self.AppDelegate debugMode]) { + NSLog(@"*** updateContextBuffer ***"); + NSLog(@"sender: %@", sender); + } + + NSRange selRange = [sender selectedRange]; + NSUInteger len = [sender length]; + if ([self.AppDelegate debugMode]) { + NSLog(@"selRange.location: %lu", (unsigned long)selRange.location); + NSLog(@"sender length: %lu", len); + } + + if (selRange.location == NSNotFound) { + // REVIEW: For now, if the client always reports its selectedRange as "not found", we're stuck assuming that any + // previous context we've built up is indeed current. This may be totally untrue, but if the client can't report + // its location, there's no point trying to check to see if it changed unexpectedly. + _clientSelectionCanChangeUnexpectedly = NO; + } + else { + len = selRange.location; + } + + NSString *preBuffer = [[sender attributedSubstringFromRange:NSMakeRange(0, len)] string]; + if ([self.AppDelegate debugMode]) { + NSLog(@"preBuffer = \"%@\"", preBuffer); + if (preBuffer.length) + NSLog(@"First character: '%x'", [preBuffer characterAtIndex:0]); + else + NSLog(@"preBuffer has a length of 0"); + } + // REVIEW: If there is ever a situation where preBuffer gets some text but the client reports its + // selectedRange as not found, we probably can't reliably assume that the current location is really + // at the end of the "preBuffer", so maybe we just need to assume no context. + [self.AppDelegate setContextBuffer:preBuffer.length?[NSMutableString stringWithString:preBuffer]:nil]; + _contextOutOfDate = NO; + if ([self.AppDelegate debugMode]) { + NSLog(@"contextBuffer = \"%@\"", self.contextBuffer.length?[self.contextBuffer codeString]:@"{empty}"); + NSLog(@"***"); + } + _previousSelRange = selRange; +} + +// Return the pending buffer. If it is NIL create it. +-(NSMutableString*)pendingBuffer; +{ + if ( _pendingBuffer == nil ) { + _pendingBuffer = [[NSMutableString alloc] init]; + } + return _pendingBuffer; +} + +// Change the pending buffer. +-(void)setPendingBuffer:(NSString*)string +{ + NSMutableString* buffer = [self pendingBuffer]; + [buffer setString:string]; +} + +// Append to (creating if necessary) the pending buffer. +-(void)appendPendingBuffer:(NSString*)string +{ + NSMutableString* buffer = [self pendingBuffer]; + [buffer appendString:string]; +} + +- (void)updateContextBufferIfNeeded:(id)client { + if ([self.AppDelegate debugMode]) { + NSLog(@"_clientSelectionCanChangeUnexpectedly = %@", _clientSelectionCanChangeUnexpectedly ? @"YES" : @"NO"); + } + // The following is needed because some clients (e.g. Chrome & Terminal) handle mouse down events and we + // never get a crack at them: + // At the start of the loop before asking the Engine to process the event, unless we still have + // pending work to do based on posted deletes or the special kProcessPendingBuffer code, check to see if + // the client still reports a selection that matches the position we would expect based on the length of + // the context buffer. If not, then reset the context buffer. + if (_clientSelectionCanChangeUnexpectedly && _numberOfPostedDeletesToExpect == 0 && + (_pendingBuffer == nil || _pendingBuffer.length == 0)) { + NSRange currentSelRange = [client selectedRange]; + + if (currentSelRange.location == NSNotFound) { + _clientSelectionCanChangeUnexpectedly = NO; + } + else if ((_previousSelRange.location != currentSelRange.location || _cannnotTrustSelectionLength || _previousSelRange.length != currentSelRange.length)) { + if ([self.AppDelegate debugMode]) { + NSLog(@"Client selection may have changed since context was set. Resetting context..."); + NSLog(@" _previousSelRange.location = %lu", _previousSelRange.location); + NSLog(@" _previousSelRange.length = %lu", _previousSelRange.length); + NSLog(@" currentSelRange.location = %lu", currentSelRange.location); + if (_cannnotTrustSelectionLength) + NSLog(@"The following cannot be trusted and will be ignored:"); + NSLog(@" currentSelRange.length = %lu", currentSelRange.length); + } + [self updateContextBuffer:client]; + } + } + + if (_contextOutOfDate) + [self updateContextBuffer:client]; +} + +- (BOOL) handleKeymanEngineActions:(NSEvent *)event in:(id) sender { + BOOL deleteBackPosted = NO; + NSArray *actions = nil; + if (![self willDeleteNullChar]) { + actions = [self.kme processEvent:event]; + if (actions.count == 0) + return NO; + } + else + return NO; + + if ([self.AppDelegate debugMode]) { + NSLog(@"actions = %@", actions); + } + for (NSDictionary *action in actions) { + NSString *actionType = [[action allKeys] objectAtIndex:0]; + if ([self.AppDelegate debugMode]) { + NSLog(@"Handling %@ action...", actionType); + NSLog(@"contextBuffer = \"%@\"", self.contextBuffer.length?[self.contextBuffer codeString]:@"{empty}"); + } + + if ([actionType isEqualToString:Q_STR]) { + NSString *output = [action objectForKey:actionType]; + if ([self.AppDelegate debugMode]) + NSLog(@"output = %@", output); + if (deleteBackPosted) + { + [self appendPendingBuffer:output]; + if ([self.AppDelegate debugMode]) + NSLog(@"pendingBuffer = %@", [self pendingBuffer]); + } + else { + NSUInteger nc = [self.contextBuffer deleteLastNullChars]; + // Each null in the context buffer presumably corresponds to a space we inserted when + // processing the Q_BACK, which we now need to replace with the text we're inserting. + if (nc > 0) { + if ([self.AppDelegate debugMode]) + NSLog(@"nc = %lu", nc); + NSRange selRange = [sender selectedRange]; + NSUInteger pos = selRange.location; + if (pos >= nc && pos != NSNotFound) { + if ([self.AppDelegate debugMode]) { + NSLog(@"Replacement index = %lu", pos - nc); + NSLog(@"Replacement length = %lu", nc); + } + [sender insertText:output replacementRange:NSMakeRange(pos - nc, nc)]; + _previousSelRange.location += output.length - nc; + _previousSelRange.length = 0; + } + } + else { + [self replaceExistingSelectionIn:sender with:output]; + } + } + + // Even if the characters to insert are pending, we want to append them to the context buffer now. + // Waiting until they are inserted would probably be safe, but on the off-chance that the engine + // generates additional actions beyond this current one, we want to be sure that the context reflects + // the state as it *will* be when all the posted/pending events have been processed. + [self.contextBuffer appendString:output]; + } + else if ([actionType isEqualToString:Q_BACK]) { + [self.contextBuffer deleteLastNullChars]; + NSUInteger dk = [self.contextBuffer deleteLastDeadkeys]; + NSInteger n = [[action objectForKey:actionType] integerValue] - dk; + NSUInteger dc = [[self.contextBuffer lastNChars:n] deadKeyCount]; + // n is now the number of characters to delete from the context buffer + // (which could include deadkeys the client doesn't know about). + [self.contextBuffer deleteLastNChars:n]; + n -= dc; + + // n is now the number of characters to delete from the client. + if (n > 0) { + deleteBackPosted = [self deleteBack:n in:sender for: event]; + } + } + else if ([actionType isEqualToString:Q_DEADKEY]) { + [self.contextBuffer deleteLastNullChars]; + NSUInteger x = [[action objectForKey:actionType] unsignedIntegerValue]; + [self.contextBuffer appendDeadkey:x]; + } + else if ([actionType isEqualToString:Q_NUL]) { + continue; + } + else if ([actionType isEqualToString:Q_RETURN]) { + return YES; + } + else if ([actionType isEqualToString:Q_BEEP]) { + [[NSSound soundNamed:@"Tink"] play]; + } + } + return YES; +} + +- (void)processUnhandledDeleteBack:(id)client updateEngineContext:(BOOL *)updateEngineContext { + if ([self.AppDelegate debugMode]) { + NSLog(@"Processing an unhandled delete-back..."); + NSLog(@"_numberOfPostedDeletesToExpect = %lu", _numberOfPostedDeletesToExpect); + } + + // If we have pending characters to insert following the delete-back, then + // the context buffer has already been properly set to reflect the deletions. + if ((_legacyMode && (_pendingBuffer == nil || _pendingBuffer.length == 0)) || + (!_legacyMode && (!self.willDeleteNullChar))) + { + // Backspace clears last "real" character from buffer, plus any surrounding deadkeys + [self.contextBuffer deleteLastDeadkeys]; + [self.contextBuffer deleteLastNChars:1]; + if (_legacyMode) { + _previousSelRange.location -= 1; + _previousSelRange.length = 0; + } + [self.contextBuffer deleteLastDeadkeys]; + } + if (_numberOfPostedDeletesToExpect > 0) { + if (--_numberOfPostedDeletesToExpect == 0) { + if ([self.AppDelegate debugMode]) + NSLog(@"Processing final posted delete-back..."); + + self.willDeleteNullChar = NO; + if (_legacyMode && _pendingBuffer != nil && _pendingBuffer.length > 0) { + if ([self.AppDelegate debugMode]) + NSLog(@"Posting special code to tell IM to insert characters from pending buffer."); + [self performSelector:@selector(initiatePendingBufferProcessing:) withObject:client afterDelay:0.1]; + } + else { + if ([self.AppDelegate debugMode]) { + NSLog(@"Re-posting original (unhandled) code: %d", (int)_keyCodeOfOriginalEvent); + } + [self postKeyPressToFrontProcess:_keyCodeOfOriginalEvent from:_sourceFromOriginalEvent]; + _keyCodeOfOriginalEvent = 0; + CFRelease(_sourceFromOriginalEvent); + _sourceFromOriginalEvent = nil; + } + *updateEngineContext = NO; + } + } + else { + self.willDeleteNullChar = NO; + } +} + +- (BOOL)handleEvent:(NSEvent *)event client:(id)sender { + // OSK key feedback from hardware keyboard is disabled + /*if (event.type == NSKeyDown) + [self.AppDelegate handleKeyEvent:event];*/ + + if (event.type == NSLeftMouseDown || event.type == NSLeftMouseUp ) { + // if (_clientSelectionCanChangeUnexpectedly) { + // if ([self.AppDelegate debugMode]) + // NSLog(@"WARNING: We are dealing with an app/context where we THINK we shouldn't be getting mouse events, but we just got one!"); + // _clientSelectionCanChangeUnexpectedly = NO; + // } + _contextOutOfDate = YES; + return NO; + } + else if (event.type != NSKeyDown) + return NO; // We ignore NSLeftMouseDragged events (because we'll eventually get a mouse-up). + else if ((event.modifierFlags & NSEventModifierFlagCommand) == NSEventModifierFlagCommand) { + [self handleCommand:event]; + return NO; // We let the client app handle all Command-key events. + } + + if (_legacyMode && event.keyCode == kProcessPendingBuffer) + { + if ([self.AppDelegate debugMode]) { + NSLog(@"Processing the special %hu code", kProcessPendingBuffer); + + NSUInteger length = [self pendingBuffer].length; + if (length > 0) { + for (NSUInteger ich = 0; ich < length; ich++) + NSLog(@"Char %li: '%x'", ich, [[self pendingBuffer] characterAtIndex:ich]); + } + } + + [self insertPendingBufferTextIn:sender]; + return YES; + } + + [self checkContextIn:sender]; + + [self updateContextBufferIfNeeded:sender]; + + if ([self.AppDelegate debugMode]) { + NSLog(@"sender type = %@", NSStringFromClass([sender class])); + NSLog(@"sender selection range location = %lu", [sender selectedRange].location); + } + + BOOL handled = [self handleKeymanEngineActions:event in: sender]; + + if (!handled) { + NSUInteger nc = [self.contextBuffer deleteLastNullChars]; + if (nc > 0) { + if ([self.AppDelegate debugMode]) + NSLog(@"Deleted %li null characters from context buffer", nc); + // This can presumably only happen if a previous event resulted in a chain of + // actions that had a Q_BACK not followed by a Q_STR (assuming that can happen). + // REVIEW: Need to test this scenario in Atom and Googe Docs in Safari + self.willDeleteNullChar = YES; + [self postDeleteBack:nc for:event]; + _keyCodeOfOriginalEvent = event.keyCode; + + return YES; + } + + // For other events that the Keyman engine does not have rules, just apply context changes + // and let client handle the event + NSString* charactersToAppend = nil; + BOOL updateEngineContext = YES; + unsigned short keyCode = event.keyCode; + switch (keyCode) { + case kVK_Delete: + [self processUnhandledDeleteBack:sender updateEngineContext:&updateEngineContext]; + break; + + case kVK_LeftArrow: + // I had started some code to try to guess where in the context we ended up after a left arrow, + // but too many potential pitfalls. Marc says it's better to just let it be dumb. + case kVK_RightArrow: + case kVK_UpArrow: + case kVK_DownArrow: + case kVK_Home: + case kVK_End: + case kVK_PageUp: + case kVK_PageDown: + _contextOutOfDate = YES; + updateEngineContext = NO; + break; + + case kVK_Return: + case kVK_ANSI_KeypadEnter: + charactersToAppend = @"\n"; + break; + + default: + { + unichar ch = [event.characters characterAtIndex:0]; + // REVIEW: Is ch ever != keyCode? Is there ever more than one character in event.characters? + if (keyCode < 0x33 || (ch >= 0x2A && ch <= 0x39)) { // Main keys, Numpad char range, normal punctuation + charactersToAppend = event.characters; + } + else { + // Other keys + } + } + break; + } + if (charactersToAppend != nil) { + if ([self.AppDelegate debugMode]) { + NSLog(@"Adding \"%@\" to context buffer", charactersToAppend); + } + [self.contextBuffer appendString:charactersToAppend]; + if (_legacyMode) { + _previousSelRange.location += charactersToAppend.length; + _previousSelRange.length = 0; + } + } + + if (updateEngineContext) { + [self.kme setContextBuffer:self.contextBuffer]; + } + } + + if ([self.AppDelegate debugMode]) { + if (_contextOutOfDate) + NSLog(@"Context now out of date."); + else + NSLog(@"contextBuffer = \"%@\"", self.contextBuffer.length?[self.contextBuffer codeString]:@"{empty}"); + NSLog(@"kme.contextBuffer = \"%@\"", self.kme.contextBuffer.length?[self.kme.contextBuffer codeString]:@"{empty}"); + NSRange range = [sender markedRange]; + NSLog(@"sender.markedRange.location = %lu", range.location); + NSLog(@"sender.markedRange.length = %lu", range.length); + range = [sender selectedRange]; + NSLog(@"sender.selectedRange.location = %lu", range.location); + if (_cannnotTrustSelectionLength) + NSLog(@"The following cannot be trusted and will be ignored:"); + NSLog(@"sender.selectedRange.length = %lu", range.length); + NSLog(@"***"); + } + + // Seems we can't do it this way because (at least for "legacy mode" apps) the selection range doesn't get + // updated until after we return from this method. + // if (_legacyMode) + // _previousSelRange = [sender selectedRange]; + return handled; +} + +- (BOOL)deleteBack:(NSUInteger)n in:(id) client for:(NSEvent *) event { + if ([self.AppDelegate debugMode]) + NSLog(@"Attempting to back-delete %li characters.", n); + NSRange selectedRange = [client selectedRange]; + NSInteger pos = selectedRange.location; + if (!_legacyMode) + [self deleteBack:n at: pos in: client]; + if (_legacyMode) + return [self deleteBackLegacy:n at: pos with: selectedRange for: event]; + + return NO; +} + +- (void)deleteBack:(NSUInteger)n at:(NSUInteger) pos in:(id)client { + if ([self.AppDelegate debugMode]) { + NSLog(@"Using Apple IM-compliant mode."); + NSLog(@"pos = %lu", pos); + } + + if (pos >= n && pos != NSNotFound) { + NSInteger preCharPos = pos - (n+1); + if ((preCharPos) >= 0) { + NSUInteger nbrOfPreCharacters; + NSString *preChar = nil; + + // This regex will look back through the context until it finds a *known* base + // character because some (non-legacy) apps (e.g., Mail) do not properly handle sending + // combining marks on their own via insertText. One potentially negative implication + // of this is that if the script should happen to contain characters whose class is + // not known, it will skip over them and keep looking, so it could end up using a + // longer string of characters than otherwise necessary. This could result in a + // mildly jarring visual experience for the user if the app refreshes the diplay + // between the time the characters are removed and re-inserted. But presumbly this + // algorithm will eventually find either a known base character or get all the way back + // to the start of the context, so if it doesn't find a known base character, it will + // fall back to just attempting the insert with whatever it does find. I believe this + // will always work and should at least work as reliably as the old version of the code, + // which always used just a single character regardless of its class. + NSError *error = NULL; + NSRegularExpression *regexNonCombiningMark = [NSRegularExpression regularExpressionWithPattern:@"\\P{M}" options:NSRegularExpressionCaseInsensitive error:&error]; + + for (nbrOfPreCharacters = 1; YES; nbrOfPreCharacters++, preCharPos--) { + preChar = [[client attributedSubstringFromRange:NSMakeRange(preCharPos, nbrOfPreCharacters)] string]; + if (!preChar) { + if ([self.AppDelegate debugMode]) { + NSLog(@"Client apparently doesn't implement attributedSubstringFromRange. Attempting to get preChar from context..."); + } + if (self.contextBuffer != nil && preCharPos < self.contextBuffer.length) { + preChar = [self.contextBuffer substringWithRange:NSMakeRange(preCharPos, 1)]; + } + if (!preChar) + break; + } + if ([self.AppDelegate debugMode]) + NSLog(@"Testing preChar: %@", preChar); + + if ([regexNonCombiningMark numberOfMatchesInString:preChar options:NSMatchingAnchored range:NSMakeRange(0, 1)] > 0) + break; + if (preCharPos == 0) { + if ([self.AppDelegate debugMode]) { + NSLog(@"Failed to find a base character!"); + } + break; + } + if ([self.AppDelegate debugMode]) { + NSLog(@"Have not yet found a base character. nbrOfPreCharacters = %lu", nbrOfPreCharacters); + } + } + if (preChar) { + if ([self.AppDelegate debugMode]) { + NSLog(@"preChar (to insert at %lu) = \"%@\"", preCharPos, preChar); + } + [client insertText:preChar replacementRange:NSMakeRange(preCharPos, n+nbrOfPreCharacters)]; + } + else { + if ([self.AppDelegate debugMode]) { + NSLog(@"Switching to legacy mode - client apparently doesn't implement attributedSubstringFromRange and no previous character in context buffer."); + } + _legacyMode = YES; // client apparently doesn't implement attributedSubstringFromRange. + } + } + else { + if ([self.AppDelegate debugMode]) + NSLog(@"No previous character to use for replacement - replacing range with space"); + [client insertText:@" " replacementRange:NSMakeRange(pos - n, n)]; + [self.contextBuffer appendNullChar]; + } + } +} + +- (BOOL)deleteBackLegacy:(NSUInteger)n at:(NSUInteger) pos with:(NSRange) selectedRange for:(NSEvent *) event { + if (self.contextBuffer != nil && (pos == 0 || pos == NSNotFound)) { + pos = self.contextBuffer.length + n; + } + + if ([self.AppDelegate debugMode]) { + NSLog(@"Using Legacy mode."); + NSLog(@"pos = %lu", pos); + } + + if (pos >= n) { + // n is now the number of delete-backs we need to post (plus one more if there is selected text) + if ([self.AppDelegate debugMode]) { + NSLog(@"Legacy mode: calling postDeleteBack"); + if (_cannnotTrustSelectionLength) + NSLog(@"Cannot trust client to report accurate selection length - assuming no selection."); + } + + // Note: If pos is "not found", most likely the client can't accurately report the location. This might be + // dangerous, but for now let's go ahead and attempt to delete the characters we think should be there. + if (_pendingBuffer != nil && [[self pendingBuffer] length] > 0) { + NSException* exception = [NSException + exceptionWithName:@"InvalidOperationException" + reason:@"Cannot process subsequent Q_BACK after Q_STR" + userInfo:nil]; + @throw exception; + } + + if (!_cannnotTrustSelectionLength && selectedRange.length > 0) + n++; // First delete-back will delete the existing selection. + [self postDeleteBack:n for:event]; + + CFRelease(_sourceFromOriginalEvent); + _sourceFromOriginalEvent = nil; + return YES; + } + return NO; +} + +- (void)sendEvent:(NSEvent *)event { + ProcessSerialNumber psn; + GetFrontProcess(&psn); + + CGEventSourceRef source = CGEventCreateSourceFromEvent([event CGEvent]); + CGEventRef keyDownEvent = CGEventCreateKeyboardEvent(source, event.keyCode, true); + CGEventRef keyUpEvent = CGEventCreateKeyboardEvent(source, event.keyCode, false); + + CGEventPostToPSN(&psn, keyDownEvent); + CGEventPostToPSN(&psn, keyUpEvent); + + CFRelease(source); + CFRelease(keyDownEvent); + CFRelease(keyUpEvent); +} + +- (void)postDeleteBack:(NSUInteger)count for:(NSEvent *) event { + _numberOfPostedDeletesToExpect = count; + CGEventRef ev; + _sourceFromOriginalEvent = CGEventCreateSourceFromEvent([event CGEvent]); + + for (int db = 0; db < count; db++) + { + if ([self.AppDelegate debugMode]) + NSLog(@"Posting a delete (down/up) at kCGHIDEventTap."); + + ev = CGEventCreateKeyboardEvent (_sourceFromOriginalEvent, kVK_Delete, true);//delete-back down + CGEventPost(kCGHIDEventTap, ev); + CFRelease(ev); + ev = CGEventCreateKeyboardEvent (_sourceFromOriginalEvent, kVK_Delete, false); //delete-back up + CGEventPost(kCGHIDEventTap, ev); + CFRelease(ev); + } +} + +- (void)initiatePendingBufferProcessing:(id)sender { + [self postKeyPressToFrontProcess:kProcessPendingBuffer from:NULL]; +} + +- (void)postKeyPressToFrontProcess:(CGKeyCode)code from:(CGEventSourceRef) source { + ProcessSerialNumber psn; + GetFrontProcess(&psn); + + CGEventRef event = CGEventCreateKeyboardEvent(source, code, true); + CGEventPostToPSN(&psn, event); + CFRelease(event); + + if (code != kProcessPendingBuffer) { // special 0xFF code is not a real key-press, so no "up" is needed + event = CGEventCreateKeyboardEvent(source, code, false); + CGEventPostToPSN(&psn, event); + CFRelease(event); + } +} + +@end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandlerProtected.h b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandlerProtected.h new file mode 100644 index 0000000000..73bda95e3d --- /dev/null +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandlerProtected.h @@ -0,0 +1,45 @@ +// +// KMInputMethodEventHandlerProtected.h +// Keyman4MacIM +// +// Created by tom on 1/5/18. +// Copyright © 2018 SIL International. All rights reserved. +// + +#ifndef KMInputMethodEventHandlerProtected_h +#define KMInputMethodEventHandlerProtected_h + +@interface KMInputMethodEventHandler () +//@property (nonatomic) NSMutableDictionary *kbData; +//@property (nonatomic) NSDictionary *kmModes; +@property (assign) BOOL willDeleteNullChar; +@property (assign) BOOL contextOutOfDate; +// This flag indicates which mode is being used for replacing already typed text when composing characters +// (i.e., when not using deadkeys). Some apps (and some javascript-based websites, such as Google Docs) do +// not properly deal with calls to insertText calls that replace a range of characters. So for these "legacy" +// apps, we post one or more deletes (i.e., backspace), followed by a special code that tells us we're now +// ready to insert the composed text. +@property BOOL legacyMode; +// Some clients (e.g. Chrome) handle the mouse down events before we get a crack at them. For such apps that +// are able to report their current selection location (LibreOffice can't even do that!), we can do some +// checking at the start of the event processing to see if we're probably still in the same place where we +// left off previously. +@property (assign) BOOL clientSelectionCanChangeUnexpectedly; // REVIEW: Maybe we can get notification from these clients by handling mouseDownOnCharacterIndex. +@property (assign) BOOL cannnotTrustSelectionLength; + +- (instancetype)initWithLegacyMode:(BOOL)legacy clientSelectionCanChangeUnexpectedly:(BOOL) flagClientSelectionCanChangeUnexpectedly; +- (void)handleCommand:(NSEvent *)event; +// This just sets the legacyMode property to true and spits out a debug message to that effect. +- (void)switchToLegacyMode; +- (void)checkContextIn:(id)client; +- (void)replaceExistingSelectionIn:(id)client with:(NSString *) text; +- (void)insertPendingBufferTextIn:(id)client; +- (KMInputMethodAppDelegate *)AppDelegate; +- (NSMutableString *)contextBuffer; +// Return the pending buffer. If it is NIL create it. +-(NSMutableString*)pendingBuffer; +-(void)setPendingBuffer:(NSString*)string; +- (void)initiatePendingBufferProcessing:(id)sender; +@end + +#endif /* KMInputMethodEventHandlerProtected_h */ diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodSafariClientEventHandler.h b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodSafariClientEventHandler.h new file mode 100644 index 0000000000..b65236ec83 --- /dev/null +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodSafariClientEventHandler.h @@ -0,0 +1,17 @@ +// +// KMInputMethodSafariClientEventHandler.h +// Keyman4MacIM +// This class is used for the Safari browser to get specific behavior needed to improve behavior for some sites. +// +// Created by tom on 1/9/18. +// Copyright © 2018 SIL International. All rights reserved. +// + +#ifndef KMInputMethodSafariClientEventHandler_h +#define KMInputMethodSafariClientEventHandler_h +#import "KMInputMethodBrowserClientEventHandler.h" + +@interface KMInputMethodSafariClientEventHandler : KMInputMethodBrowserClientEventHandler +@end + +#endif /* KMInputMethodSafariClientEventHandler_h */ diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodSafariClientEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodSafariClientEventHandler.m new file mode 100644 index 0000000000..535303cdd8 --- /dev/null +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodSafariClientEventHandler.m @@ -0,0 +1,86 @@ +// +// KMInputMethodSafariClientEventHandler.m +// Keyman +// +// Created by tom on 1/9/18. +// Copyright © 2018 SIL International. All rights reserved. +// + +#import "KMInputMethodSafariClientEventHandler.h" +#import "KMInputMethodEventHandlerProtected.h" +#import "KMInputMethodBrowserClientEventHandlerProtected.h" + +@implementation KMInputMethodSafariClientEventHandler + +// Because Google Docs can't report its context, if we detect that we are in Google Docs, we change to legacy mode. +//BOOL _explicitlyDeleteExistingSelectionBeforeInserting = NO; +BOOL _preserveContextForNextCmdA; + +- (instancetype)init { + self = [super initWithLegacyMode:NO clientSelectionCanChangeUnexpectedly:NO]; + if (self) { + _preserveContextForNextCmdA = NO; + //_explicitlyDeleteExistingSelectionBeforeInserting = YES; + } + return self; +} + +- (void)handleCommand:(NSEvent *)event { + // If Safari issued a Cmd-A to remove the selection, we know what the + // context should be, so keep it. + if ([event.characters isEqualTo:@"a"] && _preserveContextForNextCmdA) + _preserveContextForNextCmdA = NO; + else + [super handleCommand:event]; +} + +- (void)setInSiteThatDoesNotGiveContext { + [self switchToLegacyMode]; +} + +- (void)replaceExistingSelectionIn:(id)client with:(NSString *) text { + // The following commented out code is the code that would make it possible to type over an + // existing selection in Word in Safari, but I don't know of a way to distinguish that case + // from other contexts in Safari (wherein this code inserts an extra leading space). + // if (_explicitlyDeleteExistingSelectionBeforeInserting) { + // NSUInteger selLength = [sender selectedRange].length; + // if (selLength > 0 && selLength != NSNotFound) { + // if ([self.AppDelegate debugMode]) { + // NSLog(@"Attempting to delete existing selection of length = %lu by replacing it with a space (which should not actually appear in the text).", selLength); + // } + // + // [sender insertText:@" " replacementRange:NSMakeRange(NSNotFound, NSNotFound)]; + // + // if ([self.AppDelegate debugMode]) + // NSLog(@"Re-posting original (unhandled) code: %d", (int)_keyCodeOfOriginalEvent); + // + // CGEventSourceRef sourceFromEvent = CGEventCreateSourceFromEvent([event CGEvent]); + // [self postKeyPressToFrontProcess:event.keyCode from:sourceFromEvent]; + // CFRelease(sourceFromEvent); + // return YES; + // } + // } + [super replaceExistingSelectionIn:client with:text]; + + // In Google Docs (i.e., "legacy mode" when there is an existing selection, the inserted characters + // stay selected. The following clears the selection. + if (self.legacyMode) { + if ([self.AppDelegate debugMode]) + NSLog(@"Sending Command-Shift-A to clear selection in Google Docs"); + _preserveContextForNextCmdA = YES; + ProcessSerialNumber psn; + GetFrontProcess(&psn); + + CGEventRef event = CGEventCreateKeyboardEvent(NULL, kVK_ANSI_A, true); + //set shift and command keys down for above event + CGEventSetFlags(event, kCGEventFlagMaskShift | kCGEventFlagMaskCommand); + CGEventPostToPSN(&psn, event); + CFRelease(event); + + event = CGEventCreateKeyboardEvent(NULL, kVK_ANSI_A, false); + CGEventSetFlags(event, kCGEventFlagMaskShift | kCGEventFlagMaskCommand); + CGEventPostToPSN(&psn, event); + CFRelease(event); + } +} +@end