diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index b43eb77a3b..c94a1a446e 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -209,6 +209,7 @@ class CupertinoTextField extends StatefulWidget { this.textAlign = TextAlign.start, this.textAlignVertical, this.readOnly = false, + ToolbarOptions toolbarOptions, this.showCursor, this.autofocus = false, this.obscureText = false, @@ -229,7 +230,7 @@ class CupertinoTextField extends StatefulWidget { this.keyboardAppearance, this.scrollPadding = const EdgeInsets.all(20.0), this.dragStartBehavior = DragStartBehavior.start, - this.enableInteractiveSelection, + this.enableInteractiveSelection = true, this.onTap, this.scrollController, this.scrollPhysics, @@ -257,6 +258,17 @@ class CupertinoTextField extends StatefulWidget { assert(prefixMode != null), assert(suffixMode != null), keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), + toolbarOptions = toolbarOptions ?? obscureText ? + const ToolbarOptions( + selectAll: true, + paste: true, + ) : + const ToolbarOptions( + copy: true, + cut: true, + selectAll: true, + paste: true, + ), super(key: key); /// Controls the text being edited. @@ -358,6 +370,13 @@ class CupertinoTextField extends StatefulWidget { /// {@macro flutter.widgets.editableText.textAlign} final TextAlign textAlign; + /// Configuration of toolbar options. + /// + /// If not set, select all and paste will default to be enabled. Copy and cut + /// will be disabled if [obscureText] is true. If [readOnly] is true, + /// paste and cut will be disabled regardless. + final ToolbarOptions toolbarOptions; + /// {@macro flutter.material.inputDecorator.textAlignVertical} final TextAlignVertical textAlignVertical; @@ -498,9 +517,7 @@ class CupertinoTextField extends StatefulWidget { final ScrollPhysics scrollPhysics; /// {@macro flutter.rendering.editable.selectionEnabled} - bool get selectionEnabled { - return enableInteractiveSelection ?? !obscureText; - } + bool get selectionEnabled => enableInteractiveSelection; /// {@macro flutter.material.textfield.onTap} final GestureTapCallback onTap; @@ -804,6 +821,7 @@ class _CupertinoTextFieldState extends State with AutomaticK key: editableTextKey, controller: controller, readOnly: widget.readOnly, + toolbarOptions: widget.toolbarOptions, showCursor: widget.showCursor, showSelectionHandles: _showSelectionHandles, focusNode: _effectiveFocusNode, diff --git a/packages/flutter/lib/src/material/selectable_text.dart b/packages/flutter/lib/src/material/selectable_text.dart index 64d6aef625..0e10d9dd7e 100644 --- a/packages/flutter/lib/src/material/selectable_text.dart +++ b/packages/flutter/lib/src/material/selectable_text.dart @@ -195,6 +195,7 @@ class SelectableText extends StatefulWidget { this.textDirection, this.showCursor = false, this.autofocus = false, + ToolbarOptions toolbarOptions, this.maxLines, this.cursorWidth = 2.0, this.cursorRadius, @@ -213,6 +214,11 @@ class SelectableText extends StatefulWidget { 'A non-null String must be provided to a SelectableText widget.', ), textSpan = null, + toolbarOptions = toolbarOptions ?? + const ToolbarOptions( + selectAll: true, + copy: true, + ), super(key: key); /// Creates a selectable text widget with a [TextSpan]. @@ -229,6 +235,7 @@ class SelectableText extends StatefulWidget { this.textDirection, this.showCursor = false, this.autofocus = false, + ToolbarOptions toolbarOptions, this.maxLines, this.cursorWidth = 2.0, this.cursorRadius, @@ -247,6 +254,11 @@ class SelectableText extends StatefulWidget { 'A non-null TextSpan must be provided to a SelectableText.rich widget.', ), data = null, + toolbarOptions = toolbarOptions ?? + const ToolbarOptions( + selectAll: true, + copy: true, + ), super(key: key); /// The text to display. @@ -325,6 +337,13 @@ class SelectableText extends StatefulWidget { /// {@macro flutter.widgets.scrollable.dragStartBehavior} final DragStartBehavior dragStartBehavior; + /// Configuration of toolbar options. + /// + /// Paste and cut will be disabled regardless. + /// + /// If not set, select all and copy will be enabled by default. + final ToolbarOptions toolbarOptions; + /// {@macro flutter.rendering.editable.selectionEnabled} bool get selectionEnabled { return enableInteractiveSelection; @@ -543,6 +562,7 @@ class _SelectableTextState extends State with AutomaticKeepAlive textDirection: widget.textDirection, autofocus: widget.autofocus, forceLine: false, + toolbarOptions: widget.toolbarOptions, maxLines: widget.maxLines ?? defaultTextStyle.maxLines, selectionColor: themeData.textSelectionColor, selectionControls: widget.selectionEnabled ? textSelectionControls : null, diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index fd75788eb2..522ec4569e 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -256,6 +256,7 @@ class TextField extends StatefulWidget { this.textAlignVertical, this.textDirection, this.readOnly = false, + ToolbarOptions toolbarOptions, this.showCursor, this.autofocus = false, this.obscureText = false, @@ -276,7 +277,7 @@ class TextField extends StatefulWidget { this.keyboardAppearance, this.scrollPadding = const EdgeInsets.all(20.0), this.dragStartBehavior = DragStartBehavior.start, - this.enableInteractiveSelection, + this.enableInteractiveSelection = true, this.onTap, this.buildCounter, this.scrollController, @@ -286,6 +287,7 @@ class TextField extends StatefulWidget { assert(autofocus != null), assert(obscureText != null), assert(autocorrect != null), + assert(enableInteractiveSelection != null), assert(maxLengthEnforced != null), assert(scrollPadding != null), assert(dragStartBehavior != null), @@ -302,6 +304,17 @@ class TextField extends StatefulWidget { ), assert(maxLength == null || maxLength == TextField.noMaxLength || maxLength > 0), keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), + toolbarOptions = toolbarOptions ?? obscureText ? + const ToolbarOptions( + selectAll: true, + paste: true, + ) : + const ToolbarOptions( + copy: true, + cut: true, + selectAll: true, + paste: true, + ), super(key: key); /// Controls the text being edited. @@ -410,6 +423,13 @@ class TextField extends StatefulWidget { /// {@macro flutter.widgets.editableText.readOnly} final bool readOnly; + /// Configuration of toolbar options. + /// + /// If not set, select all and paste will default to be enabled. Copy and cut + /// will be disabled if [obscureText] is true. If [readOnly] is true, + /// paste and cut will be disabled regardless. + final ToolbarOptions toolbarOptions; + /// {@macro flutter.widgets.editableText.showCursor} final bool showCursor; @@ -538,9 +558,7 @@ class TextField extends StatefulWidget { final DragStartBehavior dragStartBehavior; /// {@macro flutter.rendering.editable.selectionEnabled} - bool get selectionEnabled { - return enableInteractiveSelection ?? !obscureText; - } + bool get selectionEnabled => enableInteractiveSelection; /// {@template flutter.material.textfield.onTap} /// Called for each distinct tap except for every second tap of a double tap. @@ -952,6 +970,7 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi child: EditableText( key: editableTextKey, readOnly: widget.readOnly, + toolbarOptions: widget.toolbarOptions, showCursor: widget.showCursor, showSelectionHandles: _showSelectionHandles, controller: controller, diff --git a/packages/flutter/lib/src/material/text_form_field.dart b/packages/flutter/lib/src/material/text_form_field.dart index cea1bf95a7..9eb69b1d1c 100644 --- a/packages/flutter/lib/src/material/text_form_field.dart +++ b/packages/flutter/lib/src/material/text_form_field.dart @@ -89,6 +89,7 @@ class TextFormField extends FormField { TextAlign textAlign = TextAlign.start, bool autofocus = false, bool readOnly = false, + ToolbarOptions toolbarOptions, bool showCursor, bool obscureText = false, bool autocorrect = true, @@ -163,6 +164,7 @@ class TextFormField extends FormField { textDirection: textDirection, textCapitalization: textCapitalization, autofocus: autofocus, + toolbarOptions: toolbarOptions, readOnly: readOnly, showCursor: showCursor, obscureText: obscureText, diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index a4ab81ab1d..caeeaad320 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -1554,6 +1554,10 @@ class RenderEditable extends RenderBox { // When long-pressing past the end of the text, we want a collapsed cursor. if (position.offset >= word.end) return TextSelection.fromPosition(position); + // If text is obscured, the entire sentence should be treated as one word. + if (obscureText) { + return TextSelection(baseOffset: 0, extentOffset: text.toPlainText().length); + } return TextSelection(baseOffset: word.start, extentOffset: word.end); } diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 27c1b91170..4cb9f68139 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -221,6 +221,54 @@ class TextEditingController extends ValueNotifier { } } +/// Toolbar configuration for [EditableText]. +/// +/// Toolbar is a context menu that will show up when user right click or long +/// press the [EditableText]. It includes several options: cut, copy, paste, +/// and select all. +/// +/// [EditableText] and its derived widgets have their own default [ToolbarOptions]. +/// Create a custom [ToolbarOptions] if you want explicit control over the toolbar +/// option. +class ToolbarOptions { + /// Create a toolbar configuration with given options. + /// + /// All options default to false if they are not explicitly set. + const ToolbarOptions({ + this.copy = false, + this.cut = false, + this.paste = false, + this.selectAll = false, + }) : assert(copy != null), + assert(cut != null), + assert(paste != null), + assert(selectAll != null); + + /// Whether to show copy option in toolbar. + /// + /// Defaults to false. Must not be null. + final bool copy; + + /// Whether to show cut option in toolbar. + /// + /// If [EditableText.readOnly] is set to true, cut will be disabled regardless. + /// + /// Defaults to false. Must not be null. + final bool cut; + + /// Whether to show paste option in toolbar. + /// + /// If [EditableText.readOnly] is set to true, paste will be disabled regardless. + /// + /// Defaults to false. Must not be null. + final bool paste; + + /// Whether to show select all option in toolbar. + /// + /// Defaults to false. Must not be null. + final bool selectAll; +} + /// A basic text input field. /// /// This widget interacts with the [TextInput] service to let the user edit the @@ -336,14 +384,21 @@ class EditableText extends StatefulWidget { this.scrollPadding = const EdgeInsets.all(20.0), this.keyboardAppearance = Brightness.light, this.dragStartBehavior = DragStartBehavior.start, - this.enableInteractiveSelection, + this.enableInteractiveSelection = true, this.scrollController, this.scrollPhysics, + this.toolbarOptions = const ToolbarOptions( + copy: true, + cut: true, + paste: true, + selectAll: true + ) }) : assert(controller != null), assert(focusNode != null), assert(obscureText != null), assert(autocorrect != null), assert(showSelectionHandles != null), + assert(enableInteractiveSelection != null), assert(readOnly != null), assert(forceLine != null), assert(style != null), @@ -367,6 +422,7 @@ class EditableText extends StatefulWidget { assert(rendererIgnoresPointer != null), assert(scrollPadding != null), assert(dragStartBehavior != null), + assert(toolbarOptions != null), _strutStyle = strutStyle, keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), inputFormatters = maxLines == 1 @@ -419,6 +475,12 @@ class EditableText extends StatefulWidget { /// * [textWidthBasis], which controls the calculation of text width. final bool forceLine; + /// Configuration of toolbar options. + /// + /// By default, all options are enabled. If [readOnly] is true, + /// paste and cut will be disabled regardless. + final ToolbarOptions toolbarOptions; + /// Whether to show selection handles. /// /// When a selection is active, there will be two handles at each side of @@ -903,9 +965,7 @@ class EditableText extends StatefulWidget { final ScrollPhysics scrollPhysics; /// {@macro flutter.rendering.editable.selectionEnabled} - bool get selectionEnabled { - return enableInteractiveSelection ?? !obscureText; - } + bool get selectionEnabled => enableInteractiveSelection; @override EditableTextState createState() => EditableTextState(); @@ -969,16 +1029,16 @@ class EditableTextState extends State with AutomaticKeepAliveClien Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value); @override - bool get cutEnabled => !widget.readOnly; + bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly; @override - bool get copyEnabled => true; + bool get copyEnabled => widget.toolbarOptions.copy; @override - bool get pasteEnabled => !widget.readOnly; + bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly; @override - bool get selectAllEnabled => true; + bool get selectAllEnabled => widget.toolbarOptions.selectAll; // State lifecycle: diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index af17c6c120..ec5605c871 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -872,7 +872,7 @@ class TextSelectionGestureDetectorBuilder { @protected final TextSelectionGestureDetectorBuilderDelegate delegate; - /// Whether to show the selection tool bar. + /// Whether to show the selection toolbar. /// /// It is based on the signal source when a [onTapDown] is called. This getter /// will return true if current [onTapDown] event is triggered by a touch or @@ -937,7 +937,7 @@ class TextSelectionGestureDetectorBuilder { /// Handler for [TextSelectionGestureDetector.onForcePressEnd]. /// /// By default, it selects words in the range specified in [details] and shows - /// tool bar if it is necessary. + /// toolbar if it is necessary. /// /// This callback is only applicable when force press is enabled. /// @@ -1022,7 +1022,7 @@ class TextSelectionGestureDetectorBuilder { /// Handler for [TextSelectionGestureDetector.onSingleLongTapEnd]. /// - /// By default, it shows tool bar if necessary. + /// By default, it shows toolbar if necessary. /// /// See also: /// @@ -1037,7 +1037,7 @@ class TextSelectionGestureDetectorBuilder { /// Handler for [TextSelectionGestureDetector.onDoubleTapDown]. /// /// By default, it selects a word through [renderEditable.selectWord] if - /// selectionEnabled and shows tool bar if necessary. + /// selectionEnabled and shows toolbar if necessary. /// /// See also: /// diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index a4ebbd131b..59bb0bc2f2 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -1461,10 +1461,7 @@ void main() { // Long press to put the cursor after the "w". const int index = 3; - final TestGesture gesture = - await tester.startGesture(textOffsetToPosition(tester, index)); - await tester.pump(const Duration(milliseconds: 500)); - await gesture.up(); + await tester.longPressAt(textOffsetToPosition(tester, index)); await tester.pump(); expect( controller.selection, @@ -1619,7 +1616,7 @@ void main() { ); testWidgets( - 'An obscured CupertinoTextField is not selectable by default', + 'An obscured CupertinoTextField is not selectable when disabled', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure', @@ -1630,6 +1627,7 @@ void main() { child: CupertinoTextField( controller: controller, obscureText: true, + enableInteractiveSelection: false, ), ), ), @@ -1666,7 +1664,7 @@ void main() { ); testWidgets( - 'An obscured CupertinoTextField is selectable when enabled', + 'An obscured CupertinoTextField is selectable by default', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure', @@ -1677,7 +1675,6 @@ void main() { child: CupertinoTextField( controller: controller, obscureText: true, - enableInteractiveSelection: true, ), ), ), @@ -1692,15 +1689,14 @@ void main() { // Hold the press. await tester.pump(const Duration(milliseconds: 500)); - // The obscured text is not broken into words, so only one letter is - // selected at a time. + // The obscured text is treated as one word, should select all expect( controller.selection, - const TextSelection(baseOffset: 9, extentOffset: 10), + const TextSelection(baseOffset: 0, extentOffset: 35), ); - // Selected text shows 3 toolbar buttons. - expect(find.byType(CupertinoButton), findsNWidgets(3)); + // Selected text shows paste toolbar buttons. + expect(find.byType(CupertinoButton), findsNWidgets(1)); await gesture.up(); await tester.pump(); @@ -1708,12 +1704,56 @@ void main() { // Still selected. expect( controller.selection, - const TextSelection(baseOffset: 9, extentOffset: 10), + const TextSelection(baseOffset: 0, extentOffset: 35), ); - expect(find.byType(CupertinoButton), findsNWidgets(3)); + expect(find.byType(CupertinoButton), findsNWidgets(1)); }, ); + testWidgets('An obscured TextField has correct default context menu', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + obscureText: true, + ), + ), + ), + ); + + final Offset textfieldStart = tester.getCenter(find.byType(CupertinoTextField)); + + await tester.tapAt(textfieldStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.longPressAt(textfieldStart + const Offset(150.0, 5.0)); + await tester.pump(); + + // Should only have paste option when whole obscure text is selected. + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + expect(find.text('Select All'), findsNothing); + + // Tap to cancel selection. + final Offset textfieldEnd = tester.getTopRight(find.byType(CupertinoTextField)); + await tester.tapAt(textfieldEnd + const Offset(-10.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + // Long tap at the end. + await tester.longPressAt(textfieldEnd + const Offset(-10.0, 5.0)); + await tester.pump(); + + // Should have paste and select all options when collapse. + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select All'), findsOneWidget); + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + }); + testWidgets( 'long press moves cursor to the exact long press position and shows toolbar', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index d0f5de10e0..291abf51d0 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -742,9 +742,7 @@ void main() { // Long press the 'e' to select 'def'. final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); - final TestGesture gesture = await tester.startGesture(ePos, pointer: 7); - await tester.pump(const Duration(seconds: 2)); - await gesture.up(); + await tester.longPressAt(ePos, pointer: 7); await tester.pump(); // 'def' is selected. @@ -867,7 +865,7 @@ void main() { expect(find.text('CUT'), findsNothing); }); - testWidgets('does not paint tool bar when no options available on ios', (WidgetTester tester) async { + testWidgets('does not paint toolbar when no options available on ios', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.iOS), @@ -889,7 +887,7 @@ void main() { expect(find.byType(CupertinoTextSelectionToolbar), paintsNothing); }); - testWidgets('text field build empty tool bar when no options available android', (WidgetTester tester) async { + testWidgets('text field build empty toolbar when no options available android', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( home: Material( @@ -1086,9 +1084,7 @@ void main() { // Long press the 'e' to select 'def'. final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); - final TestGesture gesture = await tester.startGesture(ePos, pointer: 7); - await tester.pump(const Duration(seconds: 2)); - await gesture.up(); + await tester.longPressAt(ePos, pointer: 7); await tester.pump(); expect(controller.selection.isCollapsed, true); @@ -1569,9 +1565,36 @@ void main() { // End the test here to ensure the animation is properly disposed of. }); - testWidgets('An obscured TextField is not selectable by default', (WidgetTester tester) async { + testWidgets('An obscured TextField is selectable by default', (WidgetTester tester) async { // This is a regression test for - // https://github.com/flutter/flutter/issues/24100 + // https://github.com/flutter/flutter/issues/32845 + + final TextEditingController controller = TextEditingController(); + Widget buildFrame(bool obscureText) { + return overlay( + child: TextField( + controller: controller, + obscureText: obscureText, + ), + ); + } + + // Obscure text and don't enable or disable selection. + await tester.pumpWidget(buildFrame(true)); + await tester.enterText(find.byType(TextField), 'abcdefghi'); + await skipPastScrollingAnimation(tester); + expect(controller.selection.isCollapsed, true); + + // Long press does select text. + final Offset ePos = textOffsetToPosition(tester, 1); + await tester.longPressAt(ePos, pointer: 7); + await tester.pump(); + expect(controller.selection.isCollapsed, false); + }); + + testWidgets('An obscured TextField is not selectable when disabled', (WidgetTester tester) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/32845 final TextEditingController controller = TextEditingController(); Widget buildFrame(bool obscureText, bool enableInteractiveSelection) { @@ -1584,49 +1607,75 @@ void main() { ); } - // Obscure text and don't enable or disable selection - await tester.pumpWidget(buildFrame(true, null)); + // Explicitly disabled selection on obscured text. + await tester.pumpWidget(buildFrame(true, false)); await tester.enterText(find.byType(TextField), 'abcdefghi'); await skipPastScrollingAnimation(tester); expect(controller.selection.isCollapsed, true); - // Long press doesn't select anything - final Offset ePos = textOffsetToPosition(tester, 1); - final TestGesture gesture = await tester.startGesture(ePos, pointer: 7); - await tester.pump(const Duration(seconds: 2)); - await gesture.up(); + // Long press doesn't select text. + final Offset ePos2 = textOffsetToPosition(tester, 1); + await tester.longPressAt(ePos2, pointer: 7); await tester.pump(); expect(controller.selection.isCollapsed, true); }); - testWidgets('An obscured TextField is selectable when enabled', (WidgetTester tester) async { - // This is a regression test for - // https://github.com/flutter/flutter/issues/24100 - + testWidgets('An obscured TextField is selected as one word', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); - Widget buildFrame(bool obscureText, bool enableInteractiveSelection) { - return overlay( - child: TextField( - controller: controller, - obscureText: obscureText, - enableInteractiveSelection: enableInteractiveSelection, - ), - ); - } - // Explicitly allow selection on obscured text - await tester.pumpWidget(buildFrame(true, true)); - await tester.enterText(find.byType(TextField), 'abcdefghi'); + await tester.pumpWidget(overlay( + child: TextField( + controller: controller, + obscureText: true, + ), + )); + await tester.enterText(find.byType(TextField), 'abcde fghi'); await skipPastScrollingAnimation(tester); - expect(controller.selection.isCollapsed, true); - // Long press does select text - final Offset ePos2 = textOffsetToPosition(tester, 1); - final TestGesture gesture2 = await tester.startGesture(ePos2, pointer: 7); - await tester.pump(const Duration(seconds: 2)); - await gesture2.up(); + // Long press does select text. + final Offset bPos = textOffsetToPosition(tester, 1); + await tester.longPressAt(bPos, pointer: 7); await tester.pump(); - expect(controller.selection.isCollapsed, false); + final TextSelection selection = controller.selection; + expect(selection.isCollapsed, false); + expect(selection.baseOffset, 0); + expect(selection.extentOffset, 10); + }); + + testWidgets('An obscured TextField has correct default context menu', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + + await tester.pumpWidget(overlay( + child: TextField( + controller: controller, + obscureText: true, + ), + )); + await tester.enterText(find.byType(TextField), 'abcde fghi'); + await skipPastScrollingAnimation(tester); + + // Long press to select text. + final Offset bPos = textOffsetToPosition(tester, 1); + await tester.longPressAt(bPos, pointer: 7); + await tester.pump(); + + // Should only have paste option when whole obscure text is selected. + expect(find.text('PASTE'), findsOneWidget); + expect(find.text('COPY'), findsNothing); + expect(find.text('CUT'), findsNothing); + expect(find.text('SELECT ALL'), findsNothing); + + // Long press at the end + final Offset iPos = textOffsetToPosition(tester, 10); + final Offset slightRight = iPos + const Offset(30.0, 0.0); + await tester.longPressAt(slightRight, pointer: 7); + await tester.pump(); + + // Should have paste and select all options when collapse. + expect(find.text('PASTE'), findsOneWidget); + expect(find.text('SELECT ALL'), findsOneWidget); + expect(find.text('COPY'), findsNothing); + expect(find.text('CUT'), findsNothing); }); testWidgets('TextField height with minLines unset', (WidgetTester tester) async { diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index a1301d9bed..4ac5ad54a6 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -533,6 +533,79 @@ void main() { expect(find.text('PASTE'), findsOneWidget); }); + testWidgets('can dynamically disable options in toolbar', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: TextEditingController(text: 'blah blah'), + focusNode: focusNode, + toolbarOptions: const ToolbarOptions( + copy: true, + selectAll: true, + ), + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + + // Select something. Doesn't really matter what. + state.renderEditable.selectWordsInRange( + from: const Offset(0, 0), + cause: SelectionChangedCause.tap, + ); + await tester.pump(); + expect(state.showToolbar(), true); + await tester.pump(); + expect(find.text('SELECT ALL'), findsOneWidget); + expect(find.text('COPY'), findsOneWidget); + expect(find.text('PASTE'), findsNothing); + expect(find.text('CUT'), findsNothing); + }); + + testWidgets('cut and paste are disabled in read only mode even if explicit set', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: TextEditingController(text: 'blah blah'), + focusNode: focusNode, + readOnly: true, + toolbarOptions: const ToolbarOptions( + paste: true, + cut: true, + selectAll: true, + copy: true, + ), + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + + // Select something. Doesn't really matter what. + state.renderEditable.selectWordsInRange( + from: const Offset(0, 0), + cause: SelectionChangedCause.tap, + ); + await tester.pump(); + expect(state.showToolbar(), true); + await tester.pump(); + expect(find.text('SELECT ALL'), findsOneWidget); + expect(find.text('COPY'), findsOneWidget); + expect(find.text('PASTE'), findsNothing); + expect(find.text('CUT'), findsNothing); + }); + testWidgets('Fires onChanged when text changes via TextSelectionOverlay', (WidgetTester tester) async { String changedValue; final Widget widget = MaterialApp( @@ -1628,8 +1701,14 @@ void main() { SemanticsFlag.isObscured, SemanticsFlag.isFocused, ], + actions: [ + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.setSelection, + SemanticsAction.moveCursorBackwardByWord + ], value: expectedValue, textDirection: TextDirection.ltr, + textSelection: const TextSelection.collapsed(offset: 24), ), ], ), diff --git a/packages/flutter/test/widgets/selectable_text_test.dart b/packages/flutter/test/widgets/selectable_text_test.dart index 6168acd9a8..f1353e3aa6 100644 --- a/packages/flutter/test/widgets/selectable_text_test.dart +++ b/packages/flutter/test/widgets/selectable_text_test.dart @@ -592,6 +592,27 @@ void main() { 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( + copy: false, + 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(