From 308811666caa2671c2bc4a52c8ed3efa9c0c3cb9 Mon Sep 17 00:00:00 2001 From: Chris Bracken Date: Fri, 4 Mar 2022 17:17:55 -0800 Subject: [PATCH] [macOS] Clear IME mark text on clear input client When the embedder receives a TextInput.clearClient message from the framework (typically when a text field loses focus), if the user is currently inputting composing text using an IME, commit the composing text, end composing, and clear the IME's composing state. This also exposes a public `editingState` getter on FlutterTextInputPlugin as part of the TestMethods informal protocol. This allows us to get at the text editing state as a dictionary in tests. Issue: https://github.com/flutter/flutter/issues/92060 --- .../framework/Source/FlutterTextInputPlugin.h | 1 + .../Source/FlutterTextInputPlugin.mm | 26 +++++++- .../Source/FlutterTextInputPluginTest.mm | 59 +++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.h b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.h index a189d54b28cbf..b5b8b545d4b6e 100644 --- a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.h +++ b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.h @@ -52,4 +52,5 @@ @interface FlutterTextInputPlugin (TestMethods) - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result; - (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actualRange; +- (NSDictionary*)editingState; @end diff --git a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm index 712ea7de9a1ab..55f7064b46770 100644 --- a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm @@ -275,6 +275,13 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { _shown = FALSE; [_textInputContext deactivate]; } else if ([method isEqualToString:kClearClientMethod]) { + // 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; @@ -360,14 +367,20 @@ - (void)setEditingState:(NSDictionary*)state { flutter::TextRange composing_range = RangeFromBaseExtent( state[kComposingBaseKey], state[kComposingExtentKey], _activeModel->composing_range()); size_t cursor_offset = selected_range.base() - composing_range.start(); + if (!composing_range.collapsed() && !_activeModel->composing()) { + _activeModel->BeginComposing(); + } else if (composing_range.collapsed() && _activeModel->composing()) { + _activeModel->EndComposing(); + [_textInputContext discardMarkedText]; + } _activeModel->SetComposingRange(composing_range, cursor_offset); [_client becomeFirstResponder]; [self updateTextAndSelection]; } -- (void)updateEditState { +- (NSDictionary*)editingState { if (_activeModel == nullptr) { - return; + return nil; } NSString* const textAffinity = [self textAffinityString]; @@ -375,7 +388,7 @@ - (void)updateEditState { int composingBase = _activeModel->composing() ? _activeModel->composing_range().base() : -1; int composingExtent = _activeModel->composing() ? _activeModel->composing_range().extent() : -1; - NSDictionary* state = @{ + return @{ kSelectionBaseKey : @(_activeModel->selection().base()), kSelectionExtentKey : @(_activeModel->selection().extent()), kSelectionAffinityKey : textAffinity, @@ -384,7 +397,14 @@ - (void)updateEditState { kComposingExtentKey : @(composingExtent), kTextKey : [NSString stringWithUTF8String:_activeModel->GetText().c_str()] }; +} + +- (void)updateEditState { + if (_activeModel == nullptr) { + return; + } + NSDictionary* state = [self editingState]; [_channel invokeMethod:kUpdateEditStateResponseMethod arguments:@[ self.clientID, state ]]; [self updateTextAndSelection]; } diff --git a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm index 4f0c3002f08e2..b4866f2e50ce3 100644 --- a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm @@ -30,6 +30,7 @@ - (void)updateString:(NSString*)string withSelection:(NSRange)selection { @interface FlutterInputPluginTestObjc : NSObject - (bool)testEmptyCompositionRange; +- (bool)testClearClientDuringComposing; @end @implementation FlutterInputPluginTestObjc @@ -99,6 +100,60 @@ - (bool)testEmptyCompositionRange { return true; } +- (bool)testClearClientDuringComposing { + // Set up FlutterTextInputPlugin. + 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]; + + // Set input client 1. + [plugin handleMethodCall:[FlutterMethodCall + methodCallWithMethodName:@"TextInput.setClient" + arguments:@[ + @(1), @{ + @"inputAction" : @"action", + @"inputType" : @{@"name" : @"inputName"}, + } + ]] + result:^(id){ + }]; + + // Set editing state with an active composing range. + [plugin handleMethodCall:[FlutterMethodCall methodCallWithMethodName:@"TextInput.setEditingState" + arguments:@{ + @"text" : @"Text", + @"selectionBase" : @(0), + @"selectionExtent" : @(0), + @"composingBase" : @(0), + @"composingExtent" : @(1), + }] + result:^(id){ + }]; + + // Verify composing range is (0, 1). + NSDictionary* editingState = [plugin editingState]; + EXPECT_EQ([editingState[@"composingBase"] intValue], 0); + EXPECT_EQ([editingState[@"composingExtent"] intValue], 1); + + // Clear input client. + [plugin handleMethodCall:[FlutterMethodCall methodCallWithMethodName:@"TextInput.clearClient" + arguments:@[]] + result:^(id){ + }]; + + // Verify composing range is collapsed. + editingState = [plugin editingState]; + EXPECT_EQ([editingState[@"composingBase"] intValue], [editingState[@"composingExtent"] intValue]); + return true; +} + - (bool)testFirstRectForCharacterRange { id engineMock = OCMClassMock([FlutterEngine class]); id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger)); @@ -368,6 +423,10 @@ - (bool)testOperationsThatTriggerDelta { ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testEmptyCompositionRange]); } +TEST(FlutterTextInputPluginTest, TestClearClientDuringComposing) { + ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testClearClientDuringComposing]); +} + TEST(FlutterTextInputPluginTest, TestFirstRectForCharacterRange) { ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testFirstRectForCharacterRange]); }