text_painter.dart 12.8 KB
Newer Older
1 2 3 4
// 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.

5
import 'dart:ui' as ui show Paragraph, ParagraphBuilder, ParagraphStyle, TextBox;
6

7
import 'package:flutter/gestures.dart';
8
import 'package:flutter/services.dart';
9

10
import 'basic_types.dart';
11
import 'text_editing.dart';
12
import 'text_style.dart';
13

Adam Barth's avatar
Adam Barth committed
14 15 16 17 18 19 20 21 22 23 24 25 26
// TODO(abarth): Should this be somewhere more general?
bool _deepEquals(List<Object> a, List<Object> b) {
  if (a == null)
    return b == null;
  if (b == null || a.length != b.length)
    return false;
  for (int i = 0; i < a.length; ++i) {
    if (a[i] != b[i])
      return false;
  }
  return true;
}

Florian Loitsch's avatar
Florian Loitsch committed
27
/// An immutable span of text.
28 29 30 31 32 33 34 35 36 37 38 39
///
/// A [TextSpan] object can be styled using its [style] property.
/// The style will be applied to the [text] and the [children].
///
/// A [TextSpan] object can just have plain text, or it can have
/// children [TextSpan] objects with their own styles that (possibly
/// only partially) override the [style] of this object. If a
/// [TextSpan] has both [text] and [children], then the [text] is
/// treated as if it was an unstyled [TextSpan] at the start of the
/// [children] list.
///
/// To paint a [TextSpan] on a [Canvas], use a [TextPainter].
Adam Barth's avatar
Adam Barth committed
40
class TextSpan {
41 42 43 44
  /// Creates a [TextSpan] with the given values.
  ///
  /// For the object to be useful, at least one of [text] or
  /// [children] should be set.
Adam Barth's avatar
Adam Barth committed
45 46 47
  const TextSpan({
    this.style,
    this.text,
48 49
    this.children,
    this.recognizer
Adam Barth's avatar
Adam Barth committed
50 51
  });

52
  /// The style to apply to the [text] and the [children].
Adam Barth's avatar
Adam Barth committed
53
  final TextStyle style;
54

Florian Loitsch's avatar
Florian Loitsch committed
55
  /// The text contained in the span.
Adam Barth's avatar
Adam Barth committed
56
  ///
57
  /// If both [text] and [children] are non-null, the text will preceed the
Adam Barth's avatar
Adam Barth committed
58
  /// children.
59 60
  final String text;

Adam Barth's avatar
Adam Barth committed
61 62
  /// Additional spans to include as children.
  ///
63
  /// If both [text] and [children] are non-null, the text will preceed the
Adam Barth's avatar
Adam Barth committed
64
  /// children.
65 66 67 68 69
  ///
  /// Modifying the list after the [TextSpan] has been created is not
  /// supported and may have unexpected results.
  ///
  /// The list must not contain any nulls.
70 71
  final List<TextSpan> children;

72 73 74 75 76 77 78
  /// A gesture recognizer that will receive events that hit this text span.
  ///
  /// [TextSpan] itself does not implement hit testing or event
  /// dispatch. The owner of the [TextSpan] tree to which the object
  /// belongs is responsible for dispatching events.
  ///
  /// For an example, see [RenderParagraph] in the Flutter rendering library.
79 80
  final GestureRecognizer recognizer;

81 82 83 84 85 86 87
  /// Apply the [style], [text], and [children] of this object to the
  /// given [ParagraphBuilder], from which a [Paragraph] can be obtained.
  /// [Paragraph] objects can be drawn on [Canvas] objects.
  ///
  /// Rather than using this directly, it's simpler to use the
  /// [TextPainter] class to paint [TextSpan] objects onto [Canvas]
  /// objects.
88
  void build(ui.ParagraphBuilder builder) {
89
    assert(debugAssertValid());
Adam Barth's avatar
Adam Barth committed
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
    final bool hasStyle = style != null;
    if (hasStyle)
      builder.pushStyle(style.textStyle);
    if (text != null)
      builder.addText(text);
    if (children != null) {
      for (TextSpan child in children) {
        assert(child != null);
        child.build(builder);
      }
    }
    if (hasStyle)
      builder.pop();
  }

105 106 107 108 109
  bool visitTextSpan(bool visitor(TextSpan span)) {
    if (text != null) {
      if (!visitor(this))
        return false;
    }
Adam Barth's avatar
Adam Barth committed
110
    if (children != null) {
111 112 113 114
      for (TextSpan child in children) {
        if (!child.visitTextSpan(visitor))
          return false;
      }
115
    }
116 117 118 119
    return true;
  }

