implement selectable text (#34019)

diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart
index d6589e5..edeaa36 100644
--- a/packages/flutter/lib/material.dart
+++ b/packages/flutter/lib/material.dart
@@ -93,6 +93,7 @@
 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 0000000..64d6aef
--- /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>[_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>[
+///       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<String>('data', data, defaultValue: null));
+    properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null));
+    properties.add(DiagnosticsProperty<TextStyle>('style', style, defaultValue: null));
+    properties.add(DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false));
+    properties.add(DiagnosticsProperty<bool>('showCursor', showCursor, defaultValue: false));
+    properties.add(IntProperty('maxLines', maxLines, defaultValue: null));
+    properties.add(EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null));
+    properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
+    properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0));
+    properties.add(DiagnosticsProperty<Radius>('cursorRadius', cursorRadius, defaultValue: null));
+    properties.add(DiagnosticsProperty<Color>('cursorColor', cursorColor, defaultValue: null));
+    properties.add(FlagProperty('selectionEnabled', value: selectionEnabled, defaultValue: true, ifFalse: 'selection disabled'));
+    properties.add(DiagnosticsProperty<ScrollPhysics>('scrollPhysics', scrollPhysics, defaultValue: null));
+  }
+}
+
+class _SelectableTextState extends State<SelectableText> 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<EditableTextState> editableTextKey = GlobalKey<EditableTextState>();
+
+  @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 ffb715b..88fee77 100644
--- a/packages/flutter/lib/src/material/text_field.dart
+++ b/packages/flutter/lib/src/material/text_field.dart
@@ -17,6 +17,7 @@
 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 @@
         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 b10e43a..e17a63a 100644
--- a/packages/flutter/lib/src/painting/text_painter.dart
+++ b/packages/flutter/lib/src/painting/text_painter.dart
@@ -652,7 +652,7 @@
 
       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 @@
       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 2519549..a4ab81a 100644
--- a/packages/flutter/lib/src/rendering/editable.dart
+++ b/packages/flutter/lib/src/rendering/editable.dart
@@ -157,6 +157,9 @@
     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 @@
        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 @@
          textScaleFactor: textScaleFactor,
          locale: locale,
          strutStyle: strutStyle,
+         textWidthBasis: textWidthBasis,
        ),
        _cursorColor = cursorColor,
        _backgroundCursorColor = backgroundCursorColor,
@@ -216,7 +223,9 @@
        _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 @@
   /// 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 @@
     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 @@
     // 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 @@
       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 @@
   }
 
   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 @@
     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 @@
     return enableInteractiveSelection ?? !obscureText;
   }
 
+  double get _caretMargin => _kCaretGap + cursorWidth;
+
   @override
   void describeSemanticsConfiguration(SemanticsConfiguration config) {
     super.describeSemanticsConfiguration(config);
@@ -995,7 +1031,8 @@
       ..isMultiline = _isMultiline
       ..textDirection = textDirection
       ..isFocused = hasFocus
-      ..isTextField = true;
+      ..isTextField = true
+      ..isReadOnly = readOnly;
 
     if (hasFocus && selectionEnabled)
       config.onSetSelection = _handleSetSelection;
@@ -1526,10 +1563,12 @@
     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 @@
     // 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 468b0a8..c821bb2 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -150,6 +150,29 @@
     );
   }
 
+  /// 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>[
+        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 @@
     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 @@
        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 @@
   /// {@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 @@
   /// {@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 @@
   ///
   /// 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 @@
               showCursor: EditableText.debugDeterministicCursor
                   ? ValueNotifier<bool>(widget.showCursor)
                   : _cursorVisibilityNotifier,
+              forceLine: widget.forceLine,
+              readOnly: widget.readOnly,
               hasFocus: _hasFocus,
               maxLines: widget.maxLines,
               minLines: widget.minLines,
@@ -1632,6 +1675,7 @@
               textAlign: widget.textAlign,
               textDirection: _textDirection,
               locale: widget.locale,
+              textWidthBasis: widget.textWidthBasis,
               obscureText: widget.obscureText,
               autocorrect: widget.autocorrect,
               offset: offset,
@@ -1657,32 +1701,20 @@
   /// 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>[
-          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 @@
     this.cursorColor,
     this.backgroundCursorColor,
     this.showCursor,
+    this.forceLine,
+    this.readOnly,
+    this.textWidthBasis,
     this.hasFocus,
     this.maxLines,
     this.minLines,
@@ -1730,6 +1765,8 @@
   final LayerLink endHandleLayerLink;
   final Color backgroundCursorColor;
   final ValueNotifier<bool> showCursor;
