From 2338576aa6b7ec0edadc8cdff2255f4dd01339aa Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Fri, 19 Jul 2019 12:17:41 -0700 Subject: [PATCH] implement selectable text (#34019) --- packages/flutter/lib/material.dart | 1 + .../lib/src/material/selectable_text.dart | 580 +++ .../flutter/lib/src/material/text_field.dart | 10 +- .../lib/src/painting/text_painter.dart | 4 +- .../flutter/lib/src/rendering/editable.dart | 89 +- .../lib/src/widgets/editable_text.dart | 84 +- .../test/material/text_field_test.dart | 26 +- .../test/widgets/selectable_text_test.dart | 3722 +++++++++++++++++ 8 files changed, 4461 insertions(+), 55 deletions(-) create mode 100644 packages/flutter/lib/src/material/selectable_text.dart create mode 100644 packages/flutter/test/widgets/selectable_text_test.dart diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index d6589e58ada..edeaa36cd19 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -93,6 +93,7 @@ export 'src/material/reorderable_list.dart'; export 'src/material/scaffold.dart'; export 'src/material/scrollbar.dart'; export 'src/material/search.dart'; +export 'src/material/selectable_text.dart'; export 'src/material/shadows.dart'; export 'src/material/slider.dart'; export 'src/material/slider_theme.dart'; diff --git a/packages/flutter/lib/src/material/selectable_text.dart b/packages/flutter/lib/src/material/selectable_text.dart new file mode 100644 index 00000000000..64d6aef6251 --- /dev/null +++ b/packages/flutter/lib/src/material/selectable_text.dart @@ -0,0 +1,580 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; + +import 'feedback.dart'; +import 'text_selection.dart'; +import 'theme.dart'; + +/// An eyeballed value that moves the cursor slightly left of where it is +/// rendered for text on Android so its positioning more accurately matches the +/// native iOS text cursor positioning. +/// +/// This value is in device pixels, not logical pixels as is typically used +/// throughout the codebase. +const int iOSHorizontalOffset = -2; + +class _TextSpanEditingController extends TextEditingController { + _TextSpanEditingController({@required TextSpan textSpan}): + assert(textSpan != null), + _textSpan = textSpan, + super(text: textSpan.toPlainText()); + + final TextSpan _textSpan; + + @override + TextSpan buildTextSpan({TextStyle style ,bool withComposing}) { + // TODO(chunhtai): Implement composing. + return TextSpan( + style: style, + children: [_textSpan], + ); + } + + @override + set text(String newText) { + // TODO(chunhtai): Implement value editing. + } +} + +class _SelectableTextSelectionGestureDetectorBuilder extends TextSelectionGestureDetectorBuilder { + _SelectableTextSelectionGestureDetectorBuilder({ + @required _SelectableTextState state + }) : _state = state, + super(delegate: state); + + final _SelectableTextState _state; + + @override + void onForcePressStart(ForcePressDetails details) { + super.onForcePressStart(details); + if (delegate.selectionEnabled && shouldShowSelectionToolbar) { + editableText.showToolbar(); + } + } + + @override + void onForcePressEnd(ForcePressDetails details) { + // Not required. + } + + @override + void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { + if (delegate.selectionEnabled) { + switch (Theme.of(_state.context).platform) { + case TargetPlatform.iOS: + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + renderEditable.selectWordsInRange( + from: details.globalPosition - details.offsetFromOrigin, + to: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + break; + } + } + } + + @override + void onSingleTapUp(TapUpDetails details) { + editableText.hideToolbar(); + if (delegate.selectionEnabled) { + switch (Theme.of(_state.context).platform) { + case TargetPlatform.iOS: + renderEditable.selectWordEdge(cause: SelectionChangedCause.tap); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + break; + } + } + if (_state.widget.onTap != null) + _state.widget.onTap(); + } + + @override + void onSingleLongTapStart(LongPressStartDetails details) { + if (delegate.selectionEnabled) { + switch (Theme.of(_state.context).platform) { + case TargetPlatform.iOS: + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + Feedback.forLongPress(_state.context); + break; + } + } + } +} + +/// A run of selectable text with a single style. +/// +/// The [SelectableText] widget displays a string of text with a single style. +/// The string might break across multiple lines or might all be displayed on +/// the same line depending on the layout constraints. +/// +/// The [style] argument is optional. When omitted, the text will use the style +/// from the closest enclosing [DefaultTextStyle]. If the given style's +/// [TextStyle.inherit] property is true (the default), the given style will +/// be merged with the closest enclosing [DefaultTextStyle]. This merging +/// behavior is useful, for example, to make the text bold while using the +/// default font family and size. +/// +/// {@tool sample} +/// +/// ```dart +/// SelectableText( +/// 'Hello! How are you?', +/// textAlign: TextAlign.center, +/// style: TextStyle(fontWeight: FontWeight.bold), +/// ) +/// ``` +/// {@end-tool} +/// +/// Using the [SelectableText.rich] constructor, the [SelectableText] widget can +/// display a paragraph with differently styled [TextSpan]s. The sample +/// that follows displays "Hello beautiful world" with different styles +/// for each word. +/// +/// {@tool sample} +/// +/// ```dart +/// const SelectableText.rich( +/// TextSpan( +/// text: 'Hello', // default text style +/// children: [ +/// TextSpan(text: ' beautiful ', style: TextStyle(fontStyle: FontStyle.italic)), +/// TextSpan(text: 'world', style: TextStyle(fontWeight: FontWeight.bold)), +/// ], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Interactivity +/// +/// To make [SelectableText] react to touch events, use callback [onTap] to achieve +/// the desired behavior. +/// +/// See also: +/// +/// * [Text], which is the non selectable version of this widget. +/// * [TextField], which is the editable version of this widget. +class SelectableText extends StatefulWidget { + /// Creates a selectable text widget. + /// + /// If the [style] argument is null, the text will use the style from the + /// closest enclosing [DefaultTextStyle]. + /// + /// The [data] parameter must not be null. + const SelectableText( + this.data, { + Key key, + this.focusNode, + this.style, + this.strutStyle, + this.textAlign, + this.textDirection, + this.showCursor = false, + this.autofocus = false, + this.maxLines, + this.cursorWidth = 2.0, + this.cursorRadius, + this.cursorColor, + this.dragStartBehavior = DragStartBehavior.start, + this.enableInteractiveSelection = true, + this.onTap, + this.scrollPhysics, + this.textWidthBasis, + }) : assert(showCursor != null), + assert(autofocus != null), + assert(dragStartBehavior != null), + assert(maxLines == null || maxLines > 0), + assert( + data != null, + 'A non-null String must be provided to a SelectableText widget.', + ), + textSpan = null, + super(key: key); + + /// Creates a selectable text widget with a [TextSpan]. + /// + /// The [textSpan] parameter must not be null and only contain [TextSpan] in + /// [textSpan.children]. Other type of [InlineSpan] is not allowed. + const SelectableText.rich( + this.textSpan, { + Key key, + this.focusNode, + this.style, + this.strutStyle, + this.textAlign, + this.textDirection, + this.showCursor = false, + this.autofocus = false, + this.maxLines, + this.cursorWidth = 2.0, + this.cursorRadius, + this.cursorColor, + this.dragStartBehavior = DragStartBehavior.start, + this.enableInteractiveSelection = true, + this.onTap, + this.scrollPhysics, + this.textWidthBasis, + }) : assert(showCursor != null), + assert(autofocus != null), + assert(dragStartBehavior != null), + assert(maxLines == null || maxLines > 0), + assert( + textSpan != null, + 'A non-null TextSpan must be provided to a SelectableText.rich widget.', + ), + data = null, + super(key: key); + + /// The text to display. + /// + /// This will be null if a [textSpan] is provided instead. + final String data; + + /// The text to display as a [TextSpan]. + /// + /// This will be null if [data] is provided instead. + final TextSpan textSpan; + + /// Defines the focus for this widget. + /// + /// Text is only selectable when widget is focused. + /// + /// The [focusNode] is a long-lived object that's typically managed by a + /// [StatefulWidget] parent. See [FocusNode] for more information. + /// + /// To give the focus to this widget, provide a [focusNode] and then + /// use the current [FocusScope] to request the focus: + /// + /// ```dart + /// FocusScope.of(context).requestFocus(myFocusNode); + /// ``` + /// + /// This happens automatically when the widget is tapped. + /// + /// To be notified when the widget gains or loses the focus, add a listener + /// to the [focusNode]: + /// + /// ```dart + /// focusNode.addListener(() { print(myFocusNode.hasFocus); }); + /// ``` + /// + /// If null, this widget will create its own [FocusNode]. + final FocusNode focusNode; + + /// The style to use for the text. + /// + /// If null, defaults [DefaultTextStyle] of context. + final TextStyle style; + + /// {@macro flutter.widgets.editableText.strutStyle} + final StrutStyle strutStyle; + + /// {@macro flutter.widgets.editableText.textAlign} + final TextAlign textAlign; + + /// {@macro flutter.widgets.editableText.textDirection} + final TextDirection textDirection; + + /// {@macro flutter.widgets.editableText.autofocus} + final bool autofocus; + + /// {@macro flutter.widgets.editableText.maxLines} + final int maxLines; + + /// {@macro flutter.widgets.editableText.showCursor} + final bool showCursor; + + /// {@macro flutter.widgets.editableText.cursorWidth} + final double cursorWidth; + + /// {@macro flutter.widgets.editableText.cursorRadius} + final Radius cursorRadius; + + /// The color to use when painting the cursor. + /// + /// Defaults to the theme's `cursorColor` when null. + final Color cursorColor; + + /// {@macro flutter.widgets.editableText.enableInteractiveSelection} + final bool enableInteractiveSelection; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@macro flutter.rendering.editable.selectionEnabled} + bool get selectionEnabled { + return enableInteractiveSelection; + } + + /// Called when the user taps on this selectable text. + /// + /// The selectable text builds a [GestureDetector] to handle input events like tap, + /// to trigger focus requests, to move the caret, adjust the selection, etc. + /// Handling some of those events by wrapping the selectable text with a competing + /// GestureDetector is problematic. + /// + /// To unconditionally handle taps, without interfering with the selectable text's + /// internal gesture detector, provide this callback. + /// + /// To be notified when the text field gains or loses the focus, provide a + /// [focusNode] and add a listener to that. + /// + /// To listen to arbitrary pointer events without competing with the + /// selectable text's internal gesture detector, use a [Listener]. + final GestureTapCallback onTap; + + /// {@macro flutter.widgets.edtiableText.scrollPhysics} + final ScrollPhysics scrollPhysics; + + /// {@macro flutter.dart:ui.text.TextWidthBasis} + final TextWidthBasis textWidthBasis; + + @override + _SelectableTextState createState() => _SelectableTextState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('data', data, defaultValue: null)); + properties.add(DiagnosticsProperty('focusNode', focusNode, defaultValue: null)); + properties.add(DiagnosticsProperty('style', style, defaultValue: null)); + properties.add(DiagnosticsProperty('autofocus', autofocus, defaultValue: false)); + properties.add(DiagnosticsProperty('showCursor', showCursor, defaultValue: false)); + properties.add(IntProperty('maxLines', maxLines, defaultValue: null)); + properties.add(EnumProperty('textAlign', textAlign, defaultValue: null)); + properties.add(EnumProperty('textDirection', textDirection, defaultValue: null)); + properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0)); + properties.add(DiagnosticsProperty('cursorRadius', cursorRadius, defaultValue: null)); + properties.add(DiagnosticsProperty('cursorColor', cursorColor, defaultValue: null)); + properties.add(FlagProperty('selectionEnabled', value: selectionEnabled, defaultValue: true, ifFalse: 'selection disabled')); + properties.add(DiagnosticsProperty('scrollPhysics', scrollPhysics, defaultValue: null)); + } +} + +class _SelectableTextState extends State with AutomaticKeepAliveClientMixin implements TextSelectionGestureDetectorBuilderDelegate { + EditableTextState get _editableText => editableTextKey.currentState; + + _TextSpanEditingController _controller; + + FocusNode _focusNode; + FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode()); + + bool _showSelectionHandles = false; + + _SelectableTextSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder; + + // API for TextSelectionGestureDetectorBuilderDelegate. + @override + bool forcePressEnabled; + + @override + final GlobalKey editableTextKey = GlobalKey(); + + @override + bool get selectionEnabled => widget.selectionEnabled; + // End of API for TextSelectionGestureDetectorBuilderDelegate. + + @override + void initState() { + super.initState(); + _selectionGestureDetectorBuilder = _SelectableTextSelectionGestureDetectorBuilder(state: this); + _controller = _TextSpanEditingController( + textSpan: widget.textSpan ?? TextSpan(text: widget.data) + ); + } + + @override + void didUpdateWidget(SelectableText oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.data != oldWidget.data || widget.textSpan != oldWidget.textSpan) { + _controller = _TextSpanEditingController( + textSpan: widget.textSpan ?? TextSpan(text: widget.data) + ); + } + if (_effectiveFocusNode.hasFocus && _controller.selection.isCollapsed) { + _showSelectionHandles = false; + } + } + + @override + void dispose() { + _focusNode?.dispose(); + super.dispose(); + } + + void _handleSelectionChanged(TextSelection selection, SelectionChangedCause cause) { + final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); + if (willShowSelectionHandles != _showSelectionHandles) { + setState(() { + _showSelectionHandles = willShowSelectionHandles; + }); + } + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + if (cause == SelectionChangedCause.longPress) { + _editableText?.bringIntoView(selection.base); + } + return; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + // Do nothing. + } + } + + /// Toggle the toolbar when a selection handle is tapped. + void _handleSelectionHandleTapped() { + if (_controller.selection.isCollapsed) { + _editableText.toggleToolbar(); + } + } + + bool _shouldShowSelectionHandles(SelectionChangedCause cause) { + // When the text field is activated by something that doesn't trigger the + // selection overlay, we shouldn't show the handles either. + if (!_selectionGestureDetectorBuilder.shouldShowSelectionToolbar) + return false; + + if (_controller.selection.isCollapsed) + return false; + + if (cause == SelectionChangedCause.keyboard) + return false; + + if (cause == SelectionChangedCause.longPress) + return true; + + if (_controller.text.isNotEmpty) + return true; + + return false; + } + + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); // See AutomaticKeepAliveClientMixin. + assert(() { + return _controller._textSpan.visitChildren((InlineSpan span) => span.runtimeType == TextSpan); + }(), 'SelectableText only supports TextSpan; Other type of InlineSpan is not allowed'); + assert(debugCheckHasMediaQuery(context)); + assert(debugCheckHasDirectionality(context)); + assert( + !(widget.style != null && widget.style.inherit == false && + (widget.style.fontSize == null || widget.style.textBaseline == null)), + 'inherit false style must supply fontSize and textBaseline', + ); + + final ThemeData themeData = Theme.of(context); + final FocusNode focusNode = _effectiveFocusNode; + + TextSelectionControls textSelectionControls; + bool paintCursorAboveText; + bool cursorOpacityAnimates; + Offset cursorOffset; + Color cursorColor = widget.cursorColor; + Radius cursorRadius = widget.cursorRadius; + + switch (themeData.platform) { + case TargetPlatform.iOS: + forcePressEnabled = true; + textSelectionControls = cupertinoTextSelectionControls; + paintCursorAboveText = true; + cursorOpacityAnimates = true; + cursorColor ??= CupertinoTheme.of(context).primaryColor; + cursorRadius ??= const Radius.circular(2.0); + cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); + break; + + case TargetPlatform.android: + case TargetPlatform.fuchsia: + forcePressEnabled = false; + textSelectionControls = materialTextSelectionControls; + paintCursorAboveText = false; + cursorOpacityAnimates = false; + cursorColor ??= themeData.cursorColor; + break; + } + + final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context); + TextStyle effectiveTextStyle = widget.style; + if (widget.style == null || widget.style.inherit) + effectiveTextStyle = defaultTextStyle.style.merge(widget.style); + if (MediaQuery.boldTextOverride(context)) + effectiveTextStyle = effectiveTextStyle.merge(const TextStyle(fontWeight: FontWeight.bold)); + final Widget child = RepaintBoundary( + child: EditableText( + key: editableTextKey, + style: effectiveTextStyle, + readOnly: true, + textWidthBasis: widget.textWidthBasis ?? defaultTextStyle.textWidthBasis, + showSelectionHandles: _showSelectionHandles, + showCursor: widget.showCursor, + controller: _controller, + focusNode: focusNode, + strutStyle: widget.strutStyle ?? StrutStyle.disabled, + textAlign: widget.textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start, + textDirection: widget.textDirection, + autofocus: widget.autofocus, + forceLine: false, + maxLines: widget.maxLines ?? defaultTextStyle.maxLines, + selectionColor: themeData.textSelectionColor, + selectionControls: widget.selectionEnabled ? textSelectionControls : null, + onSelectionChanged: _handleSelectionChanged, + onSelectionHandleTapped: _handleSelectionHandleTapped, + rendererIgnoresPointer: true, + cursorWidth: widget.cursorWidth, + cursorRadius: cursorRadius, + cursorColor: cursorColor, + cursorOpacityAnimates: cursorOpacityAnimates, + cursorOffset: cursorOffset, + paintCursorAboveText: paintCursorAboveText, + backgroundCursorColor: CupertinoColors.inactiveGray, + enableInteractiveSelection: widget.enableInteractiveSelection, + dragStartBehavior: widget.dragStartBehavior, + scrollPhysics: widget.scrollPhysics, + ), + ); + + return Semantics( + onTap: () { + if (!_controller.selection.isValid) + _controller.selection = TextSelection.collapsed(offset: _controller.text.length); + _effectiveFocusNode.requestFocus(); + }, + onLongPress: () { + _effectiveFocusNode.requestFocus(); + }, + child: _selectionGestureDetectorBuilder.buildGestureDetector( + behavior: HitTestBehavior.translucent, + child: child, + ), + ); + } +} diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index ffb715b8d37..88fee77e0b3 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -17,6 +17,7 @@ import 'ink_well.dart' show InteractiveInkFeature; import 'input_decorator.dart'; import 'material.dart'; import 'material_localizations.dart'; +import 'selectable_text.dart' show iOSHorizontalOffset; import 'text_selection.dart'; import 'theme.dart'; @@ -932,14 +933,7 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi cursorOpacityAnimates = true; cursorColor ??= CupertinoTheme.of(context).primaryColor; cursorRadius ??= const Radius.circular(2.0); - // An eyeballed value that moves the cursor slightly left of where it is - // rendered for text on Android so its positioning more accurately matches the - // native iOS text cursor positioning. - // - // This value is in device pixels, not logical pixels as is typically used - // throughout the codebase. - const int _iOSHorizontalOffset = -2; - cursorOffset = Offset(_iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); + cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); break; case TargetPlatform.android: diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index b10e43a9f83..e17a63a4902 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -652,7 +652,7 @@ class TextPainter { final double caretEnd = box.end; final double dx = box.direction == TextDirection.rtl ? caretEnd - caretPrototype.width : caretEnd; - return Rect.fromLTRB(min(dx, width), box.top, min(dx, width), box.bottom); + return Rect.fromLTRB(min(dx, _paragraph.width), box.top, min(dx, _paragraph.width), box.bottom); } return null; } @@ -694,7 +694,7 @@ class TextPainter { final TextBox box = boxes.last; final double caretStart = box.start; final double dx = box.direction == TextDirection.rtl ? caretStart - caretPrototype.width : caretStart; - return Rect.fromLTRB(min(dx, width), box.top, min(dx, width), box.bottom); + return Rect.fromLTRB(min(dx, _paragraph.width), box.top, min(dx, _paragraph.width), box.bottom); } return null; } diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 25195492488..a4ab81ab1db 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -157,6 +157,9 @@ class RenderEditable extends RenderBox { this.onSelectionChanged, this.onCaretChanged, this.ignorePointer = false, + bool readOnly = false, + bool forceLine = true, + TextWidthBasis textWidthBasis = TextWidthBasis.parent, bool obscureText = false, Locale locale, double cursorWidth = 1.0, @@ -185,11 +188,14 @@ class RenderEditable extends RenderBox { assert(textScaleFactor != null), assert(offset != null), assert(ignorePointer != null), + assert(textWidthBasis != null), assert(paintCursorAboveText != null), assert(obscureText != null), assert(textSelectionDelegate != null), assert(cursorWidth != null && cursorWidth >= 0.0), - assert(devicePixelRatio != null), + assert(readOnly != null), + assert(forceLine != null), + assert(devicePixelRatio != null), _textPainter = TextPainter( text: text, textAlign: textAlign, @@ -197,6 +203,7 @@ class RenderEditable extends RenderBox { textScaleFactor: textScaleFactor, locale: locale, strutStyle: strutStyle, + textWidthBasis: textWidthBasis, ), _cursorColor = cursorColor, _backgroundCursorColor = backgroundCursorColor, @@ -216,7 +223,9 @@ class RenderEditable extends RenderBox { _devicePixelRatio = devicePixelRatio, _startHandleLayerLink = startHandleLayerLink, _endHandleLayerLink = endHandleLayerLink, - _obscureText = obscureText { + _obscureText = obscureText, + _readOnly = readOnly, + _forceLine = forceLine { assert(_showCursor != null); assert(!_showCursor.value || cursorColor != null); this.hasFocus = hasFocus ?? false; @@ -245,12 +254,15 @@ class RenderEditable extends RenderBox { /// The default value of this property is false. bool ignorePointer; - /// Whether text is composed. - /// - /// Text is composed when user selects it for editing. The [TextSpan] will have - /// children with composing effect and leave text property to be null. - @visibleForTesting - bool get isComposingText => text.text == null; + /// {@macro flutter.widgets.text.DefaultTextStyle.textWidthBasis} + TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis; + set textWidthBasis(TextWidthBasis value) { + assert(value != null); + if (_textPainter.textWidthBasis == value) + return; + _textPainter.textWidthBasis = value; + markNeedsTextLayout(); + } /// The pixel ratio of the current device. /// @@ -444,7 +456,7 @@ class RenderEditable extends RenderBox { if (leftArrow && _extentOffset > 2) { final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: _extentOffset - 2)); newOffset = textSelection.baseOffset + 1; - } else if (rightArrow && _extentOffset < text.text.length - 2) { + } else if (rightArrow && _extentOffset < text.toPlainText().length - 2) { final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: _extentOffset + 1)); newOffset = textSelection.extentOffset - 1; } @@ -487,7 +499,7 @@ class RenderEditable extends RenderBox { // case that the user wants to unhighlight some text. if (position.offset == _extentOffset) { if (downArrow) - newOffset = text.text.length; + newOffset = text.toPlainText().length; else if (upArrow) newOffset = 0; _resetCursor = shift; @@ -554,16 +566,16 @@ class RenderEditable extends RenderBox { case _kCKeyCode: if (!selection.isCollapsed) { Clipboard.setData( - ClipboardData(text: selection.textInside(text.text))); + ClipboardData(text: selection.textInside(text.toPlainText()))); } break; case _kXKeyCode: if (!selection.isCollapsed) { Clipboard.setData( - ClipboardData(text: selection.textInside(text.text))); + ClipboardData(text: selection.textInside(text.toPlainText()))); textSelectionDelegate.textEditingValue = TextEditingValue( - text: selection.textBefore(text.text) - + selection.textAfter(text.text), + text: selection.textBefore(text.toPlainText()) + + selection.textAfter(text.toPlainText()), selection: TextSelection.collapsed(offset: selection.start), ); } @@ -601,15 +613,15 @@ class RenderEditable extends RenderBox { } void _handleDelete() { - if (selection.textAfter(text.text).isNotEmpty) { + if (selection.textAfter(text.toPlainText()).isNotEmpty) { textSelectionDelegate.textEditingValue = TextEditingValue( - text: selection.textBefore(text.text) - + selection.textAfter(text.text).substring(1), + text: selection.textBefore(text.toPlainText()) + + selection.textAfter(text.toPlainText()).substring(1), selection: TextSelection.collapsed(offset: selection.start), ); } else { textSelectionDelegate.textEditingValue = TextEditingValue( - text: selection.textBefore(text.text), + text: selection.textBefore(text.toPlainText()), selection: TextSelection.collapsed(offset: selection.start), ); } @@ -758,6 +770,28 @@ class RenderEditable extends RenderBox { markNeedsSemanticsUpdate(); } + /// Whether this rendering object will take a full line regardless the text width. + bool get forceLine => _forceLine; + bool _forceLine = false; + set forceLine(bool value) { + assert(value != null); + if (_forceLine == value) + return; + _forceLine = value; + markNeedsLayout(); + } + + /// Whether this rendering object is read only. + bool get readOnly => _readOnly; + bool _readOnly = false; + set readOnly(bool value) { + assert(value != null); + if (_readOnly == value) + return; + _readOnly = value; + markNeedsSemanticsUpdate(); + } + /// The maximum number of lines for the text to span, wrapping if necessary. /// /// If this is 1 (the default), the text will not wrap, but will extend @@ -983,6 +1017,8 @@ class RenderEditable extends RenderBox { return enableInteractiveSelection ?? !obscureText; } + double get _caretMargin => _kCaretGap + cursorWidth; + @override void describeSemanticsConfiguration(SemanticsConfiguration config) { super.describeSemanticsConfiguration(config); @@ -995,7 +1031,8 @@ class RenderEditable extends RenderBox { ..isMultiline = _isMultiline ..textDirection = textDirection ..isFocused = hasFocus - ..isTextField = true; + ..isTextField = true + ..isReadOnly = readOnly; if (hasFocus && selectionEnabled) config.onSetSelection = _handleSetSelection; @@ -1526,10 +1563,12 @@ class RenderEditable extends RenderBox { assert(constraintWidth != null); if (_textLayoutLastWidth == constraintWidth) return; - final double caretMargin = _kCaretGap + cursorWidth; - final double availableWidth = math.max(0.0, constraintWidth - caretMargin); + final double availableWidth = math.max(0.0, constraintWidth - _caretMargin); final double maxWidth = _isMultiline ? availableWidth : double.infinity; - _textPainter.layout(minWidth: availableWidth, maxWidth: maxWidth); + _textPainter.layout( + minWidth: forceLine ? availableWidth : 0, + maxWidth: maxWidth, + ); _textLayoutLastWidth = constraintWidth; } @@ -1566,8 +1605,10 @@ class RenderEditable extends RenderBox { // though we currently don't use those here. // See also RenderParagraph which has a similar issue. final Size textPainterSize = _textPainter.size; - size = Size(constraints.maxWidth, constraints.constrainHeight(_preferredHeight(constraints.maxWidth))); - final Size contentSize = Size(textPainterSize.width + _kCaretGap + cursorWidth, textPainterSize.height); + final double width = forceLine ? constraints.maxWidth : constraints + .constrainWidth(_textPainter.size.width + _caretMargin); + size = Size(width, constraints.constrainHeight(_preferredHeight(constraints.maxWidth))); + final Size contentSize = Size(textPainterSize.width + _caretMargin, textPainterSize.height); _maxScrollExtent = _getMaxScrollExtent(contentSize); offset.applyViewportDimension(_viewportExtent); offset.applyContentDimensions(0.0, _maxScrollExtent); diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 468b0a834b5..c821bb2a6e7 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -150,6 +150,29 @@ class TextEditingController extends ValueNotifier { ); } + /// Builds [TextSpan] from current editing value. + /// + /// By default makes text in composing range appear as underlined. + /// Descendants can override this method to customize appearance of text. + TextSpan buildTextSpan({TextStyle style , bool withComposing}) { + if (!value.composing.isValid || !withComposing) { + return TextSpan(style: style, text: text); + } + final TextStyle composingStyle = style.merge( + const TextStyle(decoration: TextDecoration.underline), + ); + return TextSpan( + style: style, + children: [ + TextSpan(text: value.composing.textBefore(value.text)), + TextSpan( + style: composingStyle, + text: value.composing.textInside(value.text), + ), + TextSpan(text: value.composing.textAfter(value.text)), + ]); + } + /// The currently selected [text]. /// /// If the selection is collapsed, then this property gives the offset of the @@ -288,6 +311,8 @@ class EditableText extends StatefulWidget { this.maxLines = 1, this.minLines, this.expands = false, + this.forceLine = true, + this.textWidthBasis = TextWidthBasis.parent, this.autofocus = false, bool showCursor, this.showSelectionHandles = false, @@ -320,6 +345,7 @@ class EditableText extends StatefulWidget { assert(autocorrect != null), assert(showSelectionHandles != null), assert(readOnly != null), + assert(forceLine != null), assert(style != null), assert(cursorColor != null), assert(cursorOpacityAnimates != null), @@ -368,6 +394,9 @@ class EditableText extends StatefulWidget { /// {@endtemplate} final bool obscureText; + /// {@macro flutter.widgets.text.DefaultTextStyle.textWidthBasis} + final TextWidthBasis textWidthBasis; + /// {@template flutter.widgets.editableText.readOnly} /// Whether the text can be changed. /// @@ -378,6 +407,18 @@ class EditableText extends StatefulWidget { /// {@endtemplate} final bool readOnly; + /// Whether the text will take the full width regardless of the text width. + /// + /// When this is set to false, the width will be based on text width, which + /// will also be affected by [textWidthBasis]. + /// + /// Defaults to true. Must not be null. + /// + /// See also: + /// + /// * [textWidthBasis], which controls the calculation of text width. + final bool forceLine; + /// Whether to show selection handles. /// /// When a selection is active, there will be two handles at each side of @@ -396,7 +437,7 @@ class EditableText extends StatefulWidget { /// /// See also: /// - /// * [showSelectionHandles], which controls the visibility of the selection handles.. + /// * [showSelectionHandles], which controls the visibility of the selection handles. /// {@endtemplate} final bool showCursor; @@ -1622,6 +1663,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien showCursor: EditableText.debugDeterministicCursor ? ValueNotifier(widget.showCursor) : _cursorVisibilityNotifier, + forceLine: widget.forceLine, + readOnly: widget.readOnly, hasFocus: _hasFocus, maxLines: widget.maxLines, minLines: widget.minLines, @@ -1632,6 +1675,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien textAlign: widget.textAlign, textDirection: _textDirection, locale: widget.locale, + textWidthBasis: widget.textWidthBasis, obscureText: widget.obscureText, autocorrect: widget.autocorrect, offset: offset, @@ -1657,32 +1701,20 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// By default makes text in composing range appear as underlined. /// Descendants can override this method to customize appearance of text. TextSpan buildTextSpan() { - // Read only mode should not paint text composing. - if (!widget.obscureText && _value.composing.isValid && !widget.readOnly) { - final TextStyle composingStyle = widget.style.merge( - const TextStyle(decoration: TextDecoration.underline), - ); - return TextSpan( - style: widget.style, - children: [ - TextSpan(text: _value.composing.textBefore(_value.text)), - TextSpan( - style: composingStyle, - text: _value.composing.textInside(_value.text), - ), - TextSpan(text: _value.composing.textAfter(_value.text)), - ]); - } - - String text = _value.text; if (widget.obscureText) { + String text = _value.text; text = RenderEditable.obscuringCharacter * text.length; final int o = _obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null; if (o != null && o >= 0 && o < text.length) text = text.replaceRange(o, o + 1, _value.text.substring(o, o + 1)); + return TextSpan(style: widget.style, text: text); } - return TextSpan(style: widget.style, text: text); + // Read only mode should not paint text composing. + return widget.controller.buildTextSpan( + style: widget.style, + withComposing: !widget.readOnly, + ); } } @@ -1696,6 +1728,9 @@ class _Editable extends LeafRenderObjectWidget { this.cursorColor, this.backgroundCursorColor, this.showCursor, + this.forceLine, + this.readOnly, + this.textWidthBasis, this.hasFocus, this.maxLines, this.minLines, @@ -1730,6 +1765,8 @@ class _Editable extends LeafRenderObjectWidget { final LayerLink endHandleLayerLink; final Color backgroundCursorColor; final ValueNotifier showCursor; + final bool forceLine; + final bool readOnly; final bool hasFocus; final int maxLines; final int minLines; @@ -1741,6 +1778,7 @@ class _Editable extends LeafRenderObjectWidget { final TextDirection textDirection; final Locale locale; final bool obscureText; + final TextWidthBasis textWidthBasis; final bool autocorrect; final ViewportOffset offset; final SelectionChangedHandler onSelectionChanged; @@ -1763,6 +1801,8 @@ class _Editable extends LeafRenderObjectWidget { endHandleLayerLink: endHandleLayerLink, backgroundCursorColor: backgroundCursorColor, showCursor: showCursor, + forceLine: forceLine, + readOnly: readOnly, hasFocus: hasFocus, maxLines: maxLines, minLines: minLines, @@ -1779,6 +1819,7 @@ class _Editable extends LeafRenderObjectWidget { onCaretChanged: onCaretChanged, ignorePointer: rendererIgnoresPointer, obscureText: obscureText, + textWidthBasis: textWidthBasis, cursorWidth: cursorWidth, cursorRadius: cursorRadius, cursorOffset: cursorOffset, @@ -1797,6 +1838,8 @@ class _Editable extends LeafRenderObjectWidget { ..startHandleLayerLink = startHandleLayerLink ..endHandleLayerLink = endHandleLayerLink ..showCursor = showCursor + ..forceLine = forceLine + ..readOnly = readOnly ..hasFocus = hasFocus ..maxLines = maxLines ..minLines = minLines @@ -1812,6 +1855,7 @@ class _Editable extends LeafRenderObjectWidget { ..onSelectionChanged = onSelectionChanged ..onCaretChanged = onCaretChanged ..ignorePointer = rendererIgnoresPointer + ..textWidthBasis = textWidthBasis ..obscureText = obscureText ..cursorWidth = cursorWidth ..cursorRadius = cursorRadius diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index a432de80439..d0f5de10e0c 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -973,7 +973,7 @@ void main() { final RenderEditable renderEditable = findRenderEditable(tester); // There should be no composing. - expect(renderEditable.isComposingText, false); + expect(renderEditable.text, TextSpan(text:'readonly', style: renderEditable.text.style)); }); testWidgets('Dynamically switching between read only and not read only should hide or show collapse cursor', (WidgetTester tester) async { @@ -3231,6 +3231,30 @@ void main() { semantics.dispose(); }); + testWidgets('Read only TextField identifies as read only text field in semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: TextField( + maxLength: 10, + readOnly: true, + ), + ), + ), + ), + ); + + expect( + semantics, + includesNodeWith(flags: [SemanticsFlag.isTextField, SemanticsFlag.isReadOnly]) + ); + + semantics.dispose(); + }); + void sendFakeKeyEvent(Map data) { defaultBinaryMessenger.handlePlatformMessage( SystemChannels.keyEvent.name, diff --git a/packages/flutter/test/widgets/selectable_text_test.dart b/packages/flutter/test/widgets/selectable_text_test.dart new file mode 100644 index 00000000000..6168acd9a83 --- /dev/null +++ b/packages/flutter/test/widgets/selectable_text_test.dart @@ -0,0 +1,3722 @@ +// Copyright 2015 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind; + +import '../widgets/semantics_tester.dart'; + +class MockClipboard { + Object _clipboardData = { + 'text': null, + }; + + Future handleMethodCall(MethodCall methodCall) async { + switch (methodCall.method) { + case 'Clipboard.getData': + return _clipboardData; + case 'Clipboard.setData': + _clipboardData = methodCall.arguments; + break; + } + } +} + +class MaterialLocalizationsDelegate extends LocalizationsDelegate { + @override + bool isSupported(Locale locale) => true; + + @override + Future load(Locale locale) => DefaultMaterialLocalizations.load(locale); + + @override + bool shouldReload(MaterialLocalizationsDelegate old) => false; +} + +class WidgetsLocalizationsDelegate extends LocalizationsDelegate { + @override + bool isSupported(Locale locale) => true; + + @override + Future load(Locale locale) => DefaultWidgetsLocalizations.load(locale); + + @override + bool shouldReload(WidgetsLocalizationsDelegate old) => false; +} + +Widget overlay({ Widget child }) { + final OverlayEntry entry = OverlayEntry( + builder: (BuildContext context) { + return Center( + child: Material( + child: child, + ), + ); + }, + ); + return overlayWithEntry(entry); +} + +Widget overlayWithEntry(OverlayEntry entry) { + return Localizations( + locale: const Locale('en', 'US'), + delegates: >[ + WidgetsLocalizationsDelegate(), + MaterialLocalizationsDelegate(), + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Overlay( + initialEntries: [ + entry + ], + ), + ), + ), + ); +} + +Widget boilerplate({ Widget child }) { + return Localizations( + locale: const Locale('en', 'US'), + delegates: >[ + WidgetsLocalizationsDelegate(), + MaterialLocalizationsDelegate(), + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Center( + child: Material( + child: child, + ), + ), + ), + ), + ); +} + +Future skipPastScrollingAnimation(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); +} + +double getOpacity(WidgetTester tester, Finder finder) { + return tester.widget( + find.ancestor( + of: finder, + matching: find.byType(FadeTransition), + ) + ).opacity.value; +} + +void main() { + final MockClipboard mockClipboard = MockClipboard(); + SystemChannels.platform.setMockMethodCallHandler(mockClipboard.handleMethodCall); + + const String kThreeLines = + 'First line of text is\n' + 'Second line goes until\n' + 'Third line of stuff'; + const String kMoreThanFourLines = + kThreeLines + + '\nFourth line won\'t display and ends at'; + + // Returns the first RenderEditable. + RenderEditable findRenderEditable(WidgetTester tester) { + final RenderObject root = tester.renderObject(find.byType(EditableText)); + expect(root, isNotNull); + + RenderEditable renderEditable; + void recursiveFinder(RenderObject child) { + if (child is RenderEditable) { + renderEditable = child; + return; + } + child.visitChildren(recursiveFinder); + } + root.visitChildren(recursiveFinder); + expect(renderEditable, isNotNull); + return renderEditable; + } + + List globalize(Iterable points, RenderBox box) { + return points.map((TextSelectionPoint point) { + return TextSelectionPoint( + box.localToGlobal(point.point), + point.direction, + ); + }).toList(); + } + + Offset textOffsetToPosition(WidgetTester tester, int offset) { + final RenderEditable renderEditable = findRenderEditable(tester); + final List endpoints = globalize( + renderEditable.getEndpointsForSelection( + TextSelection.collapsed(offset: offset), + ), + renderEditable, + ); + expect(endpoints.length, 1); + return endpoints[0].point + const Offset(0.0, -2.0); + } + + setUp(() { + debugResetSemanticsIdCounter(); + }); + + Widget selectableTextBuilder({ + String text = '', + int maxLines = 1, + }) { + return boilerplate( + child: SelectableText( + text, + style: const TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: maxLines, + ), + ); + } + + testWidgets('has expected defaults', (WidgetTester tester) async { + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: SelectableText('selectable text'), + ), + ), + ); + + final SelectableText selectableText = + tester.firstWidget(find.byType(SelectableText)); + expect(selectableText.showCursor, false); + expect(selectableText.autofocus, false); + expect(selectableText.dragStartBehavior, DragStartBehavior.start); + expect(selectableText.cursorWidth, 2.0); + expect(selectableText.enableInteractiveSelection, true); + }); + + testWidgets('Rich selectable text has expected defaults', (WidgetTester tester) async { + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: SelectableText.rich( + TextSpan( + text: 'First line!', + style: TextStyle( + fontSize: 14, + fontFamily: 'Roboto' + ), + children: [ + TextSpan( + text: 'Second line!\n', + style: TextStyle( + fontSize: 30, + fontFamily: 'Roboto', + ), + ), + TextSpan( + text: 'Third line!\n', + style: TextStyle( + fontSize: 14, + fontFamily: 'Roboto', + ), + ), + ], + ) + ), + ), + ), + ); + + final SelectableText selectableText = + tester.firstWidget(find.byType(SelectableText)); + expect(selectableText.showCursor, false); + expect(selectableText.autofocus, false); + expect(selectableText.dragStartBehavior, DragStartBehavior.start); + expect(selectableText.cursorWidth, 2.0); + expect(selectableText.enableInteractiveSelection, true); + }); + + testWidgets('Rich selectable text only support TextSpan', (WidgetTester tester) async { + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: SelectableText.rich( + TextSpan( + text: 'First line!', + style: TextStyle( + fontSize: 14, + fontFamily: 'Roboto' + ), + children: [ + WidgetSpan( + child: SizedBox( + width: 120, + height: 50, + child: Card( + child: Center( + child: Text('Hello World!') + ) + ), + ) + ), + TextSpan( + text: 'Third line!\n', + style: TextStyle( + fontSize: 14, + fontFamily: 'Roboto', + ), + ), + ], + ) + ), + ), + ), + ); + expect(tester.takeException(), isAssertionError); + }); + + testWidgets('no text keyboard when widget is focused', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SelectableText('selectable text'), + ) + ); + await tester.tap(find.byType(SelectableText)); + await tester.idle(); + expect(tester.testTextInput.hasAnyClients, false); + }); + + testWidgets('Selectable Text has adaptive size', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + child: const SelectableText('s'), + ) + ); + + RenderBox findSelectableTextBox() => tester.renderObject(find.byType(SelectableText)); + + final RenderBox textBox = findSelectableTextBox(); + expect(textBox.size, const Size(17.0, 14.0)); + + await tester.pumpWidget( + boilerplate( + child: const SelectableText('very very long'), + ) + ); + + final RenderBox longtextBox = findSelectableTextBox(); + expect(longtextBox.size, const Size(199.0, 14.0)); + }); + + testWidgets('can switch between textWidthBasis', (WidgetTester tester) async { + RenderBox findTextBox() => tester.renderObject(find.byType(SelectableText)); + const String text = 'I can face roll keyboardkeyboardaszzaaaaszzaaaaszzaaaaszzaaaa'; + await tester.pumpWidget( + boilerplate( + child: const SelectableText( + text, + textWidthBasis: TextWidthBasis.parent + ), + ) + ); + RenderBox textBox = findTextBox(); + expect(textBox.size, const Size(800.0, 28.0)); + + await tester.pumpWidget( + boilerplate( + child: const SelectableText( + text, + textWidthBasis: TextWidthBasis.longestLine + ), + ) + ); + textBox = findTextBox(); + expect(textBox.size, const Size(633.0, 28.0)); + }); + + testWidgets('Cursor blinks when showCursor is true', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SelectableText( + 'some text', + showCursor: true, + ), + ), + ); + await tester.tap(find.byType(SelectableText)); + await tester.idle(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + + // Check that the cursor visibility toggles after each blink interval. + final bool initialShowCursor = editableText.cursorCurrentlyVisible; + await tester.pump(editableText.cursorBlinkInterval); + expect(editableText.cursorCurrentlyVisible, equals(!initialShowCursor)); + await tester.pump(editableText.cursorBlinkInterval); + expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor)); + await tester.pump(editableText.cursorBlinkInterval ~/ 10); + expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor)); + await tester.pump(editableText.cursorBlinkInterval); + expect(editableText.cursorCurrentlyVisible, equals(!initialShowCursor)); + await tester.pump(editableText.cursorBlinkInterval); + expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor)); + }); + + testWidgets('selectable text selection toolbar renders correctly inside opacity', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Container( + width: 100, + height: 100, + child: const Opacity( + opacity: 0.5, + child: SelectableText('selectable text'), + ), + ), + ), + ), + ), + ); + + // The selectWordsInRange with SelectionChangedCause.tap seems to be needed to show the toolbar. + final EditableTextState state = tester.state(find.byType(EditableText)); + state.renderEditable.selectWordsInRange(from: const Offset(0, 0), cause: SelectionChangedCause.tap); + + expect(state.showToolbar(), true); + + // This is needed for the AnimatedOpacity to turn from 0 to 1 so the toolbar is visible. + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('SELECT ALL'), findsOneWidget); + }); + + testWidgets('Caret position is updated on tap', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SelectableText('abc def ghi'), + ) + ); + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.controller.selection.baseOffset, -1); + expect(editableText.controller.selection.extentOffset, -1); + + // Tap to reposition the caret. + const int tapIndex = 4; + final Offset ePos = textOffsetToPosition(tester, tapIndex); + await tester.tapAt(ePos); + await tester.pump(); + + expect(editableText.controller.selection.baseOffset, tapIndex); + expect(editableText.controller.selection.extentOffset, tapIndex); + }); + + testWidgets('enableInteractiveSelection = false, tap', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SelectableText( + 'abc def ghi', + enableInteractiveSelection: false, + ), + ) + ); + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.controller.selection.baseOffset, -1); + expect(editableText.controller.selection.extentOffset, -1); + + // Tap would ordinarily reposition the caret. + const int tapIndex = 4; + final Offset ePos = textOffsetToPosition(tester, tapIndex); + await tester.tapAt(ePos); + await tester.pump(); + + expect(editableText.controller.selection.baseOffset, -1); + expect(editableText.controller.selection.extentOffset, -1); + }); + + testWidgets('enableInteractiveSelection = false, long-press', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SelectableText( + 'abc def ghi', + enableInteractiveSelection: false, + ), + ) + ); + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.controller.selection.baseOffset, -1); + expect(editableText.controller.selection.extentOffset, -1); + + // Long press the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + final TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + + expect(editableText.controller.selection.isCollapsed, true); + expect(editableText.controller.selection.baseOffset, -1); + expect(editableText.controller.selection.extentOffset, -1); + }); + + testWidgets('Can long press to select', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SelectableText('abc def ghi'), + ) + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + + expect(editableText.controller.selection.isCollapsed, true); + + // Long press the 'e' to select 'def'. + const int tapIndex = 5; + final Offset ePos = textOffsetToPosition(tester, tapIndex); + await tester.longPressAt(ePos); + await tester.pump(); + + // 'def' is selected. + expect(editableText.controller.selection.baseOffset, 4); + expect(editableText.controller.selection.extentOffset, 7); + + // Tapping elsewhere immediately collapses and moves the cursor. + await tester.tapAt(textOffsetToPosition(tester, 9)); + await tester.pump(); + + expect(editableText.controller.selection.isCollapsed, true); + expect(editableText.controller.selection.baseOffset, 9); + }); + + testWidgets('Slight movements in longpress don\'t hide/show handles', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SelectableText('abc def ghi'), + ) + ); + // Long press the 'e' to select 'def', but don't release the gesture. + final Offset ePos = textOffsetToPosition(tester, 5); + final TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + + // Handles are shown + final Finder fadeFinder = find.byType(FadeTransition); + expect(fadeFinder, findsNWidgets(2)); // 2 handles, 1 toolbar + FadeTransition handle = tester.widget(fadeFinder.at(0)); + expect(handle.opacity.value, equals(1.0)); + + // Move the gesture very slightly + await gesture.moveBy(const Offset(1.0, 1.0)); + await tester.pump(TextSelectionOverlay.fadeDuration * 0.5); + handle = tester.widget(fadeFinder.at(0)); + + // The handle should still be fully opaque. + expect(handle.opacity.value, equals(1.0)); + }); + + testWidgets('Mouse long press is just like a tap', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SelectableText('abc def ghi'), + ) + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + + // Long press the 'e' using a mouse device. + const int eIndex = 5; + final Offset ePos = textOffsetToPosition(tester, eIndex); + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + + // The cursor is placed just like a regular tap. + expect(editableText.controller.selection.baseOffset, eIndex); + expect(editableText.controller.selection.extentOffset, eIndex); + + await gesture.removePointer(); + }); + + testWidgets('selectable text basic', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SelectableText('selectable'), + ) + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + // selectable text cannot open keyboard. + await tester.showKeyboard(find.byType(SelectableText)); + expect(tester.testTextInput.hasAnyClients, false); + await skipPastScrollingAnimation(tester); + + expect(editableTextWidget.controller.selection.isCollapsed, true); + + await tester.tap(find.byType(SelectableText)); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + // Collapse selection should not paint. + expect(editableText.selectionOverlay.handlesAreVisible, isFalse); + // Long press on the 't' character of text 'selectable' to show context menu. + const int dIndex = 5; + final Offset dPos = textOffsetToPosition(tester, dIndex); + await tester.longPressAt(dPos); + await tester.pump(); + + // Context menu should not have paste and cut. + expect(find.text('COPY'), findsOneWidget); + expect(find.text('PASTE'), findsNothing); + expect(find.text('CUT'), findsNothing); + }); + + testWidgets('Can select text by dragging with a mouse', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText( + 'abc def ghi', + dragStartBehavior: DragStartBehavior.down, + ), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + final Offset ePos = textOffsetToPosition(tester, 5); + final Offset gPos = textOffsetToPosition(tester, 8); + + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 5); + expect(controller.selection.extentOffset, 8); + + await gesture.removePointer(); + }); + + testWidgets('Continuous dragging does not cause flickering', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText( + 'abc def ghi', + dragStartBehavior: DragStartBehavior.down, + style: TextStyle(fontFamily: 'Ahem', fontSize: 10.0) + ), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + int selectionChangedCount = 0; + + controller.addListener(() { + selectionChangedCount++; + }); + + final Offset cPos = textOffsetToPosition(tester, 2); // Index of 'c'. + final Offset gPos = textOffsetToPosition(tester, 8); // Index of 'g'. + final Offset hPos = textOffsetToPosition(tester, 9); // Index of 'h'. + + // Drag from 'c' to 'g'. + final TestGesture gesture = await tester.startGesture(cPos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(selectionChangedCount, isNonZero); + selectionChangedCount = 0; + expect(controller.selection.baseOffset, 2); + expect(controller.selection.extentOffset, 8); + + // Tiny movement shouldn't cause text selection to change. + await gesture.moveTo(gPos + const Offset(4.0, 0.0)); + await tester.pumpAndSettle(); + expect(selectionChangedCount, 0); + + // Now a text selection change will occur after a significant movement. + await gesture.moveTo(hPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(selectionChangedCount, 1); + expect(controller.selection.baseOffset, 2); + expect(controller.selection.extentOffset, 9); + + await gesture.removePointer(); + }); + + testWidgets('Dragging in opposite direction also works', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText( + 'abc def ghi', + dragStartBehavior: DragStartBehavior.down, + ), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + final Offset ePos = textOffsetToPosition(tester, 5); + final Offset gPos = textOffsetToPosition(tester, 8); + + final TestGesture gesture = await tester.startGesture(gPos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.moveTo(ePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 5); + expect(controller.selection.extentOffset, 8); + + await gesture.removePointer(); + }); + + testWidgets('Slow mouse dragging also selects text', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText( + 'abc def ghi', + dragStartBehavior: DragStartBehavior.down, + ), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + final Offset ePos = textOffsetToPosition(tester, 5); + final Offset gPos = textOffsetToPosition(tester,8); + + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(seconds: 2)); + await gesture.moveTo(gPos); + await tester.pump(); + await gesture.up(); + + expect(controller.selection.baseOffset, 5); + expect(controller.selection.extentOffset,8); + + await gesture.removePointer(); + }); + + testWidgets('Can drag handles to change selection', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText( + 'abc def ghi', + dragStartBehavior: DragStartBehavior.down, + ), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + // Long press the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 4); + expect(selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle 2 letters to the right. + // We use a small offset because the endpoint is on the very corner + // of the handle. + Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + Offset newHandlePos = textOffsetToPosition(tester, 11); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + + // Drag the left handle 2 letters to the left. + handlePos = endpoints[0].point + const Offset(-1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, 0); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 11); + }); + + testWidgets('Cannot drag one handle past the other', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText( + 'abc def ghi', + dragStartBehavior: DragStartBehavior.down, + ), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + // Long press the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); // Position before 'e'. + TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 4); + expect(selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle until there's only 1 char selected. + // We use a small offset because the endpoint is on the very corner + // of the handle. + final Offset handlePos = endpoints[1].point + const Offset(4.0, 0.0); + Offset newHandlePos = textOffsetToPosition(tester, 5); // Position before 'e'. + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 5); + + newHandlePos = textOffsetToPosition(tester, 2); // Position before 'c'. + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + // The selection doesn't move beyond the left handle. There's always at + // least 1 char selected. + expect(controller.selection.extentOffset, 5); + }); + + testWidgets('Can use selection toolbar', (WidgetTester tester) async { + const String testValue = 'abc def ghi'; + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText( + testValue, + ), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + // Tap the selection handle to bring up the "paste / select all" menu. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero + final RenderEditable renderEditable = findRenderEditable(tester); + final List endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + // Tapping on the part of the handle's GestureDetector where it overlaps + // with the text itself does not show the menu, so add a small vertical + // offset to tap below the text. + await tester.tapAt(endpoints[0].point + const Offset(1.0, 13.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero + + // SELECT ALL should select all the text. + await tester.tap(find.text('SELECT ALL')); + await tester.pump(); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, testValue.length); + + // COPY should reset the selection. + await tester.tap(find.text('COPY')); + await skipPastScrollingAnimation(tester); + expect(controller.selection.isCollapsed, true); + }); + + testWidgets('Selectable height with maxLine', (WidgetTester tester) async { + await tester.pumpWidget(selectableTextBuilder()); + + RenderBox findTextBox() => tester.renderObject(find.byType(SelectableText)); + + final RenderBox textBox = findTextBox(); + final Size emptyInputSize = textBox.size; + + await tester.pumpWidget(selectableTextBuilder(text: 'No wrapping here.')); + expect(findTextBox(), equals(textBox)); + expect(textBox.size.height, emptyInputSize.height); + + // Even when entering multiline text, SelectableText doesn't grow. It's a single + // line input. + await tester.pumpWidget(selectableTextBuilder(text: kThreeLines)); + expect(findTextBox(), equals(textBox)); + expect(textBox.size.height, emptyInputSize.height); + + // maxLines: 3 makes the SelectableText 3 lines tall + await tester.pumpWidget(selectableTextBuilder(maxLines: 3)); + expect(findTextBox(), equals(textBox)); + expect(textBox.size.height, greaterThan(emptyInputSize.height)); + + final Size threeLineInputSize = textBox.size; + + // Filling with 3 lines of text stays the same size + await tester.pumpWidget(selectableTextBuilder(text: kThreeLines, maxLines: 3)); + expect(findTextBox(), equals(textBox)); + expect(textBox.size.height, threeLineInputSize.height); + + // An extra line won't increase the size because we max at 3. + await tester.pumpWidget(selectableTextBuilder(text: kMoreThanFourLines, maxLines: 3)); + expect(findTextBox(), equals(textBox)); + expect(textBox.size.height, threeLineInputSize.height); + + // But now it will... but it will max at four + await tester.pumpWidget(selectableTextBuilder(text: kMoreThanFourLines, maxLines: 4)); + expect(findTextBox(), equals(textBox)); + expect(textBox.size.height, greaterThan(threeLineInputSize.height)); + + final Size fourLineInputSize = textBox.size; + + // Now it won't max out until the end + await tester.pumpWidget(selectableTextBuilder(maxLines: null)); + expect(findTextBox(), equals(textBox)); + expect(textBox.size, equals(emptyInputSize)); + await tester.pumpWidget(selectableTextBuilder(text: kThreeLines, maxLines: null)); + expect(textBox.size.height, equals(threeLineInputSize.height)); + await tester.pumpWidget(selectableTextBuilder(text: kMoreThanFourLines, maxLines: null)); + expect(textBox.size.height, greaterThan(fourLineInputSize.height)); + }); + + testWidgets('Can drag handles to change selection in multiline', (WidgetTester tester) async { + const String testValue = kThreeLines; + await tester.pumpWidget( + overlay( + child: const SelectableText( + testValue, + dragStartBehavior: DragStartBehavior.down, + style: TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: 3, + strutStyle: StrutStyle.disabled, + ), + ), + ); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + // Check that the text spans multiple lines. + final Offset firstPos = textOffsetToPosition(tester, testValue.indexOf('First')); + final Offset secondPos = textOffsetToPosition(tester, testValue.indexOf('Second')); + final Offset thirdPos = textOffsetToPosition(tester, testValue.indexOf('Third')); + final Offset middleStringPos = textOffsetToPosition(tester, testValue.indexOf('irst')); + + expect(firstPos.dx, 24.5); + expect(secondPos.dx, 24.5); + expect(thirdPos.dx, 24.5); + expect(middleStringPos.dx, 58.5); + expect(firstPos.dx, secondPos.dx); + expect(firstPos.dx, thirdPos.dx); + expect(firstPos.dy, lessThan(secondPos.dy)); + expect(secondPos.dy, lessThan(thirdPos.dy)); + + // Long press the 'n' in 'until' to select the word. + final Offset untilPos = textOffsetToPosition(tester, testValue.indexOf('until')+1); + TestGesture gesture = await tester.startGesture(untilPos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero + + expect(controller.selection.baseOffset, 39); + expect(controller.selection.extentOffset, 44); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle to the third line, just after 'Third'. + Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + Offset newHandlePos = textOffsetToPosition(tester, testValue.indexOf('Third') + 5); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 39); + expect(controller.selection.extentOffset, 50); + + // Drag the left handle to the first line, just after 'First'. + handlePos = endpoints[0].point + const Offset(-1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, testValue.indexOf('First') + 5); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 5); + expect(controller.selection.extentOffset, 50); + await tester.tap(find.text('COPY')); + await tester.pump(); + expect(controller.selection.isCollapsed, true); + }); + + testWidgets('Can scroll multiline input', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SelectableText( + kMoreThanFourLines, + dragStartBehavior: DragStartBehavior.down, + style: TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: 2, + ), + ), + ); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + RenderBox findInputBox() => tester.renderObject(find.byType(SelectableText)); + final RenderBox inputBox = findInputBox(); + + // Check that the last line of text is not displayed. + final Offset firstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First')); + final Offset fourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth')); + expect(firstPos.dx, 0.0); + expect(fourthPos.dx, 0.0); + expect(firstPos.dx, fourthPos.dx); + expect(firstPos.dy, lessThan(fourthPos.dy)); + expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(firstPos)), isTrue); + expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(fourthPos)), isFalse); + + TestGesture gesture = await tester.startGesture(firstPos, pointer: 7); + await tester.pump(); + await gesture.moveBy(const Offset(0.0, -1000.0)); + await tester.pump(const Duration(seconds: 1)); + // Wait and drag again to trigger https://github.com/flutter/flutter/issues/6329 + // (No idea why this is necessary, but the bug wouldn't repro without it.) + await gesture.moveBy(const Offset(0.0, -1000.0)); + await tester.pump(const Duration(seconds: 1)); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Now the first line is scrolled up, and the fourth line is visible. + Offset newFirstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First')); + Offset newFourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth')); + + expect(newFirstPos.dy, lessThan(firstPos.dy)); + expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFirstPos)), isFalse); + expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFourthPos)), isTrue); + + // Now try scrolling by dragging the selection handle. + // Long press the middle of the word "won't" in the fourth line. + final Offset selectedWordPos = textOffsetToPosition( + tester, + kMoreThanFourLines.indexOf('Fourth line') + 14, + ); + + gesture = await tester.startGesture(selectedWordPos, pointer: 7); + await tester.pump(const Duration(seconds: 1)); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(controller.selection.base.offset, 77); + expect(controller.selection.extent.offset, 82); + // Sanity check for the word selected is the intended one. + expect( + controller.text.substring(controller.selection.baseOffset, controller.selection.extentOffset), + "won't", + ); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the left handle to the first line, just after 'First'. + final Offset handlePos = endpoints[0].point + const Offset(-1, 1); + final Offset newHandlePos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First') + 5); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(const Duration(seconds: 1)); + await gesture.moveTo(newHandlePos + const Offset(0.0, -10.0)); + await tester.pump(const Duration(seconds: 1)); + await gesture.up(); + await tester.pump(const Duration(seconds: 1)); + + // The text should have scrolled up with the handle to keep the active + // cursor visible, back to its original position. + newFirstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First')); + newFourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth')); + expect(newFirstPos.dy, firstPos.dy); + expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFirstPos)), isTrue); + expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFourthPos)), isFalse); + }); + + testWidgets('Can align to center', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: Container( + width: 300.0, + child: const SelectableText( + 'abcd', + textAlign: TextAlign.center, + ), + ), + ), + ); + + final RenderEditable editable = findRenderEditable(tester); + + final Offset topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, + ); + + expect(topLeft.dx, equals(278.0)); + }); + + testWidgets('Can align to center within center', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: Container( + width: 300.0, + child: const Center( + child: SelectableText( + 'abcd', + textAlign: TextAlign.center, + ), + ), + ), + ), + ); + + final RenderEditable editable = findRenderEditable(tester); + + final Offset topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, + ); + + expect(topLeft.dx, equals(399.0)); + }); + + testWidgets('Selectable text drops selection when losing focus', (WidgetTester tester) async { + final Key key1 = UniqueKey(); + final Key key2 = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: Column( + children: [ + SelectableText( + 'text 1', + key: key1, + ), + SelectableText( + 'text 2', + key: key2 + ), + ], + ), + ), + ); + + await tester.tap(find.byKey(key1)); + await tester.pump(); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + controller.selection = const TextSelection(baseOffset: 0, extentOffset: 3); + await tester.pump(); + expect(controller.selection, isNot(equals(TextRange.empty))); + + await tester.tap(find.byKey(key2)); + await tester.pump(); + expect(controller.selection, equals(TextRange.empty)); + }); + + testWidgets('Selectable text identifies as text field in semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: SelectableText('some text'), + ), + ), + ), + ); + + expect( + semantics, + includesNodeWith( + flags: [ + SemanticsFlag.isTextField, + SemanticsFlag.isReadOnly, + SemanticsFlag.isMultiline, + ] + ) + ); + + semantics.dispose(); + }); + + void sendFakeKeyEvent(Map data) { + defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.keyEvent.name, + SystemChannels.keyEvent.codec.encodeMessage(data), + (ByteData data) { }, + ); + } + + void sendKeyEventWithCode(int code, bool down, bool shiftDown, bool ctrlDown) { + + int metaState = shiftDown ? 1 : 0; + if (ctrlDown) + metaState |= 1 << 12; + + sendFakeKeyEvent({ + 'type': down ? 'keydown' : 'keyup', + 'keymap': 'android', + 'keyCode': code, + 'hidUsage': 0x04, + 'codePoint': 0x64, + 'metaState': metaState, + }); + } + + group('Keyboard Tests', () { + TextEditingController controller; + + Future setupWidget(WidgetTester tester, String text) async { + final FocusNode focusNode = FocusNode(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RawKeyboardListener( + focusNode: focusNode, + onKey: null, + child: SelectableText( + text, + maxLines: 3, + strutStyle: StrutStyle.disabled, + ), + ), + ), + ), + ); + await tester.tap(find.byType(SelectableText)); + await tester.pumpAndSettle(); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + controller = editableTextWidget.controller; + } + + testWidgets('Shift test 1', (WidgetTester tester) async { + await setupWidget(tester, 'a big house'); + + sendKeyEventWithCode(21, true, true, false); // LEFT_ARROW keydown, SHIFT_ON + expect(controller.selection.extentOffset - controller.selection.baseOffset, 1); + }); + + testWidgets('Shift test 2', (WidgetTester tester) async { + await setupWidget(tester, 'abcdefghi'); + + controller.selection = const TextSelection.collapsed(offset: 3); + await tester.pump(); + + sendKeyEventWithCode(22, true, true, false); + await tester.pumpAndSettle(); + expect(controller.selection.extentOffset - controller.selection.baseOffset, 1); + }); + + testWidgets('Control Shift test', (WidgetTester tester) async { + await setupWidget(tester, 'their big house'); + + sendKeyEventWithCode(21, true, true, true); // LEFT_ARROW keydown SHIFT_ON, CONTROL_ON + + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 5); + }); + + testWidgets('Down and up test', (WidgetTester tester) async { + await setupWidget(tester, 'a big house'); + + sendKeyEventWithCode(19, true, true, false); // UP_ARROW keydown + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 11); + + sendKeyEventWithCode(19, false, true, false); // UP_ARROW keyup + await tester.pumpAndSettle(); + sendKeyEventWithCode(20, true, true, false); // DOWN_ARROW keydown + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 0); + }); + + testWidgets('Down and up test 2', (WidgetTester tester) async { + await setupWidget(tester, 'a big house\njumped over a mouse\nOne more line yay'); + + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + + for (int i = 0; i < 5; i += 1) { + sendKeyEventWithCode(22, true, false, false); // RIGHT_ARROW keydown + await tester.pumpAndSettle(); + sendKeyEventWithCode(22, false, false, false); // RIGHT_ARROW keyup + await tester.pumpAndSettle(); + } + sendKeyEventWithCode(20, true, true, false); // DOWN_ARROW keydown + await tester.pumpAndSettle(); + sendKeyEventWithCode(20, false, true, false); // DOWN_ARROW keyup + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 12); + + sendKeyEventWithCode(20, true, true, false); // DOWN_ARROW keydown + await tester.pumpAndSettle(); + sendKeyEventWithCode(20, false, true, false); // DOWN_ARROW keyup + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 32); + + sendKeyEventWithCode(19, true, true, false); // UP_ARROW keydown + await tester.pumpAndSettle(); + sendKeyEventWithCode(19, false, true, false); // UP_ARROW keyup + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 12); + + sendKeyEventWithCode(19, true, true, false); // UP_ARROW keydown + await tester.pumpAndSettle(); + sendKeyEventWithCode(19, false, true, false); // UP_ARROW keyup + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 0); + + sendKeyEventWithCode(19, true, true, false); // UP_ARROW keydown + await tester.pumpAndSettle(); + sendKeyEventWithCode(19, false, true, false); // UP_ARROW keyup + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 5); + }); + }); + + const int _kCKeyCode = 31; + const int _kAKeyCode = 29; + + testWidgets('Copy test', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + + String clipboardContent = ''; + SystemChannels.platform + .setMockMethodCallHandler((MethodCall methodCall) async { + if (methodCall.method == 'Clipboard.setData') + clipboardContent = methodCall.arguments['text']; + else if (methodCall.method == 'Clipboard.getData') + return {'text': clipboardContent}; + return null; + }); + const String testValue = 'a big house\njumped over a mouse'; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RawKeyboardListener( + focusNode: focusNode, + onKey: null, + child: const SelectableText( + testValue, + maxLines: 3, + ), + ), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + focusNode.requestFocus(); + await tester.pump(); + + await tester.tap(find.byType(SelectableText)); + await tester.pumpAndSettle(); + + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + + // Select the first 5 characters + for (int i = 0; i < 5; i += 1) { + sendKeyEventWithCode(22, true, true, false); // RIGHT_ARROW keydown shift + await tester.pumpAndSettle(); + sendKeyEventWithCode(22, false, false, false); // RIGHT_ARROW keyup + await tester.pumpAndSettle(); + } + + // Copy them + sendKeyEventWithCode(_kCKeyCode, true, false, true); // keydown control + await tester.pumpAndSettle(); + sendKeyEventWithCode(_kCKeyCode, false, false, false); // keyup control + await tester.pumpAndSettle(); + + expect(clipboardContent, 'a big'); + + sendKeyEventWithCode(22, true, false, false); // RIGHT_ARROW keydown + await tester.pumpAndSettle(); + sendKeyEventWithCode(22, false, false, false); // RIGHT_ARROW keyup + await tester.pumpAndSettle(); + }); + + testWidgets('Select all test', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + const String testValue = 'a big house\njumped over a mouse'; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RawKeyboardListener( + focusNode: focusNode, + onKey: null, + child: const SelectableText( + testValue, + maxLines: 3, + ), + ), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + focusNode.requestFocus(); + await tester.pump(); + + await tester.tap(find.byType(SelectableText)); + await tester.pumpAndSettle(); + + // Select All + sendKeyEventWithCode(_kAKeyCode, true, false, true); // keydown control A + await tester.pumpAndSettle(); + sendKeyEventWithCode(_kAKeyCode, false, false, true); // keyup control A + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 31); + }); + + testWidgets('Changing positions of selectable text', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + final List events = []; + + final Key key1 = UniqueKey(); + final Key key2 = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: + Material( + child: RawKeyboardListener( + focusNode: focusNode, + onKey: events.add, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SelectableText( + 'a big house', + key: key1, + maxLines: 3, + ), + SelectableText( + 'another big house', + key: key2, + maxLines: 3, + ), + ], + ), + ), + ), + ), + ); + + EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + TextEditingController c1 = editableTextWidget.controller; + + await tester.tap(find.byType(EditableText).first); + await tester.pumpAndSettle(); + + for (int i = 0; i < 5; i += 1) { + sendKeyEventWithCode(21, true, true, false); // LEFT_ARROW keydown + await tester.pumpAndSettle(); + } + + expect(c1.selection.extentOffset - c1.selection.baseOffset, 5); + + await tester.pumpWidget( + MaterialApp( + home: + Material( + child: RawKeyboardListener( + focusNode: focusNode, + onKey: events.add, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SelectableText( + 'another big house', + key: key2, + maxLines: 3, + ), + SelectableText( + 'a big house', + key: key1, + maxLines: 3, + ), + ], + ), + ), + ), + ), + ); + + for (int i = 0; i < 5; i += 1) { + sendKeyEventWithCode(21, true, true, false); // LEFT_ARROW keydown + await tester.pumpAndSettle(); + } + + editableTextWidget = tester.widget(find.byType(EditableText).last); + c1 = editableTextWidget.controller; + + expect(c1.selection.extentOffset - c1.selection.baseOffset, 10); + }); + + + testWidgets('Changing focus test', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + final List events = []; + + final Key key1 = UniqueKey(); + final Key key2 = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: + Material( + child: RawKeyboardListener( + focusNode: focusNode, + onKey: events.add, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SelectableText( + 'a big house', + key: key1, + maxLines: 3, + ), + SelectableText( + 'another big house', + key: key2, + maxLines: 3, + ), + ], + ), + ), + ), + ), + ); + + final EditableText editableTextWidget1 = tester.widget(find.byType(EditableText).first); + final TextEditingController c1 = editableTextWidget1.controller; + + final EditableText editableTextWidget2 = tester.widget(find.byType(EditableText).last); + final TextEditingController c2 = editableTextWidget2.controller; + + await tester.tap(find.byType(SelectableText).first); + await tester.pumpAndSettle(); + + for (int i = 0; i < 5; i += 1) { + sendKeyEventWithCode(21, true, true, false); // LEFT_ARROW keydown + await tester.pumpAndSettle(); + } + + expect(c1.selection.extentOffset - c1.selection.baseOffset, 5); + expect(c2.selection.extentOffset - c2.selection.baseOffset, 0); + + await tester.tap(find.byType(SelectableText).last); + await tester.pumpAndSettle(); + + for (int i = 0; i < 5; i += 1) { + sendKeyEventWithCode(21, true, true, false); // LEFT_ARROW keydown + await tester.pumpAndSettle(); + } + + expect(c1.selection.extentOffset - c1.selection.baseOffset, 0); + expect(c2.selection.extentOffset - c2.selection.baseOffset, 5); + }); + + testWidgets('Caret works when maxLines is null', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SelectableText( + 'x', + maxLines: null, + ), + ) + ); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect(controller.selection.baseOffset, -1); + + // Tap the selection handle to bring up the "paste / select all" menu. + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is + + // Confirm that the selection was updated. + expect(controller.selection.baseOffset, 0); + }); + + testWidgets('SelectableText baseline alignment no-strut', (WidgetTester tester) async { + final Key keyA = UniqueKey(); + final Key keyB = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: SelectableText( + 'A', + key: keyA, + style: const TextStyle(fontSize: 10.0), + strutStyle: StrutStyle.disabled, + ), + ), + const Text( + 'abc', + style: TextStyle(fontSize: 20.0), + ), + Expanded( + child: SelectableText( + 'B', + key: keyB, + style: const TextStyle(fontSize: 30.0), + strutStyle: StrutStyle.disabled, + ), + ), + ], + ), + ), + ); + + // The Ahem font extends 0.2 * fontSize below the baseline. + // So the three row elements line up like this: + // + // A abc B + // --------- baseline + // 2 4 6 space below the baseline = 0.2 * fontSize + // --------- rowBottomY + + final double rowBottomY = tester.getBottomLeft(find.byType(Row)).dy; + expect(tester.getBottomLeft(find.byKey(keyA)).dy, closeTo(rowBottomY - 4.0, 0.001)); + expect(tester.getBottomLeft(find.text('abc')).dy, closeTo(rowBottomY - 2.0, 0.001)); + expect(tester.getBottomLeft(find.byKey(keyB)).dy, rowBottomY); + }); + + testWidgets('SelectableText baseline alignment', (WidgetTester tester) async { + final Key keyA = UniqueKey(); + final Key keyB = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: SelectableText( + 'A', + key: keyA, + style: const TextStyle(fontSize: 10.0), + ), + ), + const Text( + 'abc', + style: TextStyle(fontSize: 20.0), + ), + Expanded( + child: SelectableText( + 'B', + key: keyB, + style: const TextStyle(fontSize: 30.0), + ), + ), + ], + ), + ), + ); + + // The Ahem font extends 0.2 * fontSize below the baseline. + // So the three row elements line up like this: + // + // A abc B + // --------- baseline + // 2 4 6 space below the baseline = 0.2 * fontSize + // --------- rowBottomY + + final double rowBottomY = tester.getBottomLeft(find.byType(Row)).dy; + expect(tester.getBottomLeft(find.byKey(keyA)).dy, closeTo(rowBottomY - 4.0, 0.001)); + expect(tester.getBottomLeft(find.text('abc')).dy, closeTo(rowBottomY - 2.0, 0.001)); + expect(tester.getBottomLeft(find.byKey(keyB)).dy, rowBottomY); + }); + + testWidgets('SelectableText semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: SelectableText( + 'Guten Tag', + key: key, + ), + ), + ); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + textDirection: TextDirection.ltr, + value: 'Guten Tag', + actions: [ + SemanticsAction.tap, + SemanticsAction.longPress, + ], + flags: [ + SemanticsFlag.isTextField, + SemanticsFlag.isReadOnly, + SemanticsFlag.isMultiline, + ], + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + + await tester.tap(find.byKey(key)); + await tester.pump(); + + controller.selection = const TextSelection.collapsed(offset: 9); + await tester.pump(); + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + textDirection: TextDirection.ltr, + value: 'Guten Tag', + textSelection: const TextSelection.collapsed(offset: 9), + actions: [ + SemanticsAction.tap, + SemanticsAction.longPress, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + ], + flags: [ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + + controller.selection = const TextSelection.collapsed(offset: 4); + await tester.pump(); + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + textDirection: TextDirection.ltr, + textSelection: const TextSelection.collapsed(offset: 4), + value: 'Guten Tag', + actions: [ + SemanticsAction.tap, + SemanticsAction.longPress, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorForwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.moveCursorForwardByWord, + SemanticsAction.setSelection, + ], + flags: [ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + textDirection: TextDirection.ltr, + textSelection: const TextSelection.collapsed(offset: 0), + value: 'Guten Tag', + actions: [ + SemanticsAction.tap, + SemanticsAction.longPress, + SemanticsAction.moveCursorForwardByCharacter, + SemanticsAction.moveCursorForwardByWord, + SemanticsAction.setSelection, + ], + flags: [ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + + semantics.dispose(); + }); + + testWidgets('SelectableText semantics, enableInteractiveSelection = false', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: SelectableText( + 'Guten Tag', + key: key, + enableInteractiveSelection: false, + ), + ), + ); + + await tester.tap(find.byKey(key)); + await tester.pump(); + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + value: 'Guten Tag', + textDirection: TextDirection.ltr, + actions: [ + SemanticsAction.tap, + SemanticsAction.longPress, + // Absent the following because enableInteractiveSelection: false + // SemanticsAction.moveCursorBackwardByCharacter, + // SemanticsAction.moveCursorBackwardByWord, + // SemanticsAction.setSelection, + // SemanticsAction.paste, + ], + flags: [ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isMultiline, + // SelectableText act like a text widget when enableInteractiveSelection + // is false. It will not respond to any pointer event. + // SemanticsFlag.isFocused, + ], + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + + semantics.dispose(); + }); + + testWidgets('SelectableText semantics for selections', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: SelectableText( + 'Hello', + key: key, + ), + ), + ); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + value: 'Hello', + textDirection: TextDirection.ltr, + actions: [ + SemanticsAction.tap, + SemanticsAction.longPress + ], + flags: [ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isMultiline, + ], + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + + // Focus the selectable text + await tester.tap(find.byKey(key)); + await tester.pump(); + + controller.selection = const TextSelection.collapsed(offset: 5); + await tester.pump(); + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + value: 'Hello', + textSelection: const TextSelection.collapsed(offset: 5), + textDirection: TextDirection.ltr, + actions: [ + SemanticsAction.tap, + SemanticsAction.longPress, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + ], + flags: [ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + + controller.selection = const TextSelection(baseOffset: 5, extentOffset: 3); + await tester.pump(); + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + value: 'Hello', + textSelection: const TextSelection(baseOffset: 5, extentOffset: 3), + textDirection: TextDirection.ltr, + actions: [ + SemanticsAction.tap, + SemanticsAction.longPress, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorForwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.moveCursorForwardByWord, + SemanticsAction.setSelection, + SemanticsAction.copy, + ], + flags: [ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + + semantics.dispose(); + }); + + testWidgets('SelectableText change selection with semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner; + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: SelectableText( + 'Hello', + key: key, + ), + ), + ); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // Focus the selectable text + await tester.tap(find.byKey(key)); + await tester.pump(); + + controller.selection = const TextSelection(baseOffset: 5, extentOffset: 5); + await tester.pump(); + + const int inputFieldId = 1; + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: inputFieldId, + value: 'Hello', + textSelection: const TextSelection.collapsed(offset: 5), + textDirection: TextDirection.ltr, + actions: [ + SemanticsAction.tap, + SemanticsAction.longPress, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + ], + flags: [ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + + // move cursor back once + semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, { + 'base': 4, + 'extent': 4, + }); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed(offset: 4)); + + // move cursor to front + semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, { + 'base': 0, + 'extent': 0, + }); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed(offset: 0)); + + // select all + semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, { + 'base': 0, + 'extent': 5, + }); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: inputFieldId, + value: 'Hello', + textSelection: const TextSelection(baseOffset: 0, extentOffset: 5), + textDirection: TextDirection.ltr, + actions: [ + SemanticsAction.tap, + SemanticsAction.longPress, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + SemanticsAction.copy, + ], + flags: [ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + + semantics.dispose(); + }); + + testWidgets('Can activate SelectableText with explicit controller via semantics', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/17801 + + const String testValue = 'Hello'; + + final SemanticsTester semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner; + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: SelectableText( + testValue, + key: key, + ), + ), + ); + + const int inputFieldId = 1; + + expect(semantics, hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: inputFieldId, + flags: [ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isMultiline, + ], + actions: [SemanticsAction.tap, SemanticsAction.longPress], + value: testValue, + textDirection: TextDirection.ltr, + ), + ], + ), + ignoreRect: true, ignoreTransform: true, + )); + + semanticsOwner.performAction(inputFieldId, SemanticsAction.tap); + await tester.pump(); + + expect(semantics, hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: inputFieldId, + flags: [ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + actions: [ + SemanticsAction.tap, + SemanticsAction.longPress, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + ], + value: testValue, + textDirection: TextDirection.ltr, + textSelection: const TextSelection( + baseOffset: testValue.length, + extentOffset: testValue.length, + ), + ), + ], + ), + ignoreRect: true, ignoreTransform: true, + )); + + semantics.dispose(); + }); + + testWidgets('SelectableText throws when not descended from a MediaQuery widget', (WidgetTester tester) async { + const Widget selectableText = SelectableText('something'); + await tester.pumpWidget(selectableText); + final dynamic exception = tester.takeException(); + expect(exception, isFlutterError); + expect(exception.toString(), startsWith('No MediaQuery widget found.\nSelectableText widgets require a MediaQuery widget ancestor.')); + }); + + testWidgets('onTap is called upon tap', (WidgetTester tester) async { + int tapCount = 0; + await tester.pumpWidget( + overlay( + child: SelectableText( + 'something', + onTap: () { + tapCount += 1; + }, + ), + ), + ); + + expect(tapCount, 0); + await tester.tap(find.byType(SelectableText)); + // Wait a bit so they're all single taps and not double taps. + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byType(SelectableText)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byType(SelectableText)); + await tester.pump(const Duration(milliseconds: 300)); + expect(tapCount, 3); + }); + + testWidgets('SelectableText style is merged with default text style', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/23994 + final TextStyle defaultStyle = TextStyle( + color: Colors.blue[500], + ); + Widget buildFrame(TextStyle style) { + return MaterialApp( + home: Material( + child: DefaultTextStyle ( + style: defaultStyle, + child: Center( + child: SelectableText( + 'something', + style: style, + ), + ), + ) + ), + ); + } + + // Empty TextStyle is overridden by theme + await tester.pumpWidget(buildFrame(const TextStyle())); + EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, defaultStyle.color); + expect(editableText.style.background, defaultStyle.background); + expect(editableText.style.shadows, defaultStyle.shadows); + expect(editableText.style.decoration, defaultStyle.decoration); + expect(editableText.style.locale, defaultStyle.locale); + expect(editableText.style.wordSpacing, defaultStyle.wordSpacing); + + // Properties set on TextStyle override theme + const Color setColor = Colors.red; + await tester.pumpWidget(buildFrame(const TextStyle(color: setColor))); + editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, setColor); + + // inherit: false causes nothing to be merged in from theme + await tester.pumpWidget(buildFrame(const TextStyle( + fontSize: 24.0, + textBaseline: TextBaseline.alphabetic, + inherit: false, + ))); + editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, isNull); + }); + + testWidgets('style enforces required fields', (WidgetTester tester) async { + Widget buildFrame(TextStyle style) { + return MaterialApp( + home: Material( + child: SelectableText( + 'something', + style: style, + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(const TextStyle( + inherit: false, + fontSize: 12.0, + textBaseline: TextBaseline.alphabetic, + ))); + expect(tester.takeException(), isNull); + + // With inherit not set to false, will pickup required fields from theme + await tester.pumpWidget(buildFrame(const TextStyle( + fontSize: 12.0, + ))); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(buildFrame(const TextStyle( + inherit: false, + fontSize: 12.0, + ))); + expect(tester.takeException(), isNotNull); + }); + + testWidgets( + 'tap moves cursor to the edge of the word it tapped on (iOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const Material( + child: Center( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + // We moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), + ); + + // But don't trigger the toolbar. + expect(find.byType(CupertinoButton), findsNothing); + }, + ); + + testWidgets( + 'tap moves cursor to the position tapped (Android)', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // We moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream), + ); + + // But don't trigger the toolbar. + expect(find.byType(FlatButton), findsNothing); + }, + ); + + testWidgets( + 'two slow taps do not trigger a word selection (iOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const Material( + child: Center( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // Plain collapsed selection. + expect( + controller.selection, + const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), + ); + + // No toolbar. + expect(find.byType(CupertinoButton), findsNothing); + }, + ); + + testWidgets( + 'double tap selects word and first tap of double tap moves cursor (iOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const Material( + child: Center( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + // This tap just puts the cursor somewhere different than where the double + // tap will occur to test that the double tap moves the existing cursor first. + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // First tap moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream), + ); + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(); + + // Second tap selects the word around the cursor. + expect( + controller.selection, + const TextSelection(baseOffset: 8, extentOffset: 12), + ); + + // Selected text shows 1 toolbar buttons. + expect(find.byType(CupertinoButton), findsNWidgets(1)); + }, + ); + + testWidgets( + 'double tap selects word and first tap of double tap moves cursor and shows toolbar (Android)', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + // This tap just puts the cursor somewhere different than where the double + // tap will occur to test that the double tap moves the existing cursor first. + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // First tap moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream), + ); + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(); + + // Second tap selects the word around the cursor. + expect( + controller.selection, + const TextSelection(baseOffset: 8, extentOffset: 12), + ); + + // Selected text shows 2 toolbar buttons: copy, select all + expect(find.byType(FlatButton), findsNWidgets(2)); + }, + ); + + testWidgets( + 'double tap on top of cursor also selects word (Android)', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ), + ); + + // Tap to put the cursor after the "w". + const int index = 3; + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 500)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect( + controller.selection, + const TextSelection.collapsed(offset: index), + ); + + // Double tap on the same location. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + + // First tap doesn't change the selection + expect( + controller.selection, + const TextSelection.collapsed(offset: index), + ); + + // Second tap selects the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7), + ); + + // Selected text shows 2 toolbar buttons: copy, select all + expect(find.byType(FlatButton), findsNWidgets(2)); + }, + ); + + testWidgets( + 'double tap hold selects word (iOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const Material( + child: Center( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + final TestGesture gesture = + await tester.startGesture(selectableTextStart + const Offset(150.0, 5.0)); + // Hold the press. + await tester.pump(const Duration(milliseconds: 500)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect( + controller.selection, + const TextSelection(baseOffset: 8, extentOffset: 12), + ); + + // Selected text shows 1 toolbar buttons. + expect(find.byType(CupertinoButton), findsNWidgets(1)); + + await gesture.up(); + await tester.pump(); + + // Still selected. + expect( + controller.selection, + const TextSelection(baseOffset: 8, extentOffset: 12), + ); + // The toolbar is still showing. + expect(find.byType(CupertinoButton), findsNWidgets(1)); + }, + ); + + testWidgets( + 'tap after a double tap select is not affected (iOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const Material( + child: Center( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // First tap moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream), + ); + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(selectableTextStart + const Offset(100.0, 5.0)); + await tester.pump(); + + // Plain collapsed selection at the edge of first word. In iOS 12, the + // the first tap after a double tap ends up putting the cursor at where + // you tapped instead of the edge like every other single tap. This is + // likely a bug in iOS 12 and not present in other versions. + expect( + controller.selection, + const TextSelection.collapsed(offset: 7), + ); + + // No toolbar. + expect(find.byType(CupertinoButton), findsNothing); + }, + ); + + testWidgets( + 'long press moves cursor to the exact long press position and shows toolbar (iOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const Material( + child: Center( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // Collapsed cursor for iOS long press. + expect( + controller.selection, + const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream), + ); + + // Collapsed toolbar shows 2 buttons. + expect(find.byType(CupertinoButton), findsNWidgets(1)); + }, + ); + + testWidgets( + 'long press selects word and shows toolbar (Android)', + (WidgetTester tester) async { + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7), + ); + + // Collapsed toolbar shows 2 buttons: copy, select all + expect(find.byType(FlatButton), findsNWidgets(2)); + }, + ); + + testWidgets( + 'long press tap cannot initiate a double tap (iOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const Material( + child: Center( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // We ended up moving the cursor to the edge of the same word and dismissed + // the toolbar. + expect( + controller.selection, + const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), + ); + + expect(find.byType(CupertinoButton), findsNothing); + }, + ); + + testWidgets( + 'long press drag moves the cursor under the drag and shows toolbar on lift (iOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const Material( + child: Center( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + final TestGesture gesture = + await tester.startGesture(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // Long press on iOS shows collapsed selection cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream), + ); + // Cursor move doesn't trigger a toolbar initially. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + + // The selection position is now moved with the drag. + expect( + controller.selection, + const TextSelection.collapsed(offset: 7, affinity: TextAffinity.downstream), + ); + // Still no toolbar. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + + // The selection position is now moved with the drag. + expect( + controller.selection, + const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream), + ); + // Still no toolbar. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.up(); + await tester.pump(); + + // The selection isn't affected by the gesture lift. + expect( + controller.selection, + const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream), + ); + // The toolbar now shows up. + expect(find.byType(CupertinoButton), findsNWidgets(1)); + }, + ); + + testWidgets('long press drag can edge scroll (iOS)', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const Material( + child: Center( + child: SelectableText( + 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + maxLines: 1, + ), + ), + ), + ), + ); + + final RenderEditable renderEditable = findRenderEditable(tester); + + List lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // Just testing the test and making sure that the last character is off + // the right side of the screen. + expect(lastCharEndpoint[0].point.dx, 924.0); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + final TestGesture gesture = + await tester.startGesture(selectableTextStart + const Offset(300, 5)); + await tester.pump(const Duration(milliseconds: 500)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect( + controller.selection, + const TextSelection.collapsed(offset: 21), + ); + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(600, 0)); + // To the edge of the screen basically. + await tester.pump(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 64, affinity: TextAffinity.downstream), + ); + // Keep moving out. + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream), + ); + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream), + ); // We're at the edge now. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.up(); + await tester.pump(); + + // The selection isn't affected by the gesture lift. + expect( + controller.selection, + const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream), + ); + // The toolbar now shows up. + expect(find.byType(CupertinoButton), findsNWidgets(1)); + + lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // The last character is now on screen near the right edge. + expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(798, epsilon: 1)); + + final List firstCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 0), // First character's position. + ); + expect(firstCharEndpoint.length, 1); + // The first character is now offscreen to the left. + expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-125, epsilon: 1)); + }); + + testWidgets( + 'long tap after a double tap select is not affected (iOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const Material( + child: Center( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // First tap moved the cursor to the beginning of the second word. + expect( + controller.selection, + const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream), + ); + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.longPressAt(selectableTextStart + const Offset(100.0, 5.0)); + await tester.pump(); + + // Plain collapsed selection at the exact tap position. + expect( + controller.selection, + const TextSelection.collapsed(offset: 7), + ); + + // Long press toolbar. + expect(find.byType(CupertinoButton), findsNWidgets(1)); + }, + ); +//convert + testWidgets( + 'double tap after a long tap is not affected (iOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const Material( + child: Center( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // First tap moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream), + ); + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(); + + // Double tap selection. + expect( + controller.selection, + const TextSelection(baseOffset: 8, extentOffset: 12), + ); + expect(find.byType(CupertinoButton), findsNWidgets(1)); + }, + ); + + testWidgets( + 'double tap chains work (iOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const Material( + child: Center( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect( + controller.selection, + const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), + ); + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7), + ); + expect(find.byType(CupertinoButton), findsNWidgets(1)); + + // Double tap selecting the same word somewhere else is fine. + await tester.tapAt(selectableTextStart + const Offset(10.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 0, affinity: TextAffinity.downstream), + ); + await tester.tapAt(selectableTextStart + const Offset(10.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7), + ); + expect(find.byType(CupertinoButton), findsNWidgets(1)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream), + ); + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect( + controller.selection, + const TextSelection(baseOffset: 8, extentOffset: 12), + ); + expect(find.byType(CupertinoButton), findsNWidgets(1)); + }, + ); + + testWidgets('force press does not select a word on (android)', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ); + + final Offset offset = tester.getTopLeft(find.byType(SelectableText)) + const Offset(150.0, 5.0); + + const int pointerValue = 1; + final TestGesture gesture = await tester.createGesture(); + await gesture.downWithCustomEvent( + offset, + PointerDownEvent( + pointer: pointerValue, + position: offset, + pressure: 0.0, + pressureMax: 6.0, + pressureMin: 0.0, + ), + ); + await gesture.updateWithCustomEvent(PointerMoveEvent(pointer: pointerValue, position: offset + const Offset(150.0, 5.0), pressure: 0.5, pressureMin: 0, pressureMax: 1)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // We don't want this gesture to select any word on Android. + expect(controller.selection, const TextSelection.collapsed(offset: -1)); + + await gesture.up(); + await tester.pump(); + expect(find.byType(FlatButton), findsNothing); + }); + + testWidgets('force press selects word (iOS)', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const Material( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + const int pointerValue = 1; + final Offset offset = selectableTextStart + const Offset(150.0, 5.0); + final TestGesture gesture = await tester.createGesture(); + await gesture.downWithCustomEvent( + offset, + PointerDownEvent( + pointer: pointerValue, + position: offset, + pressure: 0.0, + pressureMax: 6.0, + pressureMin: 0.0, + ), + ); + + await gesture.updateWithCustomEvent(PointerMoveEvent(pointer: pointerValue, position: selectableTextStart + const Offset(150.0, 5.0), pressure: 0.5, pressureMin: 0, pressureMax: 1)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // We expect the force press to select a word at the given location. + expect( + controller.selection, + const TextSelection(baseOffset: 8, extentOffset: 12), + ); + + await gesture.up(); + await tester.pump(); + expect(find.byType(CupertinoButton), findsNWidgets(1)); + }); + + testWidgets('tap on non-force-press-supported devices work (iOS)', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const Material( + child: SelectableText('Atwater Peel Sherbrooke Bonaventure'), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + const int pointerValue = 1; + final Offset offset = selectableTextStart + const Offset(150.0, 5.0); + final TestGesture gesture = await tester.createGesture(); + await gesture.downWithCustomEvent( + offset, + PointerDownEvent( + pointer: pointerValue, + position: offset, + // iPhone 6 and below report 0 across the board. + pressure: 0, + pressureMax: 0, + pressureMin: 0, + ), + ); + + await gesture.updateWithCustomEvent(PointerMoveEvent(pointer: pointerValue, position: selectableTextStart + const Offset(150.0, 5.0), pressure: 0.5, pressureMin: 0, pressureMax: 1)); + await gesture.up(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // The event should fallback to a normal tap and move the cursor. + // Single taps selects the edge of the word. + expect( + controller.selection, + const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream), + ); + + await tester.pump(); + // Single taps shouldn't trigger the toolbar. + expect(find.byType(CupertinoButton), findsNothing); + }); + + testWidgets('default SelectableText debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + + const SelectableText('something').debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()).toList(); + + expect(description, ['data: something']); + }); + + testWidgets('SelectableText implements debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + +// properties.add(DiagnosticsProperty('data', data, defaultValue: null)); +// properties.add(DiagnosticsProperty('focusNode', focusNode, defaultValue: null)); +// properties.add(DiagnosticsProperty('style', style, defaultValue: null)); +// properties.add(DiagnosticsProperty('autofocus', autofocus, defaultValue: false)); +// properties.add(DiagnosticsProperty('showCursor', showCursor, defaultValue: false)); +// properties.add(IntProperty('maxLines', maxLines, defaultValue: null)); +// properties.add(EnumProperty('textAlign', textAlign, defaultValue: null)); +// properties.add(EnumProperty('textDirection', textDirection, defaultValue: null)); +// properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0)); +// properties.add(DiagnosticsProperty('cursorRadius', cursorRadius, defaultValue: null)); +// properties.add(DiagnosticsProperty('cursorColor', cursorColor, defaultValue: null)); +// properties.add(FlagProperty('selectionEnabled', value: selectionEnabled, defaultValue: true, ifFalse: 'selection disabled')); +// properties.add(DiagnosticsProperty('scrollPhysics', scrollPhysics, defaultValue: null)); + // Not checking controller, inputFormatters, focusNode + const SelectableText( + 'something', + style: TextStyle(color: Color(0xff00ff00)), + textAlign: TextAlign.end, + textDirection: TextDirection.ltr, + autofocus: true, + showCursor: true, + maxLines: 10, + cursorWidth: 1.0, + cursorRadius: Radius.zero, + cursorColor: Color(0xff00ff00), + scrollPhysics: ClampingScrollPhysics(), + enableInteractiveSelection: false, + ).debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()).toList(); + + expect(description, [ + 'data: something', + 'style: TextStyle(inherit: true, color: Color(0xff00ff00))', + 'autofocus: true', + 'showCursor: true', + 'maxLines: 10', + 'textAlign: end', + 'textDirection: ltr', + 'cursorWidth: 1.0', + 'cursorRadius: Radius.circular(0.0)', + 'cursorColor: Color(0xff00ff00)', + 'selection disabled', + 'scrollPhysics: ClampingScrollPhysics', + ]); + }); + + testWidgets( + 'strut basic single line', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: const Material( + child: Center( + child: SelectableText('something'), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(SelectableText)), + // This is the height of the decoration (24) plus the metrics from the default + // TextStyle of the theme (16). + const Size(129.0, 14.0), + ); + }, + ); + + testWidgets( + 'strut TextStyle increases height', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: const Material( + child: Center( + child: SelectableText( + 'something', + style: TextStyle(fontSize: 20), + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(SelectableText)), + // Strut should inherit the TextStyle.fontSize by default and produce the + // same height as if it were disabled. + const Size(183.0, 20.0), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: const Material( + child: Center( + child: SelectableText( + 'something', + style: TextStyle(fontSize: 20), + strutStyle: StrutStyle.disabled, + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(SelectableText)), + // The height here should match the previous version with strut enabled. + const Size(183.0, 20.0), + ); + }, + ); + + testWidgets( + 'strut basic multi line', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: const Material( + child: Center( + child: SelectableText( + 'something', + maxLines: 6, + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(SelectableText)), + const Size(129.0, 84.0), + ); + }, + ); + + testWidgets( + 'strut no force small strut', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: const Material( + child: Center( + child: SelectableText( + 'something', + maxLines: 6, + strutStyle: StrutStyle( + // The small strut is overtaken by the larger + // TextStyle fontSize. + fontSize: 5, + ), + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(SelectableText)), + // When the strut's height is smaller than TextStyle's and forceStrutHeight + // is disabled, then the TextStyle takes precedence. Should be the same height + // as 'strut basic multi line'. + const Size(129.0, 84.0), + ); + }, + ); + + testWidgets( + 'strut no force large strut', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: const Material( + child: Center( + child: SelectableText( + 'something', + maxLines: 6, + strutStyle: StrutStyle( + fontSize: 25, + ), + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(SelectableText)), + // When the strut's height is larger than TextStyle's and forceStrutHeight + // is disabled, then the StrutStyle takes precedence. + const Size(129.0, 150.0), + ); + }, + ); + + testWidgets( + 'strut height override', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: const Material( + child: Center( + child: SelectableText( + 'something', + maxLines: 3, + strutStyle: StrutStyle( + fontSize: 8, + forceStrutHeight: true, + ), + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(SelectableText)), + // The smaller font size of strut make the field shorter than normal. + const Size(129.0, 24.0), + ); + }, + ); + + testWidgets( + 'strut forces field taller', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: const Material( + child: Center( + child: SelectableText( + 'something', + maxLines: 3, + style: TextStyle(fontSize: 10), + strutStyle: StrutStyle( + fontSize: 18, + forceStrutHeight: true, + ), + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(SelectableText)), + // When the strut fontSize is larger than a provided TextStyle, the + // the strut's height takes precedence. + const Size(93.0, 54.0), + ); + }, + ); + + testWidgets('Caret center position', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: Container( + width: 300.0, + child: const SelectableText( + 'abcd', + textAlign: TextAlign.center, + ), + ), + ), + ); + + final RenderEditable editable = findRenderEditable(tester); + + Offset topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 4)).topLeft, + ); + expect(topLeft.dx, equals(306)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 3)).topLeft, + ); + expect(topLeft.dx, equals(292)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, + ); + expect(topLeft.dx, equals(278)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 1)).topLeft, + ); + expect(topLeft.dx, equals(264)); + }); + + testWidgets('Caret indexes into trailing whitespace center align', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: Container( + width: 300.0, + child: const SelectableText( + 'abcd ', + textAlign: TextAlign.center, + ), + ), + ), + ); + + final RenderEditable editable = findRenderEditable(tester); + + Offset topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 7)).topLeft, + ); + expect(topLeft.dx, equals(362)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 8)).topLeft, + ); + // Caret is capped at text length. + expect(topLeft.dx, equals(362)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 4)).topLeft, + ); + expect(topLeft.dx, equals(334)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 3)).topLeft, + ); + expect(topLeft.dx, equals(320)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, + ); + expect(topLeft.dx, equals(306)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 1)).topLeft, + ); + expect(topLeft.dx, equals(292)); + }); + + testWidgets('selection handles are rendered and not faded away', (WidgetTester tester) async { + const String testText = 'lorem ipsum'; + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText(testText), + ), + ), + ); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + final RenderEditable renderEditable = state.renderEditable; + + await tester.tapAt(const Offset(20, 10)); + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + await tester.pumpAndSettle(); + + final List transitions = + find.byType(FadeTransition).evaluate().map((Element e) => e.widget).toList(); + // On Android, an empty app contains a single FadeTransition. The following + // two are the left and right text selection handles, respectively. + expect(transitions.length, 3); + final FadeTransition left = transitions[1]; + final FadeTransition right = transitions[2]; + + expect(left.opacity.value, equals(1.0)); + expect(right.opacity.value, equals(1.0)); + }); + + testWidgets('iOS selection handles are rendered and not faded away', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const String testText = 'lorem ipsum'; + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText(testText), + ), + ), + ); + + final RenderEditable renderEditable = + tester.state(find.byType(EditableText)).renderEditable; + + await tester.tapAt(const Offset(20, 10)); + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + await tester.pumpAndSettle(); + + final List transitions = + find.byType(FadeTransition).evaluate().map((Element e) => e.widget).toList(); + expect(transitions.length, 2); + final FadeTransition left = transitions[0]; + final FadeTransition right = transitions[1]; + + expect(left.opacity.value, equals(1.0)); + expect(right.opacity.value, equals(1.0)); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('Long press shows handles and toolbar', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText('abc def ghi'), + ), + ), + ); + + // Long press at 'e' in 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.longPressAt(ePos); + await tester.pumpAndSettle(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay.handlesAreVisible, isTrue); + expect(editableText.selectionOverlay.toolbarIsVisible, isTrue); + }); + + testWidgets('Double tap shows handles and toolbar', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText('abc def ghi'), + ), + ), + ); + + // Double tap at 'e' in 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.tapAt(ePos); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(ePos); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay.handlesAreVisible, isTrue); + expect(editableText.selectionOverlay.toolbarIsVisible, isTrue); + }); + + testWidgets( + 'Mouse tap does not show handles nor toolbar', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText('abc def ghi'), + ), + ), + ); + + // Long press to trigger the selectable text. + final Offset ePos = textOffsetToPosition(tester, 5); + final TestGesture gesture = await tester.startGesture( + ePos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay.toolbarIsVisible, isFalse); + expect(editableText.selectionOverlay.handlesAreVisible, isFalse); + + await gesture.removePointer(); + }, + ); + + testWidgets( + 'Mouse long press does not show handles nor toolbar', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText('abc def ghi'), + ), + ), + ); + + // Long press to trigger the selectable text. + final Offset ePos = textOffsetToPosition(tester, 5); + final TestGesture gesture = await tester.startGesture( + ePos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay.toolbarIsVisible, isFalse); + expect(editableText.selectionOverlay.handlesAreVisible, isFalse); + + await gesture.removePointer(); + }, + ); + + testWidgets( + 'Mouse double tap does not show handles nor toolbar', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText('abc def ghi'), + ), + ), + ); + + // Double tap to trigger the selectable text. + final Offset selectableTextPos = tester.getCenter(find.byType(SelectableText)); + final TestGesture gesture = await tester.startGesture( + selectableTextPos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 50)); + await gesture.up(); + await tester.pump(); + await gesture.down(selectableTextPos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay.toolbarIsVisible, isFalse); + expect(editableText.selectionOverlay.handlesAreVisible, isFalse); + + await gesture.removePointer(); + }, + ); +}