  TextSpan getSpanForPosition(TextPosition position) {
120
    assert(debugAssertValid());
121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139
    TextAffinity affinity = position.affinity;
    int targetOffset = position.offset;
    int offset = 0;
    TextSpan result;
    visitTextSpan((TextSpan span) {
      assert(result == null);
      int endOffset = offset + span.text.length;
      if (targetOffset == offset && affinity == TextAffinity.downstream ||
          targetOffset > offset && targetOffset < endOffset ||
          targetOffset == endOffset && affinity == TextAffinity.upstream) {
        result = span;
        return false;
      }
      offset = endOffset;
      return true;
    });
    return result;
  }

140 141 142
  /// Flattens the [TextSpan] tree into a single string.
  ///
  /// Styles are not honored in this process.
143
  String toPlainText() {
144
    assert(debugAssertValid());
145 146 147 148 149 150
    StringBuffer buffer = new StringBuffer();
    visitTextSpan((TextSpan span) {
      buffer.write(span.text);
      return true;
    });
    return buffer.toString();
151 152
  }

153
  @override
Adam Barth's avatar
Adam Barth committed
154 155 156 157
  String toString([String prefix = '']) {
    StringBuffer buffer = new StringBuffer();
    buffer.writeln('$prefix$runtimeType:');
    String indent = '$prefix  ';
158 159
    if (style != null)
      buffer.writeln(style.toString(indent));
Adam Barth's avatar
Adam Barth committed
160 161
    if (text != null)
      buffer.writeln('$indent"$text"');
162 163 164 165 166 167 168 169 170 171 172
    if (children != null) {
      for (TextSpan child in children) {
        if (child != null) {
          buffer.write(child.toString(indent));
        } else {
          buffer.writeln('$indent<null>');
        }
      }
    }
    if (style == null && text == null && children == null)
      buffer.writeln('$indent(empty)');
Adam Barth's avatar
Adam Barth committed
173 174
    return buffer.toString();
  }
175

176 177 178 179 180 181 182
  /// In checked mode, throws an exception if the object is not in a
  /// valid configuration. Otherwise, returns true.
  ///
  /// This is intended to be used as follows:
  /// ```dart
  ///   assert(myTextSpan.debugAssertValid());
  /// ```
183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205
  bool debugAssertValid() {
    assert(() {
      if (!visitTextSpan((TextSpan span) {
        if (span.children != null) {
          for (TextSpan child in span.children) {
            if (child == null)
              return false;
          }
        }
        return true;
      })) {
        throw new FlutterError(
          'TextSpan contains a null child.\n'
          'A TextSpan object with a non-null child list should not have any nulls in its child list.\n'
          'The full text in question was:\n'
          '${toString("  ")}'
        );
      }
      return true;
    });
    return true;
  }

206
  @override
Hixie's avatar
Hixie committed
207
  bool operator ==(dynamic other) {
208 209
    if (identical(this, other))
      return true;
Adam Barth's avatar
Adam Barth committed
210 211 212
    if (other is! TextSpan)
      return false;
    final TextSpan typedOther = other;
Adam Barth's avatar
Adam Barth committed
213 214
    return typedOther.text == text
        && typedOther.style == style
215
        && typedOther.recognizer == recognizer
Adam Barth's avatar
Adam Barth committed
216
        && _deepEquals(typedOther.children, children);
217
  }
218 219

  @override
220
  int get hashCode => hashValues(style, text, recognizer, hashList(children));
221 222
}

223
/// An object that paints a [TextSpan] tree into a [Canvas].
Hixie's avatar
Hixie committed
224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239
///
/// To use a [TextPainter], follow these steps:
///
/// 1. Create a [TextSpan] tree and pass it to the [TextPainter]
///    constructor.
///
/// 2. Set the [maxWidth] property of the [TextPainter] to the width
///    of the area into which the text should be painted.
///
/// 3. Call [layout] to prepare the paragraph.
///
/// 4. 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.
Adam Barth's avatar
Adam Barth committed
240
class TextPainter {
Hixie's avatar
Hixie committed
241
  TextPainter([ TextSpan text ]) {
242 243 244
    this.text = text;
  }

245
  ui.Paragraph _paragraph;
246 247 248
  bool _needsLayout = true;

