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