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