+  final bool forceLine;
+  final bool readOnly;
   final bool hasFocus;
   final int maxLines;
   final int minLines;
@@ -1741,6 +1778,7 @@
   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 @@
       endHandleLayerLink: endHandleLayerLink,
       backgroundCursorColor: backgroundCursorColor,
       showCursor: showCursor,
+      forceLine: forceLine,
+      readOnly: readOnly,
       hasFocus: hasFocus,
       maxLines: maxLines,
       minLines: minLines,
@@ -1779,6 +1819,7 @@
       onCaretChanged: onCaretChanged,
       ignorePointer: rendererIgnoresPointer,
       obscureText: obscureText,
+      textWidthBasis: textWidthBasis,
       cursorWidth: cursorWidth,
       cursorRadius: cursorRadius,
       cursorOffset: cursorOffset,
@@ -1797,6 +1838,8 @@
       ..startHandleLayerLink = startHandleLayerLink
       ..endHandleLayerLink = endHandleLayerLink
       ..showCursor = showCursor
+      ..forceLine = forceLine
+      ..readOnly = readOnly
       ..hasFocus = hasFocus
       ..maxLines = maxLines
       ..minLines = minLines
@@ -1812,6 +1855,7 @@
       ..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 a432de8..d0f5de1 100644
--- a/packages/flutter/test/material/text_field_test.dart
+++ b/packages/flutter/test/material/text_field_test.dart
@@ -973,7 +973,7 @@
 
     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 @@
     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>[SemanticsFlag.isTextField, SemanticsFlag.isReadOnly])
