// 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:math' show min, max; import 'dart:ui' as ui show Paragraph, ParagraphBuilder, ParagraphConstraints, ParagraphStyle; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'basic_types.dart'; import 'text_span.dart'; export 'package:flutter/services.dart' show TextRange, TextSelection; /// An object that paints a [TextSpan] tree into a [Canvas]. /// /// To use a [TextPainter], follow these steps: /// /// 1. Create a [TextSpan] tree and pass it to the [TextPainter] /// constructor. /// /// 2. Call [layout] to prepare the paragraph. /// /// 3. Call [paint] as often as desired to paint the paragraph. /// /// If the width of the area into which the text is being painted /// changes, return to step 2. If the text to be painted changes, /// return to step 1. /// /// The default text style is white. To change the color of the text, /// pass a [TextStyle] object to the [TextSpan] in `text`. class TextPainter { /// Creates a text painter that paints the given text. /// /// The `text` and `textDirection` arguments are optional but [text] and /// [textDirection] must be non-null before calling [layout]. /// /// The [textAlign] property must not be null. /// /// The [maxLines] property, if non-null, must be greater than zero. TextPainter({ TextSpan text, TextAlign textAlign = TextAlign.start, TextDirection textDirection, double textScaleFactor = 1.0, int maxLines, String ellipsis, Locale locale, }) : assert(text == null || text.debugAssertIsValid()), assert(textAlign != null), assert(textScaleFactor != null), assert(maxLines == null || maxLines > 0), _text = text, _textAlign = textAlign, _textDirection = textDirection, _textScaleFactor = textScaleFactor, _maxLines = maxLines, _ellipsis = ellipsis, _locale = locale; ui.Paragraph _paragraph; bool _needsLayout = true; /// The (potentially styled) text to paint. /// /// After this is set, you must call [layout] before the next call to [paint]. /// /// This and [textDirection] must be non-null before you call [layout]. TextSpan get text => _text; TextSpan _text; set text(TextSpan value) { assert(value == null || value.debugAssertIsValid()); if (_text == value) return; if (_text?.style != value?.style) _layoutTemplate = null; _text = value; _paragraph = null; _needsLayout = true; } /// How the text should be aligned horizontally. /// /// After this is set, you must call [layout] before the next call to [paint]. /// /// The [textAlign] property must not be null. It defaults to [TextAlign.start]. TextAlign get textAlign => _textAlign; TextAlign _textAlign; set textAlign(TextAlign value) { assert(value != null); if (_textAlign == value) return; _textAlign = value; _paragraph = null; _needsLayout = true; } /// The default directionality of the text. /// /// This controls how the [TextAlign.start], [TextAlign.end], and /// [TextAlign.justify] values of [textAlign] are resolved. /// /// 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. /// /// After this is set, you must call [layout] before the next call to [paint]. /// /// This and [text] must be non-null before you call [layout]. TextDirection get textDirection => _textDirection; TextDirection _textDirection; set textDirection(TextDirection value) { if (_textDirection == value) return; _textDirection = value; _paragraph = null; _layoutTemplate = null; // Shouldn't really matter, but for strict correctness... _needsLayout = true; } /// 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. /// /// After this is set, you must call [layout] before the next call to [paint]. double get textScaleFactor => _textScaleFactor; double _textScaleFactor; set textScaleFactor(double value) { assert(value != null); if (_textScaleFactor == value) return; _textScaleFactor = value; _paragraph = null; _layoutTemplate = null; _needsLayout = true; } /// The string used to ellipsize overflowing text. Setting this to a non-empty /// string will cause this string to be substituted for the remaining text /// if the text can not fit within the specified maximum width. /// /// Specifically, the ellipsis is applied to the last line before the line /// truncated by [maxLines], if [maxLines] is non-null and that line overflows /// the width constraint, or to the first line that is wider than the width /// constraint, if [maxLines] is null. The width constraint is the `maxWidth` /// passed to [layout]. /// /// After this is set, you must call [layout] before the next call to [paint]. /// /// The higher layers of the system, such as the [Text] widget, represent /// overflow effects using the [TextOverflow] enum. The /// [TextOverflow.ellipsis] value corresponds to setting this property to /// U+2026 HORIZONTAL ELLIPSIS (…). String get ellipsis => _ellipsis; String _ellipsis; set ellipsis(String value) { assert(value == null || value.isNotEmpty); if (_ellipsis == value) return; _ellipsis = value; _paragraph = null; _needsLayout = true; } /// The locale used to select region-specific glyphs. Locale get locale => _locale; Locale _locale; set locale(Locale value) { if (_locale == value) return; _locale = value; _paragraph = null; _needsLayout = true; } /// An optional maximum number of lines for the text to span, wrapping if /// necessary. /// /// If the text exceeds the given number of lines, it is truncated such that /// subsequent lines are dropped. /// /// After this is set, you must call [layout] before the next call to [paint]. int get maxLines => _maxLines; int _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 (_maxLines == value) return; _maxLines = value; _paragraph = null; _needsLayout = true; } ui.Paragraph _layoutTemplate; ui.ParagraphStyle _createParagraphStyle([TextDirection defaultTextDirection]) { // The defaultTextDirection argument is used for preferredLineHeight in case // textDirection hasn't yet been set. assert(textAlign != null); assert(textDirection != null || defaultTextDirection != null, 'TextPainter.textDirection must be set to a non-null value before using the TextPainter.'); return _text.style?.getParagraphStyle( textAlign: textAlign, textDirection: textDirection ?? defaultTextDirection, textScaleFactor: textScaleFactor, maxLines: _maxLines, ellipsis: _ellipsis, locale: _locale, ) ?? ui.ParagraphStyle( textAlign: textAlign, textDirection: textDirection ?? defaultTextDirection, maxLines: maxLines, ellipsis: ellipsis, locale: locale, ); } /// The height of a space in [text] in logical pixels. /// /// Not every line of text in [text] will have this height, but this height /// is "typical" for text in [text] and useful for sizing other objects /// relative a typical line of text. /// /// Obtaining this value does not require calling [layout]. /// /// The style of the [text] property is used to determine the font settings /// that contribute to the [preferredLineHeight]. If [text] is null or if it /// specifies no styles, the default [TextStyle] values are used (a 10 pixel /// sans-serif font). double get preferredLineHeight { if (_layoutTemplate == null) { final ui.ParagraphBuilder builder = ui.ParagraphBuilder( _createParagraphStyle(TextDirection.rtl), ); // direction doesn't matter, text is just a space if (text?.style != null) builder.pushStyle(text.style.getTextStyle(textScaleFactor: textScaleFactor)); builder.addText(' '); _layoutTemplate = builder.build() ..layout(ui.ParagraphConstraints(width: double.infinity)); } return _layoutTemplate.height; } // Unfortunately, using full precision floating point here causes bad layouts // because floating point math isn't associative. If we add and subtract // padding, for example, we'll get different values when we estimate sizes and // when we actually compute layout because the operations will end up associated // differently. To work around this problem for now, we round fractional pixel // values up to the nearest whole pixel value. The right long-term fix is to do // layout using fixed precision arithmetic. double _applyFloatingPointHack(double layoutValue) { return layoutValue.ceilToDouble(); } /// The width at which decreasing the width of the text would prevent it from /// painting itself completely within its bounds. /// /// Valid only after [layout] has been called. double get minIntrinsicWidth { assert(!_needsLayout); return _applyFloatingPointHack(_paragraph.minIntrinsicWidth); } /// The width at which increasing the width of the text no longer decreases the height. /// /// Valid only after [layout] has been called. double get maxIntrinsicWidth { assert(!_needsLayout); return _applyFloatingPointHack(_paragraph.maxIntrinsicWidth); } /// The horizontal space required to paint this text. /// /// Valid only after [layout] has been called. double get width { assert(!_needsLayout); return _applyFloatingPointHack(_paragraph.width); } /// The vertical space required to paint this text. /// /// Valid only after [layout] has been called. double get height { assert(!_needsLayout); return _applyFloatingPointHack(_paragraph.height); } /// The amount of space required to paint this text. /// /// Valid only after [layout] has been called. Size get size { assert(!_needsLayout); return Size(width, height); } /// Returns the distance from the top of the text to the first baseline of the /// given type. /// /// Valid only after [layout] has been called. double computeDistanceToActualBaseline(TextBaseline baseline) { assert(!_needsLayout); assert(baseline != null); switch (baseline) { case TextBaseline.alphabetic: return _paragraph.alphabeticBaseline; case TextBaseline.ideographic: return _paragraph.ideographicBaseline; } return null; } /// Whether any text was truncated or ellipsized. /// /// If [maxLines] is not null, this is true if there were more lines to be /// drawn than the given [maxLines], and thus at least one line was omitted in /// the output; otherwise it is false. /// /// If [maxLines] is null, this is true if [ellipsis] is not the empty string /// and there was a line that overflowed the `maxWidth` argument passed to /// [layout]; otherwise it is false. /// /// Valid only after [layout] has been called. bool get didExceedMaxLines { assert(!_needsLayout); return _paragraph.didExceedMaxLines; } double _lastMinWidth; double _lastMaxWidth; /// Computes the visual position of the glyphs for painting the text. /// /// The text will layout with a width that's as close to its max intrinsic /// width as possible while still being greater than or equal to `minWidth` and /// less than or equal to `maxWidth`. /// /// The [text] and [textDirection] properties must be non-null before this is /// called. void layout({ double minWidth = 0.0, double maxWidth = double.infinity }) { assert(text != null, 'TextPainter.text must be set to a non-null value before using the TextPainter.'); assert(textDirection != null, 'TextPainter.textDirection must be set to a non-null value before using the TextPainter.'); if (!_needsLayout && minWidth == _lastMinWidth && maxWidth == _lastMaxWidth) return; _needsLayout = false; if (_paragraph == null) { final ui.ParagraphBuilder builder = ui.ParagraphBuilder(_createParagraphStyle()); _text.build(builder, textScaleFactor: textScaleFactor); _paragraph = builder.build(); } _lastMinWidth = minWidth; _lastMaxWidth = maxWidth; _paragraph.layout(ui.ParagraphConstraints(width: maxWidth)); if (minWidth != maxWidth) { final double newWidth = maxIntrinsicWidth.clamp(minWidth, maxWidth); if (newWidth != width) _paragraph.layout(ui.ParagraphConstraints(width: newWidth)); } } /// Paints the text onto the given canvas at the given offset. /// /// Valid only after [layout] has been called. /// /// If you cannot see the text being painted, check that your text color does /// not conflict with the background on which you are drawing. The default /// text color is white (to contrast with the default black background color), /// so if you are writing an application with a white background, the text /// will not be visible by default. /// /// To set the text style, specify a [TextStyle] when creating the [TextSpan] /// that you pass to the [TextPainter] constructor or to the [text] property. void paint(Canvas canvas, Offset offset) { assert(() { if (_needsLayout) { throw FlutterError( 'TextPainter.paint called when text geometry was not yet calculated.\n' 'Please call layout() before paint() to position the text before painting it.' ); } return true; }()); canvas.drawParagraph(_paragraph, offset); } // Complex glyphs can be represented by two or more UTF16 codepoints. This // checks if the value represents a UTF16 glyph by itself or is a 'surrogate'. bool _isUtf16Surrogate(int value) { return value & 0xF800 == 0xD800; } /// Returns the closest offset after `offset` at which the input cursor can be /// positioned. int getOffsetAfter(int offset) { final int nextCodeUnit = _text.codeUnitAt(offset); if (nextCodeUnit == null) return null; // TODO(goderbauer): doesn't handle extended grapheme clusters with more than one Unicode scalar value (https://github.com/flutter/flutter/issues/13404). return _isUtf16Surrogate(nextCodeUnit) ? offset + 2 : offset + 1; } /// Returns the closest offset before `offset` at which the inout cursor can /// be positioned. int getOffsetBefore(int offset) { final int prevCodeUnit = _text.codeUnitAt(offset - 1); if (prevCodeUnit == null) return null; // TODO(goderbauer): doesn't handle extended grapheme clusters with more than one Unicode scalar value (https://github.com/flutter/flutter/issues/13404). return _isUtf16Surrogate(prevCodeUnit) ? offset - 2 : offset - 1; } // Unicode value for a zero width joiner character. static const int _zwjUtf16 = 0x200d; // Get the Offset of the cursor (in logical pixels) based off the near edge // of the character upstream from the given string offset. // TODO(garyq): Use actual extended grapheme cluster length instead of // an increasing cluster length amount to achieve deterministic performance. Offset _getOffsetFromUpstream(int offset, Rect caretPrototype) { final String flattenedText = _text.toPlainText(); final int prevCodeUnit = _text.codeUnitAt(max(0, offset - 1)); if (prevCodeUnit == null) return null; // Check for multi-code-unit glyphs such as emojis or zero width joiner final bool needsSearch = _isUtf16Surrogate(prevCodeUnit) || _text.codeUnitAt(offset) == _zwjUtf16; int graphemeClusterLength = needsSearch ? 2 : 1; List<TextBox> boxes = <TextBox>[]; while (boxes.isEmpty && flattenedText != null) { final int prevRuneOffset = offset - graphemeClusterLength; boxes = _paragraph.getBoxesForRange(prevRuneOffset, offset); // When the range does not include a full cluster, no boxes will be returned. if (boxes.isEmpty) { // When we are at the beginning of the line, a non-surrogate position will // return empty boxes. We break and try from downstream instead. if (!needsSearch) break; // Only perform one iteration if no search is required. if (prevRuneOffset < -flattenedText.length) break; // Stop iterating when beyond the max length of the text. // Multiply by two to log(n) time cover the entire text span. This allows // faster discovery of very long clusters and reduces the possibility // of certain large clusters taking much longer than others, which can // cause jank. graphemeClusterLength *= 2; continue; } final TextBox box = boxes.first; // If the upstream character is a newline, cursor is at start of next line const int NEWLINE_CODE_UNIT = 10; if (prevCodeUnit == NEWLINE_CODE_UNIT) { return Offset(_emptyOffset.dx, box.bottom); } final double caretEnd = box.end; final double dx = box.direction == TextDirection.rtl ? caretEnd - caretPrototype.width : caretEnd; return Offset(dx, box.top); } return null; } // Get the Offset of the cursor (in logical pixels) based off the near edge // of the character downstream from the given string offset. // TODO(garyq): Use actual extended grapheme cluster length instead of // an increasing cluster length amount to achieve deterministic performance. Offset _getOffsetFromDownstream(int offset, Rect caretPrototype) { final String flattenedText = _text.toPlainText(); // We cap the offset at the final index of the _text. final int nextCodeUnit = _text.codeUnitAt(min(offset, flattenedText == null ? 0 : flattenedText.length - 1)); if (nextCodeUnit == null) return null; // Check for multi-code-unit glyphs such as emojis or zero width joiner final bool needsSearch = _isUtf16Surrogate(nextCodeUnit) || nextCodeUnit == _zwjUtf16; int graphemeClusterLength = needsSearch ? 2 : 1; List<TextBox> boxes = <TextBox>[]; while (boxes.isEmpty && flattenedText != null) { final int nextRuneOffset = offset + graphemeClusterLength; boxes = _paragraph.getBoxesForRange(offset, nextRuneOffset); // When the range does not include a full cluster, no boxes will be returned. if (boxes.isEmpty) { // When we are at the end of the line, a non-surrogate position will // return empty boxes. We break and try from upstream instead. if (!needsSearch) break; // Only perform one iteration if no search is required. if (nextRuneOffset >= flattenedText.length << 1) break; // Stop iterating when beyond the max length of the text. // Multiply by two to log(n) time cover the entire text span. This allows // faster discovery of very long clusters and reduces the possibility // of certain large clusters taking much longer than others, which can // cause jank. graphemeClusterLength *= 2; continue; } final TextBox box = boxes.last; final double caretStart = box.start; final double dx = box.direction == TextDirection.rtl ? caretStart - caretPrototype.width : caretStart; return Offset(dx, box.top); } return null; } Offset get _emptyOffset { assert(!_needsLayout); // implies textDirection is non-null assert(textAlign != null); switch (textAlign) { case TextAlign.left: return Offset.zero; case TextAlign.right: return Offset(width, 0.0); case TextAlign.center: return Offset(width / 2.0, 0.0); case TextAlign.justify: case TextAlign.start: assert(textDirection != null); switch (textDirection) { case TextDirection.rtl: return Offset(width, 0.0); case TextDirection.ltr: return Offset.zero; } return null; case TextAlign.end: assert(textDirection != null); switch (textDirection) { case TextDirection.rtl: return Offset.zero; case TextDirection.ltr: return Offset(width, 0.0); } return null; } return null; } /// Returns the offset at which to paint the caret. /// /// Valid only after [layout] has been called. Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) { assert(!_needsLayout); final int offset = position.offset; assert(position.affinity != null); 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; } return null; } /// 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<TextBox> getBoxesForSelection(TextSelection selection) { assert(!_needsLayout); return _paragraph.getBoxesForRange(selection.start, selection.end); } /// Returns the position within the text for the given pixel offset. TextPosition getPositionForOffset(Offset offset) { assert(!_needsLayout); return _paragraph.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>. TextRange getWordBoundary(TextPosition position) { assert(!_needsLayout); final List<int> indices = _paragraph.getWordBoundary(position.offset); return TextRange(start: indices[0], end: indices[1]); } }