diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index c2efc0408c..f1866d1838 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -289,7 +289,7 @@ class CupertinoTextField extends StatefulWidget { this.keyboardAppearance, this.scrollPadding = const EdgeInsets.all(20.0), this.dragStartBehavior = DragStartBehavior.start, - this.enableInteractiveSelection = true, + bool? enableInteractiveSelection, this.selectionControls, this.onTap, this.scrollController, @@ -341,17 +341,31 @@ class CupertinoTextField extends StatefulWidget { ), assert(enableIMEPersonalizedLearning != 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, - )), + enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText), + toolbarOptions = toolbarOptions ?? + (obscureText + ? (readOnly + // No point in even offering "Select All" in a read-only obscured + // field. + ? const ToolbarOptions() + // Writable, but obscured. + : const ToolbarOptions( + selectAll: true, + paste: true, + )) + : (readOnly + // Read-only, not obscured. + ? const ToolbarOptions( + selectAll: true, + copy: true, + ) + // Writable, not obscured. + : const ToolbarOptions( + copy: true, + cut: true, + selectAll: true, + paste: true, + ))), super(key: key); /// Creates a borderless iOS-style text field. @@ -446,7 +460,7 @@ class CupertinoTextField extends StatefulWidget { this.keyboardAppearance, this.scrollPadding = const EdgeInsets.all(20.0), this.dragStartBehavior = DragStartBehavior.start, - this.enableInteractiveSelection = true, + bool? enableInteractiveSelection, this.selectionControls, this.onTap, this.scrollController, @@ -499,17 +513,31 @@ class CupertinoTextField extends StatefulWidget { assert(clipBehavior != null), assert(enableIMEPersonalizedLearning != 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, - )), + enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText), + toolbarOptions = toolbarOptions ?? + (obscureText + ? (readOnly + // No point in even offering "Select All" in a read-only obscured + // field. + ? const ToolbarOptions() + // Writable, but obscured. + : const ToolbarOptions( + selectAll: true, + paste: true, + )) + : (readOnly + // Read-only, not obscured. + ? const ToolbarOptions( + selectAll: true, + copy: true, + ) + // Writable, not obscured. + : const ToolbarOptions( + copy: true, + cut: true, + selectAll: true, + paste: true, + ))), super(key: key); /// Controls the text being edited. diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 0fc2a87e1b..523b3f8bec 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -319,7 +319,7 @@ class TextField extends StatefulWidget { this.keyboardAppearance, this.scrollPadding = const EdgeInsets.all(20.0), this.dragStartBehavior = DragStartBehavior.start, - this.enableInteractiveSelection = true, + bool? enableInteractiveSelection, this.selectionControls, this.onTap, this.mouseCursor, @@ -339,7 +339,6 @@ class TextField extends StatefulWidget { smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), assert(enableSuggestions != null), - assert(enableInteractiveSelection != null), assert(maxLengthEnforced != null), assert( maxLengthEnforced || maxLengthEnforcement == null, @@ -372,17 +371,31 @@ class TextField extends StatefulWidget { assert(clipBehavior != null), assert(enableIMEPersonalizedLearning != 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, - )), + enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText), + toolbarOptions = toolbarOptions ?? + (obscureText + ? (readOnly + // No point in even offering "Select All" in a read-only obscured + // field. + ? const ToolbarOptions() + // Writable, but obscured. + : const ToolbarOptions( + selectAll: true, + paste: true, + )) + : (readOnly + // Read-only, not obscured. + ? const ToolbarOptions( + selectAll: true, + copy: true, + ) + // Writable, not obscured. + : const ToolbarOptions( + copy: true, + cut: true, + selectAll: true, + paste: true, + ))), super(key: key); /// Controls the text being edited. diff --git a/packages/flutter/lib/src/material/text_form_field.dart b/packages/flutter/lib/src/material/text_form_field.dart index d6fef5c037..9c63c2c0d9 100644 --- a/packages/flutter/lib/src/material/text_form_field.dart +++ b/packages/flutter/lib/src/material/text_form_field.dart @@ -143,7 +143,7 @@ class TextFormField extends FormField { Color? cursorColor, Brightness? keyboardAppearance, EdgeInsets scrollPadding = const EdgeInsets.all(20.0), - bool enableInteractiveSelection = true, + bool? enableInteractiveSelection, TextSelectionControls? selectionControls, InputCounterWidgetBuilder? buildCounter, ScrollPhysics? scrollPhysics, @@ -179,7 +179,6 @@ class TextFormField extends FormField { ), assert(!obscureText || maxLines == 1, 'Obscured fields cannot be multiline.'), assert(maxLength == null || maxLength == TextField.noMaxLength || maxLength > 0), - assert(enableInteractiveSelection != null), assert(enableIMEPersonalizedLearning != null), super( key: key, @@ -243,7 +242,7 @@ class TextFormField extends FormField { scrollPadding: scrollPadding, scrollPhysics: scrollPhysics, keyboardAppearance: keyboardAppearance, - enableInteractiveSelection: enableInteractiveSelection, + enableInteractiveSelection: enableInteractiveSelection ?? (!obscureText || !readOnly), selectionControls: selectionControls, buildCounter: buildCounter, autofillHints: autofillHints, diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 9a09c50746..9a203c4be0 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -508,16 +508,11 @@ class EditableText extends StatefulWidget { this.scrollPadding = const EdgeInsets.all(20.0), this.keyboardAppearance = Brightness.light, this.dragStartBehavior = DragStartBehavior.start, - this.enableInteractiveSelection = true, + bool? enableInteractiveSelection, this.scrollController, this.scrollPhysics, this.autocorrectionTextRectColor, - this.toolbarOptions = const ToolbarOptions( - copy: true, - cut: true, - paste: true, - selectAll: true, - ), + ToolbarOptions? toolbarOptions, this.autofillHints = const [], this.autofillClient, this.clipBehavior = Clip.hardEdge, @@ -533,7 +528,6 @@ class EditableText extends StatefulWidget { smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), assert(enableSuggestions != null), assert(showSelectionHandles != null), - assert(enableInteractiveSelection != null), assert(readOnly != null), assert(forceLine != null), assert(style != null), @@ -560,7 +554,31 @@ class EditableText extends StatefulWidget { assert(rendererIgnoresPointer != null), assert(scrollPadding != null), assert(dragStartBehavior != null), - assert(toolbarOptions != null), + enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText), + toolbarOptions = toolbarOptions ?? + (obscureText + ? (readOnly + // No point in even offering "Select All" in a read-only obscured + // field. + ? const ToolbarOptions() + // Writable, but obscured. + : const ToolbarOptions( + selectAll: true, + paste: true, + )) + : (readOnly + // Read-only, not obscured. + ? const ToolbarOptions( + selectAll: true, + copy: true, + ) + // Writable, not obscured. + : const ToolbarOptions( + copy: true, + cut: true, + selectAll: true, + paste: true, + ))), assert(clipBehavior != null), assert(enableIMEPersonalizedLearning != null), _strutStyle = strutStyle, @@ -593,7 +611,9 @@ class EditableText extends StatefulWidget { /// Whether to hide the text being edited (e.g., for passwords). /// /// When this is set to true, all the characters in the text field are - /// replaced by [obscuringCharacter]. + /// replaced by [obscuringCharacter], and the text in the field cannot be + /// copied with copy or cut. If [readOnly] is also true, then the text cannot + /// be selected. /// /// Defaults to false. Cannot be null. /// {@endtemplate} @@ -629,8 +649,10 @@ class EditableText extends StatefulWidget { /// Configuration of toolbar options. /// - /// By default, all options are enabled. If [readOnly] is true, - /// paste and cut will be disabled regardless. + /// By default, all options are enabled. If [readOnly] is true, paste and cut + /// will be disabled regardless. If [obscureText] is true, cut and copy will + /// be disabled regardless. If [readOnly] and [obscureText] are both true, + /// select all will also be disabled. final ToolbarOptions toolbarOptions; /// Whether to show selection handles. @@ -1492,6 +1514,7 @@ class EditableText extends StatefulWidget { properties.add(DiagnosticsProperty('controller', controller)); properties.add(DiagnosticsProperty('focusNode', focusNode)); properties.add(DiagnosticsProperty('obscureText', obscureText, defaultValue: false)); + properties.add(DiagnosticsProperty('readOnly', readOnly, defaultValue: false)); properties.add(DiagnosticsProperty('autocorrect', autocorrect, defaultValue: true)); properties.add(EnumProperty('smartDashesType', smartDashesType, defaultValue: obscureText ? SmartDashesType.disabled : SmartDashesType.enabled)); properties.add(EnumProperty('smartQuotesType', smartQuotesType, defaultValue: obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled)); @@ -1511,6 +1534,7 @@ class EditableText extends StatefulWidget { properties.add(DiagnosticsProperty>('autofillHints', autofillHints, defaultValue: null)); properties.add(DiagnosticsProperty('textHeightBehavior', textHeightBehavior, defaultValue: null)); properties.add(DiagnosticsProperty('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true)); + properties.add(DiagnosticsProperty('enableInteractiveSelection', enableInteractiveSelection, defaultValue: true)); } } @@ -1573,16 +1597,16 @@ class EditableTextState extends State with AutomaticKeepAliveClien Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController!.value); @override - bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly; + bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText; @override - bool get copyEnabled => widget.toolbarOptions.copy; + bool get copyEnabled => widget.toolbarOptions.copy && !widget.obscureText; @override bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly; @override - bool get selectAllEnabled => widget.toolbarOptions.selectAll; + bool get selectAllEnabled => widget.toolbarOptions.selectAll && (!widget.readOnly || !widget.obscureText) && widget.enableInteractiveSelection; void _onChangedClipboardStatus() { setState(() { @@ -1602,11 +1626,11 @@ class EditableTextState extends State with AutomaticKeepAliveClien @override void copySelection(SelectionChangedCause cause) { final TextSelection selection = textEditingValue.selection; - final String text = textEditingValue.text; assert(selection != null); - if (selection.isCollapsed) { + if (selection.isCollapsed || widget.obscureText) { return; } + final String text = textEditingValue.text; Clipboard.setData(ClipboardData(text: selection.textInside(text))); if (cause == SelectionChangedCause.toolbar) { bringIntoView(textEditingValue.selection.extent); @@ -1636,7 +1660,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// Cut current selection to [Clipboard]. @override void cutSelection(SelectionChangedCause cause) { - if (widget.readOnly) { + if (widget.readOnly || widget.obscureText) { return; } final TextSelection selection = textEditingValue.selection; @@ -1681,6 +1705,11 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// Select the entire text value. @override void selectAll(SelectionChangedCause cause) { + if (widget.readOnly && widget.obscureText) { + // If we can't modify it, and we can't copy it, there's no point in + // selecting it. + return; + } userUpdateTextEditingValue( textEditingValue.copyWith( selection: TextSelection(baseOffset: 0, extentOffset: textEditingValue.text.length), @@ -3057,7 +3086,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien selectionHeightStyle: widget.selectionHeightStyle, selectionWidthStyle: widget.selectionWidthStyle, paintCursorAboveText: widget.paintCursorAboveText, - enableInteractiveSelection: widget.enableInteractiveSelection, + enableInteractiveSelection: widget.enableInteractiveSelection && (!widget.readOnly || !widget.obscureText), textSelectionDelegate: this, devicePixelRatio: _devicePixelRatio, promptRectRange: _currentPromptRectRange, @@ -3287,6 +3316,7 @@ class _Editable extends MultiChildRenderObjectWidget { ..cursorOffset = cursorOffset ..selectionHeightStyle = selectionHeightStyle ..selectionWidthStyle = selectionWidthStyle + ..enableInteractiveSelection = enableInteractiveSelection ..textSelectionDelegate = textSelectionDelegate ..devicePixelRatio = devicePixelRatio ..paintCursorAboveText = paintCursorAboveText diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index 6d66fd1810..fb0495ce92 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -1747,6 +1747,44 @@ void main() { }, ); + testWidgets( + 'double tap does not select word on read-only obscured field', + (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + readOnly: true, + obscureText: true, + controller: controller, + ), + ), + ), + ); + + // Long press to put the cursor after the "w". + const int index = 3; + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pump(); + + // Second tap doesn't select anything. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 35), + ); + + // Selected text shows nothing. + expect(find.byType(CupertinoButton), findsNothing); + }, + ); + testWidgets('Readonly text field does not have tap action', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); @@ -2132,6 +2170,54 @@ void main() { }, ); + testWidgets( + 'A read-only obscured CupertinoTextField is not selectable', + (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + obscureText: true, + readOnly: true, + ), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + await tester.tapAt(textFieldStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + final TestGesture gesture = + await tester.startGesture(textFieldStart + const Offset(150.0, 5.0)); + // Hold the press. + await tester.pump(const Duration(milliseconds: 500)); + + // Nothing is selected despite the double tap long press gesture. + expect( + controller.selection, + const TextSelection(baseOffset: 35, extentOffset: 35), + ); + + // The selection menu is not present. + expect(find.byType(CupertinoButton), findsNWidgets(0)); + + await gesture.up(); + await tester.pump(); + + // Still nothing selected and no selection menu. + expect( + controller.selection, + const TextSelection.collapsed(offset: 35), + ); + expect(find.byType(CupertinoButton), findsNWidgets(0)); + }, + ); + testWidgets( 'An obscured CupertinoTextField is selectable by default', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 3fef7373b6..4656ba22d6 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -2454,6 +2454,34 @@ void main() { expect(controller.selection.isCollapsed, true); }); + testWidgets('An obscured TextField is not selectable when read-only', (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 readOnly) { + return overlay( + child: TextField( + controller: controller, + obscureText: obscureText, + readOnly: readOnly, + ), + ); + } + + // Explicitly disabled selection on obscured text that is read-only. + await tester.pumpWidget(buildFrame(true, true)); + await tester.enterText(find.byType(TextField), 'abcdefghi'); + await skipPastScrollingAnimation(tester); + expect(controller.selection.isCollapsed, true); + + // 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 selected as one word', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); @@ -4970,82 +4998,6 @@ void main() { variant: KeySimulatorTransitModeVariant.all() ); - testWidgets('Copy paste obscured text test', (WidgetTester tester) async { - final FocusNode focusNode = FocusNode(); - final TextEditingController controller = TextEditingController(); - final TextField textField = - TextField( - controller: controller, - obscureText: true, - ); - - String clipboardContent = ''; - tester.binding.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { - if (methodCall.method == 'Clipboard.setData') - // ignore: avoid_dynamic_calls - clipboardContent = methodCall.arguments['text'] as String; - else if (methodCall.method == 'Clipboard.getData') - return {'text': clipboardContent}; - return null; - }); - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: RawKeyboardListener( - focusNode: focusNode, - child: textField, - ), - ), - ), - ); - focusNode.requestFocus(); - await tester.pump(); - - const String testValue = 'a big house jumped over a mouse'; - await tester.enterText(find.byType(TextField), testValue); - - await tester.idle(); - await tester.tap(find.byType(TextField)); - await tester.pumpAndSettle(); - - // 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.sendKeyEvent(LogicalKeyboardKey.keyC); - await tester.pumpAndSettle(); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight); - await tester.pumpAndSettle(); - - expect(clipboardContent, 'a big'); - - await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); - await tester.pumpAndSettle(); - - // Paste them - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlRight); - await tester.sendKeyDownEvent(LogicalKeyboardKey.keyV); - await tester.pumpAndSettle(); - await tester.pump(const Duration(milliseconds: 200)); - await tester.sendKeyUpEvent(LogicalKeyboardKey.keyV); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight); - await tester.pumpAndSettle(); - - const String expected = 'a biga big house jumped over a mouse'; - expect(find.text(expected), findsOneWidget, reason: 'Because text contains ${controller.text}'); - }, - skip: areKeyEventsHandledByPlatform, // [intended] only applies to platforms where we handle key events. - variant: KeySimulatorTransitModeVariant.all() - ); - // Regressing test for https://github.com/flutter/flutter/issues/78219 testWidgets('Paste does not crash when the section is inValid', (WidgetTester tester) async { final FocusNode focusNode = FocusNode(); @@ -5174,83 +5126,6 @@ void main() { variant: KeySimulatorTransitModeVariant.all() ); - testWidgets('Cut obscured text test', (WidgetTester tester) async { - final FocusNode focusNode = FocusNode(); - final TextEditingController controller = TextEditingController(); - final TextField textField = TextField( - controller: controller, - obscureText: true, - ); - String clipboardContent = ''; - tester.binding.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { - if (methodCall.method == 'Clipboard.setData') - // ignore: avoid_dynamic_calls - clipboardContent = methodCall.arguments['text'] as String; - else if (methodCall.method == 'Clipboard.getData') - return {'text': clipboardContent}; - return null; - }); - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: RawKeyboardListener( - focusNode: focusNode, - child: textField, - ), - ), - ), - ); - focusNode.requestFocus(); - await tester.pump(); - - const String testValue = 'a big house jumped over a mouse'; - await tester.enterText(find.byType(TextField), testValue); - - await tester.idle(); - await tester.tap(find.byType(TextField)); - await tester.pumpAndSettle(); - - // Select the first 5 characters - for (int i = 0; i < 5; i += 1) { - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); - await tester.pumpAndSettle(); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - await tester.pumpAndSettle(); - } - - // Cut them - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlRight); - await tester.sendKeyEvent(LogicalKeyboardKey.keyX); - await tester.pumpAndSettle(); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight); - await tester.pumpAndSettle(); - - expect(clipboardContent, 'a big'); - - for (int i = 0; i < 5; i += 1) { - await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); - await tester.pumpAndSettle(); - } - - // Paste them - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlRight); - await tester.sendKeyDownEvent(LogicalKeyboardKey.keyV); - await tester.pumpAndSettle(); - await tester.pump(const Duration(milliseconds: 200)); - await tester.sendKeyUpEvent(LogicalKeyboardKey.keyV); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight); - await tester.pumpAndSettle(); - - const String expected = ' housa bige jumped over a mouse'; - expect(find.text(expected), findsOneWidget); - }, - skip: areKeyEventsHandledByPlatform, // [intended] only applies to platforms where we handle key events. - variant: KeySimulatorTransitModeVariant.all() - ); - testWidgets('Select all test', (WidgetTester tester) async { final FocusNode focusNode = FocusNode(); final TextEditingController controller = TextEditingController(); @@ -7125,6 +7000,55 @@ void main() { variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), ); + testWidgets( + 'double tap does not select word on read-only obscured field', + (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + obscureText: true, + readOnly: true, + controller: controller, + ), + ), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + // 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(textfieldStart + const Offset(50.0, 9.0)); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 35), + ); + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pumpAndSettle(); + + // Second tap doesn't select anything. + expect( + controller.selection, + const TextSelection.collapsed(offset: 35), + ); + + // Selected text shows nothing. + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), + ); + testWidgets( 'double tap selects word and first tap of double tap moves cursor and shows toolbar', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/text_form_field_test.dart b/packages/flutter/test/material/text_form_field_test.dart index 3ac5618967..b4f2a1e136 100644 --- a/packages/flutter/test/material/text_form_field_test.dart +++ b/packages/flutter/test/material/text_form_field_test.dart @@ -102,6 +102,94 @@ void main() { skip: kIsWeb, // [intended] we don't supply the cut/copy/paste buttons on the web. ); + testWidgets('the desktop cut/copy/paste buttons are disabled for read-only obscured form fields', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'blah1 blah2', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextFormField( + readOnly: true, + obscureText: true, + controller: controller, + ), + ), + ), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expect(find.byType(CupertinoButton), findsNothing); + const TextSelection invalidSelection = TextSelection(baseOffset: -1, extentOffset: -1); + expect(controller.selection, invalidSelection); + + final Offset midBlah1 = textOffsetToPosition(tester, 2); + + // Right clicking shows the menu. + final TestGesture gesture = await tester.startGesture( + midBlah1, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.selection, invalidSelection); + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: TargetPlatformVariant.desktop(), + skip: kIsWeb, // [intended] we don't supply the cut/copy/paste buttons on the web. + ); + + testWidgets('the desktop cut/copy buttons are disabled for obscured form fields', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'blah1 blah2', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextFormField( + obscureText: true, + controller: controller, + ), + ), + ), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expect(find.byType(CupertinoButton), findsNothing); + const TextSelection invalidSelection = TextSelection(baseOffset: -1, extentOffset: -1); + expect(controller.selection, invalidSelection); + + final Offset midBlah1 = textOffsetToPosition(tester, 2); + + // Right clicking shows the menu. + final TestGesture gesture = await tester.startGesture( + midBlah1, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 11)); + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + }, + variant: TargetPlatformVariant.desktop(), + skip: kIsWeb, // [intended] we don't supply the cut/copy/paste buttons on the web. + ); + testWidgets('TextFormField accepts TextField.noMaxLength as value to maxLength parameter', (WidgetTester tester) async { bool asserted; try { diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 74a9384a44..025123a27d 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -1506,7 +1506,7 @@ void main() { expect(find.text('Cut'), findsNothing); }); - testWidgets('cut and paste are disabled in read only mode even if explicit set', (WidgetTester tester) async { + testWidgets('cut and paste are disabled in read only mode even if explicitly set', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: EditableText( @@ -1514,6 +1514,12 @@ void main() { controller: TextEditingController(text: 'blah blah'), focusNode: focusNode, readOnly: true, + toolbarOptions: const ToolbarOptions( + copy: true, + cut: true, + paste: true, + selectAll: true, + ), style: textStyle, cursorColor: cursorColor, selectionControls: materialTextSelectionControls, @@ -1539,6 +1545,113 @@ void main() { expect(find.text('Cut'), findsNothing); }); + testWidgets('cut and copy are disabled in obscured mode even if explicitly set', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: TextEditingController(text: 'blah blah'), + focusNode: focusNode, + obscureText: true, + toolbarOptions: const ToolbarOptions( + copy: true, + cut: true, + paste: true, + selectAll: true, + ), + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + await tester.tap(find.byType(EditableText)); + await tester.pump(); + // Select something, but not the whole thing. + state.renderEditable.selectWord(cause: SelectionChangedCause.tap); + await tester.pump(); + expect(state.selectAllEnabled, isTrue); + expect(state.pasteEnabled, isTrue); + expect(state.cutEnabled, isFalse); + expect(state.copyEnabled, isFalse); + + // On web, we don't let Flutter show the toolbar. + expect(state.showToolbar(), kIsWeb ? isFalse : isTrue); + await tester.pump(); + expect(find.text('Select all'), kIsWeb ? findsNothing : findsOneWidget); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), kIsWeb ? findsNothing : findsOneWidget); + expect(find.text('Cut'), findsNothing); + }); + + testWidgets('cut and copy do nothing in obscured mode even if explicitly called', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: TextEditingController(text: 'blah blah'), + focusNode: focusNode, + obscureText: true, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + expect(state.selectAllEnabled, isTrue); + expect(state.pasteEnabled, isTrue); + expect(state.cutEnabled, isFalse); + expect(state.copyEnabled, isFalse); + + // Select all. + state.selectAll(SelectionChangedCause.toolbar); + await tester.pump(); + await Clipboard.setData(const ClipboardData(text: '')); + state.cutSelection(SelectionChangedCause.toolbar); + ClipboardData? data = await Clipboard.getData('text/plain'); + expect(data, isNotNull); + expect(data!.text, isEmpty); + + state.selectAll(SelectionChangedCause.toolbar); + await tester.pump(); + await Clipboard.setData(const ClipboardData(text: '')); + state.copySelection(SelectionChangedCause.toolbar); + data = await Clipboard.getData('text/plain'); + expect(data, isNotNull); + expect(data!.text, isEmpty); + }); + + testWidgets('select all does nothing if obscured and read-only, even if explicitly called', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: TextEditingController(text: 'blah blah'), + focusNode: focusNode, + obscureText: true, + readOnly: true, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + + // Select all. + state.selectAll(SelectionChangedCause.toolbar); + expect(state.selectAllEnabled, isFalse); + expect(state.textEditingValue.selection.isCollapsed, isTrue); + }); + testWidgets('Handles the read-only flag correctly', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(text: 'Lorem ipsum dolor sit amet');