diff --git a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.mm b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.mm index 67dd257cc9c7e..6f6706305223f 100644 --- a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.mm +++ b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.mm @@ -196,12 +196,6 @@ - (void)processNextEvent { } - (void)performProcessEvent:(NSEvent*)event onFinish:(VoidBlock)onFinish { - if (_viewDelegate.isComposing) { - [self dispatchTextEvent:event]; - onFinish(); - return; - } - // Having no primary responders require extra logic, but Flutter hard-codes // all primary responders, so this is a situation that Flutter will never // encounter. diff --git a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManagerUnittests.mm b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManagerUnittests.mm index f091f535cf62b..e257e94a9a031 100644 --- a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManagerUnittests.mm +++ b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManagerUnittests.mm @@ -252,7 +252,6 @@ - (void)recordCallTypesTo:(nonnull NSMutableArray*)typeStorage @property(nonatomic) FlutterKeyboardManager* manager; @property(nonatomic) NSResponder* nextResponder; -@property(nonatomic, assign) BOOL isComposing; #pragma mark - Private @@ -289,7 +288,6 @@ - (nonnull instancetype)init { [self respondChannelCallsWith:FALSE]; [self respondEmbedderCallsWith:FALSE]; [self respondTextInputWith:FALSE]; - _isComposing = NO; _currentLayout = &kUsLayout; @@ -304,7 +302,6 @@ - (nonnull instancetype)init { OCMStub([viewDelegateMock onTextInputKeyEvent:[OCMArg any]]) .andCall(self, @selector(handleTextInputKeyEvent:)); OCMStub([viewDelegateMock getBinaryMessenger]).andReturn(messengerMock); - OCMStub([viewDelegateMock isComposing]).andCall(self, @selector(isComposing)); OCMStub([viewDelegateMock sendKeyEvent:FlutterKeyEvent {} callback:nil userData:nil]) .ignoringNonObjectArgs() .andCall(self, @selector(handleEmbedderEvent:callback:userData:)); @@ -430,7 +427,6 @@ @interface FlutterKeyboardManagerUnittestsObjC : NSObject - (bool)singlePrimaryResponder; - (bool)doublePrimaryResponder; - (bool)textInputPlugin; -- (bool)forwardKeyEventsToSystemWhenComposing; - (bool)emptyNextResponder; - (bool)racingConditionBetweenKeyAndText; - (bool)correctLogicalKeyForLayouts; @@ -449,10 +445,6 @@ - (bool)correctLogicalKeyForLayouts; ASSERT_TRUE([[FlutterKeyboardManagerUnittestsObjC alloc] textInputPlugin]); } -TEST(FlutterKeyboardManagerUnittests, handlingComposingText) { - ASSERT_TRUE([[FlutterKeyboardManagerUnittestsObjC alloc] forwardKeyEventsToSystemWhenComposing]); -} - TEST(FlutterKeyboardManagerUnittests, EmptyNextResponder) { ASSERT_TRUE([[FlutterKeyboardManagerUnittestsObjC alloc] emptyNextResponder]); } @@ -597,31 +589,6 @@ - (bool)textInputPlugin { return true; } -- (bool)forwardKeyEventsToSystemWhenComposing { - KeyboardTester* tester = OCMPartialMock([[KeyboardTester alloc] init]); - - NSMutableArray* channelCallbacks = - [NSMutableArray array]; - NSMutableArray* embedderCallbacks = - [NSMutableArray array]; - [tester recordEmbedderCallsTo:embedderCallbacks]; - [tester recordChannelCallsTo:channelCallbacks]; - // The event shouldn't propagate further even if TextInputPlugin does not - // claim the event. - [tester respondTextInputWith:NO]; - - tester.isComposing = YES; - // Send a down event with composing == YES. - [tester.manager handleEvent:keyUpEvent(0x50)]; - - // Nobody gets the event except for the text input plugin. - EXPECT_EQ([channelCallbacks count], 0u); - EXPECT_EQ([embedderCallbacks count], 0u); - OCMVerify(times(1), [tester handleTextInputKeyEvent:checkKeyDownEvent(0x50)]); - - return true; -} - - (bool)emptyNextResponder { KeyboardTester* tester = [[KeyboardTester alloc] init]; tester.nextResponder = nil; diff --git a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardViewDelegate.h b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardViewDelegate.h index 0f8c48843e272..cc559fe3f063e 100644 --- a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardViewDelegate.h +++ b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardViewDelegate.h @@ -73,16 +73,6 @@ typedef struct { */ - (BOOL)onTextInputKeyEvent:(nonnull NSEvent*)event; -/** - * Whether this FlutterKeyboardViewDelegate is actively taking provisional user text input. - * - * This is typically true when a Flutter text field is focused, and the user is entering composing - * text into the text field. - */ -// TODO (LongCatIsLooong): remove this method and implement a long-term fix for -// https://github.com/flutter/flutter/issues/85328. -- (BOOL)isComposing; - /** * Add a listener that is called whenever the user changes keyboard layout. * diff --git a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.h b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.h index af6dd540ccc47..dedfc661884f6 100644 --- a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.h +++ b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.h @@ -45,15 +45,6 @@ */ - (BOOL)isFirstResponder; -/** - * Whether this plugin has composing text. - * - * This is only true when the text input plugin is actively taking user input with composing text. - */ -// TODO (LongCatIsLooong): remove this method and implement a long-term fix for -// https://github.com/flutter/flutter/issues/85328. -- (BOOL)isComposing; - /** * Handles key down events received from the view controller, responding YES if * the event was handled. @@ -73,4 +64,5 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result; - (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actualRange; - (NSDictionary*)editingState; +@property(readwrite, nonatomic) NSString* customRunLoopMode; @end diff --git a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm index dda99268bc7d0..698d6fbbd03fe 100644 --- a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm @@ -32,6 +32,7 @@ static NSString* const kUpdateEditStateWithDeltasResponseMethod = @"TextInputClient.updateEditingStateWithDeltas"; static NSString* const kPerformAction = @"TextInputClient.performAction"; +static NSString* const kPerformSelectors = @"TextInputClient.performSelectors"; static NSString* const kMultilineInputType = @"TextInputType.multiline"; static NSString* const kTextAffinityDownstream = @"TextAffinity.downstream"; @@ -174,6 +175,13 @@ @interface FlutterTextInputPlugin () */ @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. */ @@ -212,6 +220,11 @@ - (void)updateTextAndSelection; */ - (NSString*)textAffinityString; +/** + * Allow overriding run loop mode for test. + */ +@property(readwrite, nonatomic) NSString* customRunLoopMode; + @end @implementation FlutterTextInputPlugin { @@ -504,10 +517,6 @@ - (NSString*)textAffinityString { : kTextAffinityDownstream; } -- (BOOL)isComposing { - return _activeModel && !_activeModel->composing_range().collapsed(); -} - - (BOOL)handleKeyEvent:(NSEvent*)event { if (event.type == NSEventTypeKeyUp || (event.type == NSEventTypeFlagsChanged && event.modifierFlags < _previouslyPressedFlags)) { @@ -615,6 +624,11 @@ - (NSTextInputContext*)inputContext { #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; @@ -670,6 +684,36 @@ - (void)doCommandBySelector:(SEL)selector { void (*func)(id, SEL, id) = reinterpret_cast(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 { @@ -696,6 +740,19 @@ - (void)setMarkedText:(id)string 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(); diff --git a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm index 2b35107f89288..83f50a3403215 100644 --- a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm @@ -173,6 +173,74 @@ - (bool)testSetMarkedTextWithSelectionChange { return true; } +- (bool)testSetMarkedTextWithReplacementRange { + id engineMock = OCMClassMock([FlutterEngine class]); + id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger)); + OCMStub( // NOLINT(google-objc-avoid-throwing-exception) + [engineMock binaryMessenger]) + .andReturn(binaryMessengerMock); + + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock + nibName:@"" + bundle:nil]; + + FlutterTextInputPlugin* plugin = + [[FlutterTextInputPlugin alloc] initWithViewController:viewController]; + + [plugin handleMethodCall:[FlutterMethodCall + methodCallWithMethodName:@"TextInput.setClient" + arguments:@[ + @(1), @{ + @"inputAction" : @"action", + @"inputType" : @{@"name" : @"inputName"}, + } + ]] + result:^(id){ + }]; + + FlutterMethodCall* call = [FlutterMethodCall methodCallWithMethodName:@"TextInput.setEditingState" + arguments:@{ + @"text" : @"1234", + @"selectionBase" : @(3), + @"selectionExtent" : @(3), + @"composingBase" : @(-1), + @"composingExtent" : @(-1), + }]; + [plugin handleMethodCall:call + result:^(id){ + }]; + + [plugin setMarkedText:@"marked" + selectedRange:NSMakeRange(1, 0) + replacementRange:NSMakeRange(1, 2)]; + + NSDictionary* expectedState = @{ + @"selectionBase" : @(2), + @"selectionExtent" : @(2), + @"selectionAffinity" : @"TextAffinity.upstream", + @"selectionIsDirectional" : @(NO), + @"composingBase" : @(1), + @"composingExtent" : @(7), + @"text" : @"1marked4", + }; + + NSData* updateCall = [[FlutterJSONMethodCodec sharedInstance] + encodeMethodCall:[FlutterMethodCall + methodCallWithMethodName:@"TextInputClient.updateEditingState" + arguments:@[ @(1), expectedState ]]]; + + OCMExpect( // NOLINT(google-objc-avoid-throwing-exception) + [binaryMessengerMock sendOnChannel:@"flutter/textinput" message:updateCall]); + + @try { + OCMVerify( // NOLINT(google-objc-avoid-throwing-exception) + [binaryMessengerMock sendOnChannel:@"flutter/textinput" message:updateCall]); + } @catch (...) { + return false; + } + return true; +} + - (bool)testComposingRegionRemovedByFramework { id engineMock = OCMClassMock([FlutterEngine class]); id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger)); @@ -1057,6 +1125,68 @@ - (bool)testLocalTextAndSelectionUpdateAfterDelta { return localTextAndSelectionUpdated; } +- (bool)testSelectorsAreForwardedToFramework { + id engineMock = OCMClassMock([FlutterEngine class]); + id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger)); + OCMStub( // NOLINT(google-objc-avoid-throwing-exception) + [engineMock binaryMessenger]) + .andReturn(binaryMessengerMock); + + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock + nibName:@"" + bundle:nil]; + + FlutterTextInputPlugin* plugin = + [[FlutterTextInputPlugin alloc] initWithViewController:viewController]; + + [plugin handleMethodCall:[FlutterMethodCall + methodCallWithMethodName:@"TextInput.setClient" + arguments:@[ + @(1), @{ + @"inputAction" : @"action", + @"enableDeltaModel" : @"true", + @"inputType" : @{@"name" : @"inputName"}, + } + ]] + result:^(id){ + }]; + + // Can't run CFRunLoop in default mode because it causes crashes from scheduled + // sources from other tests. + NSString* runLoopMode = @"FlutterTestRunLoopMode"; + plugin.customRunLoopMode = runLoopMode; + + // Ensure both selectors are grouped in one platform channel call. + [plugin doCommandBySelector:@selector(moveUp:)]; + [plugin doCommandBySelector:@selector(moveRightAndModifySelection:)]; + + __block bool done = false; + CFRunLoopPerformBlock(CFRunLoopGetMain(), (__bridge CFStringRef)runLoopMode, ^{ + done = true; + }); + + while (!done) { + // Each invocation will handle one source. + CFRunLoopRunInMode((__bridge CFStringRef)runLoopMode, 0, true); + } + + NSData* performSelectorCall = [[FlutterJSONMethodCodec sharedInstance] + encodeMethodCall:[FlutterMethodCall + methodCallWithMethodName:@"TextInputClient.performSelectors" + arguments:@[ + @(1), @[ @"moveUp:", @"moveRightAndModifySelection:" ] + ]]]; + + @try { + OCMVerify( // NOLINT(google-objc-avoid-throwing-exception) + [binaryMessengerMock sendOnChannel:@"flutter/textinput" message:performSelectorCall]); + } @catch (...) { + return false; + } + + return true; +} + @end namespace flutter::testing { @@ -1080,6 +1210,10 @@ - (bool)testLocalTextAndSelectionUpdateAfterDelta { ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testSetMarkedTextWithSelectionChange]); } +TEST(FlutterTextInputPluginTest, TestSetMarkedTextWithReplacementRange) { + ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testSetMarkedTextWithReplacementRange]); +} + TEST(FlutterTextInputPluginTest, TestComposingRegionRemovedByFramework) { ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testComposingRegionRemovedByFramework]); } @@ -1120,6 +1254,10 @@ - (bool)testLocalTextAndSelectionUpdateAfterDelta { ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] unhandledKeyEquivalent]); } +TEST(FlutterTextInputPluginTest, TestSelectorsAreForwardedToFramework) { + ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testSelectorsAreForwardedToFramework]); +} + TEST(FlutterTextInputPluginTest, CanWorkWithFlutterTextField) { FlutterEngine* engine = CreateTestEngine(); NSString* fixtures = @(testing::GetFixturesPath()); diff --git a/shell/platform/darwin/macos/framework/Source/FlutterViewController.mm b/shell/platform/darwin/macos/framework/Source/FlutterViewController.mm index 040d22649e4ff..a4c37b179607f 100644 --- a/shell/platform/darwin/macos/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/macos/framework/Source/FlutterViewController.mm @@ -688,10 +688,6 @@ - (void)viewDidReshape:(NSView*)view { #pragma mark - FlutterKeyboardViewDelegate -- (BOOL)isComposing { - return [_textInputPlugin isComposing]; -} - - (void)sendKeyEvent:(const FlutterKeyEvent&)event callback:(nullable FlutterKeyEventCallback)callback userData:(nullable void*)userData {