  TextSpan _text;
Florian Loitsch's avatar
Florian Loitsch committed
249
  /// The (potentially styled) text to paint.
250 251
  TextSpan get text => _text;
  void set text(TextSpan value) {
252
    assert(value == null || value.debugAssertValid());
253 254 255
    if (_text == value)
      return;
    _text = value;
256
    ui.ParagraphBuilder builder = new ui.ParagraphBuilder();
257
    _text.build(builder);
Adam Barth's avatar
Adam Barth committed
258
    _paragraph = builder.build(_text.style?.paragraphStyle ?? new ui.ParagraphStyle());
259 260 261
    _needsLayout = true;
  }

Florian Loitsch's avatar
Florian Loitsch committed
262
  /// The minimum width at which to layout the text.
263
  double get minWidth => _paragraph.minWidth;
264
  void set minWidth(double value) {
265 266 267 268 269 270
    if (_paragraph.minWidth == value)
      return;
    _paragraph.minWidth = value;
    _needsLayout = true;
  }

Florian Loitsch's avatar
Florian Loitsch committed
271
  /// The maximum width at which to layout the text.
272
  double get maxWidth => _paragraph.maxWidth;
273
  void set maxWidth(double value) {
274 275 276 277 278 279
    if (_paragraph.maxWidth == value)
      return;
    _paragraph.maxWidth = value;
    _needsLayout = true;
  }

Florian Loitsch's avatar
Florian Loitsch committed
280
  /// The minimum height at which to layout the text.
281
  double get minHeight => _paragraph.minHeight;
282
  void set minHeight(double value) {
283 284 285 286 287 288
    if (_paragraph.minHeight == value)
      return;
    _paragraph.minHeight = value;
    _needsLayout = true;
  }

Florian Loitsch's avatar
Florian Loitsch committed
289
  /// The maximum height at which to layout the text.
290
  double get maxHeight => _paragraph.maxHeight;
291
  void set maxHeight(double value) {
292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307
    if (_paragraph.maxHeight == value)
      return;
    _paragraph.maxHeight = value;
  }

  // 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();
  }

Florian Loitsch's avatar
Florian Loitsch committed
308
  /// The width at which decreasing the width of the text would prevent it from painting itself completely within its bounds.
309
  double get minIntrinsicWidth {
310 311 312 313
    assert(!_needsLayout);
    return _applyFloatingPointHack(_paragraph.minIntrinsicWidth);
  }

Florian Loitsch's avatar
Florian Loitsch committed
314
  /// The width at which increasing the width of the text no longer decreases the height.
315
  double get maxIntrinsicWidth {
316 317 318 319
    assert(!_needsLayout);
    return _applyFloatingPointHack(_paragraph.maxIntrinsicWidth);
  }

320 321 322 323 324 325 326 327 328 329
  double get width {
    assert(!_needsLayout);
    return _applyFloatingPointHack(_paragraph.width);
  }

  double get height {
    assert(!_needsLayout);
    return _applyFloatingPointHack(_paragraph.height);
  }

330
  Size get size {
331
    assert(!_needsLayout);
332
    return new Size(width, height);
333 334
  }

Florian Loitsch's avatar
Florian Loitsch committed
335
  /// Returns the distance from the top of the text to the first baseline of the given type.
336 337 338 339 340 341 342 343 344 345
  double computeDistanceToActualBaseline(TextBaseline baseline) {
    assert(!_needsLayout);
    switch (baseline) {
      case TextBaseline.alphabetic:
        return _paragraph.alphabeticBaseline;
      case TextBaseline.ideographic:
        return _paragraph.ideographicBaseline;
    }
  }

Florian Loitsch's avatar
Florian Loitsch committed
346
  /// Computes the visual position of the glyphs for painting the text.
347 348 349 350 351 352 353
  void layout() {
    if (!_needsLayout)
      return;
    _paragraph.layout();
    _needsLayout = false;
  }

Florian Loitsch's avatar
Florian Loitsch committed
354
  /// Paints the text onto the given canvas at the given offset.
355
  void paint(Canvas canvas, Offset offset) {
356
    assert(!_needsLayout && "Please call layout() before paint() to position the text before painting it." is String);
Adam Barth's avatar
Adam Barth committed
357
    canvas.drawParagraph(_paragraph, offset);
358
  }
359 360 361 362 363 364 365

  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;
366
    double dx = box.direction == TextDirection.rtl ? caretEnd : caretEnd - caretPrototype.width;
367 368 369 370 371 372 373 374 375
    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;
376
    double dx = box.direction == TextDirection.rtl ? caretStart - caretPrototype.width : caretStart;
377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407
    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);
  }

408 409 410 411 412
  TextPosition getPositionForOffset(Offset offset) {
    assert(!_needsLayout);
    return _paragraph.getPositionForOffset(offset);
  }

413
}