| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.h" |
| |
| #import <Foundation/Foundation.h> |
| #import <objc/message.h> |
| |
| #include <algorithm> |
| #include <memory> |
| |
| #include "flutter/fml/platform/darwin/string_range_sanitization.h" |
| #include "flutter/shell/platform/common/text_editing_delta.h" |
| #include "flutter/shell/platform/common/text_input_model.h" |
| #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterCodecs.h" |
| #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterTextInputSemanticsObject.h" |
| #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterViewController_Internal.h" |
| #import "flutter/shell/platform/darwin/macos/framework/Source/NSView+ClipsToBounds.h" |
| |
| static NSString* const kTextInputChannel = @"flutter/textinput"; |
| |
| #pragma mark - TextInput channel method names |
| // See https://api.flutter.dev/flutter/services/SystemChannels/textInput-constant.html |
| static NSString* const kSetClientMethod = @"TextInput.setClient"; |
| static NSString* const kShowMethod = @"TextInput.show"; |
| static NSString* const kHideMethod = @"TextInput.hide"; |
| static NSString* const kClearClientMethod = @"TextInput.clearClient"; |
| static NSString* const kSetEditingStateMethod = @"TextInput.setEditingState"; |
| static NSString* const kSetEditableSizeAndTransform = @"TextInput.setEditableSizeAndTransform"; |
| static NSString* const kSetCaretRect = @"TextInput.setCaretRect"; |
| static NSString* const kUpdateEditStateResponseMethod = @"TextInputClient.updateEditingState"; |
| static NSString* const kUpdateEditStateWithDeltasResponseMethod = |
| @"TextInputClient.updateEditingStateWithDeltas"; |
| static NSString* const kPerformAction = @"TextInputClient.performAction"; |
| static NSString* const kPerformSelectors = @"TextInputClient.performSelectors"; |
| static NSString* const kMultilineInputType = @"TextInputType.multiline"; |
| |
| #pragma mark - TextInputConfiguration field names |
| static NSString* const kSecureTextEntry = @"obscureText"; |
| static NSString* const kTextInputAction = @"inputAction"; |
| static NSString* const kEnableDeltaModel = @"enableDeltaModel"; |
| static NSString* const kTextInputType = @"inputType"; |
| static NSString* const kTextInputTypeName = @"name"; |
| static NSString* const kSelectionBaseKey = @"selectionBase"; |
| static NSString* const kSelectionExtentKey = @"selectionExtent"; |
| static NSString* const kSelectionAffinityKey = @"selectionAffinity"; |
| static NSString* const kSelectionIsDirectionalKey = @"selectionIsDirectional"; |
| static NSString* const kComposingBaseKey = @"composingBase"; |
| static NSString* const kComposingExtentKey = @"composingExtent"; |
| static NSString* const kTextKey = @"text"; |
| static NSString* const kTransformKey = @"transform"; |
| static NSString* const kAssociatedAutofillFields = @"fields"; |
| |
| // TextInputConfiguration.autofill and sub-field names |
| static NSString* const kAutofillProperties = @"autofill"; |
| static NSString* const kAutofillId = @"uniqueIdentifier"; |
| static NSString* const kAutofillEditingValue = @"editingValue"; |
| static NSString* const kAutofillHints = @"hints"; |
| |
| // TextAffinity types |
| static NSString* const kTextAffinityDownstream = @"TextAffinity.downstream"; |
| static NSString* const kTextAffinityUpstream = @"TextAffinity.upstream"; |
| |
| // TextInputAction types |
| static NSString* const kInputActionNewline = @"TextInputAction.newline"; |
| |
| #pragma mark - Enums |
| /** |
| * The affinity of the current cursor position. If the cursor is at a position |
| * representing a soft line break, the cursor may be drawn either at the end of |
| * the current line (upstream) or at the beginning of the next (downstream). |
| */ |
| typedef NS_ENUM(NSUInteger, FlutterTextAffinity) { |
| kFlutterTextAffinityUpstream, |
| kFlutterTextAffinityDownstream |
| }; |
| |
| #pragma mark - Static functions |
| |
| /* |
| * Updates a range given base and extent fields. |
| */ |
| static flutter::TextRange RangeFromBaseExtent(NSNumber* base, |
| NSNumber* extent, |
| const flutter::TextRange& range) { |
| if (base == nil || extent == nil) { |
| return range; |
| } |
| if (base.intValue == -1 && extent.intValue == -1) { |
| return flutter::TextRange(0, 0); |
| } |
| return flutter::TextRange([base unsignedLongValue], [extent unsignedLongValue]); |
| } |
| |
| // Returns the autofill hint content type, if specified; otherwise nil. |
| static NSString* GetAutofillHint(NSDictionary* autofill) { |
| NSArray<NSString*>* hints = autofill[kAutofillHints]; |
| return hints.count > 0 ? hints[0] : nil; |
| } |
| |
| // Returns the text content type for the specified TextInputConfiguration. |
| // NSTextContentType is only available for macOS 11.0 and later. |
| static NSTextContentType GetTextContentType(NSDictionary* configuration) |
| API_AVAILABLE(macos(11.0)) { |
| // Check autofill hints. |
| NSDictionary* autofill = configuration[kAutofillProperties]; |
| if (autofill) { |
| NSString* hint = GetAutofillHint(autofill); |
| if ([hint isEqualToString:@"username"]) { |
| return NSTextContentTypeUsername; |
| } |
| if ([hint isEqualToString:@"password"]) { |
| return NSTextContentTypePassword; |
| } |
| if ([hint isEqualToString:@"oneTimeCode"]) { |
| return NSTextContentTypeOneTimeCode; |
| } |
| } |
| // If no autofill hints, guess based on other attributes. |
| if ([configuration[kSecureTextEntry] boolValue]) { |
| return NSTextContentTypePassword; |
| } |
| return nil; |
| } |
| |
| // Returns YES if configuration describes a field for which autocomplete should be enabled for |
| // the specified TextInputConfiguration. Autocomplete is enabled by default, but will be disabled |
| // if the field is password-related, or if the configuration contains no autofill settings. |
| static BOOL EnableAutocompleteForTextInputConfiguration(NSDictionary* configuration) { |
| // Disable if obscureText is set. |
| if ([configuration[kSecureTextEntry] boolValue]) { |
| return NO; |
| } |
| |
| // Disable if autofill properties are not set. |
| NSDictionary* autofill = configuration[kAutofillProperties]; |
| if (autofill == nil) { |
| return NO; |
| } |
| |
| // Disable if autofill properties indicate a username/password. |
| // See: https://github.com/flutter/flutter/issues/119824 |
| NSString* hint = GetAutofillHint(autofill); |
| if ([hint isEqualToString:@"password"] || [hint isEqualToString:@"username"]) { |
| return NO; |
| } |
| return YES; |
| } |
| |
| // Returns YES if configuration describes a field for which autocomplete should be enabled. |
| // Autocomplete is enabled by default, but will be disabled if the field is password-related, or if |
| // the configuration contains no autofill settings. |
| // |
| // In the case where the current field is part of an AutofillGroup, the configuration will have |
| // a fields attribute with a list of TextInputConfigurations, one for each field. In the case where |
| // any field in the group disables autocomplete, we disable it for all. |
| static BOOL EnableAutocomplete(NSDictionary* configuration) { |
| for (NSDictionary* field in configuration[kAssociatedAutofillFields]) { |
| if (!EnableAutocompleteForTextInputConfiguration(field)) { |
| return NO; |
| } |
| } |
| |
| // Check the top-level TextInputConfiguration. |
| return EnableAutocompleteForTextInputConfiguration(configuration); |
| } |
| |
| #pragma mark - NSEvent (KeyEquivalentMarker) protocol |
| |
| @interface NSEvent (KeyEquivalentMarker) |
| |
| // Internally marks that the event was received through performKeyEquivalent:. |
| // When text editing is active, keyboard events that have modifier keys pressed |
| // are received through performKeyEquivalent: instead of keyDown:. If such event |
| // is passed to TextInputContext but doesn't result in a text editing action it |
| // needs to be forwarded by FlutterKeyboardManager to the next responder. |
| - (void)markAsKeyEquivalent; |
| |
| // Returns YES if the event is marked as a key equivalent. |
| - (BOOL)isKeyEquivalent; |
| |
| @end |
| |
| @implementation NSEvent (KeyEquivalentMarker) |
| |
| // This field doesn't need a value because only its address is used as a unique identifier. |
| static char markerKey; |
| |
| - (void)markAsKeyEquivalent { |
| objc_setAssociatedObject(self, &markerKey, @true, OBJC_ASSOCIATION_RETAIN); |
| } |
| |
| - (BOOL)isKeyEquivalent { |
| return [objc_getAssociatedObject(self, &markerKey) boolValue] == YES; |
| } |
| |
| @end |
| |
| #pragma mark - FlutterTextInputPlugin private interface |
| |
| /** |
| * Private properties of FlutterTextInputPlugin. |
| */ |
| @interface FlutterTextInputPlugin () |
| |
| /** |
| * A text input context, representing a connection to the Cocoa text input system. |
| */ |
| @property(nonatomic) NSTextInputContext* textInputContext; |
| |
| /** |
| * The channel used to communicate with Flutter. |
| */ |
| @property(nonatomic) FlutterMethodChannel* channel; |
| |
| /** |
| * The FlutterViewController to manage input for. |
| */ |
| @property(nonatomic, weak) FlutterViewController* flutterViewController; |
| |
| /** |
| * Whether the text input is shown in the view. |
| * |
| * Defaults to TRUE on startup. |
| */ |
| @property(nonatomic) BOOL shown; |
| |
| /** |
| * The current state of the keyboard and pressed keys. |
| */ |
| @property(nonatomic) uint64_t previouslyPressedFlags; |
| |
| /** |
| * The affinity for the current cursor position. |
| */ |
| @property FlutterTextAffinity textAffinity; |
| |
| /** |
| * ID of the text input client. |
| */ |
| @property(nonatomic, nonnull) NSNumber* clientID; |
| |
| /** |
| * Keyboard type of the client. See available options: |
| * https://api.flutter.dev/flutter/services/TextInputType-class.html |
| */ |
| @property(nonatomic, nonnull) NSString* inputType; |
| |
| /** |
| * An action requested by the user on the input client. See available options: |
| * https://api.flutter.dev/flutter/services/TextInputAction-class.html |
| */ |
| @property(nonatomic, nonnull) NSString* inputAction; |
| |
| /** |
| * Set to true if the last event fed to the input context produced a text editing command |
| * or text output. It is reset to false at the beginning of every key event, and is only |
| * used while processing this event. |
| */ |
| @property(nonatomic) BOOL eventProducedOutput; |
| |
| /** |
| * Whether to enable the sending of text input updates from the engine to the |
| * framework as TextEditingDeltas rather than as one TextEditingValue. |
| * For more information on the delta model, see: |
| * https://master-api.flutter.dev/flutter/services/TextInputConfiguration/enableDeltaModel.html |
| */ |
| @property(nonatomic) BOOL enableDeltaModel; |
| |
| /** |
| * Used to gather multiple selectors performed in one run loop turn. These |
| * will be all sent in one platform channel call so that the framework can process |
| * them in single microtask. |
| */ |
| @property(nonatomic) NSMutableArray* pendingSelectors; |
| |
| /** |
| * Handles a Flutter system message on the text input channel. |
| */ |
| - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result; |
| |
| /** |
| * Updates the text input model with state received from the framework via the |
| * TextInput.setEditingState message. |
| */ |
| - (void)setEditingState:(NSDictionary*)state; |
| |
| /** |
| * Informs the Flutter framework of changes to the text input model's state by |
| * sending the entire new state. |
| */ |
| - (void)updateEditState; |
| |
| /** |
| * Informs the Flutter framework of changes to the text input model's state by |
| * sending only the difference. |
| */ |
| - (void)updateEditStateWithDelta:(const flutter::TextEditingDelta)delta; |
| |
| /** |
| * Updates the stringValue and selectedRange that stored in the NSTextView interface |
| * that this plugin inherits from. |
| * |
| * If there is a FlutterTextField uses this plugin as its field editor, this method |
| * will update the stringValue and selectedRange through the API of the FlutterTextField. |
| */ |
| - (void)updateTextAndSelection; |
| |
| /** |
| * Return the string representation of the current textAffinity as it should be |
| * sent over the FlutterMethodChannel. |
| */ |
| - (NSString*)textAffinityString; |
| |
| /** |
| * Allow overriding run loop mode for test. |
| */ |
| @property(readwrite, nonatomic) NSString* customRunLoopMode; |
| |
| @end |
| |
| #pragma mark - FlutterTextInputPlugin |
| |
| @implementation FlutterTextInputPlugin { |
| /** |
| * The currently active text input model. |
| */ |
| std::unique_ptr<flutter::TextInputModel> _activeModel; |
| |
| /** |
| * Transform for current the editable. Used to determine position of accent selection menu. |
| */ |
| CATransform3D _editableTransform; |
| |
| /** |
| * Current position of caret in local (editable) coordinates. |
| */ |
| CGRect _caretRect; |
| } |
| |
| - (instancetype)initWithViewController:(FlutterViewController*)viewController { |
| // The view needs an empty frame otherwise it is visible on dark background. |
| // https://github.com/flutter/flutter/issues/118504 |
| self = [super initWithFrame:NSZeroRect]; |
| self.clipsToBounds = YES; |
| if (self != nil) { |
| _flutterViewController = viewController; |
| _channel = [FlutterMethodChannel methodChannelWithName:kTextInputChannel |
| binaryMessenger:viewController.engine.binaryMessenger |
| codec:[FlutterJSONMethodCodec sharedInstance]]; |
| _shown = FALSE; |
| // NSTextView does not support _weak reference, so this class has to |
| // use __unsafe_unretained and manage the reference by itself. |
| // |
| // Since the dealloc removes the handler, the pointer should |
| // be valid if the handler is ever called. |
| __unsafe_unretained FlutterTextInputPlugin* unsafeSelf = self; |
| [_channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) { |
| [unsafeSelf handleMethodCall:call result:result]; |
| }]; |
| _textInputContext = [[NSTextInputContext alloc] initWithClient:unsafeSelf]; |
| _previouslyPressedFlags = 0; |
| |
| // Initialize with the zero matrix which is not |
| // an affine transform. |
| _editableTransform = CATransform3D(); |
| _caretRect = CGRectNull; |
| } |
| return self; |
| } |
| |
| - (BOOL)isFirstResponder { |
| if (!self.flutterViewController.viewLoaded) { |
| return false; |
| } |
| return [self.flutterViewController.view.window firstResponder] == self; |
| } |
| |
| - (void)dealloc { |
| [_channel setMethodCallHandler:nil]; |
| } |
| |
| #pragma mark - Private |
| |
| - (void)resignAndRemoveFromSuperview { |
| if (self.superview != nil) { |
| [self.window makeFirstResponder:_flutterViewController.flutterView]; |
| [self removeFromSuperview]; |
| } |
| } |
| |
| - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { |
| BOOL handled = YES; |
| NSString* method = call.method; |
| if ([method isEqualToString:kSetClientMethod]) { |
| if (!call.arguments[0] || !call.arguments[1]) { |
| result([FlutterError |
| errorWithCode:@"error" |
| message:@"Missing arguments" |
| details:@"Missing arguments while trying to set a text input client"]); |
| return; |
| } |
| NSNumber* clientID = call.arguments[0]; |
| if (clientID != nil) { |
| NSDictionary* config = call.arguments[1]; |
| |
| _clientID = clientID; |
| _inputAction = config[kTextInputAction]; |
| _enableDeltaModel = [config[kEnableDeltaModel] boolValue]; |
| NSDictionary* inputTypeInfo = config[kTextInputType]; |
| _inputType = inputTypeInfo[kTextInputTypeName]; |
| self.textAffinity = kFlutterTextAffinityUpstream; |
| self.automaticTextCompletionEnabled = EnableAutocomplete(config); |
| if (@available(macOS 11.0, *)) { |
| self.contentType = GetTextContentType(config); |
| } |
| |
| _activeModel = std::make_unique<flutter::TextInputModel>(); |
| } |
| } else if ([method isEqualToString:kShowMethod]) { |
| // Ensure the plugin is in hierarchy. Only do this with accessibility disabled. |
| // When accessibility is enabled cocoa will reparent the plugin inside |
| // FlutterTextField in [FlutterTextField startEditing]. |
| if (_client == nil) { |
| [_flutterViewController.view addSubview:self]; |
| } |
| [self.window makeFirstResponder:self]; |
| _shown = TRUE; |
| } else if ([method isEqualToString:kHideMethod]) { |
| [self resignAndRemoveFromSuperview]; |
| _shown = FALSE; |
| } else if ([method isEqualToString:kClearClientMethod]) { |
| [self resignAndRemoveFromSuperview]; |
| // If there's an active mark region, commit it, end composing, and clear the IME's mark text. |
| if (_activeModel && _activeModel->composing()) { |
| _activeModel->CommitComposing(); |
| _activeModel->EndComposing(); |
| } |
| [_textInputContext discardMarkedText]; |
| |
| _clientID = nil; |
| _inputAction = nil; |
| _enableDeltaModel = NO; |
| _inputType = nil; |
| _activeModel = nullptr; |
| } else if ([method isEqualToString:kSetEditingStateMethod]) { |
| NSDictionary* state = call.arguments; |
| [self setEditingState:state]; |
| } else if ([method isEqualToString:kSetEditableSizeAndTransform]) { |
| NSDictionary* state = call.arguments; |
| [self setEditableTransform:state[kTransformKey]]; |
| } else if ([method isEqualToString:kSetCaretRect]) { |
| NSDictionary* rect = call.arguments; |
| [self updateCaretRect:rect]; |
| } else { |
| handled = NO; |
| } |
| result(handled ? nil : FlutterMethodNotImplemented); |
| } |
| |
| - (void)setEditableTransform:(NSArray*)matrix { |
| CATransform3D* transform = &_editableTransform; |
| |
| transform->m11 = [matrix[0] doubleValue]; |
| transform->m12 = [matrix[1] doubleValue]; |
| transform->m13 = [matrix[2] doubleValue]; |
| transform->m14 = [matrix[3] doubleValue]; |
| |
| transform->m21 = [matrix[4] doubleValue]; |
| transform->m22 = [matrix[5] doubleValue]; |
| transform->m23 = [matrix[6] doubleValue]; |
| transform->m24 = [matrix[7] doubleValue]; |
| |
| transform->m31 = [matrix[8] doubleValue]; |
| transform->m32 = [matrix[9] doubleValue]; |
| transform->m33 = [matrix[10] doubleValue]; |
| transform->m34 = [matrix[11] doubleValue]; |
| |
| transform->m41 = [matrix[12] doubleValue]; |
| transform->m42 = [matrix[13] doubleValue]; |
| transform->m43 = [matrix[14] doubleValue]; |
| transform->m44 = [matrix[15] doubleValue]; |
| } |
| |
| - (void)updateCaretRect:(NSDictionary*)dictionary { |
| NSAssert(dictionary[@"x"] != nil && dictionary[@"y"] != nil && dictionary[@"width"] != nil && |
| dictionary[@"height"] != nil, |
| @"Expected a dictionary representing a CGRect, got %@", dictionary); |
| _caretRect = CGRectMake([dictionary[@"x"] doubleValue], [dictionary[@"y"] doubleValue], |
| [dictionary[@"width"] doubleValue], [dictionary[@"height"] doubleValue]); |
| } |
| |
| - (void)setEditingState:(NSDictionary*)state { |
| NSString* selectionAffinity = state[kSelectionAffinityKey]; |
| if (selectionAffinity != nil) { |
| _textAffinity = [selectionAffinity isEqualToString:kTextAffinityUpstream] |
| ? kFlutterTextAffinityUpstream |
| : kFlutterTextAffinityDownstream; |
| } |
| |
| NSString* text = state[kTextKey]; |
| |
| flutter::TextRange selected_range = RangeFromBaseExtent( |
| state[kSelectionBaseKey], state[kSelectionExtentKey], _activeModel->selection()); |
| _activeModel->SetSelection(selected_range); |
| |
| flutter::TextRange composing_range = RangeFromBaseExtent( |
| state[kComposingBaseKey], state[kComposingExtentKey], _activeModel->composing_range()); |
| |
| const bool wasComposing = _activeModel->composing(); |
| _activeModel->SetText([text UTF8String], selected_range, composing_range); |
| if (composing_range.collapsed() && wasComposing) { |
| [_textInputContext discardMarkedText]; |
| } |
| [_client startEditing]; |
| |
| [self updateTextAndSelection]; |
| } |
| |
| - (NSDictionary*)editingState { |
| if (_activeModel == nullptr) { |
| return nil; |
| } |
| |
| NSString* const textAffinity = [self textAffinityString]; |
| |
| int composingBase = _activeModel->composing() ? _activeModel->composing_range().base() : -1; |
| int composingExtent = _activeModel->composing() ? _activeModel->composing_range().extent() : -1; |
| |
| return @{ |
| kSelectionBaseKey : @(_activeModel->selection().base()), |
| kSelectionExtentKey : @(_activeModel->selection().extent()), |
| kSelectionAffinityKey : textAffinity, |
| kSelectionIsDirectionalKey : @NO, |
| kComposingBaseKey : @(composingBase), |
| kComposingExtentKey : @(composingExtent), |
| kTextKey : [NSString stringWithUTF8String:_activeModel->GetText().c_str()] ?: [NSNull null], |
| }; |
| } |
| |
| - (void)updateEditState { |
| if (_activeModel == nullptr) { |
| return; |
| } |
| |
| NSDictionary* state = [self editingState]; |
| [_channel invokeMethod:kUpdateEditStateResponseMethod arguments:@[ self.clientID, state ]]; |
| [self updateTextAndSelection]; |
| } |
| |
| - (void)updateEditStateWithDelta:(const flutter::TextEditingDelta)delta { |
| NSUInteger selectionBase = _activeModel->selection().base(); |
| NSUInteger selectionExtent = _activeModel->selection().extent(); |
| int composingBase = _activeModel->composing() ? _activeModel->composing_range().base() : -1; |
| int composingExtent = _activeModel->composing() ? _activeModel->composing_range().extent() : -1; |
| |
| NSString* const textAffinity = [self textAffinityString]; |
| |
| NSDictionary* deltaToFramework = @{ |
| @"oldText" : @(delta.old_text().c_str()), |
| @"deltaText" : @(delta.delta_text().c_str()), |
| @"deltaStart" : @(delta.delta_start()), |
| @"deltaEnd" : @(delta.delta_end()), |
| @"selectionBase" : @(selectionBase), |
| @"selectionExtent" : @(selectionExtent), |
| @"selectionAffinity" : textAffinity, |
| @"selectionIsDirectional" : @(false), |
| @"composingBase" : @(composingBase), |
| @"composingExtent" : @(composingExtent), |
| }; |
| |
| NSDictionary* deltas = @{ |
| @"deltas" : @[ deltaToFramework ], |
| }; |
| |
| [_channel invokeMethod:kUpdateEditStateWithDeltasResponseMethod |
| arguments:@[ self.clientID, deltas ]]; |
| [self updateTextAndSelection]; |
| } |
| |
| - (void)updateTextAndSelection { |
| NSAssert(_activeModel != nullptr, @"Flutter text model must not be null."); |
| NSString* text = @(_activeModel->GetText().data()); |
| int start = _activeModel->selection().base(); |
| int extend = _activeModel->selection().extent(); |
| NSRange selection = NSMakeRange(MIN(start, extend), ABS(start - extend)); |
| // There may be a native text field client if VoiceOver is on. |
| // In this case, this plugin has to update text and selection through |
| // the client in order for VoiceOver to announce the text editing |
| // properly. |
| if (_client) { |
| [_client updateString:text withSelection:selection]; |
| } else { |
| self.string = text; |
| [self setSelectedRange:selection]; |
| } |
| } |
| |
| - (NSString*)textAffinityString { |
| return (self.textAffinity == kFlutterTextAffinityUpstream) ? kTextAffinityUpstream |
| : kTextAffinityDownstream; |
| } |
| |
| - (BOOL)handleKeyEvent:(NSEvent*)event { |
| if (event.type == NSEventTypeKeyUp || |
| (event.type == NSEventTypeFlagsChanged && event.modifierFlags < _previouslyPressedFlags)) { |
| return NO; |
| } |
| _previouslyPressedFlags = event.modifierFlags; |
| if (!_shown) { |
| return NO; |
| } |
| |
| _eventProducedOutput = NO; |
| BOOL res = [_textInputContext handleEvent:event]; |
| // NSTextInputContext#handleEvent returns YES if the context handles the event. One of the reasons |
| // the event is handled is because it's a key equivalent. But a key equivalent might produce a |
| // text command (indicated by calling doCommandBySelector) or might not (for example, Cmd+Q). In |
| // the latter case, this command somehow has not been executed yet and Flutter must dispatch it to |
| // the next responder. See https://github.com/flutter/flutter/issues/106354 . |
| // The event is also not redispatched if there is IME composition active, because it might be |
| // handled by the IME. See https://github.com/flutter/flutter/issues/134699 |
| |
| // both NSEventModifierFlagNumericPad and NSEventModifierFlagFunction are set for arrow keys. |
| bool is_navigation = event.modifierFlags & NSEventModifierFlagFunction && |
| event.modifierFlags & NSEventModifierFlagNumericPad; |
| bool is_navigation_in_ime = is_navigation && self.hasMarkedText; |
| |
| if (event.isKeyEquivalent && !is_navigation_in_ime && !_eventProducedOutput) { |
| return NO; |
| } |
| return res; |
| } |
| |
| #pragma mark - |
| #pragma mark NSResponder |
| |
| - (void)keyDown:(NSEvent*)event { |
| [self.flutterViewController keyDown:event]; |
| } |
| |
| - (void)keyUp:(NSEvent*)event { |
| [self.flutterViewController keyUp:event]; |
| } |
| |
| - (BOOL)performKeyEquivalent:(NSEvent*)event { |
| if ([_flutterViewController isDispatchingKeyEvent:event]) { |
| // When NSWindow is nextResponder, keyboard manager will send to it |
| // unhandled events (through [NSWindow keyDown:]). If event has both |
| // control and cmd modifiers set (i.e. cmd+control+space - emoji picker) |
| // NSWindow will then send this event as performKeyEquivalent: to first |
| // responder, which is FlutterTextInputPlugin. If that's the case, the |
| // plugin must not handle the event, otherwise the emoji picker would not |
| // work (due to first responder returning YES from performKeyEquivalent:) |
| // and there would be endless loop, because FlutterViewController will |
| // send the event back to [keyboardManager handleEvent:]. |
| return NO; |
| } |
| [event markAsKeyEquivalent]; |
| [self.flutterViewController keyDown:event]; |
| return YES; |
| } |
| |
| - (void)flagsChanged:(NSEvent*)event { |
| [self.flutterViewController flagsChanged:event]; |
| } |
| |
| - (void)mouseDown:(NSEvent*)event { |
| [self.flutterViewController mouseDown:event]; |
| } |
| |
| - (void)mouseUp:(NSEvent*)event { |
| [self.flutterViewController mouseUp:event]; |
| } |
| |
| - (void)mouseDragged:(NSEvent*)event { |
| [self.flutterViewController mouseDragged:event]; |
| } |
| |
| - (void)rightMouseDown:(NSEvent*)event { |
| [self.flutterViewController rightMouseDown:event]; |
| } |
| |
| - (void)rightMouseUp:(NSEvent*)event { |
| [self.flutterViewController rightMouseUp:event]; |
| } |
| |
| - (void)rightMouseDragged:(NSEvent*)event { |
| [self.flutterViewController rightMouseDragged:event]; |
| } |
| |
| - (void)otherMouseDown:(NSEvent*)event { |
| [self.flutterViewController otherMouseDown:event]; |
| } |
| |
| - (void)otherMouseUp:(NSEvent*)event { |
| [self.flutterViewController otherMouseUp:event]; |
| } |
| |
| - (void)otherMouseDragged:(NSEvent*)event { |
| [self.flutterViewController otherMouseDragged:event]; |
| } |
| |
| - (void)mouseMoved:(NSEvent*)event { |
| [self.flutterViewController mouseMoved:event]; |
| } |
| |
| - (void)scrollWheel:(NSEvent*)event { |
| [self.flutterViewController scrollWheel:event]; |
| } |
| |
| - (NSTextInputContext*)inputContext { |
| return _textInputContext; |
| } |
| |
| #pragma mark - |
| #pragma mark NSTextInputClient |
| |
| - (void)insertTab:(id)sender { |
| // Implementing insertTab: makes AppKit send tab as command, instead of |
| // insertText with '\t'. |
| } |
| |
| - (void)insertText:(id)string replacementRange:(NSRange)range { |
| if (_activeModel == nullptr) { |
| return; |
| } |
| |
| _eventProducedOutput |= true; |
| |
| if (range.location != NSNotFound) { |
| // The selected range can actually have negative numbers, since it can start |
| // at the end of the range if the user selected the text going backwards. |
| // Cast to a signed type to determine whether or not the selection is reversed. |
| long signedLength = static_cast<long>(range.length); |
| long location = range.location; |
| long textLength = _activeModel->text_range().end(); |
| |
| size_t base = std::clamp(location, 0L, textLength); |
| size_t extent = std::clamp(location + signedLength, 0L, textLength); |
| |
| _activeModel->SetSelection(flutter::TextRange(base, extent)); |
| } |
| |
| flutter::TextRange oldSelection = _activeModel->selection(); |
| flutter::TextRange composingBeforeChange = _activeModel->composing_range(); |
| flutter::TextRange replacedRange(-1, -1); |
| |
| std::string textBeforeChange = _activeModel->GetText().c_str(); |
| std::string utf8String = [string UTF8String]; |
| _activeModel->AddText(utf8String); |
| if (_activeModel->composing()) { |
| replacedRange = composingBeforeChange; |
| _activeModel->CommitComposing(); |
| _activeModel->EndComposing(); |
| } else { |
| replacedRange = range.location == NSNotFound |
| ? flutter::TextRange(oldSelection.base(), oldSelection.extent()) |
| : flutter::TextRange(range.location, range.location + range.length); |
| } |
| if (_enableDeltaModel) { |
| [self updateEditStateWithDelta:flutter::TextEditingDelta(textBeforeChange, replacedRange, |
| utf8String)]; |
| } else { |
| [self updateEditState]; |
| } |
| } |
| |
| - (void)doCommandBySelector:(SEL)selector { |
| _eventProducedOutput |= selector != NSSelectorFromString(@"noop:"); |
| if ([self respondsToSelector:selector]) { |
| // Note: The more obvious [self performSelector...] doesn't give ARC enough information to |
| // handle retain semantics properly. See https://stackoverflow.com/questions/7017281/ for more |
| // information. |
| IMP imp = [self methodForSelector:selector]; |
| void (*func)(id, SEL, id) = reinterpret_cast<void (*)(id, SEL, id)>(imp); |
| func(self, selector, nil); |
| } |
| |
| if (selector == @selector(insertNewline:)) { |
| // Already handled through text insertion (multiline) or action. |
| return; |
| } |
| |
| // Group multiple selectors received within a single run loop turn so that |
| // the framework can process them in single microtask. |
| NSString* name = NSStringFromSelector(selector); |
| if (_pendingSelectors == nil) { |
| _pendingSelectors = [NSMutableArray array]; |
| } |
| [_pendingSelectors addObject:name]; |
| |
| if (_pendingSelectors.count == 1) { |
| __weak NSMutableArray* selectors = _pendingSelectors; |
| __weak FlutterMethodChannel* channel = _channel; |
| __weak NSNumber* clientID = self.clientID; |
| |
| CFStringRef runLoopMode = self.customRunLoopMode != nil |
| ? (__bridge CFStringRef)self.customRunLoopMode |
| : kCFRunLoopCommonModes; |
| |
| CFRunLoopPerformBlock(CFRunLoopGetMain(), runLoopMode, ^{ |
| if (selectors.count > 0) { |
| [channel invokeMethod:kPerformSelectors arguments:@[ clientID, selectors ]]; |
| [selectors removeAllObjects]; |
| } |
| }); |
| } |
| } |
| |
| - (void)insertNewline:(id)sender { |
| if (_activeModel == nullptr) { |
| return; |
| } |
| if (_activeModel->composing()) { |
| _activeModel->CommitComposing(); |
| _activeModel->EndComposing(); |
| } |
| if ([self.inputType isEqualToString:kMultilineInputType] && |
| [self.inputAction isEqualToString:kInputActionNewline]) { |
| [self insertText:@"\n" replacementRange:self.selectedRange]; |
| } |
| [_channel invokeMethod:kPerformAction arguments:@[ self.clientID, self.inputAction ]]; |
| } |
| |
| - (void)setMarkedText:(id)string |
| selectedRange:(NSRange)selectedRange |
| replacementRange:(NSRange)replacementRange { |
| if (_activeModel == nullptr) { |
| return; |
| } |
| std::string textBeforeChange = _activeModel->GetText().c_str(); |
| if (!_activeModel->composing()) { |
| _activeModel->BeginComposing(); |
| } |
| |
| if (replacementRange.location != NSNotFound) { |
| // According to the NSTextInputClient documentation replacementRange is |
| // computed from the beginning of the marked text. That doesn't seem to be |
| // the case, because in situations where the replacementRange is actually |
| // specified (i.e. when switching between characters equivalent after long |
| // key press) the replacementRange is provided while there is no composition. |
| _activeModel->SetComposingRange( |
| flutter::TextRange(replacementRange.location, |
| replacementRange.location + replacementRange.length), |
| 0); |
| } |
| |
| flutter::TextRange composingBeforeChange = _activeModel->composing_range(); |
| flutter::TextRange selectionBeforeChange = _activeModel->selection(); |
| |
| // Input string may be NSString or NSAttributedString. |
| BOOL isAttributedString = [string isKindOfClass:[NSAttributedString class]]; |
| const NSString* rawString = isAttributedString ? [string string] : string; |
| _activeModel->UpdateComposingText( |
| (const char16_t*)[rawString cStringUsingEncoding:NSUTF16StringEncoding], |
| flutter::TextRange(selectedRange.location, selectedRange.location + selectedRange.length)); |
| |
| if (_enableDeltaModel) { |
| std::string marked_text = [rawString UTF8String]; |
| [self updateEditStateWithDelta:flutter::TextEditingDelta(textBeforeChange, |
| selectionBeforeChange.collapsed() |
| ? composingBeforeChange |
| : selectionBeforeChange, |
| marked_text)]; |
| } else { |
| [self updateEditState]; |
| } |
| } |
| |
| - (void)unmarkText { |
| if (_activeModel == nullptr) { |
| return; |
| } |
| _activeModel->CommitComposing(); |
| _activeModel->EndComposing(); |
| if (_enableDeltaModel) { |
| [self updateEditStateWithDelta:flutter::TextEditingDelta(_activeModel->GetText().c_str())]; |
| } else { |
| [self updateEditState]; |
| } |
| } |
| |
| - (NSRange)markedRange { |
| if (_activeModel == nullptr) { |
| return NSMakeRange(NSNotFound, 0); |
| } |
| return NSMakeRange( |
| _activeModel->composing_range().base(), |
| _activeModel->composing_range().extent() - _activeModel->composing_range().base()); |
| } |
| |
| - (BOOL)hasMarkedText { |
| return _activeModel != nullptr && _activeModel->composing_range().length() > 0; |
| } |
| |
| - (NSAttributedString*)attributedSubstringForProposedRange:(NSRange)range |
| actualRange:(NSRangePointer)actualRange { |
| if (_activeModel == nullptr) { |
| return nil; |
| } |
| if (actualRange != nil) { |
| *actualRange = range; |
| } |
| NSString* text = [NSString stringWithUTF8String:_activeModel->GetText().c_str()]; |
| NSString* substring = [text substringWithRange:range]; |
| return [[NSAttributedString alloc] initWithString:substring attributes:nil]; |
| } |
| |
| - (NSArray<NSString*>*)validAttributesForMarkedText { |
| return @[]; |
| } |
| |
| // Returns the bounding CGRect of the transformed incomingRect, in screen |
| // coordinates. |
| - (CGRect)screenRectFromFrameworkTransform:(CGRect)incomingRect { |
| CGPoint points[] = { |
| incomingRect.origin, |
| CGPointMake(incomingRect.origin.x, incomingRect.origin.y + incomingRect.size.height), |
| CGPointMake(incomingRect.origin.x + incomingRect.size.width, incomingRect.origin.y), |
| CGPointMake(incomingRect.origin.x + incomingRect.size.width, |
| incomingRect.origin.y + incomingRect.size.height)}; |
| |
| CGPoint origin = CGPointMake(CGFLOAT_MAX, CGFLOAT_MAX); |
| CGPoint farthest = CGPointMake(-CGFLOAT_MAX, -CGFLOAT_MAX); |
| |
| for (int i = 0; i < 4; i++) { |
| const CGPoint point = points[i]; |
| |
| CGFloat x = _editableTransform.m11 * point.x + _editableTransform.m21 * point.y + |
| _editableTransform.m41; |
| CGFloat y = _editableTransform.m12 * point.x + _editableTransform.m22 * point.y + |
| _editableTransform.m42; |
| |
| const CGFloat w = _editableTransform.m14 * point.x + _editableTransform.m24 * point.y + |
| _editableTransform.m44; |
| |
| if (w == 0.0) { |
| return CGRectZero; |
| } else if (w != 1.0) { |
| x /= w; |
| y /= w; |
| } |
| |
| origin.x = MIN(origin.x, x); |
| origin.y = MIN(origin.y, y); |
| farthest.x = MAX(farthest.x, x); |
| farthest.y = MAX(farthest.y, y); |
| } |
| |
| const NSView* fromView = self.flutterViewController.flutterView; |
| const CGRect rectInWindow = [fromView |
| convertRect:CGRectMake(origin.x, origin.y, farthest.x - origin.x, farthest.y - origin.y) |
| toView:nil]; |
| NSWindow* window = fromView.window; |
| return window ? [window convertRectToScreen:rectInWindow] : rectInWindow; |
| } |
| |
| - (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actualRange { |
| // This only determines position of caret instead of any arbitrary range, but it's enough |
| // to properly position accent selection popup |
| return !self.flutterViewController.viewLoaded || CGRectEqualToRect(_caretRect, CGRectNull) |
| ? CGRectZero |
| : [self screenRectFromFrameworkTransform:_caretRect]; |
| } |
| |
| - (NSUInteger)characterIndexForPoint:(NSPoint)point { |
| // TODO(cbracken): Implement. |
| // Note: This function can't easily be implemented under the system-message architecture. |
| return 0; |
| } |
| |
| @end |