+    );
+
+    semantics.dispose();
+  });
+
   void sendFakeKeyEvent(Map<String, dynamic> 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 0000000..6168acd
--- /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 = <String, dynamic>{
+    'text': null,
+  };
+
+  Future<dynamic> handleMethodCall(MethodCall methodCall) async {
+    switch (methodCall.method) {
+      case 'Clipboard.getData':
+        return _clipboardData;
+      case 'Clipboard.setData':
+        _clipboardData = methodCall.arguments;
+        break;
+    }
+  }
+}
+
+class MaterialLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> {
+  @override
+  bool isSupported(Locale locale) => true;
+
+  @override
+  Future<MaterialLocalizations> load(Locale locale) => DefaultMaterialLocalizations.load(locale);
+
+  @override
+  bool shouldReload(MaterialLocalizationsDelegate old) => false;
+}
+
+class WidgetsLocalizationsDelegate extends LocalizationsDelegate<WidgetsLocalizations> {
+  @override
+  bool isSupported(Locale locale) => true;
+
+  @override
+  Future<WidgetsLocalizations> 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: <LocalizationsDelegate<dynamic>>[
+      WidgetsLocalizationsDelegate(),
+      MaterialLocalizationsDelegate(),
+    ],
+    child: Directionality(
+      textDirection: TextDirection.ltr,
+      child: MediaQuery(
+        data: const MediaQueryData(size: Size(800.0, 600.0)),
+        child: Overlay(
+          initialEntries: <OverlayEntry>[
+            entry
+          ],
+        ),
+      ),
+    ),
+  );
+}
+
+Widget boilerplate({ Widget child }) {
+  return Localizations(
+    locale: const Locale('en', 'US'),
+    delegates: <LocalizationsDelegate<dynamic>>[
+      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<void> skipPastScrollingAnimation(WidgetTester tester) async {
+  await tester.pump();
+  await tester.pump(const Duration(milliseconds: 200));
+}
+
+double getOpacity(WidgetTester tester, Finder finder) {
+  return tester.widget<FadeTransition>(
+      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<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) {
+    return points.map<TextSelectionPoint>((TextSelectionPoint point) {
+      return TextSelectionPoint(
+        box.localToGlobal(point.point),
+        point.direction,
+      );
+    }).toList();
+  }
+
+  Offset textOffsetToPosition(WidgetTester tester, int offset) {
+    final RenderEditable renderEditable = findRenderEditable(tester);
+    final List<TextSelectionPoint> 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>[
+                  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: <InlineSpan>[
+                  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<EditableTextState>(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<TextSelectionPoint> 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<TextSelectionPoint> 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<TextSelectionPoint> 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<TextSelectionPoint> 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<TextSelectionPoint> 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: <Widget>[
+            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>[
+          SemanticsFlag.isTextField,
+          SemanticsFlag.isReadOnly,
+          SemanticsFlag.isMultiline,
+        ]
+      )
+    );
+
+    semantics.dispose();
+  });
+
+  void sendFakeKeyEvent(Map<String, dynamic> 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(<String, dynamic>{
+      'type': down ? 'keydown' : 'keyup',
+      'keymap': 'android',
+      'keyCode': code,
+      'hidUsage': 0x04,
+      'codePoint': 0x64,
+      'metaState': metaState,
+    });
+  }
+
+  group('Keyboard Tests', () {
+    TextEditingController controller;
+
+    Future<void> 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 <String, dynamic>{'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<RawKeyEvent> events = <RawKeyEvent>[];
+
+    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: <Widget>[
+                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: <Widget>[
+                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<RawKeyEvent> events = <RawKeyEvent>[];
+
+    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: <Widget>[
+                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: <Widget>[
+            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: <Widget>[
+            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>[
+        TestSemantics.rootChild(
+          id: 1,
+          textDirection: TextDirection.ltr,
+          value: 'Guten Tag',
+          actions: <SemanticsAction>[
+            SemanticsAction.tap,
+            SemanticsAction.longPress,
+          ],
+          flags: <SemanticsFlag>[
+            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>[
+        TestSemantics.rootChild(
+          id: 1,
+          textDirection: TextDirection.ltr,
+          value: 'Guten Tag',
+          textSelection: const TextSelection.collapsed(offset: 9),
+          actions: <SemanticsAction>[
+            SemanticsAction.tap,
+            SemanticsAction.longPress,
+            SemanticsAction.moveCursorBackwardByCharacter,
+            SemanticsAction.moveCursorBackwardByWord,
+            SemanticsAction.setSelection,
+          ],
+          flags: <SemanticsFlag>[
+            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>[
+        TestSemantics.rootChild(
+          id: 1,
+          textDirection: TextDirection.ltr,
+          textSelection: const TextSelection.collapsed(offset: 4),
+          value: 'Guten Tag',
+          actions: <SemanticsAction>[
+            SemanticsAction.tap,
+            SemanticsAction.longPress,
+            SemanticsAction.moveCursorBackwardByCharacter,
+            SemanticsAction.moveCursorForwardByCharacter,
+            SemanticsAction.moveCursorBackwardByWord,
+            SemanticsAction.moveCursorForwardByWord,
+            SemanticsAction.setSelection,
+          ],
+          flags: <SemanticsFlag>[
+            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>[
+        TestSemantics.rootChild(
+          id: 1,
+          textDirection: TextDirection.ltr,
+          textSelection: const TextSelection.collapsed(offset: 0),
+          value: 'Guten Tag',
+          actions: <SemanticsAction>[
+            SemanticsAction.tap,
+            SemanticsAction.longPress,
+            SemanticsAction.moveCursorForwardByCharacter,
+            SemanticsAction.moveCursorForwardByWord,
+            SemanticsAction.setSelection,
+          ],
+          flags: <SemanticsFlag>[
+            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>[
+        TestSemantics.rootChild(
+          id: 1,
+          value: 'Guten Tag',
+          textDirection: TextDirection.ltr,
+          actions: <SemanticsAction>[
+            SemanticsAction.tap,
+            SemanticsAction.longPress,
+            // Absent the following because enableInteractiveSelection: false
+            // SemanticsAction.moveCursorBackwardByCharacter,
+            // SemanticsAction.moveCursorBackwardByWord,
+            // SemanticsAction.setSelection,
+            // SemanticsAction.paste,
+          ],
+          flags: <SemanticsFlag>[
+            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>[
+        TestSemantics.rootChild(
+          id: 1,
+          value: 'Hello',
+          textDirection: TextDirection.ltr,
+          actions: <SemanticsAction>[
+            SemanticsAction.tap,
+            SemanticsAction.longPress
+          ],
+          flags: <SemanticsFlag>[
+            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>[
+        TestSemantics.rootChild(
+          id: 1,
+          value: 'Hello',
+          textSelection: const TextSelection.collapsed(offset: 5),
+          textDirection: TextDirection.ltr,
+          actions: <SemanticsAction>[
+            SemanticsAction.tap,
+            SemanticsAction.longPress,
+            SemanticsAction.moveCursorBackwardByCharacter,
+            SemanticsAction.moveCursorBackwardByWord,
+            SemanticsAction.setSelection,
+          ],
+          flags: <SemanticsFlag>[
+            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>[
+        TestSemantics.rootChild(
+          id: 1,
+          value: 'Hello',
+          textSelection: const TextSelection(baseOffset: 5, extentOffset: 3),
+          textDirection: TextDirection.ltr,
+          actions: <SemanticsAction>[
+            SemanticsAction.tap,
+            SemanticsAction.longPress,
+            SemanticsAction.moveCursorBackwardByCharacter,
+            SemanticsAction.moveCursorForwardByCharacter,
+            SemanticsAction.moveCursorBackwardByWord,
+            SemanticsAction.moveCursorForwardByWord,
+            SemanticsAction.setSelection,
+            SemanticsAction.copy,
+          ],
+          flags: <SemanticsFlag>[
+            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>[
+        TestSemantics.rootChild(
+          id: inputFieldId,
+          value: 'Hello',
+          textSelection: const TextSelection.collapsed(offset: 5),
+          textDirection: TextDirection.ltr,
+          actions: <SemanticsAction>[
+            SemanticsAction.tap,
+            SemanticsAction.longPress,
+            SemanticsAction.moveCursorBackwardByCharacter,
+            SemanticsAction.moveCursorBackwardByWord,
+            SemanticsAction.setSelection,
+          ],
+          flags: <SemanticsFlag>[
+            SemanticsFlag.isReadOnly,
+            SemanticsFlag.isTextField,
+            SemanticsFlag.isMultiline,
+            SemanticsFlag.isFocused,
+          ],
+        ),
+      ],
+    ), ignoreTransform: true, ignoreRect: true));
+
+    // move cursor back once
+    semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{
+      'base': 4,
+      'extent': 4,
+    });
+    await tester.pump();
+    expect(controller.selection, const TextSelection.collapsed(offset: 4));
+
+    // move cursor to front
+    semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{
+      'base': 0,
+      'extent': 0,
+    });
+    await tester.pump();
+    expect(controller.selection, const TextSelection.collapsed(offset: 0));
+
+    // select all
+    semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{
+      'base': 0,
+      'extent': 5,
+    });
+    await tester.pump();
+    expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
+    expect(semantics, hasSemantics(TestSemantics.root(
+      children: <TestSemantics>[
+        TestSemantics.rootChild(
+          id: inputFieldId,
+          value: 'Hello',
+          textSelection: const TextSelection(baseOffset: 0, extentOffset: 5),
+          textDirection: TextDirection.ltr,
+          actions: <SemanticsAction>[
+            SemanticsAction.tap,
+            SemanticsAction.longPress,
+            SemanticsAction.moveCursorBackwardByCharacter,
+            SemanticsAction.moveCursorBackwardByWord,
+            SemanticsAction.setSelection,
+            SemanticsAction.copy,
+          ],
+          flags: <SemanticsFlag>[
+            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>[
+          TestSemantics(
+            id: inputFieldId,
+            flags: <SemanticsFlag>[
+              SemanticsFlag.isReadOnly,
+              SemanticsFlag.isTextField,
+              SemanticsFlag.isMultiline,
+            ],
+            actions: <SemanticsAction>[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>[
+          TestSemantics(
+            id: inputFieldId,
+            flags: <SemanticsFlag>[
+              SemanticsFlag.isReadOnly,
+              SemanticsFlag.isTextField,
+              SemanticsFlag.isMultiline,
+              SemanticsFlag.isFocused,
+            ],
+            actions: <SemanticsAction>[
+              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<TextSelectionPoint> 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<TextSelectionPoint> 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<String> description = builder.properties
+        .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info))
+        .map((DiagnosticsNode node) => node.toString()).toList();
+
+    expect(description, <String>['data: something']);
+  });
+
+  testWidgets('SelectableText implements debugFillProperties', (WidgetTester tester) async {
+    final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
+
+//    properties.add(DiagnosticsProperty<String>('data', data, defaultValue: null));
+//    properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null));
+//    properties.add(DiagnosticsProperty<TextStyle>('style', style, defaultValue: null));
+//    properties.add(DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false));
+//    properties.add(DiagnosticsProperty<bool>('showCursor', showCursor, defaultValue: false));
+//    properties.add(IntProperty('maxLines', maxLines, defaultValue: null));
+//    properties.add(EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null));
+//    properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
+//    properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0));
+//    properties.add(DiagnosticsProperty<Radius>('cursorRadius', cursorRadius, defaultValue: null));
+//    properties.add(DiagnosticsProperty<Color>('cursorColor', cursorColor, defaultValue: null));
+//    properties.add(FlagProperty('selectionEnabled', value: selectionEnabled, defaultValue: true, ifFalse: 'selection disabled'));
+//    properties.add(DiagnosticsProperty<ScrollPhysics>('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<String> description = builder.properties
+        .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info))
+        .map((DiagnosticsNode node) => node.toString()).toList();
+
+    expect(description, <String>[
+      '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<EditableTextState>(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<Widget> 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<EditableTextState>(find.byType(EditableText)).renderEditable;
+
+    await tester.tapAt(const Offset(20, 10));
+    renderEditable.selectWord(cause: SelectionChangedCause.longPress);
+    await tester.pumpAndSettle();
+
+    final List<Widget> 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();
+    },
+  );
+}