Commit a5cfbad8 authored by Adam Barth's avatar Adam Barth

Input should paint selections

This patch teaches the editing system to paint reasonable selections for
single-line text fields, including for bidirectional text.

Requires https://github.com/flutter/engine/pull/2318
parent 1703d065
......@@ -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';
......@@ -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<Input> {
_value = config.initialValue;
_editableString = new EditableString(
text: _value,
selection: config.initialSelection,
onUpdated: _handleTextUpdated,
onSubmitted: _handleTextSubmitted
);
......@@ -215,7 +220,8 @@ class _InputState extends State<Input> {
focused: focused,
style: textStyle,
hideText: config.hideText,
cursorColor: cursorColor
cursorColor: cursorColor,
selectionColor: cursorColor
)
));
......
// 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);
}
......@@ -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<ui.TextBox> 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<ui.TextBox> 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<ui.TextBox> getBoxesForSelection(TextSelection selection) {
assert(!_needsLayout);
return _paragraph.getBoxesForRange(selection.start, selection.end);
}
}
......@@ -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<ui.TextBox> _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);
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 _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);
}
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) {
......
......@@ -14,57 +14,19 @@ import 'framework.dart';
import 'scrollable.dart';
import 'scroll_behavior.dart';
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);
}
export 'package:flutter/painting.dart' show TextSelection;
/// The text inside this range.
String textInside(String text) {
return text.substring(start, end);
}
}
const Duration _kCursorBlinkHalfPeriod = const Duration(milliseconds: 500);
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<RawEditableLine> {
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<RawEditableLine> {
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;
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment