From bb224ce08f49292b2fd18cd0f8dbefbfff94657a Mon Sep 17 00:00:00 2001 From: Huan Lin Date: Mon, 4 Nov 2024 10:23:11 -0800 Subject: [PATCH 1/8] [ios]allow reordering and more items in native context menu --- .../Source/FlutterTextInputPlugin.mm | 117 +++++++++++++++++- 1 file changed, 114 insertions(+), 3 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 61419ed9cd307..ed95dd286ef8f 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -795,6 +795,7 @@ @interface FlutterTextInputView () // etc) @property(nonatomic, copy) NSString* temporarilyDeletedComposedCharacter; @property(nonatomic, assign) CGRect editMenuTargetRect; +@property(nonatomic, strong) NSArray* editMenuItems; - (void)setEditableTransform:(NSArray*)matrix; @end @@ -868,10 +869,118 @@ - (instancetype)initWithOwner:(FlutterTextInputPlugin*)textInputPlugin { return self; } +- (void)handleSearchWebAction { + NSLog(@"put search web logic here"); +} + +- (void)handleLookUpAction { + NSLog(@"put look up logic here"); +} + +- (void)handleShareAction { + NSLog(@"put share logic here"); +} + +// DFS algorithm to search a UICommand from the menu tree. +- (UICommand*)searchCommandWithSelector:(SEL)selector + element:(UIMenuElement*)element API_AVAILABLE(ios(16.0)) { + if ([element isKindOfClass:UICommand.class]) { + UICommand* command = (UICommand*)element; + return command.action == selector ? command : nil; + } else if ([element isKindOfClass:UIMenu.class]) { + NSArray* children = ((UIMenu*)element).children; + for (UIMenuElement* child in children) { + UICommand* result = [self searchCommandWithSelector:selector element:child]; + if (result) { + return result; + } + } + return nil; + } else { + return nil; + } +} + +- (void)addBasicEditingCommandToItems:(NSMutableArray*)items + action:(NSString*)action + selector:(SEL)selector + suggestedMenu:(UIMenu*)suggestedMenu { + UICommand* command = [self searchCommandWithSelector:selector element:suggestedMenu]; + if (command) { + [items addObject:command]; + } +} + +- (void)addAdditionalBasicCommandToItems:(NSMutableArray*)items + action:(NSString*)action + selector:(SEL)selector + encodedItem:(NSDictionary*)encodedItem { + NSString* title = encodedItem[@"title"]; + if (title) { + UICommand* command = [UICommand commandWithTitle:title + image:nil + action:selector + propertyList:nil]; + [items addObject:command]; + } +} + - (UIMenu*)editMenuInteraction:(UIEditMenuInteraction*)interaction menuForConfiguration:(UIEditMenuConfiguration*)configuration suggestedActions:(NSArray*)suggestedActions API_AVAILABLE(ios(16.0)) { - return [UIMenu menuWithChildren:suggestedActions]; + UIMenu* suggestedMenu = [UIMenu menuWithChildren:suggestedActions]; + if (!_editMenuItems) { + return suggestedMenu; + } + + NSMutableArray* items = [NSMutableArray array]; + for (NSDictionary* encodedItem in _editMenuItems) { + if ([encodedItem[@"type"] isEqualToString:@"default"]) { + NSString* action = encodedItem[@"action"]; + if ([action isEqualToString:@"copy"]) { + [self addBasicEditingCommandToItems:items + action:action + selector:@selector(copy:) + suggestedMenu:suggestedMenu]; + } else if ([action isEqualToString:@"paste"]) { + [self addBasicEditingCommandToItems:items + action:action + selector:@selector(paste:) + suggestedMenu:suggestedMenu]; + } else if ([action isEqualToString:@"cut"]) { + [self addBasicEditingCommandToItems:items + action:action + selector:@selector(cut:) + suggestedMenu:suggestedMenu]; + } else if ([action isEqualToString:@"delete"]) { + [self addBasicEditingCommandToItems:items + action:action + selector:@selector(delete:) + suggestedMenu:suggestedMenu]; + } else if ([action isEqualToString:@"selectAll"]) { + [self addBasicEditingCommandToItems:items + action:action + selector:@selector(selectAll:) + suggestedMenu:suggestedMenu]; + } else if ([action isEqualToString:@"searchWeb"]) { + [self addAdditionalBasicCommandToItems:items + action:action + selector:@selector(handleSearchWebAction) + encodedItem:encodedItem]; + } else if ([action isEqualToString:@"share"]) { + [self addAdditionalBasicCommandToItems:items + action:action + selector:@selector(handleShareAction) + encodedItem:encodedItem]; + } else if ([action isEqualToString:@"lookUp"]) { + [self addAdditionalBasicCommandToItems:items + action:action + selector:@selector(handleLookUpAction) + encodedItem:encodedItem]; + } + } + } + return [UIMenu menuWithChildren:items]; } - (void)editMenuInteraction:(UIEditMenuInteraction*)interaction @@ -887,8 +996,10 @@ - (CGRect)editMenuInteraction:(UIEditMenuInteraction*)interaction return _editMenuTargetRect; } -- (void)showEditMenuWithTargetRect:(CGRect)targetRect API_AVAILABLE(ios(16.0)) { +- (void)showEditMenuWithTargetRect:(CGRect)targetRect + items:(NSArray*)items API_AVAILABLE(ios(16.0)) { _editMenuTargetRect = targetRect; + _editMenuItems = items; UIEditMenuConfiguration* config = [UIEditMenuConfiguration configurationWithIdentifier:nil sourcePoint:CGPointZero]; [self.editMenuInteraction presentEditMenuWithConfiguration:config]; @@ -2560,7 +2671,7 @@ - (BOOL)showEditMenu:(NSDictionary*)args API_AVAILABLE(ios(16.0)) { [encodedTargetRect[@"x"] doubleValue], [encodedTargetRect[@"y"] doubleValue], [encodedTargetRect[@"width"] doubleValue], [encodedTargetRect[@"height"] doubleValue]); CGRect localTargetRect = [self.hostView convertRect:globalTargetRect toView:self.activeView]; - [self.activeView showEditMenuWithTargetRect:localTargetRect]; + [self.activeView showEditMenuWithTargetRect:localTargetRect items:args[@"items"]]; return YES; } From edb9a0bb723b6b72a1f1867031a36f0023eebe8a Mon Sep 17 00:00:00 2001 From: Huan Lin Date: Fri, 8 Nov 2024 16:43:58 -0800 Subject: [PATCH 2/8] some additional actions --- .../darwin/ios/framework/Source/FlutterEngine.mm | 15 +++++++++++++++ .../ios/framework/Source/FlutterPlatformPlugin.h | 3 +++ .../framework/Source/FlutterTextInputDelegate.h | 6 ++++++ .../framework/Source/FlutterTextInputPlugin.mm | 9 ++++++--- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index 8ab4cf61c0ac3..4164c301986e5 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -1013,6 +1013,21 @@ - (void)flutterTextInputView:(FlutterTextInputView*)textInputView arguments:@[ @(client) ]]; } +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + shareSelectedText:(NSString*)selectedText { + [self.platformPlugin showShareViewController:selectedText]; +} + +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + searchWebWithSelectedText:(NSString*)selectedText { + [self.platformPlugin searchWeb:selectedText]; +} + +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + lookUpSelectedText:(NSString*)selectedText { + [self.platformPlugin showLookUpViewController:selectedText]; +} + #pragma mark - FlutterViewEngineDelegate - (void)flutterTextInputView:(FlutterTextInputView*)textInputView showToolbar:(int)client { diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.h b/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.h index 45fd69cddfb68..f8f40edabd824 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.h @@ -14,6 +14,9 @@ - (instancetype)initWithEngine:(FlutterEngine*)engine NS_DESIGNATED_INITIALIZER; - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result; +- (void)showShareViewController:(NSString*)content; +- (void)searchWeb:(NSString*)searchTerm; +- (void)showLookUpViewController:(NSString*)term; @end namespace flutter { diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h index 0bf715a88022b..d2464dcbd10e0 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h @@ -67,6 +67,12 @@ typedef NS_ENUM(NSInteger, FlutterFloatingCursorDragState) { didResignFirstResponderWithTextInputClient:(int)client; - (void)flutterTextInputView:(FlutterTextInputView*)textInputView willDismissEditMenuWithTextInputClient:(int)client; +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + shareSelectedText:(NSString*)selectedText; +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + searchWebWithSelectedText:(NSString*)selectedText; +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + lookUpSelectedText:(NSString*)selectedText; @end #endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERTEXTINPUTDELEGATE_H_ diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index ed95dd286ef8f..4eee0c6465ae5 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -870,15 +870,18 @@ - (instancetype)initWithOwner:(FlutterTextInputPlugin*)textInputPlugin { } - (void)handleSearchWebAction { - NSLog(@"put search web logic here"); + [self.textInputDelegate flutterTextInputView:self + searchWebWithSelectedText:[self textInRange:_selectedTextRange]]; } - (void)handleLookUpAction { - NSLog(@"put look up logic here"); + [self.textInputDelegate flutterTextInputView:self + lookUpSelectedText:[self textInRange:_selectedTextRange]]; } - (void)handleShareAction { - NSLog(@"put share logic here"); + [self.textInputDelegate flutterTextInputView:self + shareSelectedText:[self textInRange:_selectedTextRange]]; } // DFS algorithm to search a UICommand from the menu tree. From 37a3e125360443188ce8f9268a0c64ff8bcf9b98 Mon Sep 17 00:00:00 2001 From: Huan Lin Date: Fri, 8 Nov 2024 16:44:52 -0800 Subject: [PATCH 3/8] WIP some tests, need more testing for DFS --- .../Source/FlutterTextInputPluginTest.mm | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index cd75d64dc903f..69aafe21eda14 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -30,6 +30,9 @@ - (void)updateEditingState; - (BOOL)isVisibleToAutofill; - (id)textInputDelegate; - (void)configureWithDictionary:(NSDictionary*)configuration; +- (void)handleSearchWebAction; +- (void)handleLookUpAction; +- (void)handleShareAction; @end @interface FlutterTextInputViewSpy : FlutterTextInputView @@ -2971,6 +2974,113 @@ - (void)testEditMenu_shouldPresentEditMenuWithCorectTargetRect { } } +- (void)testEditMenu_shouldPresentEditMenuWithCorectItemsForBasicEditingActions { + if (@available(iOS 16.0, *)) { + FlutterTextInputPlugin* myInputPlugin = + [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])]; + FlutterViewController* myViewController = [[FlutterViewController alloc] init]; + myInputPlugin.viewController = myViewController; + [myViewController loadView]; + + FlutterMethodCall* setClientCall = + [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" + arguments:@[ @(123), self.mutableTemplateCopy ]]; + [myInputPlugin handleMethodCall:setClientCall + result:^(id _Nullable result){ + }]; + + FlutterTextInputView* myInputView = myInputPlugin.activeView; + + FlutterTextInputView* mockInputView = OCMPartialMock(myInputView); + OCMStub([mockInputView isFirstResponder]).andReturn(YES); + + XCTestExpectation* expectation = [[XCTestExpectation alloc] + initWithDescription:@"presentEditMenuWithConfiguration must be called."]; + + id mockInteraction = OCMClassMock([UIEditMenuInteraction class]); + OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction); + OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]]) + .andDo(^(NSInvocation* invocation) { + [expectation fulfill]; + }); + + myInputView.frame = CGRectMake(10, 20, 30, 40); + NSDictionary* encodedTargetRect = + @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; + + NSArray*>* encodedItems = @[@{@"type": @"default", @"action": @"paste"}, @{@"type": @"default", @"action": @"copy"}]; + + BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items": encodedItems}]; + XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration."); + [self waitForExpectations:@[ expectation ] timeout:1.0]; + + UICommand* copyItem = [UICommand commandWithTitle:@"Copy" image: nil action: @selector(copy:) propertyList:nil]; + UICommand* pasteItem = [UICommand commandWithTitle:@"Paste" image: nil action: @selector(paste:) propertyList:nil]; + NSArray* suggestedActions = @[ + copyItem, + pasteItem + ]; + + UIMenu* menu = [myInputView editMenuInteraction:mockInteraction menuForConfiguration: OCMClassMock([UIEditMenuConfiguration class]) suggestedActions:suggestedActions]; + XCTAssert(menu.children.count == 2, @"There must be 2 menu items"); + XCTAssertEqual(menu.children[0], pasteItem, @"Must be able to find paste item in the tree."); + XCTAssertEqual(menu.children[1], copyItem, @"Must be able to find copy item in the tree."); + } +} + +- (void)testEditMenu_shouldPresentEditMenuWithCorectItemsForMoreAdditionalItems { + if (@available(iOS 16.0, *)) { + FlutterTextInputPlugin* myInputPlugin = + [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])]; + FlutterViewController* myViewController = [[FlutterViewController alloc] init]; + myInputPlugin.viewController = myViewController; + [myViewController loadView]; + + FlutterMethodCall* setClientCall = + [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" + arguments:@[ @(123), self.mutableTemplateCopy ]]; + [myInputPlugin handleMethodCall:setClientCall + result:^(id _Nullable result){ + }]; + + FlutterTextInputView* myInputView = myInputPlugin.activeView; + + FlutterTextInputView* mockInputView = OCMPartialMock(myInputView); + OCMStub([mockInputView isFirstResponder]).andReturn(YES); + + XCTestExpectation* expectation = [[XCTestExpectation alloc] + initWithDescription:@"presentEditMenuWithConfiguration must be called."]; + + id mockInteraction = OCMClassMock([UIEditMenuInteraction class]); + OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction); + OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]]) + .andDo(^(NSInvocation* invocation) { + [expectation fulfill]; + }); + + myInputView.frame = CGRectMake(10, 20, 30, 40); + NSDictionary* encodedTargetRect = + @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; + + NSArray*>* encodedItems = @[@{@"type": @"default", @"action": @"searchWeb", @"title": @"Search Web"}, @{@"type": @"default", @"action": @"lookUp", @"title": @"Look Up"}, @{@"type": @"default", @"action": @"share", @"title": @"Share"}]; + + BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items": encodedItems}]; + XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration."); + [self waitForExpectations:@[ expectation ] timeout:1.0]; + + NSArray* suggestedActions = @[ + [UICommand commandWithTitle:@"copy" image: nil action: @selector(copy:) propertyList:nil], + ]; + + UIMenu* menu = [myInputView editMenuInteraction:mockInteraction menuForConfiguration: OCMClassMock([UIEditMenuConfiguration class]) suggestedActions:suggestedActions]; + XCTAssert(menu.children.count == 3, @"There must be 3 menu items"); + + XCTAssert(((UICommand*) menu.children[0]).action == @selector(handleSearchWebAction), @"Must create search web item in the tree."); + XCTAssert(((UICommand*) menu.children[1]).action == @selector(handleLookUpAction), @"Must create look up item in the tree."); + XCTAssert(((UICommand*) menu.children[2]).action == @selector(handleShareAction), @"Must create share item in the tree."); + } +} + - (void)testInteractiveKeyboardAfterUserScrollWillResignFirstResponder { FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; [UIApplication.sharedApplication.keyWindow addSubview:inputView]; From 955ed3dfea4fefe6ae7ec3139bb7f60e13f3acc7 Mon Sep 17 00:00:00 2001 From: Huan Lin Date: Sun, 10 Nov 2024 11:01:39 -0800 Subject: [PATCH 4/8] format --- .../Source/FlutterTextInputPluginTest.mm | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index 69aafe21eda14..fc2e19ac35f6a 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -3008,20 +3008,28 @@ - (void)testEditMenu_shouldPresentEditMenuWithCorectItemsForBasicEditingActions NSDictionary* encodedTargetRect = @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; - NSArray*>* encodedItems = @[@{@"type": @"default", @"action": @"paste"}, @{@"type": @"default", @"action": @"copy"}]; + NSArray*>* encodedItems = @[ + @{@"type" : @"default", @"action" : @"paste"}, @{@"type" : @"default", @"action" : @"copy"} + ]; - BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items": encodedItems}]; + BOOL shownEditMenu = + [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}]; XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration."); [self waitForExpectations:@[ expectation ] timeout:1.0]; - UICommand* copyItem = [UICommand commandWithTitle:@"Copy" image: nil action: @selector(copy:) propertyList:nil]; - UICommand* pasteItem = [UICommand commandWithTitle:@"Paste" image: nil action: @selector(paste:) propertyList:nil]; - NSArray* suggestedActions = @[ - copyItem, - pasteItem - ]; - - UIMenu* menu = [myInputView editMenuInteraction:mockInteraction menuForConfiguration: OCMClassMock([UIEditMenuConfiguration class]) suggestedActions:suggestedActions]; + UICommand* copyItem = [UICommand commandWithTitle:@"Copy" + image:nil + action:@selector(copy:) + propertyList:nil]; + UICommand* pasteItem = [UICommand commandWithTitle:@"Paste" + image:nil + action:@selector(paste:) + propertyList:nil]; + NSArray* suggestedActions = @[ copyItem, pasteItem ]; + + UIMenu* menu = [myInputView editMenuInteraction:mockInteraction + menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class]) + suggestedActions:suggestedActions]; XCTAssert(menu.children.count == 2, @"There must be 2 menu items"); XCTAssertEqual(menu.children[0], pasteItem, @"Must be able to find paste item in the tree."); XCTAssertEqual(menu.children[1], copyItem, @"Must be able to find copy item in the tree."); @@ -3062,22 +3070,32 @@ - (void)testEditMenu_shouldPresentEditMenuWithCorectItemsForMoreAdditionalItems NSDictionary* encodedTargetRect = @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; - NSArray*>* encodedItems = @[@{@"type": @"default", @"action": @"searchWeb", @"title": @"Search Web"}, @{@"type": @"default", @"action": @"lookUp", @"title": @"Look Up"}, @{@"type": @"default", @"action": @"share", @"title": @"Share"}]; + NSArray*>* encodedItems = @[ + @{@"type" : @"default", @"action" : @"searchWeb", @"title" : @"Search Web"}, + @{@"type" : @"default", @"action" : @"lookUp", @"title" : @"Look Up"}, + @{@"type" : @"default", @"action" : @"share", @"title" : @"Share"} + ]; - BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items": encodedItems}]; + BOOL shownEditMenu = + [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}]; XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration."); [self waitForExpectations:@[ expectation ] timeout:1.0]; NSArray* suggestedActions = @[ - [UICommand commandWithTitle:@"copy" image: nil action: @selector(copy:) propertyList:nil], + [UICommand commandWithTitle:@"copy" image:nil action:@selector(copy:) propertyList:nil], ]; - UIMenu* menu = [myInputView editMenuInteraction:mockInteraction menuForConfiguration: OCMClassMock([UIEditMenuConfiguration class]) suggestedActions:suggestedActions]; + UIMenu* menu = [myInputView editMenuInteraction:mockInteraction + menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class]) + suggestedActions:suggestedActions]; XCTAssert(menu.children.count == 3, @"There must be 3 menu items"); - XCTAssert(((UICommand*) menu.children[0]).action == @selector(handleSearchWebAction), @"Must create search web item in the tree."); - XCTAssert(((UICommand*) menu.children[1]).action == @selector(handleLookUpAction), @"Must create look up item in the tree."); - XCTAssert(((UICommand*) menu.children[2]).action == @selector(handleShareAction), @"Must create share item in the tree."); + XCTAssert(((UICommand*)menu.children[0]).action == @selector(handleSearchWebAction), + @"Must create search web item in the tree."); + XCTAssert(((UICommand*)menu.children[1]).action == @selector(handleLookUpAction), + @"Must create look up item in the tree."); + XCTAssert(((UICommand*)menu.children[2]).action == @selector(handleShareAction), + @"Must create share item in the tree."); } } From 6821d6212ee21fa2216ac5db0034bc666ab38e09 Mon Sep 17 00:00:00 2001 From: Huan Lin Date: Mon, 11 Nov 2024 21:32:36 -0800 Subject: [PATCH 5/8] more tests for DFS --- .../Source/FlutterTextInputPluginTest.mm | 145 +++++++++++++++++- 1 file changed, 141 insertions(+), 4 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index fc2e19ac35f6a..befc4749d2150 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -2974,7 +2974,63 @@ - (void)testEditMenu_shouldPresentEditMenuWithCorectTargetRect { } } -- (void)testEditMenu_shouldPresentEditMenuWithCorectItemsForBasicEditingActions { +- (void)testEditMenu_shouldPresentEditMenuWithSuggestedItemsByDefaultIfNoFrameworkData { + if (@available(iOS 16.0, *)) { + FlutterTextInputPlugin* myInputPlugin = + [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])]; + FlutterViewController* myViewController = [[FlutterViewController alloc] init]; + myInputPlugin.viewController = myViewController; + [myViewController loadView]; + + FlutterMethodCall* setClientCall = + [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" + arguments:@[ @(123), self.mutableTemplateCopy ]]; + [myInputPlugin handleMethodCall:setClientCall + result:^(id _Nullable result){ + }]; + + FlutterTextInputView* myInputView = myInputPlugin.activeView; + + FlutterTextInputView* mockInputView = OCMPartialMock(myInputView); + OCMStub([mockInputView isFirstResponder]).andReturn(YES); + + XCTestExpectation* expectation = [[XCTestExpectation alloc] + initWithDescription:@"presentEditMenuWithConfiguration must be called."]; + + id mockInteraction = OCMClassMock([UIEditMenuInteraction class]); + OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction); + OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]]) + .andDo(^(NSInvocation* invocation) { + [expectation fulfill]; + }); + + myInputView.frame = CGRectMake(10, 20, 30, 40); + NSDictionary* encodedTargetRect = + @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; + // No items provided from framework. Show the suggested items by default. + BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}]; + XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration."); + [self waitForExpectations:@[ expectation ] timeout:1.0]; + + UICommand* copyItem = [UICommand commandWithTitle:@"Copy" + image:nil + action:@selector(copy:) + propertyList:nil]; + UICommand* pasteItem = [UICommand commandWithTitle:@"Paste" + image:nil + action:@selector(paste:) + propertyList:nil]; + NSArray* suggestedActions = @[ copyItem, pasteItem ]; + + UIMenu* menu = [myInputView editMenuInteraction:mockInteraction + menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class]) + suggestedActions:suggestedActions]; + XCTAssertEqualObjects(menu.children, suggestedActions, + @"Must show suggested items by default."); + } +} + +- (void)testEditMenu_shouldPresentEditMenuWithCorectItemsAndCorrectOrderingForBasicEditingActions { if (@available(iOS 16.0, *)) { FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])]; @@ -3030,9 +3086,90 @@ - (void)testEditMenu_shouldPresentEditMenuWithCorectItemsForBasicEditingActions UIMenu* menu = [myInputView editMenuInteraction:mockInteraction menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class]) suggestedActions:suggestedActions]; - XCTAssert(menu.children.count == 2, @"There must be 2 menu items"); - XCTAssertEqual(menu.children[0], pasteItem, @"Must be able to find paste item in the tree."); - XCTAssertEqual(menu.children[1], copyItem, @"Must be able to find copy item in the tree."); + // The item ordering should follow the encoded data sent from the framework. + NSArray* expectedChildren = @[ pasteItem, copyItem ]; + XCTAssertEqualObjects(menu.children, expectedChildren); + } +} + +- (void)testEditMenu_shouldPresentEditMenuWithCorectItemsUnderNestedSubtreeForBasicEditingActions { + if (@available(iOS 16.0, *)) { + FlutterTextInputPlugin* myInputPlugin = + [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])]; + FlutterViewController* myViewController = [[FlutterViewController alloc] init]; + myInputPlugin.viewController = myViewController; + [myViewController loadView]; + + FlutterMethodCall* setClientCall = + [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" + arguments:@[ @(123), self.mutableTemplateCopy ]]; + [myInputPlugin handleMethodCall:setClientCall + result:^(id _Nullable result){ + }]; + + FlutterTextInputView* myInputView = myInputPlugin.activeView; + + FlutterTextInputView* mockInputView = OCMPartialMock(myInputView); + OCMStub([mockInputView isFirstResponder]).andReturn(YES); + + XCTestExpectation* expectation = [[XCTestExpectation alloc] + initWithDescription:@"presentEditMenuWithConfiguration must be called."]; + + id mockInteraction = OCMClassMock([UIEditMenuInteraction class]); + OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction); + OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]]) + .andDo(^(NSInvocation* invocation) { + [expectation fulfill]; + }); + + myInputView.frame = CGRectMake(10, 20, 30, 40); + NSDictionary* encodedTargetRect = + @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; + + NSArray*>* encodedItems = @[ + @{@"type" : @"default", @"action" : @"cut"}, @{@"type" : @"default", @"action" : @"paste"}, + @{@"type" : @"default", @"action" : @"copy"} + ]; + + BOOL shownEditMenu = + [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}]; + XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration."); + [self waitForExpectations:@[ expectation ] timeout:1.0]; + + UICommand* copyItem = [UICommand commandWithTitle:@"Copy" + image:nil + action:@selector(copy:) + propertyList:nil]; + UICommand* cutItem = [UICommand commandWithTitle:@"Cut" + image:nil + action:@selector(cut:) + propertyList:nil]; + UICommand* pasteItem = [UICommand commandWithTitle:@"Paste" + image:nil + action:@selector(paste:) + propertyList:nil]; + /* + A more complex menu hierarchy for DFS: + + menu + / | \ + copy menu menu + | \ + paste menu + | + cut + */ + NSArray* suggestedActions = @[ + copyItem, [UIMenu menuWithChildren:@[ pasteItem ]], + [UIMenu menuWithChildren:@[ [UIMenu menuWithChildren:@[ cutItem ]] ]] + ]; + + UIMenu* menu = [myInputView editMenuInteraction:mockInteraction + menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class]) + suggestedActions:suggestedActions]; + // The item ordering should follow the encoded data sent from the framework. + NSArray* expectedActions = @[ cutItem, pasteItem, copyItem ]; + XCTAssertEqualObjects(menu.children, expectedActions); } } From ba7d03aa5e0d3f8587d63222367bd523a98e3814 Mon Sep 17 00:00:00 2001 From: Huan Lin Date: Fri, 15 Nov 2024 18:01:57 -0800 Subject: [PATCH 6/8] print out error message if no item title provided --- .../darwin/ios/framework/Source/FlutterTextInputPlugin.mm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 4eee0c6465ae5..cdba59ae14144 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -925,6 +925,8 @@ - (void)addAdditionalBasicCommandToItems:(NSMutableArray*)items action:selector propertyList:nil]; [items addObject:command]; + } else { + FML_LOG(ERROR) << "Missing title for context menu item action \"" << action.UTF8String << "\"."; } } From 812a5e2d7e345284349ce6eed952fd052644711b Mon Sep 17 00:00:00 2001 From: Huan Lin Date: Tue, 19 Nov 2024 15:40:13 -0800 Subject: [PATCH 7/8] rename default to builtIn --- .../ios/framework/Source/FlutterTextInputPlugin.mm | 2 +- .../framework/Source/FlutterTextInputPluginTest.mm | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index cdba59ae14144..c899d28d9e1c1 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -940,7 +940,7 @@ - (UIMenu*)editMenuInteraction:(UIEditMenuInteraction*)interaction NSMutableArray* items = [NSMutableArray array]; for (NSDictionary* encodedItem in _editMenuItems) { - if ([encodedItem[@"type"] isEqualToString:@"default"]) { + if ([encodedItem[@"type"] isEqualToString:@"builtIn"]) { NSString* action = encodedItem[@"action"]; if ([action isEqualToString:@"copy"]) { [self addBasicEditingCommandToItems:items diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index befc4749d2150..232ba0611de79 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -3065,7 +3065,7 @@ - (void)testEditMenu_shouldPresentEditMenuWithCorectItemsAndCorrectOrderingForBa @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; NSArray*>* encodedItems = @[ - @{@"type" : @"default", @"action" : @"paste"}, @{@"type" : @"default", @"action" : @"copy"} + @{@"type" : @"builtIn", @"action" : @"paste"}, @{@"type" : @"builtIn", @"action" : @"copy"} ]; BOOL shownEditMenu = @@ -3127,8 +3127,8 @@ - (void)testEditMenu_shouldPresentEditMenuWithCorectItemsUnderNestedSubtreeForBa @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; NSArray*>* encodedItems = @[ - @{@"type" : @"default", @"action" : @"cut"}, @{@"type" : @"default", @"action" : @"paste"}, - @{@"type" : @"default", @"action" : @"copy"} + @{@"type" : @"builtIn", @"action" : @"cut"}, @{@"type" : @"builtIn", @"action" : @"paste"}, + @{@"type" : @"builtIn", @"action" : @"copy"} ]; BOOL shownEditMenu = @@ -3208,9 +3208,9 @@ - (void)testEditMenu_shouldPresentEditMenuWithCorectItemsForMoreAdditionalItems @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; NSArray*>* encodedItems = @[ - @{@"type" : @"default", @"action" : @"searchWeb", @"title" : @"Search Web"}, - @{@"type" : @"default", @"action" : @"lookUp", @"title" : @"Look Up"}, - @{@"type" : @"default", @"action" : @"share", @"title" : @"Share"} + @{@"type" : @"builtIn", @"action" : @"searchWeb", @"title" : @"Search Web"}, + @{@"type" : @"builtIn", @"action" : @"lookUp", @"title" : @"Look Up"}, + @{@"type" : @"builtIn", @"action" : @"share", @"title" : @"Share"} ]; BOOL shownEditMenu = From e8bbf97755e94403fa14f17e2505ee693b3ec50c Mon Sep 17 00:00:00 2001 From: Huan Lin Date: Mon, 9 Dec 2024 17:24:46 -0800 Subject: [PATCH 8/8] simplified api with combined type and action --- .../Source/FlutterTextInputPlugin.mm | 92 +++++++++---------- .../Source/FlutterTextInputPluginTest.mm | 16 ++-- 2 files changed, 52 insertions(+), 56 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index c899d28d9e1c1..034bd92856f0f 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -905,17 +905,19 @@ - (UICommand*)searchCommandWithSelector:(SEL)selector } - (void)addBasicEditingCommandToItems:(NSMutableArray*)items - action:(NSString*)action + type:(NSString*)type selector:(SEL)selector suggestedMenu:(UIMenu*)suggestedMenu { UICommand* command = [self searchCommandWithSelector:selector element:suggestedMenu]; if (command) { [items addObject:command]; + } else { + FML_LOG(ERROR) << "Cannot find context menu item of type \"" << type.UTF8String << "\"."; } } - (void)addAdditionalBasicCommandToItems:(NSMutableArray*)items - action:(NSString*)action + type:(NSString*)type selector:(SEL)selector encodedItem:(NSDictionary*)encodedItem { NSString* title = encodedItem[@"title"]; @@ -926,7 +928,7 @@ - (void)addAdditionalBasicCommandToItems:(NSMutableArray*)items propertyList:nil]; [items addObject:command]; } else { - FML_LOG(ERROR) << "Missing title for context menu item action \"" << action.UTF8String << "\"."; + FML_LOG(ERROR) << "Missing title for context menu item of type \"" << type.UTF8String << "\"."; } } @@ -940,49 +942,47 @@ - (UIMenu*)editMenuInteraction:(UIEditMenuInteraction*)interaction NSMutableArray* items = [NSMutableArray array]; for (NSDictionary* encodedItem in _editMenuItems) { - if ([encodedItem[@"type"] isEqualToString:@"builtIn"]) { - NSString* action = encodedItem[@"action"]; - if ([action isEqualToString:@"copy"]) { - [self addBasicEditingCommandToItems:items - action:action - selector:@selector(copy:) - suggestedMenu:suggestedMenu]; - } else if ([action isEqualToString:@"paste"]) { - [self addBasicEditingCommandToItems:items - action:action - selector:@selector(paste:) - suggestedMenu:suggestedMenu]; - } else if ([action isEqualToString:@"cut"]) { - [self addBasicEditingCommandToItems:items - action:action - selector:@selector(cut:) - suggestedMenu:suggestedMenu]; - } else if ([action isEqualToString:@"delete"]) { - [self addBasicEditingCommandToItems:items - action:action - selector:@selector(delete:) - suggestedMenu:suggestedMenu]; - } else if ([action isEqualToString:@"selectAll"]) { - [self addBasicEditingCommandToItems:items - action:action - selector:@selector(selectAll:) - suggestedMenu:suggestedMenu]; - } else if ([action isEqualToString:@"searchWeb"]) { - [self addAdditionalBasicCommandToItems:items - action:action - selector:@selector(handleSearchWebAction) - encodedItem:encodedItem]; - } else if ([action isEqualToString:@"share"]) { - [self addAdditionalBasicCommandToItems:items - action:action - selector:@selector(handleShareAction) - encodedItem:encodedItem]; - } else if ([action isEqualToString:@"lookUp"]) { - [self addAdditionalBasicCommandToItems:items - action:action - selector:@selector(handleLookUpAction) - encodedItem:encodedItem]; - } + NSString* type = encodedItem[@"type"]; + if ([type isEqualToString:@"copy"]) { + [self addBasicEditingCommandToItems:items + type:type + selector:@selector(copy:) + suggestedMenu:suggestedMenu]; + } else if ([type isEqualToString:@"paste"]) { + [self addBasicEditingCommandToItems:items + type:type + selector:@selector(paste:) + suggestedMenu:suggestedMenu]; + } else if ([type isEqualToString:@"cut"]) { + [self addBasicEditingCommandToItems:items + type:type + selector:@selector(cut:) + suggestedMenu:suggestedMenu]; + } else if ([type isEqualToString:@"delete"]) { + [self addBasicEditingCommandToItems:items + type:type + selector:@selector(delete:) + suggestedMenu:suggestedMenu]; + } else if ([type isEqualToString:@"selectAll"]) { + [self addBasicEditingCommandToItems:items + type:type + selector:@selector(selectAll:) + suggestedMenu:suggestedMenu]; + } else if ([type isEqualToString:@"searchWeb"]) { + [self addAdditionalBasicCommandToItems:items + type:type + selector:@selector(handleSearchWebAction) + encodedItem:encodedItem]; + } else if ([type isEqualToString:@"share"]) { + [self addAdditionalBasicCommandToItems:items + type:type + selector:@selector(handleShareAction) + encodedItem:encodedItem]; + } else if ([type isEqualToString:@"lookUp"]) { + [self addAdditionalBasicCommandToItems:items + type:type + selector:@selector(handleLookUpAction) + encodedItem:encodedItem]; } } return [UIMenu menuWithChildren:items]; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index 232ba0611de79..35be6a12218e2 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -3064,9 +3064,8 @@ - (void)testEditMenu_shouldPresentEditMenuWithCorectItemsAndCorrectOrderingForBa NSDictionary* encodedTargetRect = @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; - NSArray*>* encodedItems = @[ - @{@"type" : @"builtIn", @"action" : @"paste"}, @{@"type" : @"builtIn", @"action" : @"copy"} - ]; + NSArray*>* encodedItems = + @[ @{@"type" : @"paste"}, @{@"type" : @"copy"} ]; BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}]; @@ -3126,10 +3125,8 @@ - (void)testEditMenu_shouldPresentEditMenuWithCorectItemsUnderNestedSubtreeForBa NSDictionary* encodedTargetRect = @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; - NSArray*>* encodedItems = @[ - @{@"type" : @"builtIn", @"action" : @"cut"}, @{@"type" : @"builtIn", @"action" : @"paste"}, - @{@"type" : @"builtIn", @"action" : @"copy"} - ]; + NSArray*>* encodedItems = + @[ @{@"type" : @"cut"}, @{@"type" : @"paste"}, @{@"type" : @"copy"} ]; BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}]; @@ -3208,9 +3205,8 @@ - (void)testEditMenu_shouldPresentEditMenuWithCorectItemsForMoreAdditionalItems @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; NSArray*>* encodedItems = @[ - @{@"type" : @"builtIn", @"action" : @"searchWeb", @"title" : @"Search Web"}, - @{@"type" : @"builtIn", @"action" : @"lookUp", @"title" : @"Look Up"}, - @{@"type" : @"builtIn", @"action" : @"share", @"title" : @"Share"} + @{@"type" : @"searchWeb", @"title" : @"Search Web"}, + @{@"type" : @"lookUp", @"title" : @"Look Up"}, @{@"type" : @"share", @"title" : @"Share"} ]; BOOL shownEditMenu =