diff --git a/packages/flutter/lib/painting.dart b/packages/flutter/lib/painting.dart index 09da154f52..ca38e5f8de 100644 --- a/packages/flutter/lib/painting.dart +++ b/packages/flutter/lib/painting.dart @@ -18,6 +18,7 @@ export 'src/painting/colors.dart'; export 'src/painting/decoration.dart'; export 'src/painting/edge_dims.dart'; export 'src/painting/shadows.dart'; +export 'src/painting/text_editing.dart'; export 'src/painting/text_painter.dart'; export 'src/painting/text_style.dart'; export 'src/painting/transforms.dart'; diff --git a/packages/flutter/lib/src/material/input.dart b/packages/flutter/lib/src/material/input.dart index cad4757ab6..9c551b0de1 100644 --- a/packages/flutter/lib/src/material/input.dart +++ b/packages/flutter/lib/src/material/input.dart @@ -20,6 +20,7 @@ class Input extends StatefulComponent { Input({ GlobalKey key, this.initialValue: '', + this.initialSelection, this.keyboardType: KeyboardType.text, this.icon, this.labelText, @@ -35,9 +36,12 @@ class Input extends StatefulComponent { assert(key != null); } - /// Initial editable text for the input field. + /// The initial editable text for the input field. final String initialValue; + /// The initial selection for this input field. + final TextSelection initialSelection; + /// The type of keyboard to use for editing the text. final KeyboardType keyboardType; @@ -90,6 +94,7 @@ class _InputState extends State { _value = config.initialValue; _editableString = new EditableString( text: _value, + selection: config.initialSelection, onUpdated: _handleTextUpdated, onSubmitted: _handleTextSubmitted ); @@ -215,7 +220,8 @@ class _InputState extends State { focused: focused, style: textStyle, hideText: config.hideText, - cursorColor: cursorColor + cursorColor: cursorColor, + selectionColor: cursorColor ) )); diff --git a/packages/flutter/lib/src/painting/text_editing.dart b/packages/flutter/lib/src/painting/text_editing.dart new file mode 100644 index 0000000000..00a0835c71 --- /dev/null +++ b/packages/flutter/lib/src/painting/text_editing.dart @@ -0,0 +1,144 @@ +// 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. + +/// Whether a [TextPosition] is visually upstream or downstream of its offset. +/// +/// For example, when a text position exists at a line break, a single offset has +/// two visual positions, one prior to the line break (at the end of the first +/// line) and one after the line break (at the start of the second line). A text +/// affinity disambiguates between those cases. (Something similar happens with +/// between runs of bidirectional text.) +enum TextAffinity { + /// The position has affinity for the upstream side of the text position. + /// + /// For example, if the offset of the text position is a line break, the + /// position represents the end of the first line. + upstream, + + /// The position has affinity for the downstream side of the text position. + /// + /// For example, if the offset of the text position is a line break, the + /// position represents the start of the second line. + downstream +} + +/// A visual position in a string of text. +class TextPosition { + const TextPosition({ this.offset, this.affinity: TextAffinity.downstream }); + + /// The index of the character just prior to the position. + final int offset; + + /// If the offset has more than one visual location (e.g., occurs at a line + /// break), which of the two locations is represented by this position. + final TextAffinity affinity; +} + +/// A range of characters in a string of text. +class TextRange { + const TextRange({ this.start, this.end }); + + /// A text range that starts and ends at offset. + const TextRange.collapsed(int offset) + : start = offset, + end = offset; + + /// A text range that contains nothing and is not in the text. + static const TextRange empty = const TextRange(start: -1, end: -1); + + /// The index of the first character in the range. + final int start; + + /// The next index after the characters in this range. + final int end; + + /// Whether this range represents a valid position in the text. + bool get isValid => start >= 0 && end >= 0; + + /// Whether this range is empty (but still potentially placed inside the text). + bool get isCollapsed => start == end; + + /// Whether the start of this range preceeds the end. + bool get isNormalized => end >= start; + + /// The text before this range. + String textBefore(String text) { + assert(isNormalized); + return text.substring(0, start); + } + + /// The text after this range. + String textAfter(String text) { + assert(isNormalized); + return text.substring(end); + } + + /// The text inside this range. + String textInside(String text) { + assert(isNormalized); + return text.substring(start, end); + } +} + +/// A range of text that represents a selection. +class TextSelection extends TextRange { + const TextSelection({ + int baseOffset, + int extentOffset, + this.affinity: TextAffinity.downstream, + this.isDirectional: false + }) : baseOffset = baseOffset, + extentOffset = extentOffset, + super( + start: baseOffset < extentOffset ? baseOffset : extentOffset, + end: baseOffset < extentOffset ? extentOffset : baseOffset + ); + + const TextSelection.collapsed({ + int offset, + this.affinity: TextAffinity.downstream, + this.isDirectional: false + }) : baseOffset = offset, extentOffset = offset, super.collapsed(offset); + + /// The offset at which the selection originates. + /// + /// Might be larger than, smaller than, or equal to extent. + final int baseOffset; + + /// The offset at which the selection terminates. + /// + /// When the user uses the arrow keys to adjust the selection, this is the + /// value that changes. Similarly, if the current theme paints a caret on one + /// side of the selection, this is the location at which to paint the caret. + /// + /// Might be larger than, smaller than, or equal to base. + final int extentOffset; + + /// If the the text range is collpased and has more than one visual location + /// (e.g., occurs at a line break), which of the two locations to use when + /// painting the caret. + final TextAffinity affinity; + + /// Whether this selection has disambiguated its base and extent. + /// + /// On some platforms, the base and extent are not disambiguated until the + /// first time the user adjusts the selection. At that point, either the start + /// or the end of the selection becomes the base and the other one becomes the + /// extent and is adjusted. + final bool isDirectional; + + /// The position at which the selection originates. + /// + /// Might be larger than, smaller than, or equal to extent. + TextPosition get base => new TextPosition(offset: baseOffset, affinity: affinity); + + /// The position at which the selection terminates. + /// + /// When the user uses the arrow keys to adjust the selection, this is the + /// value that changes. Similarly, if the current theme paints a caret on one + /// side of the selection, this is the location at which to paint the caret. + /// + /// Might be larger than, smaller than, or equal to base. + TextPosition get extent => new TextPosition(offset: extentOffset, affinity: affinity); +} diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index d9b3f8976d..4325c70d0b 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -5,6 +5,7 @@ import 'dart:ui' as ui; import 'basic_types.dart'; +import 'text_editing.dart'; import 'text_style.dart'; /// An immutable span of text. @@ -215,4 +216,53 @@ class TextPainter { assert(!_needsLayout && "Please call layout() before paint() to position the text before painting it." is String); _paragraph.paint(canvas, offset); } + + Offset _getOffsetFromUpstream(int offset, Rect caretPrototype) { + List boxes = _paragraph.getBoxesForRange(offset - 1, offset); + if (boxes.isEmpty) + return null; + ui.TextBox box = boxes[0]; + double caretEnd = box.end; + double dx = box.direction == ui.TextDirection.rtl ? caretEnd : caretEnd - caretPrototype.width; + return new Offset(dx, 0.0); + } + + Offset _getOffsetFromDownstream(int offset, Rect caretPrototype) { + List boxes = _paragraph.getBoxesForRange(offset, offset + 1); + if (boxes.isEmpty) + return null; + ui.TextBox box = boxes[0]; + double caretStart = box.start; + double dx = box.direction == ui.TextDirection.rtl ? caretStart - caretPrototype.width : caretStart; + return new Offset(dx, 0.0); + } + + /// Returns the offset at which to paint the caret. + Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) { + assert(!_needsLayout); + int offset = position.offset; + // TODO(abarth): Handle the directionality of the text painter itself. + const Offset emptyOffset = Offset.zero; + switch (position.affinity) { + case TextAffinity.upstream: + return _getOffsetFromUpstream(offset, caretPrototype) + ?? _getOffsetFromDownstream(offset, caretPrototype) + ?? emptyOffset; + case TextAffinity.downstream: + return _getOffsetFromDownstream(offset, caretPrototype) + ?? _getOffsetFromUpstream(offset, caretPrototype) + ?? emptyOffset; + } + } + + /// Returns a list of rects that bound the given selection. + /// + /// A given selection might have more than one rect if this text painter + /// contains bidirectional text because logically contiguous text might not be + /// visually contiguous. + List getBoxesForSelection(TextSelection selection) { + assert(!_needsLayout); + return _paragraph.getBoxesForRange(selection.start, selection.end); + } + } diff --git a/packages/flutter/lib/src/rendering/editable_line.dart b/packages/flutter/lib/src/rendering/editable_line.dart index 5967b9bec3..4b8c47b67c 100644 --- a/packages/flutter/lib/src/rendering/editable_line.dart +++ b/packages/flutter/lib/src/rendering/editable_line.dart @@ -11,9 +11,9 @@ import 'object.dart'; import 'paragraph.dart'; import 'proxy_box.dart' show SizeChangedCallback; -const _kCursorGap = 1.0; // pixels -const _kCursorHeightOffset = 2.0; // pixels -const _kCursorWidth = 1.0; // pixels +const _kCaretGap = 1.0; // pixels +const _kCaretHeightOffset = 2.0; // pixels +const _kCaretWidth = 1.0; // pixels final String _kZeroWidthSpace = new String.fromCharCode(0x200B); @@ -23,11 +23,14 @@ class RenderEditableLine extends RenderBox { StyledTextSpan text, Color cursorColor, bool showCursor: false, + Color selectionColor, + TextSelection selection, Offset paintOffset: Offset.zero, this.onContentSizeChanged }) : _textPainter = new TextPainter(text), _cursorColor = cursorColor, _showCursor = showCursor, + _selection = selection, _paintOffset = paintOffset { assert(!showCursor || cursorColor != null); // TODO(abarth): These min/max values should be the default for TextPainter. @@ -72,6 +75,27 @@ class RenderEditableLine extends RenderBox { markNeedsPaint(); } + Color get selectionColor => _selectionColor; + Color _selectionColor; + void set selectionColor(Color value) { + if (_selectionColor == value) + return; + _selectionColor = value; + markNeedsPaint(); + } + + List _selectionRects; + + TextSelection get selection => _selection; + TextSelection _selection; + void set selection(TextSelection value) { + if (_selection == value) + return; + _selection = value; + _selectionRects = null; + markNeedsPaint(); + } + Offset get paintOffset => _paintOffset; Offset _paintOffset; void set paintOffset(Offset value) { @@ -144,10 +168,14 @@ class RenderEditableLine extends RenderBox { _constraintsForCurrentLayout = constraints; } + Rect _caretPrototype; + void performLayout() { size = new Size(constraints.maxWidth, constraints.constrainHeight(_preferredHeight)); + _caretPrototype = new Rect.fromLTWH(0.0, _kCaretHeightOffset, _kCaretWidth, size.height - 2.0 * _kCaretHeightOffset); + _selectionRects = null; _layoutText(new BoxConstraints(minHeight: constraints.minHeight, maxHeight: constraints.maxHeight)); - Size contentSize = new Size(_textPainter.width + _kCursorGap + _kCursorWidth, _textPainter.height); + Size contentSize = new Size(_textPainter.width + _kCaretGap + _kCaretWidth, _textPainter.height); if (_contentSize != contentSize) { _contentSize = contentSize; if (onContentSizeChanged != null) @@ -155,20 +183,41 @@ class RenderEditableLine extends RenderBox { } } - void _paintContents(PaintingContext context, Offset offset) { - _textPainter.paint(context.canvas, offset + _paintOffset); + void _paintCaret(Canvas canvas, Offset effectiveOffset) { + Offset caretOffset = _textPainter.getOffsetForCaret(_selection.extent, _caretPrototype); + Paint paint = new Paint()..color = _cursorColor; + canvas.drawRect(_caretPrototype.shift(caretOffset + effectiveOffset), paint); + } - if (_showCursor) { - Rect cursorRect = new Rect.fromLTWH( - offset.dx + _paintOffset.dx + _contentSize.width - _kCursorWidth, - offset.dy + _paintOffset.dy + _kCursorHeightOffset, - _kCursorWidth, - size.height - 2.0 * _kCursorHeightOffset + void _paintSelection(Canvas canvas, Offset effectiveOffset) { + assert(_selectionRects != null); + Paint paint = new Paint()..color = _selectionColor; + for (ui.TextBox box in _selectionRects) { + Rect selectionRect = new Rect.fromLTWH( + effectiveOffset.dx + box.left, + effectiveOffset.dy + _kCaretHeightOffset, + box.right - box.left, + size.height - 2.0 * _kCaretHeightOffset ); - context.canvas.drawRect(cursorRect, new Paint()..color = _cursorColor); + canvas.drawRect(selectionRect, paint); } } + void _paintContents(PaintingContext context, Offset offset) { + Offset effectiveOffset = offset + _paintOffset; + + if (_selection != null) { + if (_selection.isCollapsed && _showCursor && cursorColor != null) { + _paintCaret(context.canvas, effectiveOffset); + } else if (!_selection.isCollapsed && _selectionColor != null) { + _selectionRects ??= _textPainter.getBoxesForSelection(_selection); + _paintSelection(context.canvas, effectiveOffset); + } + } + + _textPainter.paint(context.canvas, effectiveOffset); + } + bool get _hasVisualOverflow => _contentSize.width > size.width; void paint(PaintingContext context, Offset offset) { diff --git a/packages/flutter/lib/src/widgets/editable.dart b/packages/flutter/lib/src/widgets/editable.dart index 4b5edbdb58..e2222bf4ba 100644 --- a/packages/flutter/lib/src/widgets/editable.dart +++ b/packages/flutter/lib/src/widgets/editable.dart @@ -14,57 +14,19 @@ import 'framework.dart'; import 'scrollable.dart'; import 'scroll_behavior.dart'; +export 'package:flutter/painting.dart' show TextSelection; + const Duration _kCursorBlinkHalfPeriod = const Duration(milliseconds: 500); -/// A range of characters in a string of tet. -class TextRange { - const TextRange({ this.start, this.end }); - - /// A text range that starts and ends at position. - const TextRange.collapsed(int position) - : start = position, - end = position; - - /// A text range that contains nothing and is not in the text. - static const TextRange empty = const TextRange(start: -1, end: -1); - - /// The index of the first character in the range. - final int start; - - /// The next index after the characters in this range. - final int end; - - /// Whether this range represents a valid position in the text. - bool get isValid => start >= 0 && end >= 0; - - /// Whether this range is empty (but still potentially placed inside the text). - bool get isCollapsed => start == end; - - /// The text before this range. - String textBefore(String text) { - return text.substring(0, start); - } - - /// The text after this range. - String textAfter(String text) { - return text.substring(end); - } - - /// The text inside this range. - String textInside(String text) { - return text.substring(start, end); - } -} - class _KeyboardClientImpl implements KeyboardClient { _KeyboardClientImpl({ - this.text: '', + String text: '', + TextSelection selection, this.onUpdated, this.onSubmitted - }) { + }) : text = text, selection = selection ?? new TextSelection.collapsed(offset: text.length) { assert(onUpdated != null); assert(onSubmitted != null); - selection = new TextRange(start: text.length, end: text.length); } /// The current text being edited. @@ -80,7 +42,7 @@ class _KeyboardClientImpl implements KeyboardClient { TextRange composing = TextRange.empty; /// The range of text that is currently selected. - TextRange selection = TextRange.empty; + TextSelection selection; /// A keyboard client stub that can be attached to a keyboard service. KeyboardClientStub createStub() { @@ -125,7 +87,7 @@ class _KeyboardClientImpl implements KeyboardClient { void commitText(String text, int newCursorPosition) { // TODO(abarth): Why is |newCursorPosition| always 1? TextRange committedRange = _replaceOrAppend(composing, text); - selection = new TextRange.collapsed(committedRange.end); + selection = new TextSelection.collapsed(offset: committedRange.end); composing = TextRange.empty; onUpdated(); } @@ -138,9 +100,9 @@ class _KeyboardClientImpl implements KeyboardClient { new TextRange(start: selection.end, end: afterRangeEnd); _delete(afterRange); _delete(beforeRange); - selection = new TextRange( - start: math.max(selection.start - beforeLength, 0), - end: math.max(selection.end - beforeLength, 0) + selection = new TextSelection( + baseOffset: math.max(selection.start - beforeLength, 0), + extentOffset: math.max(selection.end - beforeLength, 0) ); onUpdated(); } @@ -153,12 +115,12 @@ class _KeyboardClientImpl implements KeyboardClient { void setComposingText(String text, int newCursorPosition) { // TODO(abarth): Why is |newCursorPosition| always 1? composing = _replaceOrAppend(composing, text); - selection = new TextRange.collapsed(composing.end); + selection = new TextSelection.collapsed(offset: composing.end); onUpdated(); } void setSelection(int start, int end) { - selection = new TextRange(start: start, end: end); + selection = new TextSelection(baseOffset: start, extentOffset: end); onUpdated(); } @@ -175,10 +137,12 @@ class _KeyboardClientImpl implements KeyboardClient { class EditableString { EditableString({ String text: '', + TextSelection selection, VoidCallback onUpdated, VoidCallback onSubmitted }) : _client = new _KeyboardClientImpl( text: text, + selection: selection, onUpdated: onUpdated, onSubmitted: onSubmitted ); @@ -192,7 +156,7 @@ class EditableString { TextRange get composing => _client.composing; /// The range of text that is currently selected. - TextRange get selection => _client.selection; + TextSelection get selection => _client.selection; /// A keyboard client stub that can be attached to a keyboard service. /// @@ -215,7 +179,8 @@ class RawEditableLine extends Scrollable { this.focused: false, this.hideText: false, this.style, - this.cursorColor + this.cursorColor, + this.selectionColor }) : super( key: key, initialScrollOffset: 0.0, @@ -237,6 +202,9 @@ class RawEditableLine extends Scrollable { /// The color to use when painting the cursor. final Color cursorColor; + /// The color to use when painting the selection. + final Color selectionColor; + RawEditableTextState createState() => new RawEditableTextState(); } @@ -307,9 +275,9 @@ class RawEditableTextState extends ScrollableState { assert(config.focused != null); assert(config.cursorColor != null); - if (config.focused && _cursorTimer == null) + if (_cursorTimer == null && config.focused && config.value.selection.isCollapsed) _startCursorTimer(); - else if (!config.focused && _cursorTimer != null) + else if (_cursorTimer != null && (!config.focused || !config.value.selection.isCollapsed)) _stopCursorTimer(); return new SizeObserver( @@ -319,6 +287,7 @@ class RawEditableTextState extends ScrollableState { style: config.style, cursorColor: config.cursorColor, showCursor: _showCursor, + selectionColor: config.selectionColor, hideText: config.hideText, onContentSizeChanged: _handleContentSizeChanged, paintOffset: new Offset(-scrollOffset, 0.0) @@ -334,6 +303,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget { this.style, this.cursorColor, this.showCursor, + this.selectionColor, this.hideText, this.onContentSizeChanged, this.paintOffset @@ -343,6 +313,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget { final TextStyle style; final Color cursorColor; final bool showCursor; + final Color selectionColor; final bool hideText; final SizeChangedCallback onContentSizeChanged; final Offset paintOffset; @@ -352,6 +323,8 @@ class _EditableLineWidget extends LeafRenderObjectWidget { text: _styledTextSpan, cursorColor: cursorColor, showCursor: showCursor, + selectionColor: selectionColor, + selection: value.selection, onContentSizeChanged: onContentSizeChanged, paintOffset: paintOffset ); @@ -362,6 +335,8 @@ class _EditableLineWidget extends LeafRenderObjectWidget { renderObject.text = _styledTextSpan; renderObject.cursorColor = cursorColor; renderObject.showCursor = showCursor; + renderObject.selectionColor = selectionColor; + renderObject.selection = value.selection; renderObject.onContentSizeChanged = onContentSizeChanged; renderObject.paintOffset = paintOffset; }