// Copyright 2015 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 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_services/editing.dart' as mojom; import 'package:meta/meta.dart'; class MockKeyboard extends mojom.KeyboardProxy { MockKeyboard() : super.unbound(); mojom.KeyboardClient client; @override void setClient(@checked mojom.KeyboardClientStub client, mojom.KeyboardConfiguration configuraiton) { this.client = client.impl; } @override void show() {} @override void hide() {} @override void setEditingState(mojom.EditingState state) {} } class MockClipboard { Object _clipboardData = <String, dynamic>{ 'text': null }; Future<dynamic> handleJSONMessage(dynamic message) async { final String method = message['method']; final List<dynamic> args= message['args']; switch (method) { case 'Clipboard.getData': return _clipboardData; case 'Clipboard.setData': _clipboardData = args[0]; break; } } } void main() { MockKeyboard mockKeyboard = new MockKeyboard(); serviceMocker.registerMockService(mockKeyboard); MockClipboard mockClipboard = new MockClipboard(); PlatformMessages.setMockJSONMessageHandler('flutter/platform', mockClipboard.handleJSONMessage); const String kThreeLines = 'First line of text is here abcdef ghijkl mnopqrst. ' + 'Second line of text goes until abcdef ghijkl mnopq. ' + 'Third line of stuff keeps going until abcdef ghijk. '; const String kFourLines = kThreeLines + 'Fourth line won\'t display and ends at abcdef ghi. '; void enterText(String testValue) { // Simulate entry of text through the keyboard. expect(mockKeyboard.client, isNotNull); mockKeyboard.client.updateEditingState(new mojom.EditingState() ..text = testValue ..composingBase = 0 ..composingExtent = testValue.length); } // Returns the first RenderEditable. RenderEditable findRenderEditable(WidgetTester tester) { RenderObject root = tester.renderObject(find.byType(RawInput)); expect(root, isNotNull); RenderEditable renderEditable; void recursiveFinder(RenderObject child) { if (child is RenderEditable) { renderEditable = child; return; } child.visitChildren(recursiveFinder); } root.visitChildren(recursiveFinder); expect(renderEditable, isNotNull); return renderEditable; } Point textOffsetToPosition(WidgetTester tester, int offset) { RenderEditable renderEditable = findRenderEditable(tester); List<TextSelectionPoint> endpoints = renderEditable.getEndpointsForSelection( new TextSelection.collapsed(offset: offset)); expect(endpoints.length, 1); return endpoints[0].point + new Offset(0.0, -2.0); } testWidgets('Editable text has consistent size', (WidgetTester tester) async { GlobalKey inputKey = new GlobalKey(); InputValue inputValue = InputValue.empty; Widget builder() { return new Center( child: new Material( child: new Input( value: inputValue, key: inputKey, hintText: 'Placeholder', onChanged: (InputValue value) { inputValue = value; } ) ) ); } await tester.pumpWidget(builder()); RenderBox findInputBox() => tester.renderObject(find.byKey(inputKey)); RenderBox inputBox = findInputBox(); Size emptyInputSize = inputBox.size; Future<Null> checkText(String testValue) { enterText(testValue); // Check that the onChanged event handler fired. expect(inputValue.text, equals(testValue)); return tester.pumpWidget(builder()); } await checkText(' '); expect(findInputBox(), equals(inputBox)); expect(inputBox.size, equals(emptyInputSize)); await checkText('Test'); expect(findInputBox(), equals(inputBox)); expect(inputBox.size, equals(emptyInputSize)); }); testWidgets('Cursor blinks', (WidgetTester tester) async { GlobalKey inputKey = new GlobalKey(); Widget builder() { return new Center( child: new Material( child: new Input( key: inputKey, hintText: 'Placeholder' ) ) ); } await tester.pumpWidget(builder()); RawInputState editableText = tester.state(find.byType(RawInput)); // Check that the cursor visibility toggles after each blink interval. Future<Null> checkCursorToggle() async { bool initialShowCursor = editableText.cursorCurrentlyVisible; await tester.pump(editableText.cursorBlinkInterval); expect(editableText.cursorCurrentlyVisible, equals(!initialShowCursor)); await tester.pump(editableText.cursorBlinkInterval); expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor)); await tester.pump(editableText.cursorBlinkInterval ~/ 10); expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor)); await tester.pump(editableText.cursorBlinkInterval); expect(editableText.cursorCurrentlyVisible, equals(!initialShowCursor)); await tester.pump(editableText.cursorBlinkInterval); expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor)); } await checkCursorToggle(); // Try the test again with a nonempty EditableText. mockKeyboard.client.updateEditingState(new mojom.EditingState() ..text = 'X' ..selectionBase = 1 ..selectionExtent = 1); await checkCursorToggle(); }); testWidgets('hideText control test', (WidgetTester tester) async { GlobalKey inputKey = new GlobalKey(); Widget builder() { return new Center( child: new Material( child: new Input( key: inputKey, hideText: true, hintText: 'Placeholder' ) ) ); } await tester.pumpWidget(builder()); const String testValue = 'ABC'; mockKeyboard.client.updateEditingState(new mojom.EditingState() ..text = testValue ..selectionBase = testValue.length ..selectionExtent = testValue.length); await tester.pump(); }); testWidgets('Can long press to select', (WidgetTester tester) async { GlobalKey inputKey = new GlobalKey(); InputValue inputValue = InputValue.empty; Widget builder() { return new Overlay( initialEntries: <OverlayEntry>[ new OverlayEntry( builder: (BuildContext context) { return new Center( child: new Material( child: new Input( value: inputValue, key: inputKey, onChanged: (InputValue value) { inputValue = value; } ) ) ); } ) ] ); } await tester.pumpWidget(builder()); String testValue = 'abc def ghi'; enterText(testValue); expect(inputValue.text, testValue); await tester.pumpWidget(builder()); expect(inputValue.selection.isCollapsed, true); // Long press the 'e' to select 'def'. Point ePos = textOffsetToPosition(tester, testValue.indexOf('e')); TestGesture gesture = await tester.startGesture(ePos, pointer: 7); await tester.pump(const Duration(seconds: 2)); await gesture.up(); await tester.pump(); // 'def' is selected. expect(inputValue.selection.baseOffset, testValue.indexOf('d')); expect(inputValue.selection.extentOffset, testValue.indexOf('f')+1); }); testWidgets('Can drag handles to change selection', (WidgetTester tester) async { GlobalKey inputKey = new GlobalKey(); InputValue inputValue = InputValue.empty; Widget builder() { return new Overlay( initialEntries: <OverlayEntry>[ new OverlayEntry( builder: (BuildContext context) { return new Center( child: new Material( child: new Input( value: inputValue, key: inputKey, onChanged: (InputValue value) { inputValue = value; } ) ) ); } ) ] ); } await tester.pumpWidget(builder()); String testValue = 'abc def ghi'; enterText(testValue); await tester.pumpWidget(builder()); // Long press the 'e' to select 'def'. Point ePos = textOffsetToPosition(tester, testValue.indexOf('e')); TestGesture gesture = await tester.startGesture(ePos, pointer: 7); await tester.pump(const Duration(seconds: 2)); await gesture.up(); await tester.pump(); TextSelection selection = inputValue.selection; RenderEditable renderEditable = findRenderEditable(tester); List<TextSelectionPoint> endpoints = renderEditable.getEndpointsForSelection( selection); expect(endpoints.length, 2); // Drag the right handle 2 letters to the right. // Note: use a small offset because the endpoint is on the very corner // of the handle. Point handlePos = endpoints[1].point + new Offset(1.0, 1.0); Point newHandlePos = textOffsetToPosition(tester, selection.extentOffset+2); gesture = await tester.startGesture(handlePos, pointer: 7); await tester.pump(); await gesture.moveTo(newHandlePos); await tester.pump(); await gesture.up(); await tester.pump(); expect(inputValue.selection.baseOffset, selection.baseOffset); expect(inputValue.selection.extentOffset, selection.extentOffset+2); // Drag the left handle 2 letters to the left. handlePos = endpoints[0].point + new Offset(-1.0, 1.0); newHandlePos = textOffsetToPosition(tester, selection.baseOffset-2); gesture = await tester.startGesture(handlePos, pointer: 7); await tester.pump(); await gesture.moveTo(newHandlePos); await tester.pump(); await gesture.up(); await tester.pumpWidget(builder()); expect(inputValue.selection.baseOffset, selection.baseOffset-2); expect(inputValue.selection.extentOffset, selection.extentOffset+2); }); testWidgets('Can use selection toolbar', (WidgetTester tester) async { GlobalKey inputKey = new GlobalKey(); InputValue inputValue = InputValue.empty; Widget builder() { return new Overlay( initialEntries: <OverlayEntry>[ new OverlayEntry( builder: (BuildContext context) { return new Center( child: new Material( child: new Input( value: inputValue, key: inputKey, onChanged: (InputValue value) { inputValue = value; } ) ) ); } ) ] ); } await tester.pumpWidget(builder()); String testValue = 'abc def ghi'; enterText(testValue); await tester.pumpWidget(builder()); // Tap the selection handle to bring up the "paste / select all" menu. await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); await tester.pumpWidget(builder()); RenderEditable renderEditable = findRenderEditable(tester); List<TextSelectionPoint> endpoints = renderEditable.getEndpointsForSelection( inputValue.selection); await tester.tapAt(endpoints[0].point + new Offset(1.0, 1.0)); await tester.pumpWidget(builder()); // SELECT ALL should select all the text. await tester.tap(find.text('SELECT ALL')); await tester.pumpWidget(builder()); expect(inputValue.selection.baseOffset, 0); expect(inputValue.selection.extentOffset, testValue.length); // COPY should reset the selection. await tester.tap(find.text('COPY')); await tester.pumpWidget(builder()); expect(inputValue.selection.isCollapsed, true); // Tap again to bring back the menu. await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); await tester.pumpWidget(builder()); renderEditable = findRenderEditable(tester); endpoints = renderEditable.getEndpointsForSelection(inputValue.selection); await tester.tapAt(endpoints[0].point + new Offset(1.0, 1.0)); await tester.pumpWidget(builder()); // PASTE right before the 'e'. await tester.tap(find.text('PASTE')); await tester.pumpWidget(builder()); expect(inputValue.text, 'abc d${testValue}ef ghi'); }); testWidgets('Selection toolbar fades in', (WidgetTester tester) async { GlobalKey inputKey = new GlobalKey(); InputValue inputValue = InputValue.empty; Widget builder() { return new Overlay( initialEntries: <OverlayEntry>[ new OverlayEntry( builder: (BuildContext context) { return new Center( child: new Material( child: new Input( value: inputValue, key: inputKey, onChanged: (InputValue value) { inputValue = value; } ) ) ); } ) ] ); } await tester.pumpWidget(builder()); String testValue = 'abc def ghi'; enterText(testValue); await tester.pumpWidget(builder()); // Tap the selection handle to bring up the "paste / select all" menu. await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); await tester.pumpWidget(builder()); RenderEditable renderEditable = findRenderEditable(tester); List<TextSelectionPoint> endpoints = renderEditable.getEndpointsForSelection( inputValue.selection); await tester.tapAt(endpoints[0].point + new Offset(1.0, 1.0)); await tester.pumpWidget(builder()); // Toolbar should fade in. Starting at 0% opacity. Element target = tester.element(find.text('SELECT ALL')); Opacity opacity = target.ancestorWidgetOfExactType(Opacity); expect(opacity, isNotNull); expect(opacity.opacity, equals(0.0)); // Still fading in. await tester.pump(const Duration(milliseconds: 50)); opacity = target.ancestorWidgetOfExactType(Opacity); expect(opacity.opacity, greaterThan(0.0)); expect(opacity.opacity, lessThan(1.0)); // End the test here to ensure the animation is properly disposed of. }); testWidgets('Multiline text will wrap up to maxLines', (WidgetTester tester) async { GlobalKey inputKey = new GlobalKey(); InputValue inputValue = InputValue.empty; Widget builder(int maxLines) { return new Center( child: new Material( child: new Input( value: inputValue, key: inputKey, style: const TextStyle(color: Colors.black, fontSize: 34.0), maxLines: maxLines, hintText: 'Placeholder', onChanged: (InputValue value) { inputValue = value; } ) ) ); } await tester.pumpWidget(builder(3)); RenderBox findInputBox() => tester.renderObject(find.byKey(inputKey)); RenderBox inputBox = findInputBox(); Size emptyInputSize = inputBox.size; enterText('No wrapping here.'); await tester.pumpWidget(builder(3)); expect(findInputBox(), equals(inputBox)); expect(inputBox.size, equals(emptyInputSize)); enterText(kThreeLines); await tester.pumpWidget(builder(3)); expect(findInputBox(), equals(inputBox)); expect(inputBox.size, greaterThan(emptyInputSize)); Size threeLineInputSize = inputBox.size; // An extra line won't increase the size because we max at 3. enterText(kFourLines); await tester.pumpWidget(builder(3)); expect(findInputBox(), equals(inputBox)); expect(inputBox.size, threeLineInputSize); // But now it will. enterText(kFourLines); await tester.pumpWidget(builder(4)); expect(findInputBox(), equals(inputBox)); expect(inputBox.size, greaterThan(threeLineInputSize)); }); testWidgets('Can drag handles to change selection in multiline', (WidgetTester tester) async { GlobalKey inputKey = new GlobalKey(); InputValue inputValue = InputValue.empty; Widget builder() { return new Overlay( initialEntries: <OverlayEntry>[ new OverlayEntry( builder: (BuildContext context) { return new Center( child: new Material( child: new Input( value: inputValue, key: inputKey, style: const TextStyle(color: Colors.black, fontSize: 34.0), maxLines: 3, onChanged: (InputValue value) { inputValue = value; } ) ) ); } ) ] ); } await tester.pumpWidget(builder()); String testValue = kThreeLines; String cutValue = 'First line of stuff keeps going until abcdef ghijk. '; enterText(testValue); await tester.pumpWidget(builder()); // Check that the text spans multiple lines. Point firstPos = textOffsetToPosition(tester, testValue.indexOf('First')); Point secondPos = textOffsetToPosition(tester, testValue.indexOf('Second')); Point thirdPos = textOffsetToPosition(tester, testValue.indexOf('Third')); expect(firstPos.x, secondPos.x); expect(firstPos.x, thirdPos.x); expect(firstPos.y, lessThan(secondPos.y)); expect(secondPos.y, lessThan(thirdPos.y)); // Long press the 'n' in 'until' to select the word. Point untilPos = textOffsetToPosition(tester, testValue.indexOf('until')+1); TestGesture gesture = await tester.startGesture(untilPos, pointer: 7); await tester.pump(const Duration(seconds: 2)); await gesture.up(); await tester.pump(); expect(inputValue.selection.baseOffset, 76); expect(inputValue.selection.extentOffset, 81); RenderEditable renderEditable = findRenderEditable(tester); List<TextSelectionPoint> endpoints = renderEditable.getEndpointsForSelection( inputValue.selection); expect(endpoints.length, 2); // Drag the right handle to the third line, just after 'Third'. Point handlePos = endpoints[1].point + new Offset(1.0, 1.0); Point newHandlePos = textOffsetToPosition(tester, testValue.indexOf('Third') + 5); gesture = await tester.startGesture(handlePos, pointer: 7); await tester.pump(); await gesture.moveTo(newHandlePos); await tester.pump(); await gesture.up(); await tester.pump(); expect(inputValue.selection.baseOffset, 76); expect(inputValue.selection.extentOffset, 108); // Drag the left handle to the first line, just after 'First'. handlePos = endpoints[0].point + new Offset(-1.0, 1.0); newHandlePos = textOffsetToPosition(tester, testValue.indexOf('First') + 5); gesture = await tester.startGesture(handlePos, pointer: 7); await tester.pump(); await gesture.moveTo(newHandlePos); await tester.pump(); await gesture.up(); await tester.pumpWidget(builder()); expect(inputValue.selection.baseOffset, 5); expect(inputValue.selection.extentOffset, 108); await tester.tap(find.text('CUT')); await tester.pumpWidget(builder()); expect(inputValue.selection.isCollapsed, true); expect(inputValue.text, cutValue); }); testWidgets('Can scroll multiline input', (WidgetTester tester) async { GlobalKey inputKey = new GlobalKey(); InputValue inputValue = InputValue.empty; Widget builder() { return new Overlay( initialEntries: <OverlayEntry>[ new OverlayEntry( builder: (BuildContext context) { return new Center( child: new Material( child: new Input( value: inputValue, key: inputKey, style: const TextStyle(color: Colors.black, fontSize: 34.0), maxLines: 2, onChanged: (InputValue value) { inputValue = value; } ) ) ); } ) ] ); } await tester.pumpWidget(builder()); enterText(kFourLines); await tester.pumpWidget(builder()); RenderBox findInputBox() => tester.renderObject(find.byKey(inputKey)); RenderBox inputBox = findInputBox(); // Check that the last line of text is not displayed. Point firstPos = textOffsetToPosition(tester, kFourLines.indexOf('First')); Point fourthPos = textOffsetToPosition(tester, kFourLines.indexOf('Fourth')); expect(firstPos.x, fourthPos.x); expect(firstPos.y, lessThan(fourthPos.y)); expect(inputBox.hitTest(new HitTestResult(), position: inputBox.globalToLocal(firstPos)), isTrue); expect(inputBox.hitTest(new HitTestResult(), position: inputBox.globalToLocal(fourthPos)), isFalse); TestGesture gesture = await tester.startGesture(firstPos, pointer: 7); await tester.pump(); await gesture.moveBy(new Offset(0.0, -1000.0)); await tester.pump(); await gesture.up(); await tester.pump(); // Now the first line is scrolled up, and the fourth line is visible. Point newFirstPos = textOffsetToPosition(tester, kFourLines.indexOf('First')); Point newFourthPos = textOffsetToPosition(tester, kFourLines.indexOf('Fourth')); expect(newFirstPos.y, lessThan(firstPos.y)); expect(inputBox.hitTest(new HitTestResult(), position: inputBox.globalToLocal(newFirstPos)), isFalse); expect(inputBox.hitTest(new HitTestResult(), position: inputBox.globalToLocal(newFourthPos)), isTrue); // Now try scrolling by dragging the selection handle. // Long press the 'i' in 'Fourth line' to select the word. await tester.pump(const Duration(seconds: 2)); Point untilPos = textOffsetToPosition(tester, kFourLines.indexOf('Fourth line')+8); gesture = await tester.startGesture(untilPos, pointer: 7); await tester.pump(const Duration(seconds: 2)); await gesture.up(); await tester.pump(); RenderEditable renderEditable = findRenderEditable(tester); List<TextSelectionPoint> endpoints = renderEditable.getEndpointsForSelection( inputValue.selection); expect(endpoints.length, 2); // Drag the left handle to the first line, just after 'First'. Point handlePos = endpoints[0].point + new Offset(-1.0, 1.0); Point newHandlePos = textOffsetToPosition(tester, kFourLines.indexOf('First') + 5); gesture = await tester.startGesture(handlePos, pointer: 7); await tester.pump(); await gesture.moveTo(newHandlePos + new Offset(0.0, -10.0)); await tester.pump(); await gesture.up(); await tester.pump(); // The text should have scrolled up with the handle to keep the active // cursor visible, back to its original position. newFirstPos = textOffsetToPosition(tester, kFourLines.indexOf('First')); newFourthPos = textOffsetToPosition(tester, kFourLines.indexOf('Fourth')); expect(newFirstPos.y, firstPos.y); expect(inputBox.hitTest(new HitTestResult(), position: inputBox.globalToLocal(newFirstPos)), isTrue); expect(inputBox.hitTest(new HitTestResult(), position: inputBox.globalToLocal(newFourthPos)), isFalse); }); }