// Copyright 2017 The Chromium 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 'dart:async'; import 'dart:io'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter/services.dart'; import 'package:mockito/mockito.dart'; import 'semantics_tester.dart'; void main() { final TextEditingController controller = TextEditingController(); final FocusNode focusNode = FocusNode(); final FocusScopeNode focusScopeNode = FocusScopeNode(); const TextStyle textStyle = TextStyle(); const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00); setUp(() { debugResetSemanticsIdCounter(); }); // Tests that the desired keyboard action button is requested. // // More technically, when an EditableText is given a particular [action], Flutter // requests [serializedActionName] when attaching to the platform's input // system. Future _desiredKeyboardActionIsRequested({ WidgetTester tester, TextInputAction action, String serializedActionName, }) async { await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: FocusScope( node: focusScopeNode, autofocus: true, child: EditableText( controller: controller, focusNode: focusNode, textInputAction: action, style: textStyle, cursorColor: cursorColor, ), ), ), ); await tester.tap(find.byType(EditableText)); await tester.showKeyboard(find.byType(EditableText)); controller.text = 'test'; await tester.idle(); expect(tester.testTextInput.editingState['text'], equals('test')); expect(tester.testTextInput.setClientArgs['inputAction'], equals(serializedActionName)); } testWidgets('has expected defaults', (WidgetTester tester) async { await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: EditableText( controller: controller, focusNode: focusNode, style: textStyle, cursorColor: cursorColor, ), ), ); final EditableText editableText = tester.firstWidget(find.byType(EditableText)); expect(editableText.maxLines, equals(1)); expect(editableText.obscureText, isFalse); expect(editableText.autocorrect, isTrue); expect(editableText.cursorWidth, 2.0); }); testWidgets('cursor has expected width and radius', (WidgetTester tester) async { await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: EditableText( controller: controller, focusNode: focusNode, style: textStyle, cursorColor: cursorColor, cursorWidth: 10.0, cursorRadius: const Radius.circular(2.0), ))); final EditableText editableText = tester.firstWidget(find.byType(EditableText)); expect(editableText.cursorWidth, 10.0); expect(editableText.cursorRadius.x, 2.0); }); testWidgets('text keyboard is requested when maxLines is default', (WidgetTester tester) async { await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: FocusScope( node: focusScopeNode, autofocus: true, child: EditableText( controller: controller, focusNode: focusNode, style: textStyle, cursorColor: cursorColor, ), ), ), ); await tester.tap(find.byType(EditableText)); await tester.showKeyboard(find.byType(EditableText)); controller.text = 'test'; await tester.idle(); final EditableText editableText = tester.firstWidget(find.byType(EditableText)); expect(editableText.maxLines, equals(1)); expect(tester.testTextInput.editingState['text'], equals('test')); expect(tester.testTextInput.setClientArgs['inputType']['name'], equals('TextInputType.text')); expect(tester.testTextInput.setClientArgs['inputAction'], equals('TextInputAction.done')); }); testWidgets( 'Keyboard is configured for "unspecified" action when explicitly requested', (WidgetTester tester) async { await _desiredKeyboardActionIsRequested( tester: tester, action: TextInputAction.unspecified, serializedActionName: 'TextInputAction.unspecified', ); }); testWidgets( 'Keyboard is configured for "none" action when explicitly requested', (WidgetTester tester) async { await _desiredKeyboardActionIsRequested( tester: tester, action: TextInputAction.none, serializedActionName: 'TextInputAction.none', ); }); testWidgets( 'Keyboard is configured for "done" action when explicitly requested', (WidgetTester tester) async { await _desiredKeyboardActionIsRequested( tester: tester, action: TextInputAction.done, serializedActionName: 'TextInputAction.done', ); }); testWidgets( 'Keyboard is configured for "send" action when explicitly requested', (WidgetTester tester) async { await _desiredKeyboardActionIsRequested( tester: tester, action: TextInputAction.send, serializedActionName: 'TextInputAction.send', ); }); testWidgets( 'Keyboard is configured for "go" action when explicitly requested', (WidgetTester tester) async { await _desiredKeyboardActionIsRequested( tester: tester, action: TextInputAction.go, serializedActionName: 'TextInputAction.go', ); }); testWidgets( 'Keyboard is configured for "search" action when explicitly requested', (WidgetTester tester) async { await _desiredKeyboardActionIsRequested( tester: tester, action: TextInputAction.search, serializedActionName: 'TextInputAction.search', ); }); testWidgets( 'Keyboard is configured for "send" action when explicitly requested', (WidgetTester tester) async { await _desiredKeyboardActionIsRequested( tester: tester, action: TextInputAction.send, serializedActionName: 'TextInputAction.send', ); }); testWidgets( 'Keyboard is configured for "next" action when explicitly requested', (WidgetTester tester) async { await _desiredKeyboardActionIsRequested( tester: tester, action: TextInputAction.next, serializedActionName: 'TextInputAction.next', ); }); testWidgets( 'Keyboard is configured for "previous" action when explicitly requested', (WidgetTester tester) async { await _desiredKeyboardActionIsRequested( tester: tester, action: TextInputAction.previous, serializedActionName: 'TextInputAction.previous', ); }); testWidgets( 'Keyboard is configured for "continue" action when explicitly requested', (WidgetTester tester) async { await _desiredKeyboardActionIsRequested( tester: tester, action: TextInputAction.continueAction, serializedActionName: 'TextInputAction.continueAction', ); }); testWidgets( 'Keyboard is configured for "join" action when explicitly requested', (WidgetTester tester) async { await _desiredKeyboardActionIsRequested( tester: tester, action: TextInputAction.join, serializedActionName: 'TextInputAction.join', ); }); testWidgets( 'Keyboard is configured for "route" action when explicitly requested', (WidgetTester tester) async { await _desiredKeyboardActionIsRequested( tester: tester, action: TextInputAction.route, serializedActionName: 'TextInputAction.route', ); }); testWidgets( 'Keyboard is configured for "emergencyCall" action when explicitly requested', (WidgetTester tester) async { await _desiredKeyboardActionIsRequested( tester: tester, action: TextInputAction.emergencyCall, serializedActionName: 'TextInputAction.emergencyCall', ); }); testWidgets('multiline keyboard is requested when set explicitly', (WidgetTester tester) async { await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: FocusScope( node: focusScopeNode, autofocus: true, child: EditableText( controller: controller, focusNode: focusNode, keyboardType: TextInputType.multiline, style: textStyle, cursorColor: cursorColor, ), ), ), ); await tester.tap(find.byType(EditableText)); await tester.showKeyboard(find.byType(EditableText)); controller.text = 'test'; await tester.idle(); expect(tester.testTextInput.editingState['text'], equals('test')); expect(tester.testTextInput.setClientArgs['inputType']['name'], equals('TextInputType.multiline')); expect(tester.testTextInput.setClientArgs['inputAction'], equals('TextInputAction.newline')); }); testWidgets('Multiline keyboard with newline action is requested when maxLines = null', (WidgetTester tester) async { await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: FocusScope( node: focusScopeNode, autofocus: true, child: EditableText( controller: controller, focusNode: focusNode, maxLines: null, style: textStyle, cursorColor: cursorColor, ), ), ), ); await tester.tap(find.byType(EditableText)); await tester.showKeyboard(find.byType(EditableText)); controller.text = 'test'; await tester.idle(); expect(tester.testTextInput.editingState['text'], equals('test')); expect(tester.testTextInput.setClientArgs['inputType']['name'], equals('TextInputType.multiline')); expect(tester.testTextInput.setClientArgs['inputAction'], equals('TextInputAction.newline')); }); testWidgets('Text keyboard is requested when explicitly set and maxLines = null', (WidgetTester tester) async { await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: FocusScope( node: focusScopeNode, autofocus: true, child: EditableText( controller: controller, focusNode: focusNode, maxLines: null, keyboardType: TextInputType.text, style: textStyle, cursorColor: cursorColor, ), ), ), ); await tester.tap(find.byType(EditableText)); await tester.showKeyboard(find.byType(EditableText)); controller.text = 'test'; await tester.idle(); expect(tester.testTextInput.editingState['text'], equals('test')); expect(tester.testTextInput.setClientArgs['inputType']['name'], equals('TextInputType.text')); expect(tester.testTextInput.setClientArgs['inputAction'], equals('TextInputAction.done')); }); testWidgets( 'Correct keyboard is requested when set explicitly and maxLines > 1', (WidgetTester tester) async { await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: FocusScope( node: focusScopeNode, autofocus: true, child: EditableText( controller: controller, focusNode: focusNode, keyboardType: TextInputType.phone, maxLines: 3, style: textStyle, cursorColor: cursorColor, ), ), ), ); await tester.tap(find.byType(EditableText)); await tester.showKeyboard(find.byType(EditableText)); controller.text = 'test'; await tester.idle(); expect(tester.testTextInput.editingState['text'], equals('test')); expect(tester.testTextInput.setClientArgs['inputType']['name'], equals('TextInputType.phone')); expect(tester.testTextInput.setClientArgs['inputAction'], equals('TextInputAction.done')); }); testWidgets('multiline keyboard is requested when set implicitly', (WidgetTester tester) async { await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: FocusScope( node: focusScopeNode, autofocus: true, child: EditableText( controller: controller, focusNode: focusNode, maxLines: 3, // Sets multiline keyboard implicitly. style: textStyle, cursorColor: cursorColor, ), ), ), ); await tester.tap(find.byType(EditableText)); await tester.showKeyboard(find.byType(EditableText)); controller.text = 'test'; await tester.idle(); expect(tester.testTextInput.editingState['text'], equals('test')); expect(tester.testTextInput.setClientArgs['inputType']['name'], equals('TextInputType.multiline')); expect(tester.testTextInput.setClientArgs['inputAction'], equals('TextInputAction.newline')); }); testWidgets('single line inputs have correct default keyboard', (WidgetTester tester) async { await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: FocusScope( node: focusScopeNode, autofocus: true, child: EditableText( controller: controller, focusNode: focusNode, maxLines: 1, // Sets text keyboard implicitly. style: textStyle, cursorColor: cursorColor, ), ), ), ); await tester.tap(find.byType(EditableText)); await tester.showKeyboard(find.byType(EditableText)); controller.text = 'test'; await tester.idle(); expect(tester.testTextInput.editingState['text'], equals('test')); expect(tester.testTextInput.setClientArgs['inputType']['name'], equals('TextInputType.text')); expect(tester.testTextInput.setClientArgs['inputAction'], equals('TextInputAction.done')); }); testWidgets('Fires onChanged when text changes via TextSelectionOverlay', (WidgetTester tester) async { final GlobalKey editableTextKey = GlobalKey(); String changedValue; final Widget widget = MaterialApp( home: EditableText( key: editableTextKey, controller: TextEditingController(), focusNode: FocusNode(), style: Typography(platform: TargetPlatform.android).black.subhead, cursorColor: Colors.blue, selectionControls: materialTextSelectionControls, keyboardType: TextInputType.text, onChanged: (String value) { changedValue = value; }, ), ); await tester.pumpWidget(widget); // Populate a fake clipboard. const String clipboardContent = 'Dobunezumi mitai ni utsukushiku naritai'; SystemChannels.platform .setMockMethodCallHandler((MethodCall methodCall) async { if (methodCall.method == 'Clipboard.getData') return const {'text': clipboardContent}; return null; }); // Long-press to bring up the text editing controls. final Finder textFinder = find.byKey(editableTextKey); await tester.longPress(textFinder); await tester.pump(); await tester.tap(find.text('PASTE')); await tester.pump(); expect(changedValue, clipboardContent); }); testWidgets('cursor layout has correct width', (WidgetTester tester) async { final GlobalKey editableTextKey = GlobalKey(); String changedValue; final Widget widget = MaterialApp( home: RepaintBoundary( key: const ValueKey(1), child: EditableText( key: editableTextKey, controller: TextEditingController(), focusNode: FocusNode(), style: Typography(platform: TargetPlatform.android).black.subhead, cursorColor: Colors.blue, selectionControls: materialTextSelectionControls, keyboardType: TextInputType.text, onChanged: (String value) { changedValue = value; }, cursorWidth: 15.0, ), ), ); await tester.pumpWidget(widget); // Populate a fake clipboard. const String clipboardContent = ' '; SystemChannels.platform .setMockMethodCallHandler((MethodCall methodCall) async { if (methodCall.method == 'Clipboard.getData') return const {'text': clipboardContent}; return null; }); // Long-press to bring up the text editing controls. final Finder textFinder = find.byKey(editableTextKey); await tester.longPress(textFinder); await tester.pump(); await tester.tap(find.text('PASTE')); await tester.pump(); expect(changedValue, clipboardContent); await expectLater( find.byKey(const ValueKey(1)), matchesGoldenFile('editable_text_test.0.0.png'), ); }, skip: !Platform.isLinux); testWidgets('cursor layout has correct radius', (WidgetTester tester) async { final GlobalKey editableTextKey = GlobalKey(); String changedValue; final Widget widget = MaterialApp( home: RepaintBoundary( key: const ValueKey(1), child: EditableText( key: editableTextKey, controller: TextEditingController(), focusNode: FocusNode(), style: Typography(platform: TargetPlatform.android).black.subhead, cursorColor: Colors.blue, selectionControls: materialTextSelectionControls, keyboardType: TextInputType.text, onChanged: (String value) { changedValue = value; }, cursorWidth: 15.0, cursorRadius: const Radius.circular(3.0), ), ), ); await tester.pumpWidget(widget); // Populate a fake clipboard. const String clipboardContent = ' '; SystemChannels.platform .setMockMethodCallHandler((MethodCall methodCall) async { if (methodCall.method == 'Clipboard.getData') return const {'text': clipboardContent}; return null; }); // Long-press to bring up the text editing controls. final Finder textFinder = find.byKey(editableTextKey); await tester.longPress(textFinder); await tester.pump(); await tester.tap(find.text('PASTE')); await tester.pump(); expect(changedValue, clipboardContent); await expectLater( find.byKey(const ValueKey(1)), matchesGoldenFile('editable_text_test.1.0.png'), ); }, skip: !Platform.isLinux); testWidgets('Does not lose focus by default when "next" action is pressed', (WidgetTester tester) async { final GlobalKey editableTextKey = GlobalKey(); final FocusNode focusNode = FocusNode(); final Widget widget = MaterialApp( home: EditableText( key: editableTextKey, controller: TextEditingController(), focusNode: focusNode, style: Typography(platform: TargetPlatform.android).black.subhead, cursorColor: Colors.blue, selectionControls: materialTextSelectionControls, keyboardType: TextInputType.text, ), ); await tester.pumpWidget(widget); // Select EditableText to give it focus. final Finder textFinder = find.byKey(editableTextKey); await tester.tap(textFinder); await tester.pump(); assert(focusNode.hasFocus); await tester.testTextInput.receiveAction(TextInputAction.next); await tester.pump(); // Still has focus after pressing "next". expect(focusNode.hasFocus, true); }); testWidgets( 'Does not lose focus by default when "done" action is pressed and onEditingComplete is provided', (WidgetTester tester) async { final GlobalKey editableTextKey = GlobalKey(); final FocusNode focusNode = FocusNode(); final Widget widget = MaterialApp( home: EditableText( key: editableTextKey, controller: TextEditingController(), focusNode: focusNode, style: Typography(platform: TargetPlatform.android).black.subhead, cursorColor: Colors.blue, selectionControls: materialTextSelectionControls, keyboardType: TextInputType.text, onEditingComplete: () { // This prevents the default focus change behavior on submission. }, ), ); await tester.pumpWidget(widget); // Select EditableText to give it focus. final Finder textFinder = find.byKey(editableTextKey); await tester.tap(textFinder); await tester.pump(); assert(focusNode.hasFocus); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pump(); // Still has focus even though "done" was pressed because onEditingComplete // was provided and it overrides the default behavior. expect(focusNode.hasFocus, true); }); testWidgets( 'When "done" is pressed callbacks are invoked: onEditingComplete > onSubmitted', (WidgetTester tester) async { final GlobalKey editableTextKey = GlobalKey(); final FocusNode focusNode = FocusNode(); bool onEditingCompleteCalled = false; bool onSubmittedCalled = false; final Widget widget = MaterialApp( home: EditableText( key: editableTextKey, controller: TextEditingController(), focusNode: focusNode, style: Typography(platform: TargetPlatform.android).black.subhead, cursorColor: Colors.blue, onEditingComplete: () { onEditingCompleteCalled = true; expect(onSubmittedCalled, false); }, onSubmitted: (String value) { onSubmittedCalled = true; expect(onEditingCompleteCalled, true); }, ), ); await tester.pumpWidget(widget); // Select EditableText to give it focus. final Finder textFinder = find.byKey(editableTextKey); await tester.tap(textFinder); await tester.pump(); assert(focusNode.hasFocus); // The execution path starting with receiveAction() will trigger the // onEditingComplete and onSubmission callbacks. await tester.testTextInput.receiveAction(TextInputAction.done); // The expectations we care about are up above in the onEditingComplete // and onSubmission callbacks. }); testWidgets( 'When "next" is pressed callbacks are invoked: onEditingComplete > onSubmitted', (WidgetTester tester) async { final GlobalKey editableTextKey = GlobalKey(); final FocusNode focusNode = FocusNode(); bool onEditingCompleteCalled = false; bool onSubmittedCalled = false; final Widget widget = MaterialApp( home: EditableText( key: editableTextKey, controller: TextEditingController(), focusNode: focusNode, style: Typography(platform: TargetPlatform.android).black.subhead, cursorColor: Colors.blue, onEditingComplete: () { onEditingCompleteCalled = true; assert(!onSubmittedCalled); }, onSubmitted: (String value) { onSubmittedCalled = true; assert(onEditingCompleteCalled); }, ), ); await tester.pumpWidget(widget); // Select EditableText to give it focus. final Finder textFinder = find.byKey(editableTextKey); await tester.tap(textFinder); await tester.pump(); assert(focusNode.hasFocus); // The execution path starting with receiveAction() will trigger the // onEditingComplete and onSubmission callbacks. await tester.testTextInput.receiveAction(TextInputAction.done); // The expectations we care about are up above in the onEditingComplete // and onSubmission callbacks. }); testWidgets('Changing controller updates EditableText', (WidgetTester tester) async { final GlobalKey editableTextKey = GlobalKey(); final TextEditingController controller1 = TextEditingController(text: 'Wibble'); final TextEditingController controller2 = TextEditingController(text: 'Wobble'); TextEditingController currentController = controller1; StateSetter setState; Widget builder() { return StatefulBuilder( builder: (BuildContext context, StateSetter setter) { setState = setter; return Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: EditableText( key: editableTextKey, controller: currentController, focusNode: FocusNode(), style: Typography(platform: TargetPlatform.android) .black .subhead, cursorColor: Colors.blue, selectionControls: materialTextSelectionControls, keyboardType: TextInputType.text, onChanged: (String value) {}, ), ), ), ); }, ); } await tester.pumpWidget(builder()); await tester.showKeyboard(find.byType(EditableText)); // Verify TextInput.setEditingState is fired with updated text when controller is replaced. final List log = []; SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async { log.add(methodCall); }); setState(() { currentController = controller2; }); await tester.pump(); expect(log, hasLength(1)); expect( log.single, isMethodCall( 'TextInput.setEditingState', arguments: const { 'text': 'Wobble', 'selectionBase': -1, 'selectionExtent': -1, 'selectionAffinity': 'TextAffinity.downstream', 'selectionIsDirectional': false, 'composingBase': -1, 'composingExtent': -1, }, )); }); testWidgets('EditableText identifies as text field (w/ focus) in semantics', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: FocusScope( node: focusScopeNode, autofocus: true, child: EditableText( controller: controller, focusNode: focusNode, style: textStyle, cursorColor: cursorColor, ), ), ), ); expect(semantics, includesNodeWith(flags: [SemanticsFlag.isTextField])); await tester.tap(find.byType(EditableText)); await tester.idle(); await tester.pump(); expect( semantics, includesNodeWith(flags: [ SemanticsFlag.isTextField, SemanticsFlag.isFocused ])); semantics.dispose(); }); testWidgets('EditableText includes text as value in semantics', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); const String value1 = 'EditableText content'; controller.text = value1; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: FocusScope( node: focusScopeNode, child: EditableText( controller: controller, focusNode: focusNode, style: textStyle, cursorColor: cursorColor, ), ), ), ); expect( semantics, includesNodeWith( flags: [SemanticsFlag.isTextField], value: value1, )); const String value2 = 'Changed the EditableText content'; controller.text = value2; await tester.idle(); await tester.pump(); expect( semantics, includesNodeWith( flags: [SemanticsFlag.isTextField], value: value2, )); semantics.dispose(); }); testWidgets('changing selection with keyboard does not show handles', (WidgetTester tester) async { const String value1 = 'Hello World'; controller.text = value1; await tester.pumpWidget( MaterialApp( home: EditableText( controller: controller, selectionControls: materialTextSelectionControls, focusNode: focusNode, style: textStyle, cursorColor: cursorColor, ), ), ); // Simulate selection change via tap to show handles. final RenderEditable render = tester.allRenderObjects .firstWhere((RenderObject o) => o.runtimeType == RenderEditable); render.onSelectionChanged(const TextSelection.collapsed(offset: 4), render, SelectionChangedCause.tap); await tester.pumpAndSettle(); final EditableTextState textState = tester.state(find.byType(EditableText)); expect(textState.selectionOverlay.handlesAreVisible, isTrue); expect( textState.selectionOverlay.selectionDelegate.textEditingValue.selection, const TextSelection.collapsed(offset: 4)); // Simulate selection change via keyboard and expect handles to disappear. render.onSelectionChanged(const TextSelection.collapsed(offset: 10), render, SelectionChangedCause.keyboard); await tester.pumpAndSettle(); expect(textState.selectionOverlay.handlesAreVisible, isFalse); expect( textState.selectionOverlay.selectionDelegate.textEditingValue.selection, const TextSelection.collapsed(offset: 10)); }); testWidgets('exposes correct cursor movement semantics', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); controller.text = 'test'; await tester.pumpWidget(MaterialApp( home: EditableText( controller: controller, focusNode: focusNode, style: textStyle, cursorColor: cursorColor, ), )); expect( semantics, includesNodeWith( value: 'test', )); controller.selection = TextSelection.collapsed(offset: controller.text.length); await tester.pumpAndSettle(); // At end, can only go backwards. expect( semantics, includesNodeWith( value: 'test', actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByWord, SemanticsAction.setSelection, ], )); controller.selection = TextSelection.collapsed(offset: controller.text.length - 2); await tester.pumpAndSettle(); // Somewhere in the middle, can go in both directions. expect( semantics, includesNodeWith( value: 'test', actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.moveCursorBackwardByWord, SemanticsAction.moveCursorForwardByWord, SemanticsAction.setSelection, ], )); controller.selection = const TextSelection.collapsed(offset: 0); await tester.pumpAndSettle(); // At beginning, can only go forward. expect( semantics, includesNodeWith( value: 'test', actions: [ SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.moveCursorForwardByWord, SemanticsAction.setSelection, ], )); semantics.dispose(); }); testWidgets('can move cursor with a11y means - character', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); const bool doNotExtendSelection = false; controller.text = 'test'; controller.selection = TextSelection.collapsed(offset: controller.text.length); await tester.pumpWidget(MaterialApp( home: EditableText( controller: controller, focusNode: focusNode, style: textStyle, cursorColor: cursorColor, ), )); expect( semantics, includesNodeWith( value: 'test', actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByWord, ], )); final RenderEditable render = tester.allRenderObjects .firstWhere((RenderObject o) => o.runtimeType == RenderEditable); final int semanticsId = render.debugSemantics.id; expect(controller.selection.baseOffset, 4); expect(controller.selection.extentOffset, 4); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter, doNotExtendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 3); expect(controller.selection.extentOffset, 3); expect( semantics, includesNodeWith( value: 'test', actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.moveCursorBackwardByWord, SemanticsAction.moveCursorForwardByWord, SemanticsAction.setSelection, ], )); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter, doNotExtendSelection); await tester.pumpAndSettle(); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter, doNotExtendSelection); await tester.pumpAndSettle(); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter, doNotExtendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 0); expect(controller.selection.extentOffset, 0); await tester.pumpAndSettle(); expect( semantics, includesNodeWith( value: 'test', actions: [ SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.moveCursorForwardByWord, SemanticsAction.setSelection, ], )); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorForwardByCharacter, doNotExtendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 1); expect(controller.selection.extentOffset, 1); semantics.dispose(); }); testWidgets('can move cursor with a11y means - word', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); const bool doNotExtendSelection = false; controller.text = 'test for words'; controller.selection = TextSelection.collapsed(offset: controller.text.length); await tester.pumpWidget(MaterialApp( home: EditableText( controller: controller, focusNode: focusNode, style: textStyle, cursorColor: cursorColor, ), )); expect( semantics, includesNodeWith( value: 'test for words', actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByWord, ], )); final RenderEditable render = tester.allRenderObjects .firstWhere((RenderObject o) => o.runtimeType == RenderEditable); final int semanticsId = render.debugSemantics.id; expect(controller.selection.baseOffset, 14); expect(controller.selection.extentOffset, 14); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByWord, doNotExtendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 9); expect(controller.selection.extentOffset, 9); expect( semantics, includesNodeWith( value: 'test for words', actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.moveCursorBackwardByWord, SemanticsAction.moveCursorForwardByWord, SemanticsAction.setSelection, ], )); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByWord, doNotExtendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 5); expect(controller.selection.extentOffset, 5); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByWord, doNotExtendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 0); expect(controller.selection.extentOffset, 0); await tester.pumpAndSettle(); expect( semantics, includesNodeWith( value: 'test for words', actions: [ SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.moveCursorForwardByWord, SemanticsAction.setSelection, ], )); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorForwardByWord, doNotExtendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 5); expect(controller.selection.extentOffset, 5); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorForwardByWord, doNotExtendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 9); expect(controller.selection.extentOffset, 9); semantics.dispose(); }); testWidgets('can extend selection with a11y means - character', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); const bool extendSelection = true; const bool doNotExtendSelection = false; controller.text = 'test'; controller.selection = TextSelection.collapsed(offset: controller.text.length); await tester.pumpWidget(MaterialApp( home: EditableText( controller: controller, focusNode: focusNode, style: textStyle, cursorColor: cursorColor, ), )); expect( semantics, includesNodeWith( value: 'test', actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByWord, ], )); final RenderEditable render = tester.allRenderObjects .firstWhere((RenderObject o) => o.runtimeType == RenderEditable); final int semanticsId = render.debugSemantics.id; expect(controller.selection.baseOffset, 4); expect(controller.selection.extentOffset, 4); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter, extendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 4); expect(controller.selection.extentOffset, 3); expect( semantics, includesNodeWith( value: 'test', actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.moveCursorBackwardByWord, SemanticsAction.moveCursorForwardByWord, SemanticsAction.setSelection, ], )); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter, extendSelection); await tester.pumpAndSettle(); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter, extendSelection); await tester.pumpAndSettle(); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter, extendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 4); expect(controller.selection.extentOffset, 0); await tester.pumpAndSettle(); expect( semantics, includesNodeWith( value: 'test', actions: [ SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.moveCursorForwardByWord, SemanticsAction.setSelection, ], )); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorForwardByCharacter, doNotExtendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 1); expect(controller.selection.extentOffset, 1); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorForwardByCharacter, extendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 1); expect(controller.selection.extentOffset, 2); semantics.dispose(); }); testWidgets('can extend selection with a11y means - word', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); const bool extendSelection = true; const bool doNotExtendSelection = false; controller.text = 'test for words'; controller.selection = TextSelection.collapsed(offset: controller.text.length); await tester.pumpWidget(MaterialApp( home: EditableText( controller: controller, focusNode: focusNode, style: textStyle, cursorColor: cursorColor, ), )); expect( semantics, includesNodeWith( value: 'test for words', actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByWord, ], )); final RenderEditable render = tester.allRenderObjects .firstWhere((RenderObject o) => o.runtimeType == RenderEditable); final int semanticsId = render.debugSemantics.id; expect(controller.selection.baseOffset, 14); expect(controller.selection.extentOffset, 14); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByWord, extendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 14); expect(controller.selection.extentOffset, 9); expect( semantics, includesNodeWith( value: 'test for words', actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.moveCursorBackwardByWord, SemanticsAction.moveCursorForwardByWord, SemanticsAction.setSelection, ], )); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByWord, extendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 14); expect(controller.selection.extentOffset, 5); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByWord, extendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 14); expect(controller.selection.extentOffset, 0); await tester.pumpAndSettle(); expect( semantics, includesNodeWith( value: 'test for words', actions: [ SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.moveCursorForwardByWord, SemanticsAction.setSelection, ], )); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorForwardByWord, doNotExtendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 5); expect(controller.selection.extentOffset, 5); tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorForwardByWord, extendSelection); await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 5); expect(controller.selection.extentOffset, 9); semantics.dispose(); }); testWidgets('password fields have correct semantics', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); controller.text = 'super-secret-password!!1'; await tester.pumpWidget(MaterialApp( home: EditableText( obscureText: true, controller: controller, focusNode: focusNode, style: textStyle, cursorColor: cursorColor, ), )); final String expectedValue = '•' * controller.text.length; expect( semantics, hasSemantics( TestSemantics( children: [ TestSemantics.rootChild( children: [ TestSemantics( flags: [SemanticsFlag.scopesRoute], children: [ TestSemantics( flags: [ SemanticsFlag.isTextField, SemanticsFlag.isObscured ], value: expectedValue, textDirection: TextDirection.ltr, ), ], ), ], ), ], ), ignoreTransform: true, ignoreRect: true, ignoreId: true)); semantics.dispose(); }); group('a11y copy/cut/paste', () { Future _buildApp( MockTextSelectionControls controls, WidgetTester tester) { return tester.pumpWidget(MaterialApp( home: EditableText( controller: controller, focusNode: focusNode, style: textStyle, cursorColor: cursorColor, selectionControls: controls, ), )); } MockTextSelectionControls controls; setUp(() { controller.text = 'test'; controller.selection = TextSelection.collapsed(offset: controller.text.length); controls = MockTextSelectionControls(); when(controls.buildHandle(any, any, any)).thenReturn(Container()); when(controls.buildToolbar(any, any, any, any)) .thenReturn(Container()); }); testWidgets('are exposed', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); when(controls.canCopy(any)).thenReturn(false); when(controls.canCut(any)).thenReturn(false); when(controls.canPaste(any)).thenReturn(false); await _buildApp(controls, tester); await tester.tap(find.byType(EditableText)); await tester.pump(); expect( semantics, includesNodeWith( value: 'test', actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByWord, SemanticsAction.setSelection, ], )); when(controls.canCopy(any)).thenReturn(true); await _buildApp(controls, tester); expect( semantics, includesNodeWith( value: 'test', actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByWord, SemanticsAction.setSelection, SemanticsAction.copy, ], )); when(controls.canCopy(any)).thenReturn(false); when(controls.canPaste(any)).thenReturn(true); await _buildApp(controls, tester); expect( semantics, includesNodeWith( value: 'test', actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByWord, SemanticsAction.setSelection, SemanticsAction.paste, ], )); when(controls.canPaste(any)).thenReturn(false); when(controls.canCut(any)).thenReturn(true); await _buildApp(controls, tester); expect( semantics, includesNodeWith( value: 'test', actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByWord, SemanticsAction.setSelection, SemanticsAction.cut, ], )); when(controls.canCopy(any)).thenReturn(true); when(controls.canCut(any)).thenReturn(true); when(controls.canPaste(any)).thenReturn(true); await _buildApp(controls, tester); expect( semantics, includesNodeWith( value: 'test', actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByWord, SemanticsAction.setSelection, SemanticsAction.cut, SemanticsAction.copy, SemanticsAction.paste, ], )); semantics.dispose(); }); testWidgets('can copy/cut/paste with a11y', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); when(controls.canCopy(any)).thenReturn(true); when(controls.canCut(any)).thenReturn(true); when(controls.canPaste(any)).thenReturn(true); await _buildApp(controls, tester); await tester.tap(find.byType(EditableText)); await tester.pump(); final SemanticsOwner owner = tester.binding.pipelineOwner.semanticsOwner; const int expectedNodeId = 4; expect( semantics, hasSemantics( TestSemantics.root( children: [ TestSemantics.rootChild( id: 1, children: [ TestSemantics( id: 2, flags: [SemanticsFlag.scopesRoute], children: [ TestSemantics.rootChild( id: expectedNodeId, flags: [ SemanticsFlag.isTextField, SemanticsFlag.isFocused ], actions: [ SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByWord, SemanticsAction.setSelection, SemanticsAction.copy, SemanticsAction.cut, SemanticsAction.paste ], value: 'test', textSelection: TextSelection.collapsed( offset: controller.text.length), textDirection: TextDirection.ltr, ), ], ), ], ), ], ), ignoreRect: true, ignoreTransform: true)); owner.performAction(expectedNodeId, SemanticsAction.copy); verify(controls.handleCopy(any)).called(1); owner.performAction(expectedNodeId, SemanticsAction.cut); verify(controls.handleCut(any)).called(1); owner.performAction(expectedNodeId, SemanticsAction.paste); verify(controls.handlePaste(any)).called(1); semantics.dispose(); }); }); testWidgets('allows customizing text style in subclasses', (WidgetTester tester) async { controller.text = 'Hello World'; await tester.pumpWidget(MaterialApp( home: CustomStyleEditableText( controller: controller, focusNode: focusNode, style: textStyle, cursorColor: cursorColor, ), )); // Simulate selection change via tap to show handles. final RenderEditable render = tester.allRenderObjects .firstWhere((RenderObject o) => o.runtimeType == RenderEditable); expect(render.text.style.fontStyle, FontStyle.italic); }); testWidgets('autofocus sets cursor to the end of text', (WidgetTester tester) async { const String text = 'hello world'; final FocusScopeNode focusScopeNode = FocusScopeNode(); final FocusNode focusNode = FocusNode(); controller.text = text; await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: FocusScope( node: focusScopeNode, autofocus: true, child: EditableText( controller: controller, focusNode: focusNode, autofocus: true, style: textStyle, cursorColor: cursorColor, ), ), )); expect(focusNode.hasFocus, true); expect(controller.selection.isCollapsed, true); expect(controller.selection.baseOffset, text.length); }); } class MockTextSelectionControls extends Mock implements TextSelectionControls {} class CustomStyleEditableText extends EditableText { CustomStyleEditableText({ TextEditingController controller, Color cursorColor, FocusNode focusNode, TextStyle style, }) : super( controller: controller, cursorColor: cursorColor, focusNode: focusNode, style: style, ); @override CustomStyleEditableTextState createState() => CustomStyleEditableTextState(); } class CustomStyleEditableTextState extends EditableTextState { @override TextSpan buildTextSpan() { return TextSpan( style: const TextStyle(fontStyle: FontStyle.italic), text: widget.controller.value.text, ); } }