blob: d8d485bd3a649d5a425e6af5b4c58e849df6a4d8 [file] [log] [blame]
// Copyright 2015 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:ui' as ui show Gradient, Shader, TextBox;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'box.dart';
import 'debug.dart';
import 'object.dart';
/// How overflowing text should be handled.
enum TextOverflow {
/// Clip the overflowing text to fix its container.
clip,
/// Fade the overflowing text to transparent.
fade,
/// Use an ellipsis to indicate that the text has overflowed.
ellipsis,
/// Render overflowing text outside of its container.
visible,
}
const String _kEllipsis = '\u2026';
/// A render object that displays a paragraph of text
class RenderParagraph extends RenderBox {
/// Creates a paragraph render object.
///
/// The [text], [textAlign], [textDirection], [overflow], [softWrap], and
/// [textScaleFactor] arguments must not be null.
///
/// The [maxLines] property may be null (and indeed defaults to null), but if
/// it is not null, it must be greater than zero.
RenderParagraph(
TextSpan text, {
TextAlign textAlign = TextAlign.start,
@required TextDirection textDirection,
bool softWrap = true,
TextOverflow overflow = TextOverflow.clip,
double textScaleFactor = 1.0,
int maxLines,
TextWidthBasis textWidthBasis = TextWidthBasis.parent,
Locale locale,
StrutStyle strutStyle,
}) : assert(text != null),
assert(text.debugAssertIsValid()),
assert(textAlign != null),
assert(textDirection != null),
assert(softWrap != null),
assert(overflow != null),
assert(textScaleFactor != null),
assert(maxLines == null || maxLines > 0),
assert(textWidthBasis != null),
_softWrap = softWrap,
_overflow = overflow,
_textPainter = TextPainter(
text: text,
textAlign: textAlign,
textDirection: textDirection,
textScaleFactor: textScaleFactor,
maxLines: maxLines,
ellipsis: overflow == TextOverflow.ellipsis ? _kEllipsis : null,
locale: locale,
strutStyle: strutStyle,
textWidthBasis: textWidthBasis,
);
final TextPainter _textPainter;
/// The text to display
TextSpan get text => _textPainter.text;
set text(TextSpan value) {
assert(value != null);
switch (_textPainter.text.compareTo(value)) {
case RenderComparison.identical:
case RenderComparison.metadata:
return;
case RenderComparison.paint:
_textPainter.text = value;
markNeedsPaint();
markNeedsSemanticsUpdate();
break;
case RenderComparison.layout:
_textPainter.text = value;
_overflowShader = null;
markNeedsLayout();
break;
}
}
/// How the text should be aligned horizontally.
TextAlign get textAlign => _textPainter.textAlign;
set textAlign(TextAlign value) {
assert(value != null);
if (_textPainter.textAlign == value)
return;
_textPainter.textAlign = value;
markNeedsPaint();
}
/// The directionality of the text.
///
/// This decides how the [TextAlign.start], [TextAlign.end], and
/// [TextAlign.justify] values of [textAlign] are interpreted.
///
/// This is also used to disambiguate how to render bidirectional text. For
/// example, if the [text] is an English phrase followed by a Hebrew phrase,
/// in a [TextDirection.ltr] context the English phrase will be on the left
/// and the Hebrew phrase to its right, while in a [TextDirection.rtl]
/// context, the English phrase will be on the right and the Hebrew phrase on
/// its left.
///
/// This must not be null.
TextDirection get textDirection => _textPainter.textDirection;
set textDirection(TextDirection value) {
assert(value != null);
if (_textPainter.textDirection == value)
return;
_textPainter.textDirection = value;
markNeedsLayout();
}
/// Whether the text should break at soft line breaks.
///
/// If false, the glyphs in the text will be positioned as if there was
/// unlimited horizontal space.
///
/// If [softWrap] is false, [overflow] and [textAlign] may have unexpected
/// effects.
bool get softWrap => _softWrap;
bool _softWrap;
set softWrap(bool value) {
assert(value != null);
if (_softWrap == value)
return;
_softWrap = value;
markNeedsLayout();
}
/// How visual overflow should be handled.
TextOverflow get overflow => _overflow;
TextOverflow _overflow;
set overflow(TextOverflow value) {
assert(value != null);
if (_overflow == value)
return;
_overflow = value;
_textPainter.ellipsis = value == TextOverflow.ellipsis ? _kEllipsis : null;
markNeedsLayout();
}
/// The number of font pixels for each logical pixel.
///
/// For example, if the text scale factor is 1.5, text will be 50% larger than
/// the specified font size.
double get textScaleFactor => _textPainter.textScaleFactor;
set textScaleFactor(double value) {
assert(value != null);
if (_textPainter.textScaleFactor == value)
return;
_textPainter.textScaleFactor = value;
_overflowShader = null;
markNeedsLayout();
}
/// An optional maximum number of lines for the text to span, wrapping if necessary.
/// If the text exceeds the given number of lines, it will be truncated according
/// to [overflow] and [softWrap].
int get maxLines => _textPainter.maxLines;
/// The value may be null. If it is not null, then it must be greater than zero.
set maxLines(int value) {
assert(value == null || value > 0);
if (_textPainter.maxLines == value)
return;
_textPainter.maxLines = value;
_overflowShader = null;
markNeedsLayout();
}
/// Used by this paragraph's internal [TextPainter] to select a locale-specific
/// font.
///
/// In some cases the same Unicode character may be rendered differently depending
/// on the locale. For example the '骨' character is rendered differently in
/// the Chinese and Japanese locales. In these cases the [locale] may be used
/// to select a locale-specific font.
Locale get locale => _textPainter.locale;
/// The value may be null.
set locale(Locale value) {
if (_textPainter.locale == value)
return;
_textPainter.locale = value;
_overflowShader = null;
markNeedsLayout();
}
/// {@macro flutter.painting.textPainter.strutStyle}
StrutStyle get strutStyle => _textPainter.strutStyle;
/// The value may be null.
set strutStyle(StrutStyle value) {
if (_textPainter.strutStyle == value)
return;
_textPainter.strutStyle = value;
_overflowShader = null;
markNeedsLayout();
}
/// {@macro flutter.widgets.basic.TextWidthBasis}
TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis;
set textWidthBasis(TextWidthBasis value) {
assert(value != null);
if (_textPainter.textWidthBasis == value)
return;
_textPainter.textWidthBasis = value;
_overflowShader = null;
markNeedsLayout();
}
void _layoutText({ double minWidth = 0.0, double maxWidth = double.infinity }) {
final bool widthMatters = softWrap || overflow == TextOverflow.ellipsis;
_textPainter.layout(minWidth: minWidth, maxWidth: widthMatters ? maxWidth : double.infinity);
}
void _layoutTextWithConstraints(BoxConstraints constraints) {
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
}
@override
double computeMinIntrinsicWidth(double height) {
_layoutText();
return _textPainter.minIntrinsicWidth;
}
@override
double computeMaxIntrinsicWidth(double height) {
_layoutText();
return _textPainter.maxIntrinsicWidth;
}
double _computeIntrinsicHeight(double width) {
_layoutText(minWidth: width, maxWidth: width);
return _textPainter.height;
}
@override
double computeMinIntrinsicHeight(double width) {
return _computeIntrinsicHeight(width);
}
@override
double computeMaxIntrinsicHeight(double width) {
return _computeIntrinsicHeight(width);
}
@override
double computeDistanceToActualBaseline(TextBaseline baseline) {
assert(!debugNeedsLayout);
assert(constraints != null);
assert(constraints.debugAssertIsValid());
_layoutTextWithConstraints(constraints);
return _textPainter.computeDistanceToActualBaseline(baseline);
}
@override
bool hitTestSelf(Offset position) => true;
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is! PointerDownEvent)
return;
_layoutTextWithConstraints(constraints);
final Offset offset = entry.localPosition;
final TextPosition position = _textPainter.getPositionForOffset(offset);
final TextSpan span = _textPainter.text.getSpanForPosition(position);
span?.recognizer?.addPointer(event);
}
bool _needsClipping = false;
ui.Shader _overflowShader;
/// Whether this paragraph currently has a [dart:ui.Shader] for its overflow
/// effect.
///
/// Used to test this object. Not for use in production.
@visibleForTesting
bool get debugHasOverflowShader => _overflowShader != null;
@override
void performLayout() {
_layoutTextWithConstraints(constraints);
// We grab _textPainter.size and _textPainter.didExceedMaxLines here because
// assigning to `size` will trigger us to validate our intrinsic sizes,
// which will change _textPainter's layout because the intrinsic size
// calculations are destructive. Other _textPainter state will also be
// affected. See also RenderEditable which has a similar issue.
final Size textSize = _textPainter.size;
final bool textDidExceedMaxLines = _textPainter.didExceedMaxLines;
size = constraints.constrain(textSize);
final bool didOverflowHeight = size.height < textSize.height || textDidExceedMaxLines;
final bool didOverflowWidth = size.width < textSize.width;
// TODO(abarth): We're only measuring the sizes of the line boxes here. If
// the glyphs draw outside the line boxes, we might think that there isn't
// visual overflow when there actually is visual overflow. This can become
// a problem if we start having horizontal overflow and introduce a clip
// that affects the actual (but undetected) vertical overflow.
final bool hasVisualOverflow = didOverflowWidth || didOverflowHeight;
if (hasVisualOverflow) {
switch (_overflow) {
case TextOverflow.visible:
_needsClipping = false;
_overflowShader = null;
break;
case TextOverflow.clip:
case TextOverflow.ellipsis:
_needsClipping = true;
_overflowShader = null;
break;
case TextOverflow.fade:
assert(textDirection != null);
_needsClipping = true;
final TextPainter fadeSizePainter = TextPainter(
text: TextSpan(style: _textPainter.text.style, text: '\u2026'),
textDirection: textDirection,
textScaleFactor: textScaleFactor,
locale: locale,
)..layout();
if (didOverflowWidth) {
double fadeEnd, fadeStart;
switch (textDirection) {
case TextDirection.rtl:
fadeEnd = 0.0;
fadeStart = fadeSizePainter.width;
break;
case TextDirection.ltr:
fadeEnd = size.width;
fadeStart = fadeEnd - fadeSizePainter.width;
break;
}
_overflowShader = ui.Gradient.linear(
Offset(fadeStart, 0.0),
Offset(fadeEnd, 0.0),
<Color>[const Color(0xFFFFFFFF), const Color(0x00FFFFFF)],
);
} else {
final double fadeEnd = size.height;
final double fadeStart = fadeEnd - fadeSizePainter.height / 2.0;
_overflowShader = ui.Gradient.linear(
Offset(0.0, fadeStart),
Offset(0.0, fadeEnd),
<Color>[const Color(0xFFFFFFFF), const Color(0x00FFFFFF)],
);
}
break;
}
} else {
_needsClipping = false;
_overflowShader = null;
}
}
@override
void paint(PaintingContext context, Offset offset) {
// Ideally we could compute the min/max intrinsic width/height with a
// non-destructive operation. However, currently, computing these values
// will destroy state inside the painter. If that happens, we need to
// get back the correct state by calling _layout again.
//
// TODO(abarth): Make computing the min/max intrinsic width/height
// a non-destructive operation.
//
// If you remove this call, make sure that changing the textAlign still
// works properly.
_layoutTextWithConstraints(constraints);
final Canvas canvas = context.canvas;
assert(() {
if (debugRepaintTextRainbowEnabled) {
final Paint paint = Paint()
..color = debugCurrentRepaintColor.toColor();
canvas.drawRect(offset & size, paint);
}
return true;
}());
if (_needsClipping) {
final Rect bounds = offset & size;
if (_overflowShader != null) {
// This layer limits what the shader below blends with to be just the text
// (as opposed to the text and its background).
canvas.saveLayer(bounds, Paint());
} else {
canvas.save();
}
canvas.clipRect(bounds);
}
_textPainter.paint(canvas, offset);
if (_needsClipping) {
if (_overflowShader != null) {
canvas.translate(offset.dx, offset.dy);
final Paint paint = Paint()
..blendMode = BlendMode.modulate
..shader = _overflowShader;
canvas.drawRect(Offset.zero & size, paint);
}
canvas.restore();
}
}
/// Returns the offset at which to paint the caret.
///
/// Valid only after [layout].
Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) {
assert(!debugNeedsLayout);
_layoutTextWithConstraints(constraints);
return _textPainter.getOffsetForCaret(position, caretPrototype);
}
/// 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.
///
/// Valid only after [layout].
List<ui.TextBox> getBoxesForSelection(TextSelection selection) {
assert(!debugNeedsLayout);
_layoutTextWithConstraints(constraints);
return _textPainter.getBoxesForSelection(selection);
}
/// Returns the position within the text for the given pixel offset.
///
/// Valid only after [layout].
TextPosition getPositionForOffset(Offset offset) {
assert(!debugNeedsLayout);
_layoutTextWithConstraints(constraints);
return _textPainter.getPositionForOffset(offset);
}
/// Returns the text range of the word at the given offset. Characters not
/// part of a word, such as spaces, symbols, and punctuation, have word breaks
/// on both sides. In such cases, this method will return a text range that
/// contains the given text position.
///
/// Word boundaries are defined more precisely in Unicode Standard Annex #29
/// <http://www.unicode.org/reports/tr29/#Word_Boundaries>.
///
/// Valid only after [layout].
TextRange getWordBoundary(TextPosition position) {
assert(!debugNeedsLayout);
_layoutTextWithConstraints(constraints);
return _textPainter.getWordBoundary(position);
}
/// Returns the size of the text as laid out.
///
/// This can differ from [size] if the text overflowed or if the [constraints]
/// provided by the parent [RenderObject] forced the layout to be bigger than
/// necessary for the given [text].
///
/// This returns the [TextPainter.size] of the underlying [TextPainter].
///
/// Valid only after [layout].
Size get textSize {
assert(!debugNeedsLayout);
return _textPainter.size;
}
final List<int> _recognizerOffsets = <int>[];
final List<GestureRecognizer> _recognizers = <GestureRecognizer>[];
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
_recognizerOffsets.clear();
_recognizers.clear();
int offset = 0;
text.visitTextSpan((TextSpan span) {
if (span.recognizer != null && (span.recognizer is TapGestureRecognizer || span.recognizer is LongPressGestureRecognizer)) {
final int length = span.semanticsLabel?.length ?? span.text.length;
_recognizerOffsets.add(offset);
_recognizerOffsets.add(offset + length);
_recognizers.add(span.recognizer);
}
offset += span.text.length;
return true;
});
if (_recognizerOffsets.isNotEmpty) {
config.explicitChildNodes = true;
config.isSemanticBoundary = true;
} else {
config.label = text.toPlainText();
config.textDirection = textDirection;
}
}
@override
void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) {
assert(_recognizerOffsets.isNotEmpty);
assert(_recognizerOffsets.length.isEven);
assert(_recognizers.isNotEmpty);
assert(children.isEmpty);
final List<SemanticsNode> newChildren = <SemanticsNode>[];
final String rawLabel = text.toPlainText();
int current = 0;
double order = -1.0;
TextDirection currentDirection = textDirection;
Rect currentRect;
SemanticsConfiguration buildSemanticsConfig(int start, int end) {
final TextDirection initialDirection = currentDirection;
final TextSelection selection = TextSelection(baseOffset: start, extentOffset: end);
final List<ui.TextBox> rects = getBoxesForSelection(selection);
Rect rect;
for (ui.TextBox textBox in rects) {
rect ??= textBox.toRect();
rect = rect.expandToInclude(textBox.toRect());
currentDirection = textBox.direction;
}
// round the current rectangle to make this API testable and add some
// padding so that the accessibility rects do not overlap with the text.
// TODO(jonahwilliams): implement this for all text accessibility rects.
currentRect = Rect.fromLTRB(
rect.left.floorToDouble() - 4.0,
rect.top.floorToDouble() - 4.0,
rect.right.ceilToDouble() + 4.0,
rect.bottom.ceilToDouble() + 4.0,
);
order += 1;
return SemanticsConfiguration()
..sortKey = OrdinalSortKey(order)
..textDirection = initialDirection
..label = rawLabel.substring(start, end);
}
for (int i = 0, j = 0; i < _recognizerOffsets.length; i += 2, j++) {
final int start = _recognizerOffsets[i];
final int end = _recognizerOffsets[i + 1];
if (current != start) {
final SemanticsNode node = SemanticsNode();
final SemanticsConfiguration configuration = buildSemanticsConfig(current, start);
node.updateWith(config: configuration);
node.rect = currentRect;
newChildren.add(node);
}
final SemanticsNode node = SemanticsNode();
final SemanticsConfiguration configuration = buildSemanticsConfig(start, end);
final GestureRecognizer recognizer = _recognizers[j];
if (recognizer is TapGestureRecognizer) {
configuration.onTap = recognizer.onTap;
} else if (recognizer is LongPressGestureRecognizer) {
configuration.onLongPress = recognizer.onLongPress;
} else {
assert(false);
}
node.updateWith(config: configuration);
node.rect = currentRect;
newChildren.add(node);
current = end;
}
if (current < rawLabel.length) {
final SemanticsNode node = SemanticsNode();
final SemanticsConfiguration configuration = buildSemanticsConfig(current, rawLabel.length);
node.updateWith(config: configuration);
node.rect = currentRect;
newChildren.add(node);
}
node.updateWith(config: config, childrenInInversePaintOrder: newChildren);
}
@override
List<DiagnosticsNode> debugDescribeChildren() {
return <DiagnosticsNode>[text.toDiagnosticsNode(name: 'text', style: DiagnosticsTreeStyle.transition)];
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(EnumProperty<TextAlign>('textAlign', textAlign));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
properties.add(FlagProperty('softWrap', value: softWrap, ifTrue: 'wrapping at box width', ifFalse: 'no wrapping except at line break characters', showName: true));
properties.add(EnumProperty<TextOverflow>('overflow', overflow));
properties.add(DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: 1.0));
properties.add(DiagnosticsProperty<Locale>('locale', locale, defaultValue: null));
properties.add(IntProperty('maxLines', maxLines, ifNull: 'unlimited'));
}
}