diff --git a/packages/flutter/lib/src/material/input.dart b/packages/flutter/lib/src/material/input.dart
index 659b65ece28..d4f11381eac 100644
--- a/packages/flutter/lib/src/material/input.dart
+++ b/packages/flutter/lib/src/material/input.dart
@@ -2,8 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-import 'dart:math' as math;
-
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@@ -11,12 +9,12 @@ import 'colors.dart';
import 'debug.dart';
import 'icon.dart';
import 'icons.dart';
+import 'material.dart';
+import 'text_selection.dart';
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.
@@ -198,7 +196,8 @@ class _InputState extends State {
hideText: config.hideText,
cursorColor: themeData.textSelectionColor,
selectionColor: themeData.textSelectionColor,
- selectionHandleBuilder: _textSelectionHandleBuilder,
+ selectionHandleBuilder: buildTextSelectionHandle,
+ selectionToolbarBuilder: buildTextSelectionToolbar,
keyboardType: config.keyboardType,
onChanged: onChanged,
onSubmitted: onSubmitted
@@ -245,37 +244,6 @@ class _InputState extends State {
)
);
}
-
- Widget _textSelectionHandleBuilder(
- BuildContext context, TextSelectionHandleType type) {
- Widget handle = new SizedBox(
- width: _kTextSelectionHandleSize,
- height: _kTextSelectionHandleSize,
- child: new CustomPaint(
- painter: new _TextSelectionHandlePainter(
- color: Theme.of(context).textSelectionHandleColor
- )
- )
- );
-
- // [handle] is a circle, with a rectangle in the top left quadrant of that
- // circle (an onion pointing to 10:30). We rotate [handle] to point
- // straight up or up-right depending on the handle type.
- switch (type) {
- case TextSelectionHandleType.left: // points up-right
- return new Transform(
- transform: new Matrix4.identity().rotateZ(math.PI / 2.0),
- child: handle
- );
- case TextSelectionHandleType.right: // points up-left
- return handle;
- case TextSelectionHandleType.collapsed: // points up
- return new Transform(
- transform: new Matrix4.identity().rotateZ(math.PI / 4.0),
- child: handle
- );
- }
- }
}
class _FormFieldData {
@@ -310,24 +278,3 @@ 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.color });
-
- final Color color;
-
- @override
- void paint(Canvas canvas, Size size) {
- Paint paint = new Paint()..color = color;
- double radius = size.width/2.0;
- canvas.drawCircle(new Point(radius, radius), radius, paint);
- canvas.drawRect(new Rect.fromLTWH(0.0, 0.0, radius, radius), paint);
- }
-
- @override
- bool shouldRepaint(_TextSelectionHandlePainter oldPainter) {
- return color != oldPainter.color;
- }
-}
diff --git a/packages/flutter/lib/src/material/text_selection.dart b/packages/flutter/lib/src/material/text_selection.dart
new file mode 100644
index 00000000000..8e483bfd408
--- /dev/null
+++ b/packages/flutter/lib/src/material/text_selection.dart
@@ -0,0 +1,186 @@
+// 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 'dart:math' as math;
+
+import 'package:flutter/widgets.dart';
+
+import 'flat_button.dart';
+import 'icon_button.dart';
+import 'icons.dart';
+import 'material.dart';
+import 'theme.dart';
+
+const double _kHandleSize = 22.0; // pixels
+const double _kToolbarScreenPadding = 8.0; // pixels
+
+/// Manages a copy/paste text selection toolbar.
+class _TextSelectionToolbar extends StatelessWidget {
+ _TextSelectionToolbar(this.delegate, {Key key}) : super(key: key);
+
+ final TextSelectionDelegate delegate;
+ InputValue get value => delegate.inputValue;
+
+ @override
+ Widget build(BuildContext context) {
+ List items = [];
+
+ if (!value.selection.isCollapsed) {
+ items.add(new FlatButton(child: new Text('CUT'), onPressed: _handleCut));
+ items.add(new FlatButton(child: new Text('COPY'), onPressed: _handleCopy));
+ }
+ items.add(new FlatButton(
+ child: new Text('PASTE'),
+ onPressed: delegate.pasteBuffer != null ? _handlePaste : null)
+ );
+ if (value.selection.isCollapsed) {
+ items.add(new FlatButton(child: new Text('SELECT ALL'), onPressed: _handleSelectAll));
+ }
+ // TODO(mpcomplete): implement `more` menu.
+ items.add(new IconButton(icon: Icons.more_vert));
+
+ return new Material(
+ elevation: 1,
+ child: new Container(
+ height: 44.0,
+ child: new Row(mainAxisAlignment: MainAxisAlignment.collapse, children: items)
+ )
+ );
+ }
+
+ void _handleCut() {
+ InputValue value = this.value;
+ delegate.pasteBuffer = value.selection.textInside(value.text);
+ delegate.inputValue = new InputValue(
+ text: value.selection.textBefore(value.text) + value.selection.textAfter(value.text),
+ selection: new TextSelection.collapsed(offset: value.selection.start)
+ );
+ delegate.hideToolbar();
+ }
+
+ void _handleCopy() {
+ delegate.pasteBuffer = value.selection.textInside(value.text);
+ delegate.inputValue = new InputValue(
+ text: value.text,
+ selection: new TextSelection.collapsed(offset: value.selection.end)
+ );
+ delegate.hideToolbar();
+ }
+
+ void _handlePaste() {
+ delegate.inputValue = new InputValue(
+ text: value.selection.textBefore(value.text) + delegate.pasteBuffer + value.selection.textAfter(value.text),
+ selection: new TextSelection.collapsed(offset: value.selection.start + delegate.pasteBuffer.length)
+ );
+ delegate.hideToolbar();
+ }
+
+ void _handleSelectAll() {
+ delegate.inputValue = new InputValue(
+ text: value.text,
+ selection: new TextSelection(baseOffset: 0, extentOffset: value.text.length)
+ );
+ }
+}
+
+/// Centers the toolbar around the given position, ensuring that it remains on
+/// screen.
+class _TextSelectionToolbarLayout extends SingleChildLayoutDelegate {
+ _TextSelectionToolbarLayout(this.position);
+
+ final Point position;
+
+ @override
+ BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
+ return constraints.loosen();
+ }
+
+ @override
+ Offset getPositionForChild(Size size, Size childSize) {
+ double x = position.x - childSize.width/2.0;
+ double y = position.y - childSize.height;
+
+ if (x < _kToolbarScreenPadding)
+ x = _kToolbarScreenPadding;
+ else if (x + childSize.width > size.width - 2 * _kToolbarScreenPadding)
+ x = size.width - childSize.width - _kToolbarScreenPadding;
+ if (y < _kToolbarScreenPadding)
+ y = _kToolbarScreenPadding;
+ else if (y + childSize.height > size.height - 2 * _kToolbarScreenPadding)
+ y = size.height - childSize.height - _kToolbarScreenPadding;
+
+ return new Offset(x, y);
+ }
+
+ @override
+ bool shouldRelayout(_TextSelectionToolbarLayout oldDelegate) {
+ return position != oldDelegate.position;
+ }
+}
+
+/// 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.color });
+
+ final Color color;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ Paint paint = new Paint()..color = color;
+ double radius = size.width/2.0;
+ canvas.drawCircle(new Point(radius, radius), radius, paint);
+ canvas.drawRect(new Rect.fromLTWH(0.0, 0.0, radius, radius), paint);
+ }
+
+ @override
+ bool shouldRepaint(_TextSelectionHandlePainter oldPainter) {
+ return color != oldPainter.color;
+ }
+}
+
+/// Builder for material-style copy/paste text selection toolbar.
+Widget buildTextSelectionToolbar(
+ BuildContext context, Point position, TextSelectionDelegate delegate) {
+ final Size screenSize = MediaQuery.of(context).size;
+ return new ConstrainedBox(
+ constraints: new BoxConstraints.loose(screenSize),
+ child: new CustomSingleChildLayout(
+ delegate: new _TextSelectionToolbarLayout(position),
+ child: new _TextSelectionToolbar(delegate)
+ )
+ );
+}
+
+/// Builder for material-style text selection handles.
+Widget buildTextSelectionHandle(
+ BuildContext context, TextSelectionHandleType type) {
+ Widget handle = new SizedBox(
+ width: _kHandleSize,
+ height: _kHandleSize,
+ child: new CustomPaint(
+ painter: new _TextSelectionHandlePainter(
+ color: Theme.of(context).textSelectionHandleColor
+ )
+ )
+ );
+
+ // [handle] is a circle, with a rectangle in the top left quadrant of that
+ // circle (an onion pointing to 10:30). We rotate [handle] to point
+ // straight up or up-right depending on the handle type.
+ switch (type) {
+ case TextSelectionHandleType.left: // points up-right
+ return new Transform(
+ transform: new Matrix4.identity().rotateZ(math.PI / 2.0),
+ child: handle
+ );
+ case TextSelectionHandleType.right: // points up-left
+ return handle;
+ case TextSelectionHandleType.collapsed: // points up
+ return new Transform(
+ transform: new Matrix4.identity().rotateZ(math.PI / 4.0),
+ child: handle
+ );
+ }
+}
diff --git a/packages/flutter/lib/src/widgets/editable.dart b/packages/flutter/lib/src/widgets/editable.dart
index 7ce0fffa7c8..5e4f6c86bdc 100644
--- a/packages/flutter/lib/src/widgets/editable.dart
+++ b/packages/flutter/lib/src/widgets/editable.dart
@@ -154,6 +154,7 @@ class RawInputLine extends Scrollable {
this.cursorColor,
this.selectionColor,
this.selectionHandleBuilder,
+ this.selectionToolbarBuilder,
this.keyboardType,
this.onChanged,
this.onSubmitted
@@ -181,8 +182,14 @@ class RawInputLine extends Scrollable {
/// The color to use when painting the selection.
final Color selectionColor;
+ /// Optional builder function for a widget that controls the boundary of a
+ /// text selection.
final TextSelectionHandleBuilder selectionHandleBuilder;
+ /// Optional builder function for a set of controls for working with a
+ /// text selection (e.g. copy and paste).
+ final TextSelectionToolbarBuilder selectionToolbarBuilder;
+
/// The type of keyboard to use for editing the text.
final KeyboardType keyboardType;
@@ -202,7 +209,7 @@ class RawInputLineState extends ScrollableState {
_KeyboardClientImpl _keyboardClient;
KeyboardHandle _keyboardHandle;
- TextSelectionHandles _selectionHandles;
+ TextSelectionOverlay _selectionOverlay;
@override
ScrollBehavior createScrollBehavior() => new BoundedBehavior();
@@ -229,12 +236,6 @@ 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;
@@ -287,8 +288,8 @@ class RawInputLineState extends ScrollableState {
if (config.onChanged != null)
config.onChanged(_keyboardClient.inputValue);
if (_keyboardClient.inputValue.text != config.value.text) {
- _selectionHandles?.hide();
- _selectionHandles = null;
+ _selectionOverlay?.hide();
+ _selectionOverlay = null;
}
}
@@ -307,25 +308,27 @@ class RawInputLineState extends ScrollableState {
if (config.onChanged != null)
config.onChanged(newInput);
- if (_selectionHandles != null) {
- _selectionHandles.hide();
- _selectionHandles = null;
+ if (_selectionOverlay != null) {
+ _selectionOverlay.hide();
+ _selectionOverlay = null;
}
- if (_keyboardClient.inputValue.text.isNotEmpty &&
- config.selectionHandleBuilder != null) {
- _selectionHandles = new TextSelectionHandles(
- selection: selection,
+ if (newInput.text.isNotEmpty && config.selectionHandleBuilder != null) {
+ _selectionOverlay = new TextSelectionOverlay(
+ input: newInput,
+ context: context,
+ debugRequiredFor: config,
renderObject: renderObject,
- onSelectionHandleChanged: _handleSelectionHandleChanged,
- builder: config.selectionHandleBuilder
+ onSelectionOverlayChanged: _handleSelectionOverlayChanged,
+ handleBuilder: config.selectionHandleBuilder,
+ toolbarBuilder: config.selectionToolbarBuilder
);
- _selectionHandles.show(context, debugRequiredFor: config);
+ _selectionOverlay.show();
}
}
- void _handleSelectionHandleChanged(TextSelection selection) {
- InputValue newInput = new InputValue(text: _keyboardClient.inputValue.text, selection: selection);
+ void _handleSelectionOverlayChanged(InputValue newInput) {
+ assert(!newInput.composing.isValid); // composing range must be empty while selecting
if (config.onChanged != null)
config.onChanged(newInput);
}
@@ -357,7 +360,7 @@ class RawInputLineState extends ScrollableState {
if (_cursorTimer != null)
_stopCursorTimer();
scheduleMicrotask(() { // can't hide while disposing, since it triggers a rebuild
- _selectionHandles?.hide();
+ _selectionOverlay?.hide();
});
super.dispose();
}
@@ -382,13 +385,13 @@ class RawInputLineState extends ScrollableState {
else if (_cursorTimer != null && (!focused || !config.value.selection.isCollapsed))
_stopCursorTimer();
- if (_selectionHandles != null) {
+ if (_selectionOverlay != null) {
scheduleMicrotask(() { // can't update while disposing, since it triggers a rebuild
if (focused) {
- _selectionHandles.update(config.value.selection);
+ _selectionOverlay.update(config.value);
} else {
- _selectionHandles.hide();
- _selectionHandles = null;
+ _selectionOverlay.hide();
+ _selectionOverlay = null;
}
});
}
diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart
index 86c03ec0489..8243ed718f5 100644
--- a/packages/flutter/lib/src/widgets/text_selection.dart
+++ b/packages/flutter/lib/src/widgets/text_selection.dart
@@ -5,6 +5,7 @@
import 'package:flutter/rendering.dart';
import 'basic.dart';
+import 'editable.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'overlay.dart';
@@ -24,73 +25,154 @@ enum TextSelectionHandleType { left, right, collapsed }
/// Builds a handle of the given type.
typedef Widget TextSelectionHandleBuilder(BuildContext context, TextSelectionHandleType type);
+// Builds a copy/paste toolbar.
+// TODO(mpcomplete): A single position is probably insufficient.
+typedef Widget TextSelectionToolbarBuilder(BuildContext context, Point position, TextSelectionDelegate delegate);
+
/// The text position that a give selection handle manipulates. Dragging the
/// [start] handle always moves the [start]/[baseOffset] of the selection.
enum _TextSelectionHandlePosition { start, end }
+/// An interface for manipulating the selection, to be used by the implementor
+/// of the toolbar widget.
+abstract class TextSelectionDelegate {
+ /// Gets the current text input.
+ InputValue get inputValue;
+
+ /// Sets the current text input (replaces the whole line).
+ void set inputValue(InputValue value);
+
+ /// The copy/paste buffer. Application-wide.
+ String get pasteBuffer;
+
+ /// Sets the copy/paste buffer.
+ void set pasteBuffer(String value);
+
+ /// Hides the text selection toolbar.
+ void hideToolbar();
+}
+
+// TODO(mpcomplete): need to interact with the system clipboard.
+String _pasteBuffer;
+
/// Manages a pair of text selection handles to be shown in an Overlay
/// containing the owning widget.
-class TextSelectionHandles {
- TextSelectionHandles({
- TextSelection selection,
+class TextSelectionOverlay implements TextSelectionDelegate {
+ TextSelectionOverlay({
+ InputValue input,
+ this.context,
+ this.debugRequiredFor,
this.renderObject,
- this.onSelectionHandleChanged,
- this.builder
- }): _selection = selection {
- assert(builder != null);
- }
+ this.onSelectionOverlayChanged,
+ this.handleBuilder,
+ this.toolbarBuilder
+ }): _input = input;
+ final BuildContext context;
+ final Widget debugRequiredFor;
// TODO(mpcomplete): what if the renderObject is removed or replaced, or
// moves? Not sure what cases I need to handle, or how to handle them.
final RenderEditableLine renderObject;
- final ValueChanged onSelectionHandleChanged;
- final TextSelectionHandleBuilder builder;
- TextSelection _selection;
+ final ValueChanged onSelectionOverlayChanged;
+ final TextSelectionHandleBuilder handleBuilder;
+ final TextSelectionToolbarBuilder toolbarBuilder;
+ InputValue _input;
/// 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;
+ OverlayEntry _toolbar;
+
+ TextSelection get _selection => _input.selection;
+
/// Shows the handles by inserting them into the [context]'s overlay.
- void show(BuildContext context, { Widget debugRequiredFor }) {
+ void show() {
assert(_handles == null);
_handles = [
new OverlayEntry(builder: (BuildContext c) => _buildOverlay(c, _TextSelectionHandlePosition.start)),
new OverlayEntry(builder: (BuildContext c) => _buildOverlay(c, _TextSelectionHandlePosition.end)),
];
+ _toolbar = new OverlayEntry(builder: _buildToolbar);
Overlay.of(context, debugRequiredFor: debugRequiredFor).insertAll(_handles);
+ Overlay.of(context, debugRequiredFor: debugRequiredFor).insert(_toolbar);
}
/// Updates the handles after the [selection] has changed.
- void update(TextSelection newSelection) {
- _selection = newSelection;
+ void update(InputValue newInput) {
+ _input = newInput;
+ if (_handles == null)
+ return;
_handles[0].markNeedsBuild();
_handles[1].markNeedsBuild();
+ _toolbar.markNeedsBuild();
}
/// Hides the handles.
void hide() {
+ if (_handles == null)
+ return;
_handles[0].remove();
_handles[1].remove();
_handles = null;
+ _toolbar.remove();
+ _toolbar = null;
}
Widget _buildOverlay(BuildContext context, _TextSelectionHandlePosition position) {
- if (_selection.isCollapsed && position == _TextSelectionHandlePosition.end)
+ if ((_selection.isCollapsed && position == _TextSelectionHandlePosition.end) ||
+ handleBuilder == null)
return new Container(); // hide the second handle when collapsed
return new _TextSelectionHandleOverlay(
onSelectionHandleChanged: _handleSelectionHandleChanged,
renderObject: renderObject,
selection: _selection,
- builder: builder,
+ builder: handleBuilder,
position: position
);
}
+ Widget _buildToolbar(BuildContext context) {
+ if (toolbarBuilder == null)
+ return new Container();
+
+ // Find the horizontal midpoint, just above the selected text.
+ List endpoints = renderObject.getEndpointsForSelection(_selection);
+ Point midpoint = new Point(
+ (endpoints.length == 1) ?
+ endpoints[0].point.x :
+ (endpoints[0].point.x + endpoints[1].point.x) / 2.0,
+ endpoints[0].point.y - renderObject.size.height
+ );
+
+ return toolbarBuilder(context, midpoint, this);
+ }
+
void _handleSelectionHandleChanged(TextSelection newSelection) {
- if (onSelectionHandleChanged != null)
- onSelectionHandleChanged(newSelection);
- update(newSelection);
+ inputValue = _input.copyWith(selection: newSelection, composing: TextRange.empty);
+ }
+
+ @override
+ InputValue get inputValue => _input;
+
+ @override
+ void set inputValue(InputValue value) {
+ update(value);
+ if (onSelectionOverlayChanged != null)
+ onSelectionOverlayChanged(value);
+ }
+
+ @override
+ String get pasteBuffer => _pasteBuffer;
+
+ @override
+ void set pasteBuffer(String value) {
+ _pasteBuffer = value;
+ }
+
+ @override
+ void hideToolbar() {
+ hide();
}
}
diff --git a/packages/flutter/test/widget/input_test.dart b/packages/flutter/test/widget/input_test.dart
index 4c4477a8908..eca419d0460 100644
--- a/packages/flutter/test/widget/input_test.dart
+++ b/packages/flutter/test/widget/input_test.dart
@@ -296,4 +296,58 @@ void main() {
expect(inputValue.selection.extentOffset, selection.extentOffset+2);
});
+ testWidgets('Can use selection toolbar', (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());
+
+ // Tap the text to bring up the "paste / select all" menu.
+ tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
+ tester.pumpWidget(builder());
+
+ // SELECT ALL should select all the text.
+ tester.tap(find.text('SELECT ALL'));
+ tester.pumpWidget(builder());
+ expect(inputValue.selection.baseOffset, 0);
+ expect(inputValue.selection.extentOffset, testValue.length);
+
+ // COPY should reset the selection.
+ tester.tap(find.text('COPY'));
+ tester.pumpWidget(builder());
+ expect(inputValue.selection.isCollapsed, true);
+
+ // Tap again to bring back the menu.
+ tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
+ tester.pumpWidget(builder());
+
+ // PASTE right before the 'e'.
+ tester.tap(find.text('PASTE'));
+ tester.pumpWidget(builder());
+ expect(inputValue.text, 'abc d${testValue}ef ghi');
+ });
}