From 11f236ec25ba192ecafb4aa332f7d93b2b24326f Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 21 Apr 2016 13:53:31 -0400 Subject: [PATCH] Add basic text selection to editable text. (#3223) Only partially works. Editing the selected text doesn't work very well, which probably will require engine changes. Currently only draws the selected text and allows you to manipulate the selection with draggable selection handles. --- packages/flutter/lib/src/material/input.dart | 66 ++++++ .../flutter/lib/src/material/theme_data.dart | 12 +- .../lib/src/rendering/editable_line.dart | 83 +++++++- .../flutter/lib/src/widgets/editable.dart | 52 ++++- .../lib/src/widgets/text_selection.dart | 201 ++++++++++++++++++ packages/flutter/lib/widgets.dart | 1 + packages/flutter/test/widget/input_test.dart | 173 ++++++++++++++- 7 files changed, 570 insertions(+), 18 deletions(-) create mode 100644 packages/flutter/lib/src/widgets/text_selection.dart 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); + }); + }); + }