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);
+ });
+ });
+
}