Disallow copy and cut when text field is obscured. (#96309)

Before this change, it was possible to select and copy obscured text from a text field.

This  changes things so that:
- Obscured text fields don't allow copy or cut.
- If a field is both obscured and read-only, then selection is disabled as well (if you can't modify it, and can't copy it, there's no point in selecting it).
This commit is contained in:
Greg Spencer 2022-01-14 14:31:59 -08:00 committed by GitHub
parent e25e1f9037
commit a9e0dd40dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 494 additions and 213 deletions

View file

@ -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.

View file

@ -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.

View file

@ -143,7 +143,7 @@ class TextFormField extends FormField<String> {
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<String> {
),
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<String> {
scrollPadding: scrollPadding,
scrollPhysics: scrollPhysics,
keyboardAppearance: keyboardAppearance,
enableInteractiveSelection: enableInteractiveSelection,
enableInteractiveSelection: enableInteractiveSelection ?? (!obscureText || !readOnly),
selectionControls: selectionControls,
buildCounter: buildCounter,
autofillHints: autofillHints,

View file

@ -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 <String>[],
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<TextEditingController>('controller', controller));
properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode));
properties.add(DiagnosticsProperty<bool>('obscureText', obscureText, defaultValue: false));
properties.add(DiagnosticsProperty<bool>('readOnly', readOnly, defaultValue: false));
properties.add(DiagnosticsProperty<bool>('autocorrect', autocorrect, defaultValue: true));
properties.add(EnumProperty<SmartDashesType>('smartDashesType', smartDashesType, defaultValue: obscureText ? SmartDashesType.disabled : SmartDashesType.enabled));
properties.add(EnumProperty<SmartQuotesType>('smartQuotesType', smartQuotesType, defaultValue: obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled));
@ -1511,6 +1534,7 @@ class EditableText extends StatefulWidget {
properties.add(DiagnosticsProperty<Iterable<String>>('autofillHints', autofillHints, defaultValue: null));
properties.add(DiagnosticsProperty<TextHeightBehavior>('textHeightBehavior', textHeightBehavior, defaultValue: null));
properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('enableInteractiveSelection', enableInteractiveSelection, defaultValue: true));
}
}
@ -1573,16 +1597,16 @@ class EditableTextState extends State<EditableText> 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<EditableText> 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<EditableText> 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<EditableText> 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<EditableText> 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

View file

@ -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 {

View file

@ -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 <String, dynamic>{'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 <String, dynamic>{'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>{ 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>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
testWidgets(
'double tap selects word and first tap of double tap moves cursor and shows toolbar',
(WidgetTester tester) async {

View file

@ -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 {

View file

@ -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<EditableTextState>(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<EditableTextState>(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<EditableTextState>(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');