diff --git a/packages/flutter/lib/src/cupertino/text_selection.dart b/packages/flutter/lib/src/cupertino/text_selection.dart index a839603a8e..c9c89a9d34 100644 --- a/packages/flutter/lib/src/cupertino/text_selection.dart +++ b/packages/flutter/lib/src/cupertino/text_selection.dart @@ -6,7 +6,6 @@ import 'dart:math' as math; import 'package:flutter/widgets.dart'; import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; import 'button.dart'; @@ -59,16 +58,12 @@ class _TextSelectionToolbarNotchPainter extends CustomPainter { class _TextSelectionToolbar extends StatelessWidget { const _TextSelectionToolbar({ Key key, - this.delegate, this.handleCut, this.handleCopy, this.handlePaste, this.handleSelectAll, }) : super(key: key); - final TextSelectionDelegate delegate; - TextEditingValue get value => delegate.textEditingValue; - final VoidCallback handleCut; final VoidCallback handleCopy; final VoidCallback handlePaste; @@ -80,20 +75,24 @@ class _TextSelectionToolbar extends StatelessWidget { final Widget onePhysicalPixelVerticalDivider = new SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio); - if (!value.selection.isCollapsed) { + if (handleCut != null) items.add(_buildToolbarButton('Cut', handleCut)); - items.add(onePhysicalPixelVerticalDivider); + + if (handleCopy != null) { + if (items.isNotEmpty) + items.add(onePhysicalPixelVerticalDivider); items.add(_buildToolbarButton('Copy', handleCopy)); } - // TODO(https://github.com/flutter/flutter/issues/11254): - // This should probably be grayed-out if there is nothing to paste. - if (items.isNotEmpty) - items.add(onePhysicalPixelVerticalDivider); - items.add(_buildToolbarButton('Paste', handlePaste)); + if (handlePaste != null) { + if (items.isNotEmpty) + items.add(onePhysicalPixelVerticalDivider); + items.add(_buildToolbarButton('Paste', handlePaste)); + } - if (value.text.isNotEmpty && value.selection.isCollapsed) { - items.add(onePhysicalPixelVerticalDivider); + if (handleSelectAll != null) { + if (items.isNotEmpty) + items.add(onePhysicalPixelVerticalDivider); items.add(_buildToolbarButton('Select All', handleSelectAll)); } @@ -236,11 +235,10 @@ class _CupertinoTextSelectionControls extends TextSelectionControls { position, ), child: new _TextSelectionToolbar( - delegate: delegate, - handleCut: () => handleCut(delegate), - handleCopy: () => handleCopy(delegate), - handlePaste: () => handlePaste(delegate), - handleSelectAll: () => handleSelectAll(delegate), + handleCut: canCut(delegate) ? () => handleCut(delegate) : null, + handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null, + handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null, + handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, ), ) ); diff --git a/packages/flutter/lib/src/material/text_selection.dart b/packages/flutter/lib/src/material/text_selection.dart index d191dc65b6..1a551ca5d9 100644 --- a/packages/flutter/lib/src/material/text_selection.dart +++ b/packages/flutter/lib/src/material/text_selection.dart @@ -6,7 +6,6 @@ import 'dart:math' as math; import 'package:flutter/widgets.dart'; import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; import 'flat_button.dart'; import 'material.dart'; @@ -22,16 +21,12 @@ const double _kToolbarScreenPadding = 8.0; class _TextSelectionToolbar extends StatelessWidget { const _TextSelectionToolbar({ Key key, - this.delegate, this.handleCut, this.handleCopy, this.handlePaste, this.handleSelectAll, }) : super(key: key); - final TextSelectionDelegate delegate; - TextEditingValue get value => delegate.textEditingValue; - final VoidCallback handleCut; final VoidCallback handleCopy; final VoidCallback handlePaste; @@ -42,20 +37,14 @@ class _TextSelectionToolbar extends StatelessWidget { final List items = []; final MaterialLocalizations localizations = MaterialLocalizations.of(context); - if (!value.selection.isCollapsed) { + if (handleCut != null) items.add(new FlatButton(child: new Text(localizations.cutButtonLabel), onPressed: handleCut)); + if (handleCopy != null) items.add(new FlatButton(child: new Text(localizations.copyButtonLabel), onPressed: handleCopy)); - } - items.add(new FlatButton( - child: new Text(localizations.pasteButtonLabel), - // TODO(https://github.com/flutter/flutter/issues/11254): - // This should probably be grayed-out if there is nothing to paste. - onPressed: handlePaste, - )); - if (value.text.isNotEmpty) { - if (value.selection.isCollapsed) - items.add(new FlatButton(child: new Text(localizations.selectAllButtonLabel), onPressed: handleSelectAll)); - } + if (handlePaste != null) + items.add(new FlatButton(child: new Text(localizations.pasteButtonLabel), onPressed: handlePaste,)); + if (handleSelectAll != null) + items.add(new FlatButton(child: new Text(localizations.selectAllButtonLabel), onPressed: handleSelectAll)); return new Material( elevation: 1.0, @@ -152,11 +141,10 @@ class _MaterialTextSelectionControls extends TextSelectionControls { position, ), child: new _TextSelectionToolbar( - delegate: delegate, - handleCut: () => handleCut(delegate), - handleCopy: () => handleCopy(delegate), - handlePaste: () => handlePaste(delegate), - handleSelectAll: () => handleSelectAll(delegate), + handleCut: canCut(delegate) ? () => handleCut(delegate) : null, + handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null, + handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null, + handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, ), ) ); diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 22c2824859..d476a8810a 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -308,7 +308,7 @@ class EditableText extends StatefulWidget { } /// State for a [EditableText]. -class EditableTextState extends State with AutomaticKeepAliveClientMixin implements TextInputClient { +class EditableTextState extends State with AutomaticKeepAliveClientMixin implements TextInputClient, TextSelectionDelegate { Timer _cursorTimer; final ValueNotifier _showCursor = new ValueNotifier(false); final GlobalKey _editableKey = new GlobalKey(); @@ -516,8 +516,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien debugRequiredFor: widget, layerLink: _layerLink, renderObject: renderObject, - onSelectionOverlayChanged: _handleSelectionOverlayChanged, selectionControls: widget.selectionControls, + selectionDelegate: this, ); final bool longPress = cause == SelectionChangedCause.longPress; if (cause != SelectionChangedCause.keyboard && (_value.text.isNotEmpty || longPress)) @@ -529,12 +529,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } - void _handleSelectionOverlayChanged(TextEditingValue value, Rect caretRect) { - assert(!value.composing.isValid); // composing range must be empty while selecting. - _formatAndSetValue(value); - _scrollController.jumpTo(_getScrollOffsetForCaret(caretRect)); - } - bool _textChangedSinceLastCaretUpdate = false; void _handleCaretChanged(Rect caretRect) { @@ -643,10 +637,30 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// when [ignorePointer] is true. See [RenderEditable.ignorePointer]. RenderEditable get renderEditable => _editableKey.currentContext.findRenderObject(); + @override + TextEditingValue get textEditingValue => _value; + + @override + set textEditingValue(TextEditingValue value) { + _selectionOverlay?.update(value); + _formatAndSetValue(value); + } + + @override + void bringIntoView(TextPosition position) { + _scrollController.jumpTo(_getScrollOffsetForCaret(renderEditable.getLocalRectForCaret(position))); + } + + @override + void hideToolbar() { + _selectionOverlay?.hide(); + } + @override Widget build(BuildContext context) { FocusScope.of(context).reparentIfNeeded(widget.focusNode); super.build(context); // See AutomaticKeepAliveClientMixin. + final TextSelectionControls controls = widget.selectionControls; return new Scrollable( excludeFromSemantics: true, axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right, @@ -655,25 +669,30 @@ class EditableTextState extends State with AutomaticKeepAliveClien viewportBuilder: (BuildContext context, ViewportOffset offset) { return new CompositedTransformTarget( link: _layerLink, - child: new _Editable( - key: _editableKey, - value: _value, - style: widget.style, - cursorColor: widget.cursorColor, - showCursor: _showCursor, - hasFocus: _hasFocus, - maxLines: widget.maxLines, - selectionColor: widget.selectionColor, - textScaleFactor: widget.textScaleFactor ?? MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1.0, - textAlign: widget.textAlign, - textDirection: _textDirection, - obscureText: widget.obscureText, - obscureShowCharacterAtIndex: _obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null, - autocorrect: widget.autocorrect, - offset: offset, - onSelectionChanged: _handleSelectionChanged, - onCaretChanged: _handleCaretChanged, - rendererIgnoresPointer: widget.rendererIgnoresPointer, + child: new Semantics( + onCopy: _hasFocus && controls?.canCopy(this) == true ? () => controls.handleCopy(this) : null, + onCut: _hasFocus && controls?.canCut(this) == true ? () => controls.handleCut(this) : null, + onPaste: _hasFocus && controls?.canPaste(this) == true ? () => controls.handlePaste(this) : null, + child: new _Editable( + key: _editableKey, + value: _value, + style: widget.style, + cursorColor: widget.cursorColor, + showCursor: _showCursor, + hasFocus: _hasFocus, + maxLines: widget.maxLines, + selectionColor: widget.selectionColor, + textScaleFactor: widget.textScaleFactor ?? MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1.0, + textAlign: widget.textAlign, + textDirection: _textDirection, + obscureText: widget.obscureText, + obscureShowCharacterAtIndex: _obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null, + autocorrect: widget.autocorrect, + offset: offset, + onSelectionChanged: _handleSelectionChanged, + onCaretChanged: _handleCaretChanged, + rendererIgnoresPointer: widget.rendererIgnoresPointer, + ), ), ); }, diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index ce54a8e8cf..17f46b1069 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -72,6 +72,10 @@ abstract class TextSelectionDelegate { /// Hides the text selection toolbar. void hideToolbar(); + + /// Brings the provided [TextPosition] into the visible area of the text + /// input. + void bringIntoView(TextPosition position); } /// An interface for building the selection UI, to be provided by the @@ -93,6 +97,49 @@ abstract class TextSelectionControls { /// Returns the size of the selection handle. Size get handleSize; + /// Whether the current selection of the text field managed by the given + /// `delegate` can be removed from the text field and placed into the + /// [Clipboard]. + /// + /// By default, false is returned when nothing is selected in the text field. + /// + /// Subclasses can use this to decide if they should expose the cut + /// functionality to the user. + bool canCut(TextSelectionDelegate delegate) { + return !delegate.textEditingValue.selection.isCollapsed; + } + + /// Whether the current selection of the text field managed by the given + /// `delegate` can be copied to the [Clipboard]. + /// + /// By default, false is returned when nothing is selected in the text field. + /// + /// Subclasses can use this to decide if they should expose the copy + /// functionality to the user. + bool canCopy(TextSelectionDelegate delegate) { + return !delegate.textEditingValue.selection.isCollapsed; + } + + /// Whether the current [Clipboard] content can be pasted into the text field + /// managed by the given `delegate`. + /// + /// Subclasses can use this to decide if they should expose the paste + /// functionality to the user. + bool canPaste(TextSelectionDelegate delegate) { + // TODO(goderbauer): return false when clipboard is empty, https://github.com/flutter/flutter/issues/11254 + return true; + } + + /// Whether the the current selection of the text field managed by the given + /// `delegate` can be extended to include the entire content of the text + /// field. + /// + /// Subclasses can use this to decide if they should expose the select all + /// functionality to the user. + bool canSelectAll(TextSelectionDelegate delegate) { + return delegate.textEditingValue.text.isNotEmpty && delegate.textEditingValue.selection.isCollapsed; + } + /// Copy the current selection of the text field managed by the given /// `delegate` to the [Clipboard]. Then, remove the selected text from the /// text field and hide the toolbar. @@ -111,6 +158,7 @@ abstract class TextSelectionControls { offset: value.selection.start ), ); + delegate.bringIntoView(delegate.textEditingValue.selection.extent); delegate.hideToolbar(); } @@ -129,6 +177,7 @@ abstract class TextSelectionControls { text: value.text, selection: new TextSelection.collapsed(offset: value.selection.end), ); + delegate.bringIntoView(delegate.textEditingValue.selection.extent); delegate.hideToolbar(); } @@ -156,6 +205,7 @@ abstract class TextSelectionControls { ), ); } + delegate.bringIntoView(delegate.textEditingValue.selection.extent); delegate.hideToolbar(); } @@ -174,6 +224,7 @@ abstract class TextSelectionControls { extentOffset: delegate.textEditingValue.text.length ), ); + delegate.bringIntoView(delegate.textEditingValue.selection.extent); } } @@ -181,7 +232,7 @@ abstract class TextSelectionControls { /// /// The selection handles are displayed in the [Overlay] that most closely /// encloses the given [BuildContext]. -class TextSelectionOverlay implements TextSelectionDelegate { +class TextSelectionOverlay { /// Creates an object that manages overly entries for selection handles. /// /// The [context] must not be null and must have an [Overlay] as an ancestor. @@ -191,8 +242,8 @@ class TextSelectionOverlay implements TextSelectionDelegate { this.debugRequiredFor, @required this.layerLink, @required this.renderObject, - this.onSelectionOverlayChanged, this.selectionControls, + this.selectionDelegate, }): assert(value != null), assert(context != null), _value = value { @@ -220,15 +271,13 @@ class TextSelectionOverlay implements TextSelectionDelegate { /// The editable line in which the selected text is being displayed. final RenderEditable renderObject; - /// Called when the the selection changes. - /// - /// For example, if the use drags one of the selection handles, this function - /// will be called with a new input value with an updated selection. - final TextSelectionOverlayChanged onSelectionOverlayChanged; - /// Builds text selection handles and toolbar. final TextSelectionControls selectionControls; + /// The delegate for manipulating the current selection in the owning + /// text field. + final TextSelectionDelegate selectionDelegate; + /// Controls the fade-in animations. static const Duration _kFadeDuration = const Duration(milliseconds: 150); AnimationController _handleController; @@ -372,24 +421,23 @@ class TextSelectionOverlay implements TextSelectionDelegate { link: layerLink, showWhenUnlinked: false, offset: -editingRegion.topLeft, - child: selectionControls.buildToolbar(context, editingRegion, midpoint, this), + child: selectionControls.buildToolbar(context, editingRegion, midpoint, selectionDelegate), ), ); } void _handleSelectionHandleChanged(TextSelection newSelection, _TextSelectionHandlePosition position) { - Rect caretRect; + TextPosition textPosition; switch (position) { case _TextSelectionHandlePosition.start: - caretRect = renderObject.getLocalRectForCaret(newSelection.base); + textPosition = newSelection.base; break; case _TextSelectionHandlePosition.end: - caretRect = renderObject.getLocalRectForCaret(newSelection.extent); + textPosition =newSelection.extent; break; } - update(_value.copyWith(selection: newSelection, composing: TextRange.empty)); - if (onSelectionOverlayChanged != null) - onSelectionOverlayChanged(_value, caretRect); + selectionDelegate.textEditingValue = _value.copyWith(selection: newSelection, composing: TextRange.empty); + selectionDelegate.bringIntoView(textPosition); } void _handleSelectionHandleTapped() { @@ -402,23 +450,6 @@ class TextSelectionOverlay implements TextSelectionDelegate { } } } - - @override - TextEditingValue get textEditingValue => _value; - - @override - set textEditingValue(TextEditingValue newValue) { - update(newValue); - if (onSelectionOverlayChanged != null) { - final Rect caretRect = renderObject.getLocalRectForCaret(newValue.selection.extent); - onSelectionOverlayChanged(newValue, caretRect); - } - } - - @override - void hideToolbar() { - hide(); - } } /// This widget represents a single draggable text selection handle. diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 338147b8c2..3b1103c26f 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -1813,6 +1813,7 @@ void main() { SemanticsAction.tap, SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.setSelection, + SemanticsAction.paste, ], flags: [ SemanticsFlag.isTextField, @@ -1837,6 +1838,7 @@ void main() { SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.setSelection, + SemanticsAction.paste, ], flags: [ SemanticsFlag.isTextField, @@ -1861,6 +1863,7 @@ void main() { SemanticsAction.tap, SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.setSelection, + SemanticsAction.paste, ], flags: [ SemanticsFlag.isTextField, @@ -1919,6 +1922,7 @@ void main() { SemanticsAction.tap, SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.setSelection, + SemanticsAction.paste, ], flags: [ SemanticsFlag.isTextField, @@ -1943,6 +1947,9 @@ void main() { SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.setSelection, + SemanticsAction.paste, + SemanticsAction.cut, + SemanticsAction.copy, ], flags: [ SemanticsFlag.isTextField, @@ -1989,6 +1996,7 @@ void main() { SemanticsAction.tap, SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.setSelection, + SemanticsAction.paste, ], flags: [ SemanticsFlag.isTextField, @@ -2032,6 +2040,9 @@ void main() { SemanticsAction.tap, SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.setSelection, + SemanticsAction.paste, + SemanticsAction.cut, + SemanticsAction.copy, ], flags: [ SemanticsFlag.isTextField, diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index d581305bb1..e20fd28abf 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter/services.dart'; +import 'package:mockito/mockito.dart'; import 'semantics_tester.dart'; @@ -19,6 +20,10 @@ void main() { final TextStyle textStyle = const TextStyle(); final Color cursorColor = const Color.fromARGB(0xFF, 0xFF, 0x00, 0x00); + setUp(() { + debugResetSemanticsIdCounter(); + }); + testWidgets('has expected defaults', (WidgetTester tester) async { await tester.pumpWidget(new Directionality( textDirection: TextDirection.ltr, @@ -347,14 +352,14 @@ void main() { final EditableTextState textState = tester.state(find.byType(EditableText)); expect(textState.selectionOverlay.handlesAreVisible, isTrue); - expect(textState.selectionOverlay.textEditingValue.selection, const TextSelection.collapsed(offset: 4)); + expect(textState.selectionOverlay.selectionDelegate.textEditingValue.selection, const TextSelection.collapsed(offset: 4)); // Simulate selection change via keyboard and expect handles to disappear. render.onSelectionChanged(const TextSelection.collapsed(offset: 10), render, SelectionChangedCause.keyboard); await tester.pumpAndSettle(); expect(textState.selectionOverlay.handlesAreVisible, isFalse); - expect(textState.selectionOverlay.textEditingValue.selection, const TextSelection.collapsed(offset: 10)); + expect(textState.selectionOverlay.selectionDelegate.textEditingValue.selection, const TextSelection.collapsed(offset: 10)); }); testWidgets('exposes correct cursor movement semantics', (WidgetTester tester) async { @@ -565,4 +570,151 @@ void main() { semantics.dispose(); }); + + group('a11y copy/cut/paste', () { + Future _buildApp(MockTextSelectionControls controls, WidgetTester tester) { + return tester.pumpWidget(new MaterialApp( + home: new EditableText( + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: controls, + ), + )); + } + + MockTextSelectionControls controls; + + setUp(() { + controller.text = 'test'; + controller.selection = new TextSelection.collapsed(offset: controller.text.length); + + controls = new MockTextSelectionControls(); + when(controls.buildHandle(any, any, any)).thenReturn(new Container()); + when(controls.buildToolbar(any, any, any, any)).thenReturn(new Container()); + }); + + testWidgets('are exposed', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + + when(controls.canCopy(any)).thenReturn(false); + when(controls.canCut(any)).thenReturn(false); + when(controls.canPaste(any)).thenReturn(false); + + await _buildApp(controls, tester); + await tester.tap(find.byType(EditableText)); + await tester.pump(); + + expect(semantics, includesNodeWith( + value: 'test', + actions: [ + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.setSelection, + ], + )); + + when(controls.canCopy(any)).thenReturn(true); + await _buildApp(controls, tester); + expect(semantics, includesNodeWith( + value: 'test', + actions: [ + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.setSelection, + SemanticsAction.copy, + ], + )); + + when(controls.canCopy(any)).thenReturn(false); + when(controls.canPaste(any)).thenReturn(true); + await _buildApp(controls, tester); + expect(semantics, includesNodeWith( + value: 'test', + actions: [ + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.setSelection, + SemanticsAction.paste, + ], + )); + + when(controls.canPaste(any)).thenReturn(false); + when(controls.canCut(any)).thenReturn(true); + await _buildApp(controls, tester); + expect(semantics, includesNodeWith( + value: 'test', + actions: [ + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.setSelection, + SemanticsAction.cut, + ], + )); + + when(controls.canCopy(any)).thenReturn(true); + when(controls.canCut(any)).thenReturn(true); + when(controls.canPaste(any)).thenReturn(true); + await _buildApp(controls, tester); + expect(semantics, includesNodeWith( + value: 'test', + actions: [ + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.setSelection, + SemanticsAction.cut, + SemanticsAction.copy, + SemanticsAction.paste, + ], + )); + + semantics.dispose(); + }); + + testWidgets('can copy/cut/paste with a11y', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + + when(controls.canCopy(any)).thenReturn(true); + when(controls.canCut(any)).thenReturn(true); + when(controls.canPaste(any)).thenReturn(true); + await _buildApp(controls, tester); + await tester.tap(find.byType(EditableText)); + await tester.pump(); + + final SemanticsOwner owner = tester.binding.pipelineOwner.semanticsOwner; + const int expectedNodeId = 3; + + expect(semantics, hasSemantics(new TestSemantics.root( + children: [ + new TestSemantics.rootChild( + id: expectedNodeId, + flags: [ + SemanticsFlag.isTextField, + SemanticsFlag.isFocused + ], + actions: [ + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.setSelection, + SemanticsAction.copy, + SemanticsAction.cut, + SemanticsAction.paste + ], + value: 'test', + textSelection: new TextSelection.collapsed(offset: controller.text.length), + textDirection: TextDirection.ltr, + ), + ], + ), ignoreRect: true, ignoreTransform: true)); + + owner.performAction(expectedNodeId, SemanticsAction.copy); + verify(controls.handleCopy(any)).called(1); + + owner.performAction(expectedNodeId, SemanticsAction.cut); + verify(controls.handleCut(any)).called(1); + + owner.performAction(expectedNodeId, SemanticsAction.paste); + verify(controls.handlePaste(any)).called(1); + + semantics.dispose(); + }); + }); + } + +class MockTextSelectionControls extends Mock implements TextSelectionControls {} diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart index d99bbfde5e..45b1abc191 100644 --- a/packages/flutter/test/widgets/semantics_tester.dart +++ b/packages/flutter/test/widgets/semantics_tester.dart @@ -257,7 +257,7 @@ class TestSemantics { if (!ignoreTransform && transform != nodeData.transform) return fail('expected node id $id to have transform $transform but found transform:\n${nodeData.transform}.'); if (textSelection?.baseOffset != nodeData.textSelection?.baseOffset || textSelection?.extentOffset != nodeData.textSelection?.extentOffset) { - return fail('expected node id $id to have textDirection [${textSelection?.baseOffset}, ${textSelection?.end}] but found: [${nodeData.textSelection?.baseOffset}, ${nodeData.textSelection?.extentOffset}].'); + return fail('expected node id $id to have textSelection [${textSelection?.baseOffset}, ${textSelection?.end}] but found: [${nodeData.textSelection?.baseOffset}, ${nodeData.textSelection?.extentOffset}].'); } final int childrenCount = node.mergeAllDescendantsIntoThisNode ? 0 : node.childrenCount; if (children.length != childrenCount)