diff --git a/packages/flutter/lib/src/material/input.dart b/packages/flutter/lib/src/material/input.dart index f25810d1a5..2d473b8554 100644 --- a/packages/flutter/lib/src/material/input.dart +++ b/packages/flutter/lib/src/material/input.dart @@ -13,6 +13,8 @@ import 'theme.dart'; export 'package:sky_services/editing/editing.mojom.dart' show KeyboardType; +const double _kTextSelectionHandleSize = 20.0; // pixels + /// A material design text input field. /// /// Requires one of its ancestors to be a [Material] widget. @@ -191,6 +193,7 @@ class _InputState extends State { hideText: config.hideText, cursorColor: themeData.textSelectionColor, selectionColor: themeData.textSelectionColor, + selectionHandleBuilder: _textSelectionHandleBuilder, keyboardType: config.keyboardType, onChanged: onChanged, onSubmitted: onSubmitted @@ -237,6 +240,32 @@ class _InputState extends State { ) ); } + + Widget _textSelectionHandleBuilder( + BuildContext context, TextSelectionHandleType type) { + Widget handle = new SizedBox( + width: _kTextSelectionHandleSize, + height: _kTextSelectionHandleSize, + child: new CustomPaint( + painter: new _TextSelectionHandlePainter( + type: type, + color: Theme.of(context).textSelectionHandleColor + ) + ) + ); + + switch (type) { + case TextSelectionHandleType.left: + // Shift the child left by 100% of its width, so its top-right corner + // touches the selection endpoint. + return new FractionalTranslation( + translation: const FractionalOffset(-1.0, 0.0), + child: handle + ); + case TextSelectionHandleType.right: + return handle; + } + } } class _FormFieldData { @@ -271,3 +300,40 @@ class _FormFieldData { scope.onFieldChanged(); } } + +/// Draws a single text selection handle. The [type] determines where the handle +/// points (e.g. the [left] handle points up and to the right). +class _TextSelectionHandlePainter extends CustomPainter { + _TextSelectionHandlePainter({this.type, this.color}); + + final TextSelectionHandleType type; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + Paint paint = new Paint()..color = color; + + // Each handle is a circle, with a rectangle in the top quadrant of that + // circle in the direction it's pointing. [rect] here is the size of the + // corner rect, e.g. half the diameter of the circle. + double radius = size.width/2.0; + canvas.drawCircle(new Point(radius, radius), radius, paint); + + Rect rect; + switch (type) { + case TextSelectionHandleType.left: + rect = new Rect.fromLTWH(radius, 0.0, radius, radius); + break; + case TextSelectionHandleType.right: + rect = new Rect.fromLTWH(0.0, 0.0, radius, radius); + break; + } + canvas.drawRect(rect, paint); + } + + @override + bool shouldRepaint(_TextSelectionHandlePainter oldPainter) { + return type != oldPainter.type || + color != oldPainter.color; + } +} diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index ffb8e00b17..0314529ed1 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -84,6 +84,7 @@ class ThemeData { Color disabledColor, Color buttonColor, Color textSelectionColor, + Color textSelectionHandleColor, Color backgroundColor, Color indicatorColor, Color hintColor, @@ -109,6 +110,7 @@ class ThemeData { disabledColor ??= isDark ? Colors.white30 : Colors.black26; buttonColor ??= isDark ? primarySwatch[600] : Colors.grey[300]; textSelectionColor ??= isDark ? accentColor : primarySwatch[200]; + textSelectionHandleColor ??= isDark ? Colors.tealAccent[400] : primarySwatch[300]; backgroundColor ??= isDark ? Colors.grey[700] : primarySwatch[200]; indicatorColor ??= accentColor == primaryColor ? Colors.white : accentColor; hintColor ??= isDark ? const Color(0x42FFFFFF) : const Color(0x4C000000); @@ -132,6 +134,7 @@ class ThemeData { disabledColor: disabledColor, buttonColor: buttonColor, textSelectionColor: textSelectionColor, + textSelectionHandleColor: textSelectionHandleColor, backgroundColor: backgroundColor, indicatorColor: indicatorColor, hintColor: hintColor, @@ -164,6 +167,7 @@ class ThemeData { this.disabledColor, this.buttonColor, this.textSelectionColor, + this.textSelectionHandleColor, this.backgroundColor, this.indicatorColor, this.hintColor, @@ -187,6 +191,7 @@ class ThemeData { assert(disabledColor != null); assert(buttonColor != null); assert(textSelectionColor != null); + assert(textSelectionHandleColor != null); assert(disabledColor != null); assert(indicatorColor != null); assert(hintColor != null); @@ -271,6 +276,8 @@ class ThemeData { /// The color of text selections in text fields, such as [Input]. final Color textSelectionColor; + final Color textSelectionHandleColor; + /// A color that contrasts with the [primaryColor], e.g. used as the /// remaining part of a progress bar. final Color backgroundColor; @@ -309,6 +316,7 @@ class ThemeData { disabledColor: Color.lerp(begin.disabledColor, end.disabledColor, t), buttonColor: Color.lerp(begin.buttonColor, end.buttonColor, t), textSelectionColor: Color.lerp(begin.textSelectionColor, end.textSelectionColor, t), + textSelectionHandleColor: Color.lerp(begin.textSelectionHandleColor, end.textSelectionHandleColor, t), backgroundColor: Color.lerp(begin.backgroundColor, end.backgroundColor, t), accentColor: Color.lerp(begin.accentColor, end.accentColor, t), accentColorBrightness: t < 0.5 ? begin.accentColorBrightness : end.accentColorBrightness, @@ -339,6 +347,7 @@ class ThemeData { (otherData.disabledColor == disabledColor) && (otherData.buttonColor == buttonColor) && (otherData.textSelectionColor == textSelectionColor) && + (otherData.textSelectionHandleColor == textSelectionHandleColor) && (otherData.backgroundColor == backgroundColor) && (otherData.accentColor == accentColor) && (otherData.accentColorBrightness == accentColorBrightness) && @@ -366,10 +375,11 @@ class ThemeData { disabledColor, buttonColor, textSelectionColor, + textSelectionHandleColor, backgroundColor, accentColor, - accentColorBrightness, hashValues( // Too many values. + accentColorBrightness, indicatorColor, hintColor, errorColor, diff --git a/packages/flutter/lib/src/rendering/editable_line.dart b/packages/flutter/lib/src/rendering/editable_line.dart index ecb8081282..1a125bde52 100644 --- a/packages/flutter/lib/src/rendering/editable_line.dart +++ b/packages/flutter/lib/src/rendering/editable_line.dart @@ -4,7 +4,6 @@ import 'dart:ui' as ui show Paragraph, ParagraphBuilder, ParagraphStyle, TextBox; -import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'box.dart'; @@ -17,6 +16,21 @@ const double _kCaretWidth = 1.0; // pixels final String _kZeroWidthSpace = new String.fromCharCode(0x200B); +/// Called when the user changes the selection (including cursor location). +typedef void SelectionChangedHandler(TextSelection selection, RenderEditableLine renderObject); + +/// Represents a global screen coordinate of the point in a selection, and the +/// text direction at that point. +class TextSelectionPoint { + TextSelectionPoint(this.point, this.direction); + + /// Screen coordinates of the lower left or lower right corner of the selection. + final Point point; + + /// Direction of the text at this edge of the selection. + final TextDirection direction; +} + /// A single line of editable text. class RenderEditableLine extends RenderBox { RenderEditableLine({ @@ -44,9 +58,11 @@ class RenderEditableLine extends RenderBox { ..onTapDown = _handleTapDown ..onTap = _handleTap ..onTapCancel = _handleTapCancel; + _longPress = new LongPressGestureRecognizer() + ..onLongPress = _handleLongPress; } - ValueChanged onSelectionChanged; + SelectionChangedHandler onSelectionChanged; ViewportDimensionsChangeCallback onPaintOffsetUpdateNeeded; /// The text to display @@ -111,6 +127,32 @@ class RenderEditableLine extends RenderBox { markNeedsPaint(); } + List getEndpointsForSelection(TextSelection selection) { + _textPainter.layout(); // TODO(mpcomplete): is this hacky? + + Offset offset = _paintOffset + new Offset(0.0, -_kCaretHeightOffset); + + if (selection.isCollapsed) { + // TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary. + Offset caretOffset = _textPainter.getOffsetForCaret(selection.extent, _caretPrototype); + Point start = new Point(caretOffset.dx, _contentSize.height) + offset; + return [new TextSelectionPoint(localToGlobal(start), null)]; + } else { + List boxes = _textPainter.getBoxesForSelection(selection); + Point start = new Point(boxes.first.start, boxes.first.bottom) + offset; + Point end = new Point(boxes.last.end, boxes.last.bottom) + offset; + return [ + new TextSelectionPoint(localToGlobal(start), boxes.first.direction), + new TextSelectionPoint(localToGlobal(end), boxes.last.direction), + ]; + } + } + + TextPosition getPositionForPoint(Point global) { + global += -paintOffset; + return _textPainter.getPositionForOffset(globalToLocal(global).toOffset()); + } + Size _contentSize; ui.Paragraph _layoutTemplate; @@ -159,16 +201,20 @@ class RenderEditableLine extends RenderBox { bool hitTestSelf(Point position) => true; TapGestureRecognizer _tap; + LongPressGestureRecognizer _longPress; @override void handleEvent(PointerEvent event, BoxHitTestEntry entry) { - if (event is PointerDownEvent && onSelectionChanged != null) + if (event is PointerDownEvent && onSelectionChanged != null) { _tap.addPointer(event); + _longPress.addPointer(event); + } } Point _lastTapDownPosition; + Point _longPressPosition; void _handleTapDown(Point globalPosition) { - _lastTapDownPosition = globalPosition; + _lastTapDownPosition = globalPosition + -paintOffset; } void _handleTap() { @@ -177,14 +223,41 @@ class RenderEditableLine extends RenderBox { _lastTapDownPosition = null; if (onSelectionChanged != null) { TextPosition position = _textPainter.getPositionForOffset(globalToLocal(global).toOffset()); - onSelectionChanged(new TextSelection.fromPosition(position)); + onSelectionChanged(new TextSelection.fromPosition(position), this); } } void _handleTapCancel() { + // longPress arrives after tapCancel, so remember the tap position. + _longPressPosition = _lastTapDownPosition; _lastTapDownPosition = null; } + void _handleLongPress() { + final Point global = _longPressPosition; + _longPressPosition = null; + if (onSelectionChanged != null) { + TextPosition position = _textPainter.getPositionForOffset(globalToLocal(global).toOffset()); + onSelectionChanged(_selectWordAtOffset(position), this); + } + } + + TextSelection _selectWordAtOffset(TextPosition position) { + // TODO(mpcomplete): Placeholder. Need to ask the engine for this info to do + // it correctly. + String str = text.toPlainText(); + int start = position.offset - 1; + while (start >= 0 && str[start] != ' ') + --start; + ++start; + + int end = position.offset; + while (end < str.length && str[end] != ' ') + ++end; + + return new TextSelection(baseOffset: start, extentOffset: end); + } + BoxConstraints _constraintsForCurrentLayout; // when null, we don't have a current layout // TODO(abarth): This logic should live in TextPainter and be shared with RenderParagraph. diff --git a/packages/flutter/lib/src/widgets/editable.dart b/packages/flutter/lib/src/widgets/editable.dart index 9bbaf70c67..3802e92019 100644 --- a/packages/flutter/lib/src/widgets/editable.dart +++ b/packages/flutter/lib/src/widgets/editable.dart @@ -4,7 +4,7 @@ import 'dart:async'; -import 'package:flutter/rendering.dart' show RenderEditableLine; +import 'package:flutter/rendering.dart' show RenderEditableLine, SelectionChangedHandler; import 'package:sky_services/editing/editing.mojom.dart' as mojom; import 'package:flutter/services.dart'; @@ -13,6 +13,7 @@ import 'framework.dart'; import 'focus.dart'; import 'scrollable.dart'; import 'scroll_behavior.dart'; +import 'text_selection.dart'; export 'package:flutter/painting.dart' show TextSelection; export 'package:sky_services/editing/editing.mojom.dart' show KeyboardType; @@ -152,6 +153,7 @@ class RawInputLine extends Scrollable { this.style, this.cursorColor, this.selectionColor, + this.selectionHandleBuilder, this.keyboardType, this.onChanged, this.onSubmitted @@ -179,6 +181,8 @@ class RawInputLine extends Scrollable { /// The color to use when painting the selection. final Color selectionColor; + final TextSelectionHandleBuilder selectionHandleBuilder; + /// The type of keyboard to use for editing the text. final KeyboardType keyboardType; @@ -198,6 +202,7 @@ class RawInputLineState extends ScrollableState { _KeyboardClientImpl _keyboardClient; KeyboardHandle _keyboardHandle; + TextSelectionHandles _selectionHandles; @override ScrollBehavior createScrollBehavior() => new BoundedBehavior(); @@ -224,6 +229,12 @@ class RawInputLineState extends ScrollableState { } } + @override + void dispatchOnScroll() { + super.dispatchOnScroll(); + _selectionHandles?.update(_keyboardClient.inputValue.selection); + } + bool get _isAttachedToKeyboard => _keyboardHandle != null && _keyboardHandle.attached; double _contentWidth = 0.0; @@ -275,6 +286,14 @@ class RawInputLineState extends ScrollableState { void _handleTextUpdated() { if (config.onChanged != null) config.onChanged(_keyboardClient.inputValue); + if (_keyboardClient.inputValue.text != config.value.text) { + _selectionHandles?.hide(); + _selectionHandles = null; + } else { + // If the text is unchanged, this was probably called for a selection + // change. + _selectionHandles?.update(_keyboardClient.inputValue.selection); + } } void _handleTextSubmitted() { @@ -283,13 +302,30 @@ class RawInputLineState extends ScrollableState { config.onSubmitted(_keyboardClient.inputValue); } - void _handleSelectionChanged(TextSelection selection) { + void _handleSelectionChanged(TextSelection selection, RenderEditableLine renderObject) { // Note that this will show the keyboard for all selection changes on the // EditableLineWidget, not just changes triggered by user gestures. requestKeyboard(); if (config.onChanged != null) config.onChanged(_keyboardClient.inputValue.copyWith(selection: selection)); + + if (_selectionHandles == null && + _keyboardClient.inputValue.text.isNotEmpty && + config.selectionHandleBuilder != null) { + _selectionHandles = new TextSelectionHandles( + selection: selection, + renderObject: renderObject, + onSelectionHandleChanged: _handleSelectionHandleChanged, + builder: config.selectionHandleBuilder + ); + _selectionHandles.show(context, debugRequiredFor: config); + } + } + + void _handleSelectionHandleChanged(TextSelection selection) { + if (config.onChanged != null) + config.onChanged(_keyboardClient.inputValue.copyWith(selection: selection)); } /// Whether the blinking cursor is actually visible at this precise moment @@ -318,6 +354,9 @@ class RawInputLineState extends ScrollableState { _keyboardHandle.release(); if (_cursorTimer != null) _stopCursorTimer(); + scheduleMicrotask(() { // can't hide while disposing, since it triggers a rebuild + _selectionHandles?.hide(); + }); super.dispose(); } @@ -341,6 +380,13 @@ class RawInputLineState extends ScrollableState { else if (_cursorTimer != null && (!focused || !config.value.selection.isCollapsed)) _stopCursorTimer(); + if (_selectionHandles != null && !focused) { + scheduleMicrotask(() { // can't hide while disposing, since it triggers a rebuild + _selectionHandles.hide(); + _selectionHandles = null; + }); + } + return new _EditableLineWidget( value: config.value, style: config.style, @@ -375,7 +421,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget { final bool showCursor; final Color selectionColor; final bool hideText; - final ValueChanged onSelectionChanged; + final SelectionChangedHandler onSelectionChanged; final Offset paintOffset; final ViewportDimensionsChangeCallback onPaintOffsetUpdateNeeded; diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart new file mode 100644 index 0000000000..995650aa30 --- /dev/null +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -0,0 +1,201 @@ +// Copyright 2016 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/rendering.dart'; + +import 'basic.dart'; +import 'framework.dart'; +import 'gesture_detector.dart'; +import 'overlay.dart'; + +// TODO(mpcomplete): Need one for [collapsed]. +/// Which type of selection handle to be displayed. With mixed-direction text, +/// both handles may be the same type. Examples: +/// LTR text: 'the fox' +/// The '<' is drawn with the [left] type, the '>' with the [right] +/// RTL text: 'xof eht' +/// Same as above. +/// mixed text: ' onSelectionHandleChanged; + final TextSelectionHandleBuilder builder; + TextSelection _selection; + + /// A pair of handles. If this is non-null, there are always 2, though the + /// second is hidden when the selection is collapsed. + List _handles; + + /// Shows the handles by inserting them into the [context]'s overlay. + void show(BuildContext context, { Widget debugRequiredFor }) { + assert(_handles == null); + _handles = [ + new OverlayEntry(builder: (BuildContext c) => _buildOverlay(c, _TextSelectionHandlePosition.start)), + new OverlayEntry(builder: (BuildContext c) => _buildOverlay(c, _TextSelectionHandlePosition.end)), + ]; + Overlay.of(context, debugRequiredFor: debugRequiredFor).insertAll(_handles); + } + + /// Updates the handles after the [selection] has changed. + void update(TextSelection newSelection) { + _selection = newSelection; + _handles[0].markNeedsBuild(); + _handles[1].markNeedsBuild(); + } + + /// Hides the handles. + void hide() { + _handles[0].remove(); + _handles[1].remove(); + _handles = null; + } + + Widget _buildOverlay(BuildContext context, _TextSelectionHandlePosition position) { + if (_selection.isCollapsed && position == _TextSelectionHandlePosition.end) + return new Container(); // hide the second handle when collapsed + return new _TextSelectionHandleOverlay( + onSelectionHandleChanged: _handleSelectionHandleChanged, + renderObject: renderObject, + selection: _selection, + builder: builder, + position: position + ); + } + + void _handleSelectionHandleChanged(TextSelection newSelection) { + if (onSelectionHandleChanged != null) + onSelectionHandleChanged(newSelection); + update(newSelection); + } +} + +/// This widget represents a single draggable text selection handle. +class _TextSelectionHandleOverlay extends StatefulWidget { + _TextSelectionHandleOverlay({ + Key key, + this.selection, + this.position, + this.renderObject, + this.onSelectionHandleChanged, + this.builder + }) : super(key: key); + + final TextSelection selection; + final _TextSelectionHandlePosition position; + final RenderEditableLine renderObject; + final ValueChanged onSelectionHandleChanged; + final TextSelectionHandleBuilder builder; + + @override + _TextSelectionHandleOverlayState createState() => new _TextSelectionHandleOverlayState(); +} + +class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay> { + Point _dragPosition; + void _handleDragStart(Point position) { + _dragPosition = position; + } + + void _handleDragUpdate(double delta) { + _dragPosition += new Offset(delta, 0.0); + TextPosition position = config.renderObject.getPositionForPoint(_dragPosition); + + if (config.selection.isCollapsed) { + config.onSelectionHandleChanged(new TextSelection.fromPosition(position)); + return; + } + + TextSelection newSelection; + switch (config.position) { + case _TextSelectionHandlePosition.start: + newSelection = new TextSelection( + baseOffset: position.offset, + extentOffset: config.selection.extentOffset + ); + break; + case _TextSelectionHandlePosition.end: + newSelection = new TextSelection( + baseOffset: config.selection.baseOffset, + extentOffset: position.offset + ); + break; + } + + if (newSelection.baseOffset >= newSelection.extentOffset) + return; // don't allow order swapping. + + config.onSelectionHandleChanged(newSelection); + } + + @override + Widget build(BuildContext context) { + List endpoints = config.renderObject.getEndpointsForSelection(config.selection); + Point point; + TextSelectionHandleType type; + + switch (config.position) { + case _TextSelectionHandlePosition.start: + point = endpoints[0].point; + type = _chooseType(endpoints[0], TextSelectionHandleType.left, TextSelectionHandleType.right); + break; + case _TextSelectionHandlePosition.end: + // [endpoints] will only contain 1 point for collapsed selections, in + // which case we shouldn't be building the [end] handle. + assert(endpoints.length == 2); + point = endpoints[1].point; + type = _chooseType(endpoints[1], TextSelectionHandleType.right, TextSelectionHandleType.left); + break; + } + + return new GestureDetector( + onHorizontalDragStart: _handleDragStart, + onHorizontalDragUpdate: _handleDragUpdate, + child: new Stack( + children: [ + new Positioned( + left: point.x, + top: point.y, + child: config.builder(context, type) + ) + ] + ) + ); + } + + TextSelectionHandleType _chooseType( + TextSelectionPoint endpoint, + TextSelectionHandleType ltrType, + TextSelectionHandleType rtlType + ) { + // [direction] is null when it doesn't matter. + switch (endpoint.direction ?? TextDirection.ltr) { + case TextDirection.ltr: + return ltrType; + case TextDirection.rtl: + return rtlType; + } + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index faa25297c5..a4639464a9 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -45,6 +45,7 @@ export 'src/widgets/scrollable.dart'; export 'src/widgets/semantics_debugger.dart'; export 'src/widgets/status_transitions.dart'; export 'src/widgets/table.dart'; +export 'src/widgets/text_selection.dart'; export 'src/widgets/title.dart'; export 'src/widgets/transitions.dart'; export 'src/widgets/unique_widget.dart'; diff --git a/packages/flutter/test/widget/input_test.dart b/packages/flutter/test/widget/input_test.dart index 22358dc7d4..20346e26e3 100644 --- a/packages/flutter/test/widget/input_test.dart +++ b/packages/flutter/test/widget/input_test.dart @@ -31,6 +31,15 @@ void main() { MockKeyboard mockKeyboard = new MockKeyboard(); serviceMocker.registerMockService(mojom.Keyboard.serviceName, mockKeyboard); + void enterText(String testValue) { + // Simulate entry of text through the keyboard. + expect(mockKeyboard.client, isNotNull); + mockKeyboard.client.updateEditingState(new mojom.EditingState() + ..text = testValue + ..composingBase = 0 + ..composingExtent = testValue.length); + } + test('Editable text has consistent size', () { testWidgets((WidgetTester tester) { GlobalKey inputKey = new GlobalKey(); @@ -56,13 +65,8 @@ void main() { RenderBox inputBox = findInputBox(); Size emptyInputSize = inputBox.size; - void enterText(String testValue) { - // Simulate entry of text through the keyboard. - expect(mockKeyboard.client, isNotNull); - mockKeyboard.client.updateEditingState(new mojom.EditingState() - ..text = testValue - ..composingBase = 0 - ..composingExtent = testValue.length); + void checkText(String testValue) { + enterText(testValue); // Check that the onChanged event handler fired. expect(inputValue.text, equals(testValue)); @@ -70,11 +74,11 @@ void main() { tester.pumpWidget(builder()); } - enterText(' '); + checkText(' '); expect(findInputBox(), equals(inputBox)); expect(inputBox.size, equals(emptyInputSize)); - enterText('Test'); + checkText('Test'); expect(findInputBox(), equals(inputBox)); expect(inputBox.size, equals(emptyInputSize)); }); @@ -152,4 +156,155 @@ void main() { tester.pump(); }); }); + + // Returns the first RenderEditableLine. + RenderEditableLine findRenderEditableLine(WidgetTester tester) { + RenderObject root = tester.renderObjectOf(find.byType(RawInputLine)); + expect(root, isNotNull); + + RenderEditableLine renderLine; + void recursiveFinder(RenderObject child) { + if (child is RenderEditableLine) { + renderLine = child; + return; + } + child.visitChildren(recursiveFinder); + } + root.visitChildren(recursiveFinder); + expect(renderLine, isNotNull); + return renderLine; + } + + Point textOffsetToPosition(WidgetTester tester, int offset) { + RenderEditableLine renderLine = findRenderEditableLine(tester); + List endpoints = renderLine.getEndpointsForSelection( + new TextSelection.collapsed(offset: offset)); + expect(endpoints.length, 1); + return endpoints[0].point; + } + + test('Can long press to select', () { + testWidgets((WidgetTester tester) { + GlobalKey inputKey = new GlobalKey(); + InputValue inputValue = InputValue.empty; + + Widget builder() { + return new Overlay( + initialEntries: [ + new OverlayEntry( + builder: (BuildContext context) { + return new Center( + child: new Material( + child: new Input( + value: inputValue, + key: inputKey, + onChanged: (InputValue value) { inputValue = value; } + ) + ) + ); + } + ) + ] + ); + } + + tester.pumpWidget(builder()); + + String testValue = 'abc def ghi'; + enterText(testValue); + expect(inputValue.text, testValue); + + tester.pumpWidget(builder()); + + expect(inputValue.selection.isCollapsed, true); + + // Long press the 'e' to select 'def'. + Point ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + TestGesture gesture = tester.startGesture(ePos, pointer: 7); + tester.pump(const Duration(seconds: 2)); + gesture.up(); + tester.pump(); + + // 'def' is selected. + expect(inputValue.selection.baseOffset, testValue.indexOf('d')); + expect(inputValue.selection.extentOffset, testValue.indexOf('f')+1); + }); + }); + + test('Can drag handles to change selection', () { + testWidgets((WidgetTester tester) { + GlobalKey inputKey = new GlobalKey(); + InputValue inputValue = InputValue.empty; + + Widget builder() { + return new Overlay( + initialEntries: [ + new OverlayEntry( + builder: (BuildContext context) { + return new Center( + child: new Material( + child: new Input( + value: inputValue, + key: inputKey, + onChanged: (InputValue value) { inputValue = value; } + ) + ) + ); + } + ) + ] + ); + } + + tester.pumpWidget(builder()); + + String testValue = 'abc def ghi'; + enterText(testValue); + + tester.pumpWidget(builder()); + + // Long press the 'e' to select 'def'. + Point ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + TestGesture gesture = tester.startGesture(ePos, pointer: 7); + tester.pump(const Duration(seconds: 2)); + gesture.up(); + tester.pump(); + + TextSelection selection = inputValue.selection; + + RenderEditableLine renderLine = findRenderEditableLine(tester); + List endpoints = renderLine.getEndpointsForSelection( + selection); + expect(endpoints.length, 2); + + // Drag the right handle 2 letters to the right. + // Note: use a small offset because the endpoint is on the very corner + // of the handle. + Point handlePos = endpoints[1].point + new Offset(1.0, 1.0); + Point newHandlePos = textOffsetToPosition(tester, selection.extentOffset+2); + gesture = tester.startGesture(handlePos, pointer: 7); + tester.pump(); + gesture.moveTo(newHandlePos); + tester.pump(); + gesture.up(); + tester.pump(); + + expect(inputValue.selection.baseOffset, selection.baseOffset); + expect(inputValue.selection.extentOffset, selection.extentOffset+2); + + // Drag the left handle 2 letters to the left. + handlePos = endpoints[0].point + new Offset(-1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, selection.baseOffset-2); + gesture = tester.startGesture(handlePos, pointer: 7); + tester.pump(); + gesture.moveTo(newHandlePos); + tester.pump(); + gesture.up(); + tester.pumpWidget(builder()); + + expect(inputValue.selection.baseOffset, selection.baseOffset-2); + expect(inputValue.selection.extentOffset, selection.extentOffset+2); + }); + }); + }