| // Copyright 2014 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| // This file is run as part of a reduced test set in CI on Mac and Windows |
| // machines. |
| @Tags(<String>['reduced-test-set']) |
| |
| @TestOn('!chrome') |
| import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; |
| import 'dart:ui'; |
| |
| import 'package:flutter/cupertino.dart'; |
| import 'package:flutter/foundation.dart'; |
| import 'package:flutter/gestures.dart'; |
| import 'package:flutter/material.dart'; |
| import 'package:flutter/rendering.dart'; |
| import 'package:flutter/services.dart'; |
| import 'package:flutter_test/flutter_test.dart'; |
| |
| import '../widgets/clipboard_utils.dart'; |
| import '../widgets/editable_text_utils.dart' show textOffsetToPosition; |
| import '../widgets/semantics_tester.dart'; |
| |
| class MaterialLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> { |
| @override |
| bool isSupported(Locale locale) => true; |
| |
| @override |
| Future<MaterialLocalizations> load(Locale locale) => DefaultMaterialLocalizations.load(locale); |
| |
| @override |
| bool shouldReload(MaterialLocalizationsDelegate old) => false; |
| } |
| |
| class WidgetsLocalizationsDelegate extends LocalizationsDelegate<WidgetsLocalizations> { |
| @override |
| bool isSupported(Locale locale) => true; |
| |
| @override |
| Future<WidgetsLocalizations> load(Locale locale) => DefaultWidgetsLocalizations.load(locale); |
| |
| @override |
| bool shouldReload(WidgetsLocalizationsDelegate old) => false; |
| } |
| |
| Widget overlay({ Widget? child }) { |
| final OverlayEntry entry = OverlayEntry( |
| builder: (BuildContext context) { |
| return Center( |
| child: Material( |
| child: child, |
| ), |
| ); |
| }, |
| ); |
| return overlayWithEntry(entry); |
| } |
| |
| Widget overlayWithEntry(OverlayEntry entry) { |
| return Localizations( |
| locale: const Locale('en', 'US'), |
| delegates: <LocalizationsDelegate<dynamic>>[ |
| WidgetsLocalizationsDelegate(), |
| MaterialLocalizationsDelegate(), |
| ], |
| child: Directionality( |
| textDirection: TextDirection.ltr, |
| child: MediaQuery( |
| data: const MediaQueryData(size: Size(800.0, 600.0)), |
| child: Overlay( |
| initialEntries: <OverlayEntry>[ |
| entry, |
| ], |
| ), |
| ), |
| ), |
| ); |
| } |
| |
| Widget boilerplate({ Widget? child }) { |
| return Localizations( |
| locale: const Locale('en', 'US'), |
| delegates: <LocalizationsDelegate<dynamic>>[ |
| WidgetsLocalizationsDelegate(), |
| MaterialLocalizationsDelegate(), |
| ], |
| child: Directionality( |
| textDirection: TextDirection.ltr, |
| child: MediaQuery( |
| data: const MediaQueryData(size: Size(800.0, 600.0)), |
| child: Center( |
| child: Material( |
| child: child, |
| ), |
| ), |
| ), |
| ), |
| ); |
| } |
| |
| Future<void> skipPastScrollingAnimation(WidgetTester tester) async { |
| await tester.pump(); |
| await tester.pump(const Duration(milliseconds: 200)); |
| } |
| |
| void main() { |
| TestWidgetsFlutterBinding.ensureInitialized(); |
| final MockClipboard mockClipboard = MockClipboard(); |
| |
| const String kThreeLines = |
| 'First line of text is\n' |
| 'Second line goes until\n' |
| 'Third line of stuff'; |
| const String kMoreThanFourLines = |
| '$kThreeLines\n' |
| "Fourth line won't display and ends at"; |
| |
| // Returns the first RenderEditable. |
| RenderEditable findRenderEditable(WidgetTester tester) { |
| final RenderObject root = tester.renderObject(find.byType(EditableText)); |
| expect(root, isNotNull); |
| |
| late RenderEditable renderEditable; |
| void recursiveFinder(RenderObject child) { |
| if (child is RenderEditable) { |
| renderEditable = child; |
| return; |
| } |
| child.visitChildren(recursiveFinder); |
| } |
| root.visitChildren(recursiveFinder); |
| expect(renderEditable, isNotNull); |
| return renderEditable; |
| } |
| |
| List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) { |
| return points.map<TextSelectionPoint>((TextSelectionPoint point) { |
| return TextSelectionPoint( |
| box.localToGlobal(point.point), |
| point.direction, |
| ); |
| }).toList(); |
| } |
| |
| setUp(() async { |
| debugResetSemanticsIdCounter(); |
| TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler( |
| SystemChannels.platform, |
| mockClipboard.handleMethodCall, |
| ); |
| // Fill the clipboard so that the Paste option is available in the text |
| // selection menu. |
| await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); |
| }); |
| |
| tearDown(() { |
| TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler( |
| SystemChannels.platform, |
| null, |
| ); |
| }); |
| |
| Widget selectableTextBuilder({ |
| String text = '', |
| int? maxLines = 1, |
| int? minLines, |
| }) { |
| return boilerplate( |
| child: SelectableText( |
| text, |
| style: const TextStyle(color: Colors.black, fontSize: 34.0), |
| maxLines: maxLines, |
| minLines: minLines, |
| ), |
| ); |
| } |
| |
| testWidgets('throw if no Overlay widget exists above', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| const Directionality( |
| textDirection: TextDirection.ltr, |
| child: MediaQuery( |
| data: MediaQueryData(size: Size(800.0, 600.0)), |
| child: Center( |
| child: Material( |
| child: SelectableText('I love Flutter!'), |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| final Offset textFieldStart = tester.getTopLeft(find.byType(SelectableText)); |
| final TestGesture gesture = await tester.startGesture(textFieldStart, kind: PointerDeviceKind.mouse); |
| await tester.pump(const Duration(seconds: 2)); |
| await gesture.up(); |
| await tester.pumpAndSettle(); |
| |
| final FlutterError error = tester.takeException() as FlutterError; |
| expect( |
| error.message, |
| contains('EditableText widgets require an Overlay widget ancestor'), |
| ); |
| |
| await tester.pumpWidget(const SizedBox.shrink()); |
| expect(tester.takeException(), isNotNull); // side effect exception |
| }); |
| |
| testWidgets('Do not crash when remove SelectableText during handle drag', (WidgetTester tester) async { |
| // Regression test https://github.com/flutter/flutter/issues/108242 |
| bool isShow = true; |
| late StateSetter setter; |
| await tester.pumpWidget( |
| MaterialApp( |
| home: Material( |
| child: StatefulBuilder( |
| builder: (BuildContext context, StateSetter setState) { |
| setter = setState; |
| if (isShow) { |
| return const SelectableText( |
| 'abc def ghi', |
| dragStartBehavior: DragStartBehavior.down, |
| ); |
| } else { |
| return const SizedBox.shrink(); |
| } |
| }, |
| ), |
| ), |
| ), |
| ); |
| |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| // Long press the 'e' to select 'def'. |
| final Offset ePos = textOffsetToPosition(tester, 5); |
| TestGesture gesture = await tester.startGesture(ePos, pointer: 7); |
| await tester.pump(const Duration(seconds: 2)); |
| await gesture.up(); |
| await tester.pump(); |
| await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero |
| |
| final TextSelection selection = controller.selection; |
| expect(selection.baseOffset, 4); |
| expect(selection.extentOffset, 7); |
| |
| final RenderEditable renderEditable = findRenderEditable(tester); |
| final List<TextSelectionPoint> endpoints = globalize( |
| renderEditable.getEndpointsForSelection(selection), |
| renderEditable, |
| ); |
| expect(endpoints.length, 2); |
| |
| // Drag the left handle to the left. |
| final Offset handlePos = endpoints[0].point + const Offset(-1.0, 1.0); |
| final Offset newHandlePos = textOffsetToPosition(tester, 1); |
| final Offset newHandlePos1 = textOffsetToPosition(tester, 0); |
| gesture = await tester.startGesture(handlePos, pointer: 7); |
| await tester.pump(); |
| await gesture.moveTo(newHandlePos); |
| await tester.pump(); |
| |
| // Unmount the SelectableText during handle drag |
| setter(() { |
| isShow = false; |
| }); |
| await tester.pump(); |
| |
| await gesture.moveTo(newHandlePos1); |
| await tester.pump(); // Do not crash here |
| |
| await gesture.up(); |
| await tester.pump(); |
| }); |
| |
| testWidgets('has expected defaults', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| boilerplate( |
| child: const SelectableText('selectable text'), |
| ), |
| ); |
| |
| final SelectableText selectableText = tester.firstWidget(find.byType(SelectableText)); |
| expect(selectableText.showCursor, false); |
| expect(selectableText.autofocus, false); |
| expect(selectableText.dragStartBehavior, DragStartBehavior.start); |
| expect(selectableText.cursorWidth, 2.0); |
| expect(selectableText.cursorHeight, isNull); |
| expect(selectableText.enableInteractiveSelection, true); |
| }); |
| |
| testWidgets('Rich selectable text has expected defaults', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| const MediaQuery( |
| data: MediaQueryData(), |
| child: Directionality( |
| textDirection: TextDirection.ltr, |
| child: SelectableText.rich( |
| TextSpan( |
| text: 'First line!', |
| style: TextStyle( |
| fontSize: 14, |
| fontFamily: 'Roboto', |
| ), |
| children: <TextSpan>[ |
| TextSpan( |
| text: 'Second line!\n', |
| style: TextStyle( |
| fontSize: 30, |
| fontFamily: 'Roboto', |
| ), |
| ), |
| TextSpan( |
| text: 'Third line!\n', |
| style: TextStyle( |
| fontSize: 14, |
| fontFamily: 'Roboto', |
| ), |
| ), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| final SelectableText selectableText = |
| tester.firstWidget(find.byType(SelectableText)); |
| expect(selectableText.showCursor, false); |
| expect(selectableText.autofocus, false); |
| expect(selectableText.dragStartBehavior, DragStartBehavior.start); |
| expect(selectableText.cursorWidth, 2.0); |
| expect(selectableText.cursorHeight, isNull); |
| expect(selectableText.enableInteractiveSelection, true); |
| }); |
| |
| testWidgets('Rich selectable text supports WidgetSpan', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| const MediaQuery( |
| data: MediaQueryData(), |
| child: Directionality( |
| textDirection: TextDirection.ltr, |
| child: SelectableText.rich( |
| TextSpan( |
| text: 'First line!', |
| style: TextStyle( |
| fontSize: 14, |
| fontFamily: 'Roboto', |
| ), |
| children: <InlineSpan>[ |
| WidgetSpan( |
| child: SizedBox( |
| width: 120, |
| height: 50, |
| child: Card( |
| child: Center( |
| child: Text('Hello World!'), |
| ), |
| ), |
| ), |
| ), |
| TextSpan( |
| text: 'Third line!\n', |
| style: TextStyle( |
| fontSize: 14, |
| fontFamily: 'Roboto', |
| ), |
| ), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ); |
| expect(tester.takeException(), isNull); |
| }); |
| |
| testWidgets('no text keyboard when widget is focused', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| overlay( |
| child: const SelectableText('selectable text'), |
| ), |
| ); |
| await tester.tap(find.byType(SelectableText)); |
| await tester.idle(); |
| expect(tester.testTextInput.hasAnyClients, false); |
| }); |
| |
| testWidgets('uses DefaultSelectionStyle for selection and cursor colors if provided', (WidgetTester tester) async { |
| const Color selectionColor = Colors.orange; |
| const Color cursorColor = Colors.red; |
| |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: DefaultSelectionStyle( |
| selectionColor: selectionColor, |
| cursorColor: cursorColor, |
| child: SelectableText('text'), |
| ), |
| ), |
| ), |
| ); |
| await tester.pump(); |
| final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); |
| expect(state.widget.selectionColor, selectionColor); |
| expect(state.widget.cursorColor, cursorColor); |
| }); |
| |
| testWidgets('Selectable Text has adaptive size', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| boilerplate( |
| child: const SelectableText('s'), |
| ), |
| ); |
| |
| RenderBox findSelectableTextBox() => tester.renderObject(find.byType(SelectableText)); |
| |
| final RenderBox textBox = findSelectableTextBox(); |
| expect(textBox.size, const Size(17.0, 14.0)); |
| |
| await tester.pumpWidget( |
| boilerplate( |
| child: const SelectableText('very very long'), |
| ), |
| ); |
| |
| final RenderBox longtextBox = findSelectableTextBox(); |
| expect(longtextBox.size, const Size(199.0, 14.0)); |
| }); |
| |
| testWidgets('can scale with textScaleFactor', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| boilerplate( |
| child: const SelectableText('selectable text'), |
| ), |
| ); |
| |
| final RenderBox renderBox = tester.renderObject(find.byType(SelectableText)); |
| expect(renderBox.size.height, 14.0); |
| |
| await tester.pumpWidget( |
| boilerplate( |
| child: const SelectableText( |
| 'selectable text', |
| textScaleFactor: 1.9, |
| ), |
| ), |
| ); |
| |
| final RenderBox scaledBox = tester.renderObject(find.byType(SelectableText)); |
| expect(scaledBox.size.height, 27.0); |
| }); |
| |
| testWidgets('can switch between textWidthBasis', (WidgetTester tester) async { |
| RenderBox findTextBox() => tester.renderObject(find.byType(SelectableText)); |
| const String text = 'I can face roll keyboardkeyboardaszzaaaaszzaaaaszzaaaaszzaaaa'; |
| await tester.pumpWidget( |
| boilerplate( |
| child: const SelectableText( |
| text, |
| textWidthBasis: TextWidthBasis.parent, |
| ), |
| ), |
| ); |
| RenderBox textBox = findTextBox(); |
| expect(textBox.size, const Size(800.0, 28.0)); |
| |
| await tester.pumpWidget( |
| boilerplate( |
| child: const SelectableText( |
| text, |
| textWidthBasis: TextWidthBasis.longestLine, |
| ), |
| ), |
| ); |
| textBox = findTextBox(); |
| expect(textBox.size, const Size(633.0, 28.0)); |
| }); |
| |
| testWidgets('can switch between textHeightBehavior', (WidgetTester tester) async { |
| const String text = 'selectable text'; |
| const TextHeightBehavior textHeightBehavior = TextHeightBehavior( |
| applyHeightToFirstAscent: false, |
| applyHeightToLastDescent: false, |
| ); |
| await tester.pumpWidget( |
| boilerplate( |
| child: const SelectableText(text), |
| ), |
| ); |
| expect(findRenderEditable(tester).textHeightBehavior, isNull); |
| |
| await tester.pumpWidget( |
| boilerplate( |
| child: const SelectableText( |
| text, |
| textHeightBehavior: textHeightBehavior, |
| ), |
| ), |
| ); |
| expect(findRenderEditable(tester).textHeightBehavior, textHeightBehavior); |
| }); |
| |
| testWidgets('Cursor blinks when showCursor is true', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| overlay( |
| child: const SelectableText( |
| 'some text', |
| showCursor: true, |
| ), |
| ), |
| ); |
| await tester.tap(find.byType(SelectableText)); |
| await tester.idle(); |
| |
| final EditableTextState editableText = tester.state(find.byType(EditableText)); |
| |
| // Check that the cursor visibility toggles after each blink interval. |
| final 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)); |
| }); |
| |
| testWidgets('selectable text selection toolbar renders correctly inside opacity', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Scaffold( |
| body: Center( |
| child: SizedBox( |
| width: 100, |
| height: 100, |
| child: Opacity( |
| opacity: 0.5, |
| child: SelectableText('selectable text'), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| // The selectWordsInRange with SelectionChangedCause.tap seems to be needed to show the toolbar. |
| final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); |
| state.renderEditable.selectWordsInRange(from: Offset.zero, cause: SelectionChangedCause.tap); |
| |
| expect(state.showToolbar(), true); |
| |
| // This is needed for the AnimatedOpacity to turn from 0 to 1 so the toolbar is visible. |
| await tester.pumpAndSettle(); |
| await tester.pump(const Duration(seconds: 1)); |
| |
| expect(find.text('Select all'), findsOneWidget); |
| }); |
| |
| testWidgets('Caret position is updated on tap', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| overlay( |
| child: const SelectableText('abc def ghi'), |
| ), |
| ); |
| final EditableText editableText = tester.widget(find.byType(EditableText)); |
| expect(editableText.controller.selection.baseOffset, -1); |
| expect(editableText.controller.selection.extentOffset, -1); |
| |
| // Tap to reposition the caret. |
| const int tapIndex = 4; |
| final Offset ePos = textOffsetToPosition(tester, tapIndex); |
| await tester.tapAt(ePos); |
| await tester.pump(); |
| |
| expect(editableText.controller.selection.baseOffset, tapIndex); |
| expect(editableText.controller.selection.extentOffset, tapIndex); |
| }); |
| |
| testWidgets('enableInteractiveSelection = false, tap', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| overlay( |
| child: const SelectableText( |
| 'abc def ghi', |
| enableInteractiveSelection: false, |
| ), |
| ), |
| ); |
| final EditableText editableText = tester.widget(find.byType(EditableText)); |
| expect(editableText.controller.selection.baseOffset, -1); |
| expect(editableText.controller.selection.extentOffset, -1); |
| |
| // Tap would ordinarily reposition the caret. |
| const int tapIndex = 4; |
| final Offset ePos = textOffsetToPosition(tester, tapIndex); |
| await tester.tapAt(ePos); |
| await tester.pump(); |
| |
| expect(editableText.controller.selection.baseOffset, -1); |
| expect(editableText.controller.selection.extentOffset, -1); |
| }); |
| |
| testWidgets('enableInteractiveSelection = false, long-press', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| overlay( |
| child: const SelectableText( |
| 'abc def ghi', |
| enableInteractiveSelection: false, |
| ), |
| ), |
| ); |
| final EditableText editableText = tester.widget(find.byType(EditableText)); |
| expect(editableText.controller.selection.baseOffset, -1); |
| expect(editableText.controller.selection.extentOffset, -1); |
| |
| // Long press the 'e' to select 'def'. |
| final Offset ePos = textOffsetToPosition(tester, 5); |
| final TestGesture gesture = await tester.startGesture(ePos, pointer: 7); |
| await tester.pump(const Duration(seconds: 2)); |
| await gesture.up(); |
| await tester.pump(); |
| |
| expect(editableText.controller.selection.isCollapsed, true); |
| expect(editableText.controller.selection.baseOffset, -1); |
| expect(editableText.controller.selection.extentOffset, -1); |
| }); |
| |
| testWidgets('Can long press to select', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| overlay( |
| child: const SelectableText('abc def ghi'), |
| ), |
| ); |
| |
| final EditableText editableText = tester.widget(find.byType(EditableText)); |
| |
| expect(editableText.controller.selection.isCollapsed, true); |
| |
| // Long press the 'e' to select 'def'. |
| const int tapIndex = 5; |
| final Offset ePos = textOffsetToPosition(tester, tapIndex); |
| await tester.longPressAt(ePos); |
| await tester.pump(); |
| |
| // 'def' is selected. |
| expect(editableText.controller.selection.baseOffset, 4); |
| expect(editableText.controller.selection.extentOffset, 7); |
| |
| // Tapping elsewhere immediately collapses and moves the cursor. |
| await tester.tapAt(textOffsetToPosition(tester, 9)); |
| await tester.pump(); |
| |
| expect(editableText.controller.selection.isCollapsed, true); |
| expect(editableText.controller.selection.baseOffset, 9); |
| }); |
| |
| testWidgets("Slight movements in longpress don't hide/show handles", (WidgetTester tester) async { |
| await tester.pumpWidget( |
| overlay( |
| child: const SelectableText('abc def ghi'), |
| ), |
| ); |
| // Long press the 'e' to select 'def', but don't release the gesture. |
| final Offset ePos = textOffsetToPosition(tester, 5); |
| final TestGesture gesture = await tester.startGesture(ePos, pointer: 7); |
| await tester.pump(const Duration(seconds: 2)); |
| await tester.pumpAndSettle(); |
| |
| // Handles are shown |
| final Finder fadeFinder = find.byType(FadeTransition); |
| expect(fadeFinder, findsNWidgets(2)); // 2 handles, 1 toolbar |
| FadeTransition handle = tester.widget(fadeFinder.at(0)); |
| expect(handle.opacity.value, equals(1.0)); |
| |
| // Move the gesture very slightly |
| await gesture.moveBy(const Offset(1.0, 1.0)); |
| await tester.pump(SelectionOverlay.fadeDuration * 0.5); |
| handle = tester.widget(fadeFinder.at(0)); |
| |
| // The handle should still be fully opaque. |
| expect(handle.opacity.value, equals(1.0)); |
| }); |
| |
| testWidgets('Mouse long press is just like a tap', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| overlay( |
| child: const SelectableText('abc def ghi'), |
| ), |
| ); |
| |
| final EditableText editableText = tester.widget(find.byType(EditableText)); |
| |
| // Long press the 'e' using a mouse device. |
| const int eIndex = 5; |
| final Offset ePos = textOffsetToPosition(tester, eIndex); |
| final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); |
| await tester.pump(const Duration(seconds: 2)); |
| await gesture.up(); |
| await tester.pump(); |
| |
| // The cursor is placed just like a regular tap. |
| expect(editableText.controller.selection.baseOffset, eIndex); |
| expect(editableText.controller.selection.extentOffset, eIndex); |
| }); |
| |
| testWidgets('selectable text basic', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| overlay( |
| child: const SelectableText('selectable'), |
| ), |
| ); |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); |
| // selectable text cannot open keyboard. |
| await tester.showKeyboard(find.byType(SelectableText)); |
| expect(tester.testTextInput.hasAnyClients, false); |
| await skipPastScrollingAnimation(tester); |
| |
| expect(editableTextWidget.controller.selection.isCollapsed, true); |
| |
| await tester.tap(find.byType(SelectableText)); |
| await tester.pump(); |
| |
| final EditableTextState editableText = tester.state(find.byType(EditableText)); |
| // Collapse selection should not paint. |
| expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); |
| // Long press on the 't' character of text 'selectable' to show context menu. |
| const int dIndex = 5; |
| final Offset dPos = textOffsetToPosition(tester, dIndex); |
| await tester.longPressAt(dPos); |
| await tester.pump(); |
| |
| // Context menu should not have paste and cut. |
| expect(find.text('Copy'), findsOneWidget); |
| expect(find.text('Paste'), findsNothing); |
| expect(find.text('Cut'), findsNothing); |
| }); |
| |
| testWidgets('selectable text can disable toolbar options', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| overlay( |
| child: const SelectableText( |
| 'a selectable text', |
| toolbarOptions: ToolbarOptions( |
| selectAll: true, |
| ), |
| ), |
| ), |
| ); |
| const int dIndex = 5; |
| final Offset dPos = textOffsetToPosition(tester, dIndex); |
| await tester.longPressAt(dPos); |
| await tester.pump(); |
| // Context menu should not have copy. |
| expect(find.text('Copy'), findsNothing); |
| expect(find.text('Select all'), findsOneWidget); |
| }); |
| |
| testWidgets('Can select text by dragging with a mouse', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: SelectableText( |
| 'abc def ghi', |
| dragStartBehavior: DragStartBehavior.down, |
| ), |
| ), |
| ), |
| ); |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| final Offset ePos = textOffsetToPosition(tester, 5); |
| final Offset gPos = textOffsetToPosition(tester, 8); |
| |
| final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); |
| await tester.pump(); |
| await gesture.moveTo(gPos); |
| await tester.pump(); |
| await gesture.up(); |
| await tester.pumpAndSettle(); |
| |
| expect(controller.selection.baseOffset, 5); |
| expect(controller.selection.extentOffset, 8); |
| }); |
| |
| testWidgets('Continuous dragging does not cause flickering', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: SelectableText( |
| 'abc def ghi', |
| dragStartBehavior: DragStartBehavior.down, |
| style: TextStyle(fontFamily: 'Ahem', fontSize: 10.0), |
| ), |
| ), |
| ), |
| ); |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| int selectionChangedCount = 0; |
| |
| controller.addListener(() { |
| selectionChangedCount++; |
| }); |
| |
| final Offset cPos = textOffsetToPosition(tester, 2); // Index of 'c'. |
| final Offset gPos = textOffsetToPosition(tester, 8); // Index of 'g'. |
| final Offset hPos = textOffsetToPosition(tester, 9); // Index of 'h'. |
| |
| // Drag from 'c' to 'g'. |
| final TestGesture gesture = await tester.startGesture(cPos, kind: PointerDeviceKind.mouse); |
| await tester.pump(); |
| await gesture.moveTo(gPos); |
| await tester.pumpAndSettle(); |
| |
| expect(selectionChangedCount, isNonZero); |
| selectionChangedCount = 0; |
| expect(controller.selection.baseOffset, 2); |
| expect(controller.selection.extentOffset, 8); |
| |
| // Tiny movement shouldn't cause text selection to change. |
| await gesture.moveTo(gPos + const Offset(4.0, 0.0)); |
| await tester.pumpAndSettle(); |
| expect(selectionChangedCount, 0); |
| |
| // Now a text selection change will occur after a significant movement. |
| await gesture.moveTo(hPos); |
| await tester.pump(); |
| await gesture.up(); |
| await tester.pumpAndSettle(); |
| |
| expect(selectionChangedCount, 1); |
| expect(controller.selection.baseOffset, 2); |
| expect(controller.selection.extentOffset, 9); |
| }); |
| |
| testWidgets('Dragging in opposite direction also works', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: SelectableText( |
| 'abc def ghi', |
| dragStartBehavior: DragStartBehavior.down, |
| ), |
| ), |
| ), |
| ); |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| final Offset ePos = textOffsetToPosition(tester, 5); |
| final Offset gPos = textOffsetToPosition(tester, 8); |
| |
| final TestGesture gesture = await tester.startGesture(gPos, kind: PointerDeviceKind.mouse); |
| await tester.pump(); |
| await gesture.moveTo(ePos); |
| await tester.pump(); |
| await gesture.up(); |
| await tester.pumpAndSettle(); |
| |
| expect(controller.selection.baseOffset, 8); |
| expect(controller.selection.extentOffset, 5); |
| }); |
| |
| testWidgets('Slow mouse dragging also selects text', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: SelectableText( |
| 'abc def ghi', |
| dragStartBehavior: DragStartBehavior.down, |
| ), |
| ), |
| ), |
| ); |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| final Offset ePos = textOffsetToPosition(tester, 5); |
| final Offset gPos = textOffsetToPosition(tester,8); |
| |
| final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); |
| await tester.pump(const Duration(seconds: 2)); |
| await gesture.moveTo(gPos); |
| await tester.pump(); |
| await gesture.up(); |
| |
| expect(controller.selection.baseOffset, 5); |
| expect(controller.selection.extentOffset,8); |
| }); |
| |
| testWidgets('Can drag handles to change selection', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: SelectableText( |
| 'abc def ghi', |
| dragStartBehavior: DragStartBehavior.down, |
| ), |
| ), |
| ), |
| ); |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| // Long press the 'e' to select 'def'. |
| final Offset ePos = textOffsetToPosition(tester, 5); |
| TestGesture gesture = await tester.startGesture(ePos, pointer: 7); |
| await tester.pump(const Duration(seconds: 2)); |
| await gesture.up(); |
| await tester.pump(); |
| await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero |
| |
| final TextSelection selection = controller.selection; |
| expect(selection.baseOffset, 4); |
| expect(selection.extentOffset, 7); |
| |
| final RenderEditable renderEditable = findRenderEditable(tester); |
| final List<TextSelectionPoint> endpoints = globalize( |
| renderEditable.getEndpointsForSelection(selection), |
| renderEditable, |
| ); |
| expect(endpoints.length, 2); |
| |
| // Drag the right handle 2 letters to the right. |
| // We use a small offset because the endpoint is on the very corner |
| // of the handle. |
| Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); |
| Offset newHandlePos = textOffsetToPosition(tester, 11); |
| gesture = await tester.startGesture(handlePos, pointer: 7); |
| await tester.pump(); |
| await gesture.moveTo(newHandlePos); |
| await tester.pump(); |
| await gesture.up(); |
| await tester.pump(); |
| |
| expect(controller.selection.baseOffset, 4); |
| expect(controller.selection.extentOffset, 11); |
| |
| // Drag the left handle 2 letters to the left. |
| handlePos = endpoints[0].point + const Offset(-1.0, 1.0); |
| newHandlePos = textOffsetToPosition(tester, 0); |
| gesture = await tester.startGesture(handlePos, pointer: 7); |
| await tester.pump(); |
| await gesture.moveTo(newHandlePos); |
| await tester.pump(); |
| await gesture.up(); |
| await tester.pump(); |
| |
| expect(controller.selection.baseOffset, 0); |
| expect(controller.selection.extentOffset, 11); |
| }); |
| |
| testWidgets('Dragging handles calls onSelectionChanged', (WidgetTester tester) async { |
| TextSelection? newSelection; |
| await tester.pumpWidget( |
| MaterialApp( |
| home: Material( |
| child: SelectableText( |
| 'abc def ghi', |
| dragStartBehavior: DragStartBehavior.down, |
| onSelectionChanged: (TextSelection selection, SelectionChangedCause? cause) { |
| expect(newSelection, isNull); |
| newSelection = selection; |
| }, |
| ), |
| ), |
| ), |
| ); |
| |
| // Long press the 'e' to select 'def'. |
| final Offset ePos = textOffsetToPosition(tester, 5); |
| TestGesture gesture = await tester.startGesture(ePos, pointer: 7); |
| await tester.pump(const Duration(seconds: 2)); |
| await gesture.up(); |
| await tester.pump(); |
| await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero |
| |
| expect(newSelection!.baseOffset, 4); |
| expect(newSelection!.extentOffset, 7); |
| |
| final RenderEditable renderEditable = findRenderEditable(tester); |
| final List<TextSelectionPoint> endpoints = globalize( |
| renderEditable.getEndpointsForSelection(newSelection!), |
| renderEditable, |
| ); |
| expect(endpoints.length, 2); |
| newSelection = null; |
| |
| // Drag the right handle 2 letters to the right. |
| // We use a small offset because the endpoint is on the very corner |
| // of the handle. |
| final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); |
| final Offset newHandlePos = textOffsetToPosition(tester, 9); |
| gesture = await tester.startGesture(handlePos, pointer: 7); |
| await tester.pump(); |
| await gesture.moveTo(newHandlePos); |
| await tester.pump(); |
| await gesture.up(); |
| await tester.pump(); |
| |
| expect(newSelection!.baseOffset, 4); |
| expect(newSelection!.extentOffset, 9); |
| }); |
| |
| testWidgets('Cannot drag one handle past the other', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: SelectableText( |
| 'abc def ghi', |
| dragStartBehavior: DragStartBehavior.down, |
| ), |
| ), |
| ), |
| ); |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| // Long press the 'e' to select 'def'. |
| final Offset ePos = textOffsetToPosition(tester, 5); // Position before 'e'. |
| TestGesture gesture = await tester.startGesture(ePos, pointer: 7); |
| await tester.pump(const Duration(seconds: 2)); |
| await gesture.up(); |
| await tester.pump(); |
| await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero |
| |
| final TextSelection selection = controller.selection; |
| expect(selection.baseOffset, 4); |
| expect(selection.extentOffset, 7); |
| |
| final RenderEditable renderEditable = findRenderEditable(tester); |
| final List<TextSelectionPoint> endpoints = globalize( |
| renderEditable.getEndpointsForSelection(selection), |
| renderEditable, |
| ); |
| expect(endpoints.length, 2); |
| |
| // Drag the right handle until there's only 1 char selected. |
| // We use a small offset because the endpoint is on the very corner |
| // of the handle. |
| final Offset handlePos = endpoints[1].point + const Offset(4.0, 0.0); |
| Offset newHandlePos = textOffsetToPosition(tester, 5); // Position before 'e'. |
| gesture = await tester.startGesture(handlePos, pointer: 7); |
| await tester.pump(); |
| await gesture.moveTo(newHandlePos); |
| await tester.pump(); |
| |
| expect(controller.selection.baseOffset, 4); |
| expect(controller.selection.extentOffset, 5); |
| |
| newHandlePos = textOffsetToPosition(tester, 2); // Position before 'c'. |
| await gesture.moveTo(newHandlePos); |
| await tester.pump(); |
| await gesture.up(); |
| await tester.pump(); |
| |
| expect(controller.selection.baseOffset, 4); |
| // The selection doesn't move beyond the left handle. There's always at |
| // least 1 char selected. |
| expect(controller.selection.extentOffset, 5); |
| }); |
| |
| testWidgets('Can use selection toolbar', (WidgetTester tester) async { |
| const String testValue = 'abc def ghi'; |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: SelectableText( |
| testValue, |
| ), |
| ), |
| ), |
| ); |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| // Tap the selection handle to bring up the "paste / select all" menu. |
| await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); |
| await tester.pump(); |
| await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero |
| final RenderEditable renderEditable = findRenderEditable(tester); |
| final List<TextSelectionPoint> endpoints = globalize( |
| renderEditable.getEndpointsForSelection(controller.selection), |
| renderEditable, |
| ); |
| // Tapping on the part of the handle's GestureDetector where it overlaps |
| // with the text itself does not show the menu, so add a small vertical |
| // offset to tap below the text. |
| await tester.tapAt(endpoints[0].point + const Offset(1.0, 13.0)); |
| await tester.pump(); |
| await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero |
| |
| // Select all should select all the text. |
| await tester.tap(find.text('Select all')); |
| await tester.pump(); |
| expect(controller.selection.baseOffset, 0); |
| expect(controller.selection.extentOffset, testValue.length); |
| |
| // Copy should reset the selection. |
| await tester.tap(find.text('Copy')); |
| await skipPastScrollingAnimation(tester); |
| expect(controller.selection.isCollapsed, true); |
| }); |
| |
| testWidgets('Selectable height with maxLine', (WidgetTester tester) async { |
| await tester.pumpWidget(selectableTextBuilder()); |
| |
| RenderBox findTextBox() => tester.renderObject(find.byType(SelectableText)); |
| |
| final RenderBox textBox = findTextBox(); |
| final Size emptyInputSize = textBox.size; |
| |
| await tester.pumpWidget(selectableTextBuilder(text: 'No wrapping here.')); |
| expect(findTextBox(), equals(textBox)); |
| expect(textBox.size.height, emptyInputSize.height); |
| |
| // Even when entering multiline text, SelectableText doesn't grow. It's a single |
| // line input. |
| await tester.pumpWidget(selectableTextBuilder(text: kThreeLines)); |
| expect(findTextBox(), equals(textBox)); |
| expect(textBox.size.height, emptyInputSize.height); |
| |
| // maxLines: 3 makes the SelectableText 3 lines tall |
| await tester.pumpWidget(selectableTextBuilder(maxLines: 3)); |
| expect(findTextBox(), equals(textBox)); |
| expect(textBox.size.height, greaterThan(emptyInputSize.height)); |
| |
| final Size threeLineInputSize = textBox.size; |
| |
| // Filling with 3 lines of text stays the same size |
| await tester.pumpWidget(selectableTextBuilder(text: kThreeLines, maxLines: 3)); |
| expect(findTextBox(), equals(textBox)); |
| expect(textBox.size.height, threeLineInputSize.height); |
| |
| // An extra line won't increase the size because we max at 3. |
| await tester.pumpWidget(selectableTextBuilder(text: kMoreThanFourLines, maxLines: 3)); |
| expect(findTextBox(), equals(textBox)); |
| expect(textBox.size.height, threeLineInputSize.height); |
| |
| // But now it will... but it will max at four |
| await tester.pumpWidget(selectableTextBuilder(text: kMoreThanFourLines, maxLines: 4)); |
| expect(findTextBox(), equals(textBox)); |
| expect(textBox.size.height, greaterThan(threeLineInputSize.height)); |
| |
| final Size fourLineInputSize = textBox.size; |
| |
| // Now it won't max out until the end |
| await tester.pumpWidget(selectableTextBuilder(maxLines: null)); |
| expect(findTextBox(), equals(textBox)); |
| expect(textBox.size, equals(emptyInputSize)); |
| await tester.pumpWidget(selectableTextBuilder(text: kThreeLines, maxLines: null)); |
| expect(textBox.size.height, equals(threeLineInputSize.height)); |
| await tester.pumpWidget(selectableTextBuilder(text: kMoreThanFourLines, maxLines: null)); |
| expect(textBox.size.height, greaterThan(fourLineInputSize.height)); |
| }); |
| |
| testWidgets('Can drag handles to change selection in multiline', (WidgetTester tester) async { |
| const String testValue = kThreeLines; |
| await tester.pumpWidget( |
| overlay( |
| child: const SelectableText( |
| testValue, |
| dragStartBehavior: DragStartBehavior.down, |
| style: TextStyle(color: Colors.black, fontSize: 34.0), |
| maxLines: 3, |
| ), |
| ), |
| ); |
| |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| // Check that the text spans multiple lines. |
| final Offset firstPos = textOffsetToPosition(tester, testValue.indexOf('First')); |
| final Offset secondPos = textOffsetToPosition(tester, testValue.indexOf('Second')); |
| final Offset thirdPos = textOffsetToPosition(tester, testValue.indexOf('Third')); |
| final Offset middleStringPos = textOffsetToPosition(tester, testValue.indexOf('irst')); |
| |
| expect(firstPos.dx, 24.5); |
| expect(secondPos.dx, 24.5); |
| expect(thirdPos.dx, 24.5); |
| expect(middleStringPos.dx, 58.5); |
| expect(firstPos.dx, secondPos.dx); |
| expect(firstPos.dx, thirdPos.dx); |
| expect(firstPos.dy, lessThan(secondPos.dy)); |
| expect(secondPos.dy, lessThan(thirdPos.dy)); |
| |
| // Long press the 'n' in 'until' to select the word. |
| final Offset 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(); |
| await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero |
| |
| expect(controller.selection.baseOffset, 39); |
| expect(controller.selection.extentOffset, 44); |
| |
| final RenderEditable renderEditable = findRenderEditable(tester); |
| final List<TextSelectionPoint> endpoints = globalize( |
| renderEditable.getEndpointsForSelection(controller.selection), |
| renderEditable, |
| ); |
| expect(endpoints.length, 2); |
| |
| // Drag the right handle to the third line, just after 'Third'. |
| Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); |
| Offset 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(controller.selection.baseOffset, 39); |
| expect(controller.selection.extentOffset, 50); |
| |
| // Drag the left handle to the first line, just after 'First'. |
| handlePos = endpoints[0].point + const 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.pump(); |
| |
| expect(controller.selection.baseOffset, 5); |
| expect(controller.selection.extentOffset, 50); |
| await tester.tap(find.text('Copy')); |
| await tester.pump(); |
| expect(controller.selection.isCollapsed, true); |
| }); |
| |
| testWidgets('Can scroll multiline input', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| overlay( |
| child: const SelectableText( |
| kMoreThanFourLines, |
| dragStartBehavior: DragStartBehavior.down, |
| style: TextStyle(color: Colors.black, fontSize: 34.0), |
| maxLines: 2, |
| ), |
| ), |
| ); |
| |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); |
| final TextEditingController controller = editableTextWidget.controller; |
| RenderBox findInputBox() => tester.renderObject(find.byType(SelectableText)); |
| final RenderBox inputBox = findInputBox(); |
| |
| // Check that the last line of text is not displayed. |
| final Offset firstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First')); |
| final Offset fourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth')); |
| expect(firstPos.dx, 0.0); |
| expect(fourthPos.dx, 0.0); |
| expect(firstPos.dx, fourthPos.dx); |
| expect(firstPos.dy, lessThan(fourthPos.dy)); |
| expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(firstPos)), isTrue); |
| expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(fourthPos)), isFalse); |
| |
| TestGesture gesture = await tester.startGesture(firstPos, pointer: 7); |
| await tester.pump(); |
| await gesture.moveBy(const Offset(0.0, -1000.0)); |
| await tester.pump(const Duration(seconds: 1)); |
| // Wait and drag again to trigger https://github.com/flutter/flutter/issues/6329 |
| // (No idea why this is necessary, but the bug wouldn't repro without it.) |
| await gesture.moveBy(const Offset(0.0, -1000.0)); |
| await tester.pump(const Duration(seconds: 1)); |
| await gesture.up(); |
| await tester.pump(); |
| await tester.pump(const Duration(seconds: 1)); |
| |
| // Now the first line is scrolled up, and the fourth line is visible. |
| Offset newFirstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First')); |
| Offset newFourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth')); |
| |
| expect(newFirstPos.dy, lessThan(firstPos.dy)); |
| expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFirstPos)), isFalse); |
| expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFourthPos)), isTrue); |
| |
| // Now try scrolling by dragging the selection handle. |
| // Long press the middle of the word "won't" in the fourth line. |
| final Offset selectedWordPos = textOffsetToPosition( |
| tester, |
| kMoreThanFourLines.indexOf('Fourth line') + 14, |
| ); |
| |
| gesture = await tester.startGesture(selectedWordPos, pointer: 7); |
| await tester.pump(const Duration(seconds: 1)); |
| await gesture.up(); |
| await tester.pump(); |
| await tester.pump(const Duration(seconds: 1)); |
| |
| expect(controller.selection.base.offset, 77); |
| expect(controller.selection.extent.offset, 82); |
| // Sanity check for the word selected is the intended one. |
| expect( |
| controller.text.substring(controller.selection.baseOffset, controller.selection.extentOffset), |
| "won't", |
| ); |
| |
| final RenderEditable renderEditable = findRenderEditable(tester); |
| final List<TextSelectionPoint> endpoints = globalize( |
| renderEditable.getEndpointsForSelection(controller.selection), |
| renderEditable, |
| ); |
| expect(endpoints.length, 2); |
| |
| // Drag the left handle to the first line, just after 'First'. |
| final Offset handlePos = endpoints[0].point + const Offset(-1, 1); |
| final Offset newHandlePos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First') + 5); |
| gesture = await tester.startGesture(handlePos, pointer: 7); |
| await tester.pump(const Duration(seconds: 1)); |
| await gesture.moveTo(newHandlePos + const Offset(0.0, -10.0)); |
| await tester.pump(const Duration(seconds: 1)); |
| await gesture.up(); |
| await tester.pump(const Duration(seconds: 1)); |
| |
| // The text should have scrolled up with the handle to keep the active |
| // cursor visible, back to its original position. |
| newFirstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First')); |
| newFourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth')); |
| expect(newFirstPos.dy, firstPos.dy); |
| expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFirstPos)), isTrue); |
| expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFourthPos)), isFalse); |
| }); |
| |
| testWidgets('minLines cannot be greater than maxLines', (WidgetTester tester) async { |
| expect( |
| () async { |
| await tester.pumpWidget( |
| overlay( |
| child: SizedBox( |
| width: 300.0, |
| child: SelectableText( |
| 'abcd', |
| minLines: 4, |
| maxLines: 3, |
| ), |
| ), |
| ), |
| ); |
| }, |
| throwsA(isA<AssertionError>().having( |
| (AssertionError error) => error.toString(), |
| '.toString()', |
| contains("minLines can't be greater than maxLines"), |
| )), |
| ); |
| }); |
| |
| testWidgets('Selectable height with minLine', (WidgetTester tester) async { |
| await tester.pumpWidget(selectableTextBuilder()); |
| |
| RenderBox findTextBox() => tester.renderObject(find.byType(SelectableText)); |
| |
| final RenderBox textBox = findTextBox(); |
| final Size emptyInputSize = textBox.size; |
| |
| // Even if the text is a one liner, minimum height of SelectableText will determined by minLines |
| await tester.pumpWidget(selectableTextBuilder(text: 'No wrapping here.', minLines: 2, maxLines: 3)); |
| expect(findTextBox(), equals(textBox)); |
| expect(textBox.size.height, emptyInputSize.height * 2); |
| }); |
| |
| testWidgets('Can align to center', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| overlay( |
| child: const SizedBox( |
| width: 300.0, |
| child: SelectableText( |
| 'abcd', |
| textAlign: TextAlign.center, |
| ), |
| ), |
| ), |
| ); |
| |
| final RenderEditable editable = findRenderEditable(tester); |
| |
| final Offset topLeft = editable.localToGlobal( |
| editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, |
| ); |
| |
| expect(topLeft.dx, equals(399.0)); |
| }); |
| |
| testWidgets('Can align to center within center', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| overlay( |
| child: const SizedBox( |
| width: 300.0, |
| child: Center( |
| child: SelectableText( |
| 'abcd', |
| textAlign: TextAlign.center, |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| final RenderEditable editable = findRenderEditable(tester); |
| |
| final Offset topLeft = editable.localToGlobal( |
| editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, |
| ); |
| |
| expect(topLeft.dx, equals(399.0)); |
| }); |
| |
| testWidgets('Selectable text is skipped during focus traversal', (WidgetTester tester) async { |
| final FocusNode firstFieldFocus = FocusNode(); |
| final FocusNode lastFieldFocus = FocusNode(); |
| |
| await tester.pumpWidget( |
| MaterialApp( |
| home: Material( |
| child: Center( |
| child: Column( |
| children: <Widget>[ |
| TextField( |
| focusNode: firstFieldFocus, |
| autofocus: true, |
| ), |
| const SelectableText('some text'), |
| TextField( |
| focusNode: lastFieldFocus, |
| ), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| await tester.pump(); |
| |
| expect(firstFieldFocus.hasFocus, isTrue); |
| expect(lastFieldFocus.hasFocus, isFalse); |
| |
| firstFieldFocus.nextFocus(); |
| await tester.pump(); |
| |
| // expecting focus to skip straight to the second field |
| expect(firstFieldFocus.hasFocus, isFalse); |
| expect(lastFieldFocus.hasFocus, isTrue); |
| }); |
| |
| testWidgets('Selectable text identifies as text field in semantics', (WidgetTester tester) async { |
| final SemanticsTester semantics = SemanticsTester(tester); |
| |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: Center( |
| child: SelectableText('some text'), |
| ), |
| ), |
| ), |
| ); |
| |
| expect( |
| semantics, |
| includesNodeWith( |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isTextField, |
| SemanticsFlag.isReadOnly, |
| SemanticsFlag.isMultiline, |
| ], |
| ), |
| ); |
| |
| semantics.dispose(); |
| }); |
| |
| testWidgets('Selectable text rich text with spell out in semantics', (WidgetTester tester) async { |
| final SemanticsTester semantics = SemanticsTester(tester); |
| |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: Center( |
| child: SelectableText.rich(TextSpan(text: 'some text', spellOut: true)), |
| ), |
| ), |
| ), |
| ); |
| |
| expect( |
| semantics, |
| includesNodeWith( |
| attributedValue: AttributedString( |
| 'some text', |
| attributes: <StringAttribute>[ |
| SpellOutStringAttribute(range: const TextRange(start: 0, end:9)), |
| ], |
| ), |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isTextField, |
| SemanticsFlag.isReadOnly, |
| SemanticsFlag.isMultiline, |
| ], |
| ), |
| ); |
| |
| semantics.dispose(); |
| }); |
| |
| testWidgets('Selectable text rich text with locale in semantics', (WidgetTester tester) async { |
| final SemanticsTester semantics = SemanticsTester(tester); |
| |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: Center( |
| child: SelectableText.rich(TextSpan(text: 'some text', locale: Locale('es', 'MX'))), |
| ), |
| ), |
| ), |
| ); |
| |
| expect( |
| semantics, |
| includesNodeWith( |
| attributedValue: AttributedString( |
| 'some text', |
| attributes: <StringAttribute>[ |
| LocaleStringAttribute(range: const TextRange(start: 0, end:9), locale: const Locale('es', 'MX')), |
| ], |
| ), |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isTextField, |
| SemanticsFlag.isReadOnly, |
| SemanticsFlag.isMultiline, |
| ], |
| ), |
| ); |
| |
| semantics.dispose(); |
| }); |
| |
| testWidgets('Selectable rich text with gesture recognizer has correct semantics', (WidgetTester tester) async { |
| final SemanticsTester semantics = SemanticsTester(tester); |
| await tester.pumpWidget( |
| overlay( |
| child: SelectableText.rich( |
| TextSpan( |
| children: <TextSpan>[ |
| const TextSpan(text: 'text'), |
| TextSpan( |
| text: 'link', |
| recognizer: TapGestureRecognizer() |
| ..onTap = () { }, |
| ), |
| ], |
| ), |
| ), |
| ), |
| ); |
| |
| expect(semantics, hasSemantics(TestSemantics.root( |
| children: <TestSemantics>[ |
| TestSemantics.rootChild( |
| id: 1, |
| actions: <SemanticsAction>[SemanticsAction.longPress], |
| textDirection: TextDirection.ltr, |
| children: <TestSemantics>[ |
| TestSemantics( |
| id: 2, |
| children: <TestSemantics>[ |
| TestSemantics( |
| id: 3, |
| label: 'text', |
| textDirection: TextDirection.ltr, |
| ), |
| TestSemantics( |
| id: 4, |
| flags: <SemanticsFlag>[SemanticsFlag.isLink], |
| actions: <SemanticsAction>[SemanticsAction.tap], |
| label: 'link', |
| textDirection: TextDirection.ltr, |
| ), |
| ], |
| ), |
| ], |
| ), |
| ], |
| ), ignoreTransform: true, ignoreRect: true)); |
| |
| semantics.dispose(); |
| }); |
| |
| group('Keyboard Tests', () { |
| late TextEditingController controller; |
| |
| Future<void> setupWidget(WidgetTester tester, String text) async { |
| final FocusNode focusNode = FocusNode(); |
| await tester.pumpWidget( |
| MaterialApp( |
| home: Material( |
| child: RawKeyboardListener( |
| focusNode: focusNode, |
| child: SelectableText( |
| text, |
| maxLines: 3, |
| ), |
| ), |
| ), |
| ), |
| ); |
| await tester.tap(find.byType(SelectableText)); |
| await tester.pumpAndSettle(); |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); |
| controller = editableTextWidget.controller; |
| } |
| |
| testWidgets('Shift test 1', (WidgetTester tester) async { |
| await setupWidget(tester, 'a big house'); |
| |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); |
| expect(controller.selection.extentOffset - controller.selection.baseOffset, -1); |
| }, variant: KeySimulatorTransitModeVariant.all()); |
| |
| testWidgets('Shift test 2', (WidgetTester tester) async { |
| await setupWidget(tester, 'abcdefghi'); |
| |
| controller.selection = const TextSelection.collapsed(offset: 3); |
| await tester.pump(); |
| |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); |
| await tester.pumpAndSettle(); |
| expect(controller.selection.extentOffset - controller.selection.baseOffset, 1); |
| }, variant: KeySimulatorTransitModeVariant.all()); |
| |
| testWidgets('Control Shift test', (WidgetTester tester) async { |
| await setupWidget(tester, 'their big house'); |
| |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.control); |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); |
| |
| await tester.pumpAndSettle(); |
| |
| expect(controller.selection.extentOffset - controller.selection.baseOffset, -5); |
| }, variant: KeySimulatorTransitModeVariant.all()); |
| |
| testWidgets('Down and up test', (WidgetTester tester) async { |
| await setupWidget(tester, 'a big house'); |
| |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowUp); |
| await tester.pumpAndSettle(); |
| |
| expect(controller.selection.extentOffset - controller.selection.baseOffset, -11); |
| |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowUp); |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown); |
| await tester.pumpAndSettle(); |
| |
| expect(controller.selection.extentOffset - controller.selection.baseOffset, 0); |
| }, variant: KeySimulatorTransitModeVariant.all()); |
| |
| testWidgets('Down and up test 2', (WidgetTester tester) async { |
| await setupWidget(tester, 'a big house\njumped over a mouse\nOne more line yay'); |
| |
| controller.selection = const TextSelection.collapsed(offset: 0); |
| await tester.pump(); |
| |
| for (int i = 0; i < 5; i += 1) { |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); |
| await tester.pumpAndSettle(); |
| } |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); |
| await tester.pumpAndSettle(); |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); |
| await tester.pumpAndSettle(); |
| |
| expect(controller.selection.extentOffset - controller.selection.baseOffset, 12); |
| |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); |
| await tester.pumpAndSettle(); |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); |
| await tester.pumpAndSettle(); |
| |
| expect(controller.selection.extentOffset - controller.selection.baseOffset, 32); |
| |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); |
| await tester.pumpAndSettle(); |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); |
| await tester.pumpAndSettle(); |
| |
| expect(controller.selection.extentOffset - controller.selection.baseOffset, 12); |
| |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); |
| await tester.pumpAndSettle(); |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); |
| await tester.pumpAndSettle(); |
| |
| expect(controller.selection.extentOffset - controller.selection.baseOffset, 0); |
| |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); |
| await tester.pumpAndSettle(); |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); |
| await tester.pumpAndSettle(); |
| |
| expect(controller.selection.extentOffset - controller.selection.baseOffset, -5); |
| }, variant: KeySimulatorTransitModeVariant.all()); |
| }); |
| |
| testWidgets('Copy test', (WidgetTester tester) async { |
| final FocusNode focusNode = FocusNode(); |
| |
| String clipboardContent = ''; |
| tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { |
| if (methodCall.method == 'Clipboard.setData') { |
| clipboardContent = (methodCall.arguments as Map<String, dynamic>)['text'] as String; |
| } else if (methodCall.method == 'Clipboard.getData') { |
| return <String, dynamic>{'text': clipboardContent}; |
| } |
| return null; |
| }); |
| const String testValue = 'a big house\njumped over a mouse'; |
| await tester.pumpWidget( |
| MaterialApp( |
| home: Material( |
| child: RawKeyboardListener( |
| focusNode: focusNode, |
| child: const SelectableText( |
| testValue, |
| maxLines: 3, |
| ), |
| ), |
| ), |
| ), |
| ); |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); |
| final TextEditingController controller = editableTextWidget.controller; |
| focusNode.requestFocus(); |
| await tester.pump(); |
| |
| await tester.tap(find.byType(SelectableText)); |
| await tester.pumpAndSettle(); |
| |
| controller.selection = const TextSelection.collapsed(offset: 0); |
| await tester.pump(); |
| |
| // Select the first 5 characters |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| for (int i = 0; i < 5; i += 1) { |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); |
| await tester.pumpAndSettle(); |
| } |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); |
| |
| // Copy them |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.controlRight); |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC); |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC); |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight); |
| await tester.pumpAndSettle(); |
| |
| expect(clipboardContent, 'a big'); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); |
| await tester.pumpAndSettle(); |
| }, variant: KeySimulatorTransitModeVariant.all()); |
| |
| testWidgets('Select all test', (WidgetTester tester) async { |
| final FocusNode focusNode = FocusNode(); |
| const String testValue = 'a big house\njumped over a mouse'; |
| await tester.pumpWidget( |
| MaterialApp( |
| home: Material( |
| child: RawKeyboardListener( |
| focusNode: focusNode, |
| child: const SelectableText( |
| testValue, |
| maxLines: 3, |
| ), |
| ), |
| ), |
| ), |
| ); |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); |
| final TextEditingController controller = editableTextWidget.controller; |
| focusNode.requestFocus(); |
| await tester.pump(); |
| |
| await tester.tap(find.byType(SelectableText)); |
| await tester.pumpAndSettle(); |
| |
| // Select All |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.control); |
| await tester.sendKeyEvent(LogicalKeyboardKey.keyA); |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.control); |
| await tester.pumpAndSettle(); |
| |
| expect(controller.selection.baseOffset, 0); |
| expect(controller.selection.extentOffset, 31); |
| }, variant: KeySimulatorTransitModeVariant.all()); |
| |
| testWidgets('keyboard selection should call onSelectionChanged', (WidgetTester tester) async { |
| final FocusNode focusNode = FocusNode(); |
| TextSelection? newSelection; |
| const String testValue = 'a big house\njumped over a mouse'; |
| await tester.pumpWidget( |
| MaterialApp( |
| home: Material( |
| child: RawKeyboardListener( |
| focusNode: focusNode, |
| child: SelectableText( |
| testValue, |
| maxLines: 3, |
| onSelectionChanged: (TextSelection selection, SelectionChangedCause? cause) { |
| expect(newSelection, isNull); |
| newSelection = selection; |
| }, |
| ), |
| ), |
| ), |
| ), |
| ); |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); |
| final TextEditingController controller = editableTextWidget.controller; |
| focusNode.requestFocus(); |
| await tester.pump(); |
| |
| await tester.tap(find.byType(SelectableText)); |
| await tester.pumpAndSettle(); |
| expect(newSelection!.baseOffset, 31); |
| expect(newSelection!.extentOffset, 31); |
| newSelection = null; |
| |
| controller.selection = const TextSelection.collapsed(offset: 0); |
| await tester.pump(); |
| |
| // Select the first 5 characters |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| for (int i = 0; i < 5; i += 1) { |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); |
| await tester.pumpAndSettle(); |
| expect(newSelection!.baseOffset, 0); |
| expect(newSelection!.extentOffset, i + 1); |
| newSelection = null; |
| } |
| }, variant: KeySimulatorTransitModeVariant.all()); |
| |
| testWidgets('Changing positions of selectable text', (WidgetTester tester) async { |
| final FocusNode focusNode = FocusNode(); |
| final List<RawKeyEvent> events = <RawKeyEvent>[]; |
| |
| final Key key1 = UniqueKey(); |
| final Key key2 = UniqueKey(); |
| |
| await tester.pumpWidget( |
| MaterialApp( |
| home: |
| Material( |
| child: RawKeyboardListener( |
| focusNode: focusNode, |
| onKey: events.add, |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.stretch, |
| children: <Widget>[ |
| SelectableText( |
| 'a big house', |
| key: key1, |
| maxLines: 3, |
| ), |
| SelectableText( |
| 'another big house', |
| key: key2, |
| maxLines: 3, |
| ), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); |
| TextEditingController c1 = editableTextWidget.controller; |
| |
| await tester.tap(find.byType(EditableText).first); |
| await tester.pumpAndSettle(); |
| |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| for (int i = 0; i < 5; i += 1) { |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); |
| await tester.pumpAndSettle(); |
| } |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); |
| await tester.pumpAndSettle(); |
| |
| expect(c1.selection.extentOffset - c1.selection.baseOffset, -5); |
| |
| await tester.pumpWidget( |
| MaterialApp( |
| home: |
| Material( |
| child: RawKeyboardListener( |
| focusNode: focusNode, |
| onKey: events.add, |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.stretch, |
| children: <Widget>[ |
| SelectableText( |
| 'another big house', |
| key: key2, |
| maxLines: 3, |
| ), |
| SelectableText( |
| 'a big house', |
| key: key1, |
| maxLines: 3, |
| ), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| for (int i = 0; i < 5; i += 1) { |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); |
| } |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); |
| await tester.pumpAndSettle(); |
| |
| editableTextWidget = tester.widget(find.byType(EditableText).last); |
| c1 = editableTextWidget.controller; |
| |
| expect(c1.selection.extentOffset - c1.selection.baseOffset, -10); |
| }, variant: KeySimulatorTransitModeVariant.all()); |
| |
| testWidgets('Changing focus test', (WidgetTester tester) async { |
| final FocusNode focusNode = FocusNode(); |
| final List<RawKeyEvent> events = <RawKeyEvent>[]; |
| |
| final Key key1 = UniqueKey(); |
| final Key key2 = UniqueKey(); |
| |
| await tester.pumpWidget( |
| MaterialApp( |
| home: |
| Material( |
| child: RawKeyboardListener( |
| focusNode: focusNode, |
| onKey: events.add, |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.stretch, |
| children: <Widget>[ |
| SelectableText( |
| 'a big house', |
| key: key1, |
| maxLines: 3, |
| ), |
| SelectableText( |
| 'another big house', |
| key: key2, |
| maxLines: 3, |
| ), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| final EditableText editableTextWidget1 = tester.widget(find.byType(EditableText).first); |
| final TextEditingController c1 = editableTextWidget1.controller; |
| |
| final EditableText editableTextWidget2 = tester.widget(find.byType(EditableText).last); |
| final TextEditingController c2 = editableTextWidget2.controller; |
| |
| await tester.tap(find.byType(SelectableText).first); |
| await tester.pumpAndSettle(); |
| |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| for (int i = 0; i < 5; i += 1) { |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); |
| await tester.pumpAndSettle(); |
| } |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); |
| await tester.pumpAndSettle(); |
| |
| expect(c1.selection.extentOffset - c1.selection.baseOffset, -5); |
| expect(c2.selection.extentOffset - c2.selection.baseOffset, 0); |
| |
| await tester.tap(find.byType(SelectableText).last); |
| await tester.pumpAndSettle(); |
| |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| for (int i = 0; i < 5; i += 1) { |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); |
| await tester.pumpAndSettle(); |
| } |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); |
| await tester.pumpAndSettle(); |
| |
| expect(c1.selection.extentOffset - c1.selection.baseOffset, -5); |
| expect(c2.selection.extentOffset - c2.selection.baseOffset, -5); |
| }, variant: KeySimulatorTransitModeVariant.all()); |
| |
| testWidgets('Caret works when maxLines is null', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| overlay( |
| child: const SelectableText( |
| 'x', |
| ), |
| ), |
| ); |
| |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| expect(controller.selection.baseOffset, -1); |
| |
| // Tap the selection handle to bring up the "paste / select all" menu. |
| await tester.tapAt(textOffsetToPosition(tester, 0)); |
| await tester.pump(); |
| await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is |
| |
| // Confirm that the selection was updated. |
| expect(controller.selection.baseOffset, 0); |
| }); |
| |
| testWidgets('SelectableText baseline alignment no-strut', (WidgetTester tester) async { |
| final Key keyA = UniqueKey(); |
| final Key keyB = UniqueKey(); |
| |
| await tester.pumpWidget( |
| overlay( |
| child: Row( |
| crossAxisAlignment: CrossAxisAlignment.baseline, |
| textBaseline: TextBaseline.alphabetic, |
| children: <Widget>[ |
| Expanded( |
| child: SelectableText( |
| 'A', |
| key: keyA, |
| style: const TextStyle(fontSize: 10.0), |
| strutStyle: StrutStyle.disabled, |
| ), |
| ), |
| const Text( |
| 'abc', |
| style: TextStyle(fontSize: 20.0), |
| ), |
| Expanded( |
| child: SelectableText( |
| 'B', |
| key: keyB, |
| style: const TextStyle(fontSize: 30.0), |
| strutStyle: StrutStyle.disabled, |
| ), |
| ), |
| ], |
| ), |
| ), |
| ); |
| |
| // The Ahem font extends 0.2 * fontSize below the baseline. |
| // So the three row elements line up like this: |
| // |
| // A abc B |
| // --------- baseline |
| // 2 4 6 space below the baseline = 0.2 * fontSize |
| // --------- rowBottomY |
| |
| final double rowBottomY = tester.getBottomLeft(find.byType(Row)).dy; |
| expect(tester.getBottomLeft(find.byKey(keyA)).dy, moreOrLessEquals(rowBottomY - 4.0, epsilon: 1e-3)); |
| expect(tester.getBottomLeft(find.text('abc')).dy, moreOrLessEquals(rowBottomY - 2.0, epsilon: 1e-3)); |
| expect(tester.getBottomLeft(find.byKey(keyB)).dy, rowBottomY); |
| }); |
| |
| testWidgets('SelectableText baseline alignment', (WidgetTester tester) async { |
| final Key keyA = UniqueKey(); |
| final Key keyB = UniqueKey(); |
| |
| await tester.pumpWidget( |
| overlay( |
| child: Row( |
| crossAxisAlignment: CrossAxisAlignment.baseline, |
| textBaseline: TextBaseline.alphabetic, |
| children: <Widget>[ |
| Expanded( |
| child: SelectableText( |
| 'A', |
| key: keyA, |
| style: const TextStyle(fontSize: 10.0), |
| ), |
| ), |
| const Text( |
| 'abc', |
| style: TextStyle(fontSize: 20.0), |
| ), |
| Expanded( |
| child: SelectableText( |
| 'B', |
| key: keyB, |
| style: const TextStyle(fontSize: 30.0), |
| ), |
| ), |
| ], |
| ), |
| ), |
| ); |
| |
| // The Ahem font extends 0.2 * fontSize below the baseline. |
| // So the three row elements line up like this: |
| // |
| // A abc B |
| // --------- baseline |
| // 2 4 6 space below the baseline = 0.2 * fontSize |
| // --------- rowBottomY |
| |
| final double rowBottomY = tester.getBottomLeft(find.byType(Row)).dy; |
| expect(tester.getBottomLeft(find.byKey(keyA)).dy, moreOrLessEquals(rowBottomY - 4.0, epsilon: 1e-3)); |
| expect(tester.getBottomLeft(find.text('abc')).dy, moreOrLessEquals(rowBottomY - 2.0, epsilon: 1e-3)); |
| expect(tester.getBottomLeft(find.byKey(keyB)).dy, rowBottomY); |
| }); |
| |
| testWidgets('SelectableText semantics', (WidgetTester tester) async { |
| final SemanticsTester semantics = SemanticsTester(tester); |
| final Key key = UniqueKey(); |
| |
| await tester.pumpWidget( |
| overlay( |
| child: SelectableText( |
| 'Guten Tag', |
| key: key, |
| ), |
| ), |
| ); |
| |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| expect(semantics, hasSemantics(TestSemantics.root( |
| children: <TestSemantics>[ |
| TestSemantics.rootChild( |
| id: 1, |
| textDirection: TextDirection.ltr, |
| value: 'Guten Tag', |
| actions: <SemanticsAction>[ |
| SemanticsAction.longPress, |
| ], |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isTextField, |
| SemanticsFlag.isReadOnly, |
| SemanticsFlag.isMultiline, |
| ], |
| ), |
| ], |
| ), ignoreTransform: true, ignoreRect: true)); |
| |
| await tester.tap(find.byKey(key)); |
| await tester.pump(); |
| |
| controller.selection = const TextSelection.collapsed(offset: 9); |
| await tester.pump(); |
| |
| expect(semantics, hasSemantics(TestSemantics.root( |
| children: <TestSemantics>[ |
| TestSemantics.rootChild( |
| id: 1, |
| textDirection: TextDirection.ltr, |
| value: 'Guten Tag', |
| textSelection: const TextSelection.collapsed(offset: 9), |
| actions: <SemanticsAction>[ |
| SemanticsAction.longPress, |
| SemanticsAction.moveCursorBackwardByCharacter, |
| SemanticsAction.moveCursorBackwardByWord, |
| SemanticsAction.setSelection, |
| ], |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isReadOnly, |
| SemanticsFlag.isTextField, |
| SemanticsFlag.isMultiline, |
| SemanticsFlag.isFocused, |
| ], |
| ), |
| ], |
| ), ignoreTransform: true, ignoreRect: true)); |
| |
| controller.selection = const TextSelection.collapsed(offset: 4); |
| await tester.pump(); |
| |
| expect(semantics, hasSemantics(TestSemantics.root( |
| children: <TestSemantics>[ |
| TestSemantics.rootChild( |
| id: 1, |
| textDirection: TextDirection.ltr, |
| textSelection: const TextSelection.collapsed(offset: 4), |
| value: 'Guten Tag', |
| actions: <SemanticsAction>[ |
| SemanticsAction.longPress, |
| SemanticsAction.moveCursorBackwardByCharacter, |
| SemanticsAction.moveCursorForwardByCharacter, |
| SemanticsAction.moveCursorBackwardByWord, |
| SemanticsAction.moveCursorForwardByWord, |
| SemanticsAction.setSelection, |
| ], |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isReadOnly, |
| SemanticsFlag.isTextField, |
| SemanticsFlag.isMultiline, |
| SemanticsFlag.isFocused, |
| ], |
| ), |
| ], |
| ), ignoreTransform: true, ignoreRect: true)); |
| |
| controller.selection = const TextSelection.collapsed(offset: 0); |
| await tester.pump(); |
| |
| expect(semantics, hasSemantics(TestSemantics.root( |
| children: <TestSemantics>[ |
| TestSemantics.rootChild( |
| id: 1, |
| textDirection: TextDirection.ltr, |
| textSelection: const TextSelection.collapsed(offset: 0), |
| value: 'Guten Tag', |
| actions: <SemanticsAction>[ |
| SemanticsAction.longPress, |
| SemanticsAction.moveCursorForwardByCharacter, |
| SemanticsAction.moveCursorForwardByWord, |
| SemanticsAction.setSelection, |
| ], |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isReadOnly, |
| SemanticsFlag.isTextField, |
| SemanticsFlag.isMultiline, |
| SemanticsFlag.isFocused, |
| ], |
| ), |
| ], |
| ), ignoreTransform: true, ignoreRect: true)); |
| |
| semantics.dispose(); |
| }); |
| |
| testWidgets('SelectableText semantics, with semanticsLabel', (WidgetTester tester) async { |
| final SemanticsTester semantics = SemanticsTester(tester); |
| final Key key = UniqueKey(); |
| |
| await tester.pumpWidget( |
| overlay( |
| child: SelectableText( |
| 'Guten Tag', |
| semanticsLabel: 'German greeting for good day', |
| key: key, |
| ), |
| ), |
| ); |
| |
| expect(semantics, hasSemantics(TestSemantics.root( |
| children: <TestSemantics>[ |
| TestSemantics( |
| id: 1, |
| actions: <SemanticsAction>[SemanticsAction.longPress], |
| label: 'German greeting for good day', |
| textDirection: TextDirection.ltr, |
| ), |
| ], |
| ), ignoreTransform: true, ignoreRect: true)); |
| }); |
| |
| testWidgets('SelectableText semantics, enableInteractiveSelection = false', (WidgetTester tester) async { |
| final SemanticsTester semantics = SemanticsTester(tester); |
| final Key key = UniqueKey(); |
| |
| await tester.pumpWidget( |
| overlay( |
| child: SelectableText( |
| 'Guten Tag', |
| key: key, |
| enableInteractiveSelection: false, |
| ), |
| ), |
| ); |
| |
| await tester.tap(find.byKey(key)); |
| await tester.pump(); |
| |
| expect(semantics, hasSemantics(TestSemantics.root( |
| children: <TestSemantics>[ |
| TestSemantics.rootChild( |
| id: 1, |
| value: 'Guten Tag', |
| textDirection: TextDirection.ltr, |
| actions: <SemanticsAction>[ |
| SemanticsAction.longPress, |
| // Absent the following because enableInteractiveSelection: false |
| // SemanticsAction.moveCursorBackwardByCharacter, |
| // SemanticsAction.moveCursorBackwardByWord, |
| // SemanticsAction.setSelection, |
| // SemanticsAction.paste, |
| ], |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isReadOnly, |
| SemanticsFlag.isTextField, |
| SemanticsFlag.isMultiline, |
| // SelectableText act like a text widget when enableInteractiveSelection |
| // is false. It will not respond to any pointer event. |
| // SemanticsFlag.isFocused, |
| ], |
| ), |
| ], |
| ), ignoreTransform: true, ignoreRect: true)); |
| |
| semantics.dispose(); |
| }); |
| |
| testWidgets('SelectableText semantics for selections', (WidgetTester tester) async { |
| final SemanticsTester semantics = SemanticsTester(tester); |
| final Key key = UniqueKey(); |
| |
| await tester.pumpWidget( |
| overlay( |
| child: SelectableText( |
| 'Hello', |
| key: key, |
| ), |
| ), |
| ); |
| |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| expect(semantics, hasSemantics(TestSemantics.root( |
| children: <TestSemantics>[ |
| TestSemantics.rootChild( |
| id: 1, |
| value: 'Hello', |
| textDirection: TextDirection.ltr, |
| actions: <SemanticsAction>[ |
| SemanticsAction.longPress, |
| ], |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isReadOnly, |
| SemanticsFlag.isTextField, |
| SemanticsFlag.isMultiline, |
| ], |
| ), |
| ], |
| ), ignoreTransform: true, ignoreRect: true)); |
| |
| // Focus the selectable text |
| await tester.tap(find.byKey(key)); |
| await tester.pump(); |
| |
| controller.selection = const TextSelection.collapsed(offset: 5); |
| await tester.pump(); |
| |
| expect(semantics, hasSemantics(TestSemantics.root( |
| children: <TestSemantics>[ |
| TestSemantics.rootChild( |
| id: 1, |
| value: 'Hello', |
| textSelection: const TextSelection.collapsed(offset: 5), |
| textDirection: TextDirection.ltr, |
| actions: <SemanticsAction>[ |
| SemanticsAction.longPress, |
| SemanticsAction.moveCursorBackwardByCharacter, |
| SemanticsAction.moveCursorBackwardByWord, |
| SemanticsAction.setSelection, |
| ], |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isReadOnly, |
| SemanticsFlag.isTextField, |
| SemanticsFlag.isMultiline, |
| SemanticsFlag.isFocused, |
| ], |
| ), |
| ], |
| ), ignoreTransform: true, ignoreRect: true)); |
| |
| controller.selection = const TextSelection(baseOffset: 5, extentOffset: 3); |
| await tester.pump(); |
| |
| expect(semantics, hasSemantics(TestSemantics.root( |
| children: <TestSemantics>[ |
| TestSemantics.rootChild( |
| id: 1, |
| value: 'Hello', |
| textSelection: const TextSelection(baseOffset: 5, extentOffset: 3), |
| textDirection: TextDirection.ltr, |
| actions: <SemanticsAction>[ |
| SemanticsAction.longPress, |
| SemanticsAction.moveCursorBackwardByCharacter, |
| SemanticsAction.moveCursorForwardByCharacter, |
| SemanticsAction.moveCursorBackwardByWord, |
| SemanticsAction.moveCursorForwardByWord, |
| SemanticsAction.setSelection, |
| SemanticsAction.copy, |
| ], |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isReadOnly, |
| SemanticsFlag.isTextField, |
| SemanticsFlag.isMultiline, |
| SemanticsFlag.isFocused, |
| ], |
| ), |
| ], |
| ), ignoreTransform: true, ignoreRect: true)); |
| |
| semantics.dispose(); |
| }); |
| |
| testWidgets('semantic nodes of offscreen recognizers are marked hidden', (WidgetTester tester) async { |
| // Regression test for https://github.com/flutter/flutter/issues/100395. |
| final SemanticsTester semantics = SemanticsTester(tester); |
| const TextStyle textStyle = TextStyle(fontFamily: 'Ahem', fontSize: 200); |
| const String onScreenText = 'onscreen\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n'; |
| const String offScreenText = 'off screen'; |
| final ScrollController controller = ScrollController(); |
| await tester.pumpWidget( |
| MaterialApp( |
| home: SingleChildScrollView( |
| controller: controller, |
| child: SelectableText.rich( |
| TextSpan( |
| children: <TextSpan>[ |
| const TextSpan(text: onScreenText), |
| TextSpan( |
| text: offScreenText, |
| recognizer: TapGestureRecognizer()..onTap = () { }, |
| ), |
| ], |
| style: textStyle, |
| ), |
| textDirection: TextDirection.ltr, |
| ), |
| ) |
| ), |
| ); |
| |
| final TestSemantics expectedSemantics = TestSemantics.root( |
| children: <TestSemantics>[ |
| TestSemantics( |
| textDirection: TextDirection.ltr, |
| children: <TestSemantics>[ |
| TestSemantics( |
| children: <TestSemantics>[ |
| TestSemantics( |
| flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], |
| children: <TestSemantics>[ |
| TestSemantics( |
| flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], |
| actions: <SemanticsAction>[SemanticsAction.scrollUp], |
| children: <TestSemantics>[ |
| TestSemantics( |
| actions: <SemanticsAction>[SemanticsAction.longPress], |
| children: <TestSemantics>[ |
| TestSemantics( |
| children: <TestSemantics>[ |
| TestSemantics( |
| label: 'onscreen\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n', |
| textDirection: TextDirection.ltr, |
| ), |
| TestSemantics( |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isHidden, |
| SemanticsFlag.isLink, |
| ], |
| actions: <SemanticsAction>[SemanticsAction.tap], |
| label: 'off screen', |
| textDirection: TextDirection.ltr, |
| ), |
| ], |
| ), |
| ], |
| ), |
| ], |
| ), |
| ], |
| ), |
| ], |
| ), |
| ], |
| ), |
| ], |
| ); |
| expect( |
| semantics, |
| hasSemantics( |
| expectedSemantics, |
| ignoreTransform: true, |
| ignoreId: true, |
| ignoreRect: true, |
| ), |
| ); |
| |
| // Test show on screen. |
| expect(controller.offset, 0.0); |
| tester.binding.pipelineOwner.semanticsOwner!.performAction(8, SemanticsAction.showOnScreen); |
| await tester.pumpAndSettle(); |
| expect(controller.offset != 0.0, isTrue); |
| |
| semantics.dispose(); |
| }); |
| |
| testWidgets('SelectableText change selection with semantics', (WidgetTester tester) async { |
| final SemanticsTester semantics = SemanticsTester(tester); |
| final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; |
| final Key key = UniqueKey(); |
| |
| await tester.pumpWidget( |
| overlay( |
| child: SelectableText( |
| 'Hello', |
| key: key, |
| ), |
| ), |
| ); |
| |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| // Focus the selectable text |
| await tester.tap(find.byKey(key)); |
| await tester.pump(); |
| |
| controller.selection = const TextSelection(baseOffset: 5, extentOffset: 5); |
| await tester.pump(); |
| |
| const int inputFieldId = 1; |
| |
| expect(semantics, hasSemantics(TestSemantics.root( |
| children: <TestSemantics>[ |
| TestSemantics.rootChild( |
| id: inputFieldId, |
| value: 'Hello', |
| textSelection: const TextSelection.collapsed(offset: 5), |
| textDirection: TextDirection.ltr, |
| actions: <SemanticsAction>[ |
| SemanticsAction.longPress, |
| SemanticsAction.moveCursorBackwardByCharacter, |
| SemanticsAction.moveCursorBackwardByWord, |
| SemanticsAction.setSelection, |
| ], |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isReadOnly, |
| SemanticsFlag.isTextField, |
| SemanticsFlag.isMultiline, |
| SemanticsFlag.isFocused, |
| ], |
| ), |
| ], |
| ), ignoreTransform: true, ignoreRect: true)); |
| |
| // move cursor back once |
| semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{ |
| 'base': 4, |
| 'extent': 4, |
| }); |
| await tester.pump(); |
| expect(controller.selection, const TextSelection.collapsed(offset: 4)); |
| |
| // move cursor to front |
| semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{ |
| 'base': 0, |
| 'extent': 0, |
| }); |
| await tester.pump(); |
| expect(controller.selection, const TextSelection.collapsed(offset: 0)); |
| |
| // select all |
| semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{ |
| 'base': 0, |
| 'extent': 5, |
| }); |
| await tester.pump(); |
| expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); |
| expect(semantics, hasSemantics(TestSemantics.root( |
| children: <TestSemantics>[ |
| TestSemantics.rootChild( |
| id: inputFieldId, |
| value: 'Hello', |
| textSelection: const TextSelection(baseOffset: 0, extentOffset: 5), |
| textDirection: TextDirection.ltr, |
| actions: <SemanticsAction>[ |
| SemanticsAction.longPress, |
| SemanticsAction.moveCursorBackwardByCharacter, |
| SemanticsAction.moveCursorBackwardByWord, |
| SemanticsAction.setSelection, |
| SemanticsAction.copy, |
| ], |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isReadOnly, |
| SemanticsFlag.isTextField, |
| SemanticsFlag.isMultiline, |
| SemanticsFlag.isFocused, |
| ], |
| ), |
| ], |
| ), ignoreTransform: true, ignoreRect: true)); |
| |
| semantics.dispose(); |
| }); |
| |
| testWidgets('Can activate SelectableText with explicit controller via semantics', (WidgetTester tester) async { |
| // Regression test for https://github.com/flutter/flutter/issues/17801 |
| |
| const String testValue = 'Hello'; |
| |
| final SemanticsTester semantics = SemanticsTester(tester); |
| final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; |
| final Key key = UniqueKey(); |
| |
| await tester.pumpWidget( |
| overlay( |
| child: SelectableText( |
| testValue, |
| key: key, |
| ), |
| ), |
| ); |
| |
| const int inputFieldId = 1; |
| |
| expect(semantics, hasSemantics( |
| TestSemantics.root( |
| children: <TestSemantics>[ |
| TestSemantics( |
| id: inputFieldId, |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isReadOnly, |
| SemanticsFlag.isTextField, |
| SemanticsFlag.isMultiline, |
| ], |
| actions: <SemanticsAction>[SemanticsAction.longPress], |
| value: testValue, |
| textDirection: TextDirection.ltr, |
| ), |
| ], |
| ), |
| ignoreRect: true, ignoreTransform: true, |
| )); |
| |
| semanticsOwner.performAction(inputFieldId, SemanticsAction.longPress); |
| await tester.pump(); |
| |
| expect(semantics, hasSemantics( |
| TestSemantics.root( |
| children: <TestSemantics>[ |
| TestSemantics( |
| id: inputFieldId, |
| flags: <SemanticsFlag>[ |
| SemanticsFlag.isReadOnly, |
| SemanticsFlag.isTextField, |
| SemanticsFlag.isMultiline, |
| SemanticsFlag.isFocused, |
| ], |
| actions: <SemanticsAction>[ |
| SemanticsAction.longPress, |
| SemanticsAction.moveCursorBackwardByCharacter, |
| SemanticsAction.moveCursorBackwardByWord, |
| SemanticsAction.setSelection, |
| ], |
| value: testValue, |
| textDirection: TextDirection.ltr, |
| textSelection: const TextSelection( |
| baseOffset: testValue.length, |
| extentOffset: testValue.length, |
| ), |
| ), |
| ], |
| ), |
| ignoreRect: true, ignoreTransform: true, |
| )); |
| |
| semantics.dispose(); |
| }); |
| |
| testWidgets('SelectableText throws when not descended from a MediaQuery widget', (WidgetTester tester) async { |
| const Widget selectableText = SelectableText('something'); |
| await tester.pumpWidget(selectableText); |
| final dynamic exception = tester.takeException(); |
| expect(exception, isFlutterError); |
| expect(exception.toString(), startsWith('No MediaQuery widget ancestor found.\nSelectableText widgets require a MediaQuery widget ancestor.')); |
| }); |
| |
| testWidgets('onTap is called upon tap', (WidgetTester tester) async { |
| int tapCount = 0; |
| await tester.pumpWidget( |
| overlay( |
| child: SelectableText( |
| 'something', |
| onTap: () { |
| tapCount += 1; |
| }, |
| ), |
| ), |
| ); |
| |
| expect(tapCount, 0); |
| await tester.tap(find.byType(SelectableText)); |
| // Wait a bit so they're all single taps and not double taps. |
| await tester.pump(const Duration(milliseconds: 300)); |
| await tester.tap(find.byType(SelectableText)); |
| await tester.pump(const Duration(milliseconds: 300)); |
| await tester.tap(find.byType(SelectableText)); |
| await tester.pump(const Duration(milliseconds: 300)); |
| expect(tapCount, 3); |
| }); |
| |
| testWidgets('SelectableText style is merged with default text style', (WidgetTester tester) async { |
| // Regression test for https://github.com/flutter/flutter/issues/23994 |
| final TextStyle defaultStyle = TextStyle( |
| color: Colors.blue[500], |
| ); |
| Widget buildFrame(TextStyle style) { |
| return MaterialApp( |
| home: Material( |
| child: DefaultTextStyle ( |
| style: defaultStyle, |
| child: Center( |
| child: SelectableText( |
| 'something', |
| style: style, |
| ), |
| ), |
| ), |
| ), |
| ); |
| } |
| |
| // Empty TextStyle is overridden by theme |
| await tester.pumpWidget(buildFrame(const TextStyle())); |
| EditableText editableText = tester.widget(find.byType(EditableText)); |
| expect(editableText.style.color, defaultStyle.color); |
| expect(editableText.style.background, defaultStyle.background); |
| expect(editableText.style.shadows, defaultStyle.shadows); |
| expect(editableText.style.decoration, defaultStyle.decoration); |
| expect(editableText.style.locale, defaultStyle.locale); |
| expect(editableText.style.wordSpacing, defaultStyle.wordSpacing); |
| |
| // Properties set on TextStyle override theme |
| const Color setColor = Colors.red; |
| await tester.pumpWidget(buildFrame(const TextStyle(color: setColor))); |
| editableText = tester.widget(find.byType(EditableText)); |
| expect(editableText.style.color, setColor); |
| |
| // inherit: false causes nothing to be merged in from theme |
| await tester.pumpWidget(buildFrame(const TextStyle( |
| fontSize: 24.0, |
| textBaseline: TextBaseline.alphabetic, |
| inherit: false, |
| ))); |
| editableText = tester.widget(find.byType(EditableText)); |
| expect(editableText.style.color, isNull); |
| }); |
| |
| testWidgets('style enforces required fields', (WidgetTester tester) async { |
| Widget buildFrame(TextStyle style) { |
| return MaterialApp( |
| home: Material( |
| child: SelectableText( |
| 'something', |
| style: style, |
| ), |
| ), |
| ); |
| } |
| |
| await tester.pumpWidget(buildFrame(const TextStyle( |
| inherit: false, |
| fontSize: 12.0, |
| textBaseline: TextBaseline.alphabetic, |
| ))); |
| expect(tester.takeException(), isNull); |
| |
| // With inherit not set to false, will pickup required fields from theme |
| await tester.pumpWidget(buildFrame(const TextStyle( |
| fontSize: 12.0, |
| ))); |
| expect(tester.takeException(), isNull); |
| |
| await tester.pumpWidget(buildFrame(const TextStyle( |
| inherit: false, |
| fontSize: 12.0, |
| ))); |
| expect(tester.takeException(), isNotNull); |
| }); |
| |
| testWidgets( |
| 'tap moves cursor to the edge of the word it tapped', |
| (WidgetTester tester) async { |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: Center( |
| child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), |
| ), |
| ), |
| ), |
| ); |
| |
| final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); |
| |
| await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); |
| await tester.pump(); |
| |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); |
| final TextEditingController controller = editableTextWidget.controller; |
| // We moved the cursor. |
| expect( |
| controller.selection, |
| const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), |
| ); |
| |
| // But don't trigger the toolbar. |
| expect(find.byType(CupertinoButton), findsNothing); |
| }, |
| variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), |
| ); |
| |
| testWidgets( |
| 'tap moves cursor to the position tapped (Android)', |
| (WidgetTester tester) async { |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: Center( |
| child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), |
| ), |
| ), |
| ), |
| ); |
| |
| final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); |
| |
| await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); |
| await tester.pump(); |
| |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| // We moved the cursor. |
| expect( |
| controller.selection, |
| const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream), |
| ); |
| |
| // But don't trigger the toolbar. |
| expect(find.byType(TextButton), findsNothing); |
| }, |
| ); |
| |
| testWidgets( |
| 'two slow taps do not trigger a word selection', |
| (WidgetTester tester) async { |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: Center( |
| child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), |
| ), |
| ), |
| ), |
| ); |
| |
| final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); |
| |
| await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); |
| await tester.pump(const Duration(milliseconds: 500)); |
| await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); |
| await tester.pump(); |
| |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| // Plain collapsed selection. |
| expect( |
| controller.selection, |
| const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), |
| ); |
| |
| // No toolbar. |
| expect(find.byType(CupertinoButton), findsNothing); |
| }, |
| variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), |
| ); |
| |
| testWidgets( |
| 'double tap selects word and first tap of double tap moves cursor', |
| (WidgetTester tester) async { |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: Center( |
| child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), |
| ), |
| ), |
| ), |
| ); |
| |
| final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); |
| |
| // This tap just puts the cursor somewhere different than where the double |
| // tap will occur to test that the double tap moves the existing cursor first. |
| await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); |
| await tester.pump(const Duration(milliseconds: 500)); |
| |
| await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); |
| await tester.pump(const Duration(milliseconds: 50)); |
| |
| final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); |
| final TextEditingController controller = editableTextWidget.controller; |
| |
| // First tap moved the cursor. |
| expect( |
| controller.selection, |
| const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream), |
| ); |
| await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); |
| await tester.pump(); |
| |
| // Second tap selects the word around the cursor. |
| expect( |
| controller.selection, |
| const TextSelection(baseOffset: 8, extentOffset: 12), |
| ); |
| |
| // Selected text shows 1 toolbar buttons. |
| expect(find.byType(CupertinoButton), findsNWidgets(1)); |
| }, |
| variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), |
| ); |
| |
| testWidgets( |
| 'double tap selects word and first tap of double tap moves cursor and shows toolbar (Android)', |
| (WidgetTester tester) async { |
| await tester.pumpWidget( |
| const MaterialApp( |
| home: Material( |
| child: Center( |
| child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), |
| ), |
| ), |
| ), |
| ); |
| |
| final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); |
| |
| // This tap just puts the cursor somewhere different than where the double |
| // tap will occur to test that the double tap moves the existing cursor first. |
| await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); |
|