diff --git a/bin/internal/goldens.version b/bin/internal/goldens.version index 24d16b40da0..3ad128400a1 100644 --- a/bin/internal/goldens.version +++ b/bin/internal/goldens.version @@ -1 +1 @@ -041efaf483a1cd011f4b4f6dcd04e0d5cad9436e +eb9c1d66709a1f8a0291076865fe387ceed96dca diff --git a/dev/benchmarks/complex_layout/lib/main.dart b/dev/benchmarks/complex_layout/lib/main.dart index 2d349aea536..634be4517c9 100644 --- a/dev/benchmarks/complex_layout/lib/main.dart +++ b/dev/benchmarks/complex_layout/lib/main.dart @@ -438,8 +438,8 @@ class ItemImageBox extends StatelessWidget { borderRadius: BorderRadius.circular(2.0), ), padding: const EdgeInsets.all(4.0), - child: const RichText( - text: TextSpan( + child: RichText( + text: const TextSpan( style: TextStyle(color: Colors.white), children: [ TextSpan( diff --git a/dev/manual_tests/lib/text.dart b/dev/manual_tests/lib/text.dart index 6382fb3b8fb..3d5f482bd5d 100644 --- a/dev/manual_tests/lib/text.dart +++ b/dev/manual_tests/lib/text.dart @@ -145,7 +145,7 @@ class _FuzzerState extends State with SingleTickerProviderStateMixin { return TextSpan( text: _fiddleWithText(node.text), style: _fiddleWithStyle(node.style), - children: _fiddleWithChildren(node.children?.map((TextSpan child) => _fiddleWith(child))?.toList() ?? []), + children: _fiddleWithChildren(node.children?.map((InlineSpan child) => _fiddleWith(child))?.toList() ?? []), ); } diff --git a/packages/flutter/lib/painting.dart b/packages/flutter/lib/painting.dart index fe432cb9093..a22d5bf25c8 100644 --- a/packages/flutter/lib/painting.dart +++ b/packages/flutter/lib/painting.dart @@ -17,7 +17,7 @@ /// painting boxes. library painting; -export 'dart:ui' show Shadow; +export 'dart:ui' show Shadow, PlaceholderAlignment; export 'src/painting/alignment.dart'; export 'src/painting/basic_types.dart'; @@ -46,9 +46,11 @@ export 'src/painting/image_decoder.dart'; export 'src/painting/image_provider.dart'; export 'src/painting/image_resolution.dart'; export 'src/painting/image_stream.dart'; +export 'src/painting/inline_span.dart'; export 'src/painting/matrix_utils.dart'; export 'src/painting/notched_shapes.dart'; export 'src/painting/paint_utilities.dart'; +export 'src/painting/placeholder_span.dart'; export 'src/painting/rounded_rectangle_border.dart'; export 'src/painting/shader_warm_up.dart'; export 'src/painting/shape_decoration.dart'; diff --git a/packages/flutter/lib/src/material/time_picker.dart b/packages/flutter/lib/src/material/time_picker.dart index 9d720aaf398..8eb322b8ba7 100644 --- a/packages/flutter/lib/src/material/time_picker.dart +++ b/packages/flutter/lib/src/material/time_picker.dart @@ -993,6 +993,7 @@ class _DialPainter extends CustomPainter { final double width = labelPainter.width * _semanticNodeSizeScale; final double height = labelPainter.height * _semanticNodeSizeScale; final Offset nodeOffset = getOffsetForTheta(labelTheta, ring) + Offset(-width / 2.0, -height / 2.0); + final TextSpan textSpan = labelPainter.text; final CustomPainterSemantics node = CustomPainterSemantics( rect: Rect.fromLTRB( nodeOffset.dx - 24.0 + width / 2, @@ -1003,7 +1004,7 @@ class _DialPainter extends CustomPainter { properties: SemanticsProperties( sortKey: OrdinalSortKey(i.toDouble() + ordinalOffset), selected: label.value == selectedValue, - value: labelPainter.text.text, + value: textSpan?.text, textDirection: textDirection, onTap: label.onTap, ), diff --git a/packages/flutter/lib/src/painting/inline_span.dart b/packages/flutter/lib/src/painting/inline_span.dart new file mode 100644 index 00000000000..ac09ded5008 --- /dev/null +++ b/packages/flutter/lib/src/painting/inline_span.dart @@ -0,0 +1,249 @@ +// 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 ParagraphBuilder; + +import 'package:flutter/foundation.dart'; + +import 'basic_types.dart'; +import 'text_painter.dart'; +import 'text_style.dart'; + +/// Mutable wrapper of an integer that can be passed by reference to track a +/// value across a recursive stack. +class Accumulator { + /// [Accumulator] may be initialized with a specified value, otherwise, it will + /// initialize to zero. + Accumulator([this._value = 0]); + + /// The integer stored in this [Accumulator]. + int get value => _value; + int _value; + + /// Increases the [value] by the `addend`. + void increment(int addend) { + assert(addend >= 0); + _value += addend; + } +} +/// Called on each span as [InlineSpan.visitChildren] walks the [InlineSpan] tree. +/// +/// Returns true when the walk should continue, and false to stop visiting further +/// [InlineSpan]s. +typedef InlineSpanVisitor = bool Function(InlineSpan span); + +/// An immutable span of inline content which forms part of a paragraph. +/// +/// * The subclass [TextSpan] specifies text and may contain child [InlineSpan]s. +/// * The subclass [PlaceholderSpan] represents a placeholder that may be +/// filled with non-text content. [PlaceholderSpan] itself defines a +/// [ui.PlaceholderAlignemnt] and a [TextBaseline]. To be useful, +/// [PlaceholderSpan] must be extended to define content. An instance of +/// this is the [WidgetSpan] class in the widgets library. +/// * The subclass [WidgetSpan] specifies embedded inline widgets. +/// +/// {@tool sample} +/// +/// This example shows a tree of [InlineSpan]s that make a query asking for a +/// name with a [TextField] embedded inline. +/// +/// ```dart +/// Text.rich( +/// TextSpan( +/// text: 'My name is ', +/// style: TextStyle(color: Colors.black), +/// children: [ +/// WidgetSpan( +/// alignment: PlaceholderAlignment.baseline, +/// baseline: TextBaseline.alphabetic, +/// child: ConstrainedBox( +/// constraints: BoxConstraints(maxWidth: 100), +/// child: TextField(), +/// ) +/// ), +/// TextSpan( +/// text: '.', +/// ), +/// ], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [Text], a widget for showing uniformly-styled text. +/// * [RichText], a widget for finer control of text rendering. +/// * [TextPainter], a class for painting [InlineSpan] objects on a [Canvas]. +@immutable +abstract class InlineSpan extends DiagnosticableTree { + /// Creates an [InlineSpan] with the given values. + const InlineSpan({ + this.style, + }); + + /// The [TextStyle] to apply to this span. + /// + /// The [style] is also applied to any child spans when this is an instance + /// of [TextSpan]. + final TextStyle style; + + /// Apply the properties of this object to the given [ParagraphBuilder], from + /// which a [Paragraph] can be obtained. + /// + /// The `textScaleFactor` parameter specifies a scale that the text and + /// placeholders will be scaled by. The scaling is performed before layout, + /// so the text will be laid out with the scaled glyphs and placeholders. + /// + /// The `dimensions` parameter specifies the sizes of the placeholders. + /// Each [PlaceholderSpan] must be paired with a [PlaceholderDimensions] + /// in the same order as defined in the [InlineSpan] tree. + /// + /// [Paragraph] objects can be drawn on [Canvas] objects. + void build(ui.ParagraphBuilder builder, { double textScaleFactor = 1.0, List dimensions }); + + /// Walks this [InlineSpan] and any descendants in pre-order and calls `visitor` + /// for each span that has content. + /// + /// When `visitor` returns true, the walk will continue. When `visitor` returns + /// false, then the walk will end. + bool visitChildren(InlineSpanVisitor visitor); + + /// Returns the text span that contains the given position in the text. + InlineSpan getSpanForPosition(TextPosition position) { + assert(debugAssertIsValid()); + final Accumulator offset = Accumulator(); + InlineSpan result; + visitChildren((InlineSpan span) { + result = span.getSpanForPositionVisitor(position, offset); + return result == null; + }); + return result; + } + + /// Performs the check at each [InlineSpan] for if the `position` falls within the range + /// of the span and returns the span if it does. + /// + /// The `offset` parameter tracks the current index offset in the text buffer formed + /// if the contents of the [InlineSpan] tree were concatenated together starting + /// from the root [InlineSpan]. + /// + /// This method should not be directly called. Use [getSpanForPosition] instead. + @protected + InlineSpan getSpanForPositionVisitor(TextPosition position, Accumulator offset); + + /// Flattens the [InlineSpan] tree into a single string. + /// + /// Styles are not honored in this process. If `includeSemanticsLabels` is + /// true, then the text returned will include the [TextSpan.semanticsLabel]s + /// instead of the text contents for [TextSpan]s. + /// + /// When `includePlaceholders` is true, [PlaceholderSpan]s in the tree will be + /// represented as a 0xFFFC 'object replacement character'. + String toPlainText({bool includeSemanticsLabels = true, bool includePlaceholders = true}) { + final StringBuffer buffer = StringBuffer(); + computeToPlainText(buffer, includeSemanticsLabels: includeSemanticsLabels, includePlaceholders: includePlaceholders); + return buffer.toString(); + } + + /// Walks the [InlineSpan] tree and writes the plain text representation to `buffer`. + /// + /// This method should not be directly called. Use [toPlainText] instead. + /// + /// Styles are not honored in this process. If `includeSemanticsLabels` is + /// true, then the text returned will include the [TextSpan.semanticsLabel]s + /// instead of the text contents for [TextSpan]s. + /// + /// When `includePlaceholders` is true, [PlaceholderSpan]s in the tree will be + /// represented as a 0xFFFC 'object replacement character'. + /// + /// The plain-text representation of this [InlineSpan] is written into the `buffer`. + /// This method will then recursively call [computeToPlainText] on its childen + /// [InlineSpan]s if available. + @protected + void computeToPlainText(StringBuffer buffer, {bool includeSemanticsLabels = true, bool includePlaceholders = true}); + + /// Returns the UTF-16 code unit at the given `index` in the flattened string. + /// + /// This only accounts for the [TextSpan.text] values and ignores [PlaceholderSpans]. + /// + /// Returns null if the `index` is out of bounds. + int codeUnitAt(int index) { + if (index < 0) + return null; + final Accumulator offset = Accumulator(); + int result; + visitChildren((InlineSpan span) { + result = span.codeUnitAtVisitor(index, offset); + return result == null; + }); + return result; + } + + /// Performs the check at each [InlineSpan] for if the `index` falls within the range + /// of the span and returns the corresponding code unit. Returns null otherwise. + /// + /// The `offset` parameter tracks the current index offset in the text buffer formed + /// if the contents of the [InlineSpan] tree were concatenated together starting + /// from the root [InlineSpan]. + /// + /// This method should not be directly called. Use [codeUnitAt] instead. + @protected + int codeUnitAtVisitor(int index, Accumulator offset); + + /// Populates the `semanticsOffsets` and `semanticsElements` with the appropriate data + /// to be able to construct a [SemanticsNode]. + /// + /// If applicable, the beginning and end text offset are added to [semanticsOffsets]. + /// [PlaceholderSpan]s have a text length of 1, which corresponds to the object + /// replacement character (0xFFFC) that is inserted to represent it. + /// + /// Any [GestureRecognizer]s are added to `semanticsElements`. Null is added to + /// `semanticsElements` for [PlaceholderSpan]s. + void describeSemantics(Accumulator offset, List semanticsOffsets, List semanticsElements); + + /// 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(myInlineSpan.debugAssertIsValid()); + /// ``` + bool debugAssertIsValid() => true; + + /// Describe the difference between this span and another, in terms of + /// how much damage it will make to the rendering. The comparison is deep. + /// + /// Comparing [InlineSpan] objects of different types, for example, comparing + /// a [TextSpan] to a [WidgetSpan], always results in [RenderComparison.layout]. + /// + /// See also: + /// + /// * [TextStyle.compareTo], which does the same thing for [TextStyle]s. + RenderComparison compareTo(InlineSpan other); + + @override + bool operator ==(dynamic other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + final InlineSpan typedOther = other; + return typedOther.style == style; + } + + @override + int get hashCode => style.hashCode; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.defaultDiagnosticsTreeStyle = DiagnosticsTreeStyle.whitespace; + + if (style != null) { + style.debugFillProperties(properties); + } + } +} diff --git a/packages/flutter/lib/src/painting/placeholder_span.dart b/packages/flutter/lib/src/painting/placeholder_span.dart new file mode 100644 index 00000000000..e052ea68255 --- /dev/null +++ b/packages/flutter/lib/src/painting/placeholder_span.dart @@ -0,0 +1,85 @@ +// 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 PlaceholderAlignment; + +import 'package:flutter/foundation.dart'; + +import 'basic_types.dart'; +import 'inline_span.dart'; +import 'text_painter.dart'; +import 'text_span.dart'; +import 'text_style.dart'; + +/// An immutable placeholder that is embedded inline within text. +/// +/// [PlaceholderSpan] represents a placeholder that acts as a stand-in for other +/// content. A [PlaceholderSpan] by itself does not contain useful +/// information to change a [TextSpan]. Instead, this class must be extended +/// to define contents. +/// +/// [WidgetSpan] from the widgets library extends [PlaceholderSpan] and may be +/// used instead to specify a widget as the contents of the placeholder. +/// +/// See also: +/// +/// * [WidgetSpan], a leaf node that represents an embedded inline widget. +/// * [TextSpan], a node that represents text in a [TextSpan] tree. +/// * [Text], a widget for showing uniformly-styled text. +/// * [RichText], a widget for finer control of text rendering. +/// * [TextPainter], a class for painting [TextSpan] objects on a [Canvas]. +abstract class PlaceholderSpan extends InlineSpan { + /// Creates a [PlaceholderSpan] with the given values. + /// + /// A [TextStyle] may be provided with the [style] property, but only the + /// decoration, foreground, background, and spacing options will be used. + const PlaceholderSpan({ + this.alignment = ui.PlaceholderAlignment.bottom, + this.baseline, + TextStyle style, + }) : super(style: style,); + + /// How the placeholder aligns vertically with the text. + /// + /// See [ui.PlaceholderAlignment] for details on each mode. + final ui.PlaceholderAlignment alignment; + + /// The [TextBaseline] to align against when using [ui.PlaceholderAlignment.baseline], + /// [ui.PlaceholderAlignment.aboveBaseline], and [ui.PlaceholderAlignment.belowBaseline]. + /// + /// This is ignored when using other alignment modes. + final TextBaseline baseline; + + /// [PlaceholderSpan]s are flattened to a `0xFFFC` object replacement character in the + /// plain text representation when `includePlaceholders` is true. + @override + void computeToPlainText(StringBuffer buffer, {bool includeSemanticsLabels = true, bool includePlaceholders = true}) { + if (includePlaceholders) { + buffer.write('\uFFFC'); + } + } + + /// Populates the `semanticsOffsets` and `semanticsElements` with the appropriate data + /// to be able to construct a [SemanticsNode]. + /// + /// [PlaceholderSpan]s have a text length of 1, which corresponds to the object + /// replacement character (0xFFFC) that is inserted to represent it. + /// + /// Null is added to `semanticsElements` for [PlaceholderSpan]s. + @override + void describeSemantics(Accumulator offset, List semanticsOffsets, List semanticsElements) { + semanticsOffsets.add(offset.value); + semanticsOffsets.add(offset.value + 1); + semanticsElements.add(null); // null indicates this is a placeholder. + offset.increment(1); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + + properties.add(EnumProperty('alignment', alignment, defaultValue: null)); + properties.add(EnumProperty('baseline', baseline, defaultValue: null)); + } +} diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index b4f20cb15a3..0c619592c94 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -3,18 +3,82 @@ // found in the LICENSE file. import 'dart:math' show min, max; -import 'dart:ui' as ui show Paragraph, ParagraphBuilder, ParagraphConstraints, ParagraphStyle; +import 'dart:ui' as ui show Paragraph, ParagraphBuilder, ParagraphConstraints, ParagraphStyle, PlaceholderAlignment; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'basic_types.dart'; +import 'inline_span.dart'; +import 'placeholder_span.dart'; import 'strut_style.dart'; import 'text_span.dart'; export 'package:flutter/services.dart' show TextRange, TextSelection; +/// Holds the [Size] and baseline required to represent the dimensions of +/// a placeholder in text. +/// +/// Placeholders specify an empty space in the text layout, which is used +/// to later render arbitrary inline widgets into defined by a [WidgetSpan]. +/// +/// The [size] and [alignment] properties are required and cannot be null. +/// +/// See also: +/// +/// * [WidgetSpan], a subclass of [InlineSpan] and [PlaceholderSpan] that +/// represents an inline widget embedded within text. The space this +/// widget takes is indicated by a placeholder. +/// * [RichText], a text widget that supports text inline widgets. +@immutable +class PlaceholderDimensions { + /// Constructs a [PlaceholderDimensions] with the specified parameters. + /// + /// The `size` and `alignment` are required as a placeholder's dimensions + /// require at least `size` and `alignment` to be fully defined. + const PlaceholderDimensions({ + @required this.size, + @required this.alignment, + this.baseline, + this.baselineOffset, + }) : assert(size != null), + assert(alignment != null); + + /// Width and height dimensions of the placeholder. + final Size size; + + /// How to align the placeholder with the text. + /// + /// See also: + /// + /// * [baseline], the baseline to align to when using + /// [ui.PlaceholderAlignment.baseline], + /// [ui.PlaceholderAlignment.aboveBaseline], + /// or [ui.PlaceholderAlignment.underBaseline]. + /// * [baselineOffset], the distance of the alphabetic baseline from the upper + /// edge of the placeholder. + final ui.PlaceholderAlignment alignment; + + /// Distance of the [baseline] from the upper edge of the placeholder. + /// + /// Only used when [alignment] is [ui.PlaceholderAlignment.baseline]. + final double baselineOffset; + + /// The [TextBaseline] to align to. Used with: + /// + /// * [ui.PlaceholderAlignment.baseline] + /// * [ui.PlaceholderAlignment.aboveBaseline] + /// * [ui.PlaceholderAlignment.underBaseline] + /// * [ui.PlaceholderAlignment.middle] + final TextBaseline baseline; + + @override + String toString() { + return 'PlaceholderDimensions($size, $baseline)'; + } +} + /// The different ways of considering the width of one or more lines of text. /// /// See [Text.widthType]. @@ -30,6 +94,9 @@ enum TextWidthBasis { longestLine, } +/// This is used to cache and pass the computed metrics regarding the +/// caret's size and position. This is preferred due to the expensive +/// nature of the calculation. class _CaretMetrics { const _CaretMetrics({this.offset, this.fullHeight}); /// The offset of the top left corner of the caret from the top left @@ -67,7 +134,7 @@ class TextPainter { /// /// The [maxLines] property, if non-null, must be greater than zero. TextPainter({ - TextSpan text, + InlineSpan text, TextAlign textAlign = TextAlign.start, TextDirection textDirection, double textScaleFactor = 1.0, @@ -99,9 +166,9 @@ class TextPainter { /// 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) { + InlineSpan get text => _text; + InlineSpan _text; + set text(InlineSpan value) { assert(value == null || value.debugAssertIsValid()); if (_text == value) return; @@ -266,6 +333,53 @@ class TextPainter { ui.Paragraph _layoutTemplate; + /// An ordered list of [TextBox]es that bound the positions of the placeholders + /// in the paragraph. + /// + /// Each box corresponds to a [PlaceholderSpan] in the order they were defined + /// in the [InlineSpan] tree. + List get inlinePlaceholderBoxes => _inlinePlaceholderBoxes; + List _inlinePlaceholderBoxes; + + /// An ordered list of scales for each placeholder in the paragraph. + /// + /// The scale is used as a multiplier on the height, width and baselineOffset of + /// the placeholder. Scale is primarily used to handle accessibility scaling. + /// + /// Each scale corresponds to a [PlaceholderSpan] in the order they were defined + /// in the [InlineSpan] tree. + List get inlinePlaceholderScales => _inlinePlaceholderScales; + List _inlinePlaceholderScales; + + /// Sets the dimensions of each placeholder in [text]. + /// + /// The number of [PlaceholderDimensions] provided should be the same as the + /// number of [PlaceholderSpan]s in text. Passing in an empty or null `value` + /// will do nothing. + /// + /// If [layout] is attempted without setting the placeholder dimensions, the + /// placeholders will be ignored in the text layout and no valid + /// [inlinePlaceholderBoxes] will be returned. + void setPlaceholderDimensions(List value) { + if (value == null || value.isEmpty || listEquals(value, _placeholderDimensions)) { + return; + } + assert(() { + int placeholderCount = 0; + text.visitChildren((InlineSpan span) { + if (span is PlaceholderSpan) { + placeholderCount += 1; + } + return true; + }); + return placeholderCount; + }() == value.length); + _placeholderDimensions = value; + _needsLayout = true; + _paragraph = null; + } + List _placeholderDimensions; + ui.ParagraphStyle _createParagraphStyle([ TextDirection defaultTextDirection ]) { // The defaultTextDirection argument is used for preferredLineHeight in case // textDirection hasn't yet been set. @@ -419,7 +533,8 @@ class TextPainter { _needsLayout = false; if (_paragraph == null) { final ui.ParagraphBuilder builder = ui.ParagraphBuilder(_createParagraphStyle()); - _text.build(builder, textScaleFactor: textScaleFactor); + _text.build(builder, textScaleFactor: textScaleFactor, dimensions: _placeholderDimensions); + _inlinePlaceholderScales = builder.placeholderScales; _paragraph = builder.build(); } _lastMinWidth = minWidth; @@ -427,9 +542,11 @@ class TextPainter { _paragraph.layout(ui.ParagraphConstraints(width: maxWidth)); if (minWidth != maxWidth) { final double newWidth = maxIntrinsicWidth.clamp(minWidth, maxWidth); - if (newWidth != width) + if (newWidth != width) { _paragraph.layout(ui.ParagraphConstraints(width: newWidth)); + } } + _inlinePlaceholderBoxes = _paragraph.getBoxesForPlaceholders(); } /// Paints the text onto the given canvas at the given offset. @@ -491,7 +608,7 @@ class TextPainter { // TODO(garyq): Use actual extended grapheme cluster length instead of // an increasing cluster length amount to achieve deterministic performance. Rect _getRectFromUpstream(int offset, Rect caretPrototype) { - final String flattenedText = _text.toPlainText(); + final String flattenedText = _text.toPlainText(includePlaceholders: false); final int prevCodeUnit = _text.codeUnitAt(max(0, offset - 1)); if (prevCodeUnit == null) return null; @@ -507,10 +624,12 @@ class TextPainter { 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) + if (!needsSearch) { break; // Only perform one iteration if no search is required. - if (prevRuneOffset < -flattenedText.length) + } + 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 @@ -538,7 +657,7 @@ class TextPainter { // TODO(garyq): Use actual extended grapheme cluster length instead of // an increasing cluster length amount to achieve deterministic performance. Rect _getRectFromDownstream(int offset, Rect caretPrototype) { - final String flattenedText = _text.toPlainText(); + final String flattenedText = _text.toPlainText(includePlaceholders: false); // 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) @@ -554,10 +673,12 @@ class TextPainter { 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) + if (!needsSearch) { break; // Only perform one iteration if no search is required. - if (nextRuneOffset >= flattenedText.length << 1) + } + 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 diff --git a/packages/flutter/lib/src/painting/text_span.dart b/packages/flutter/lib/src/painting/text_span.dart index bba61a7d093..31baf09f602 100644 --- a/packages/flutter/lib/src/painting/text_span.dart +++ b/packages/flutter/lib/src/painting/text_span.dart @@ -9,6 +9,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'basic_types.dart'; +import 'inline_span.dart'; +import 'text_painter.dart'; import 'text_style.dart'; /// An immutable span of text. @@ -21,7 +23,9 @@ import 'text_style.dart'; /// 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. +/// [children] list. Leaving the [TextSpan.text] field null results +/// in the [TextSpan] acting as an empty node in the [InlineSpan] +/// tree with a list of children. /// /// To paint a [TextSpan] on a [Canvas], use a [TextPainter]. To display a text /// span in a widget, use a [RichText]. For text with a single style, consider @@ -42,27 +46,33 @@ import 'text_style.dart'; /// _There is some more detailed sample code in the documentation for the /// [recognizer] property._ /// +/// The [TextSpan.text] will be used as the semantics label unless overriden +/// by the [TextSpan.semanticsLabel] property. Any [PlaceholderSpan]s in the +/// [TextSpan.children] list will separate the text before and after it into +/// two semantics nodes. +/// /// See also: /// +/// * [WidgetSpan], a leaf node that represents an embedded inline widget +/// in an [InlineSpan] tree. Specify a widget within the [children] +/// list by wrapping the widget with a [WidgetSpan]. The widget will be +/// laid out inline within the paragraph. /// * [Text], a widget for showing uniformly-styled text. /// * [RichText], a widget for finer control of text rendering. /// * [TextPainter], a class for painting [TextSpan] objects on a [Canvas]. @immutable -class TextSpan extends DiagnosticableTree { +class TextSpan extends InlineSpan { /// Creates a [TextSpan] with the given values. /// /// For the object to be useful, at least one of [text] or /// [children] should be set. const TextSpan({ - this.style, this.text, this.children, + TextStyle style, this.recognizer, this.semanticsLabel, - }); - - /// The style to apply to the [text] and the [children]. - final TextStyle style; + }) : super(style: style,); /// The text contained in the span. /// @@ -79,26 +89,26 @@ class TextSpan extends DiagnosticableTree { /// supported and may have unexpected results. /// /// The list must not contain any nulls. - final List children; + final List children; - /// A gesture recognizer that will receive events that hit this text span. + /// A gesture recognizer that will receive events that hit this span. /// - /// [TextSpan] itself does not implement hit testing or event dispatch. The - /// object that manages the [TextSpan] painting is also responsible for + /// [InlineSpan] itself does not implement hit testing or event dispatch. The + /// object that manages the [InlineSpan] painting is also responsible for /// dispatching events. In the rendering library, that is the /// [RenderParagraph] object, which corresponds to the [RichText] widget in - /// the widgets layer; these objects do not bubble events in [TextSpan]s, so a + /// the widgets layer; these objects do not bubble events in [InlineSpan]s, so a /// [recognizer] is only effective for events that directly hit the [text] of - /// that [TextSpan], not any of its [children]. + /// that [InlineSpan], not any of its [children]. /// - /// [TextSpan] also does not manage the lifetime of the gesture recognizer. + /// [InlineSpan] also does not manage the lifetime of the gesture recognizer. /// The code that owns the [GestureRecognizer] object must call - /// [GestureRecognizer.dispose] when the [TextSpan] object is no longer used. + /// [GestureRecognizer.dispose] when the [InlineSpan] object is no longer used. /// /// {@tool sample} /// /// This example shows how to manage the lifetime of a gesture recognizer - /// provided to a [TextSpan] object. It defines a `BuzzingText` widget which + /// provided to an [InlineSpan] object. It defines a `BuzzingText` widget which /// uses the [HapticFeedback] class to vibrate the device when the user /// long-presses the "find the" span, which is underlined in wavy green. The /// hit-testing is handled by the [RichText] widget. @@ -131,11 +141,11 @@ class TextSpan extends DiagnosticableTree { /// /// @override /// Widget build(BuildContext context) { - /// return RichText( - /// text: TextSpan( + /// return Text.rich( + /// TextSpan( /// text: 'Can you ', /// style: TextStyle(color: Colors.black), - /// children: [ + /// children: [ /// TextSpan( /// text: 'find the', /// style: TextStyle( @@ -157,7 +167,7 @@ class TextSpan extends DiagnosticableTree { /// {@end-tool} final GestureRecognizer recognizer; - /// An alternative semantics label for this text. + /// An alternative semantics label for this [TextSpan]. /// /// If present, the semantics of this span will contain this value instead /// of the actual text. @@ -177,7 +187,8 @@ class TextSpan extends DiagnosticableTree { /// Rather than using this directly, it's simpler to use the /// [TextPainter] class to paint [TextSpan] objects onto [Canvas] /// objects. - void build(ui.ParagraphBuilder builder, { double textScaleFactor = 1.0 }) { + @override + void build(ui.ParagraphBuilder builder, { double textScaleFactor = 1.0, List dimensions }) { assert(debugAssertIsValid()); final bool hasStyle = style != null; if (hasStyle) @@ -185,9 +196,9 @@ class TextSpan extends DiagnosticableTree { if (text != null) builder.addText(text); if (children != null) { - for (TextSpan child in children) { + for (InlineSpan child in children) { assert(child != null); - child.build(builder, textScaleFactor: textScaleFactor); + child.build(builder, textScaleFactor: textScaleFactor, dimensions: dimensions); } } if (hasStyle) @@ -196,14 +207,15 @@ class TextSpan extends DiagnosticableTree { /// Walks this text span and its descendants in pre-order and calls [visitor] /// for each span that has text. - bool visitTextSpan(bool visitor(TextSpan span)) { + @override + bool visitChildren(InlineSpanVisitor visitor) { if (text != null) { if (!visitor(this)) return false; } if (children != null) { - for (TextSpan child in children) { - if (!child.visitTextSpan(visitor)) + for (InlineSpan child in children) { + if (!child.visitChildren(visitor)) return false; } } @@ -211,63 +223,62 @@ class TextSpan extends DiagnosticableTree { } /// Returns the text span that contains the given position in the text. - TextSpan getSpanForPosition(TextPosition position) { - assert(debugAssertIsValid()); + @override + InlineSpan getSpanForPositionVisitor(TextPosition position, Accumulator offset) { + if (text == null) { + return null; + } final TextAffinity affinity = position.affinity; final int targetOffset = position.offset; - int offset = 0; - TextSpan result; - visitTextSpan((TextSpan span) { - assert(result == null); - final 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; + final int endOffset = offset.value + text.length; + if (offset.value == targetOffset && affinity == TextAffinity.downstream || + offset.value < targetOffset && targetOffset < endOffset || + endOffset == targetOffset && affinity == TextAffinity.upstream) { + return this; + } + offset.increment(text.length); + return null; } - /// Flattens the [TextSpan] tree into a single string. - /// - /// Styles are not honored in this process. If `includeSemanticsLabels` is - /// true, then the text returned will include the [semanticsLabel]s instead of - /// the text contents when they are present. - String toPlainText({bool includeSemanticsLabels = true}) { + @override + void computeToPlainText(StringBuffer buffer, {bool includeSemanticsLabels = true, bool includePlaceholders = true}) { assert(debugAssertIsValid()); - final StringBuffer buffer = StringBuffer(); - visitTextSpan((TextSpan span) { - if (span.semanticsLabel != null && includeSemanticsLabels) { - buffer.write(span.semanticsLabel); - } else { - buffer.write(span.text); + if (semanticsLabel != null && includeSemanticsLabels) { + buffer.write(semanticsLabel); + } else if (text != null) { + buffer.write(text); + } + if (children != null) { + for (InlineSpan child in children) { + child.computeToPlainText(buffer, + includeSemanticsLabels: includeSemanticsLabels, + includePlaceholders: includePlaceholders, + ); } - return true; - }); - return buffer.toString(); + } } - /// Returns the UTF-16 code unit at the given index in the flattened string. - /// - /// Returns null if the index is out of bounds. - int codeUnitAt(int index) { - if (index < 0) + @override + int codeUnitAtVisitor(int index, Accumulator offset) { + if (text == null) { return null; - int offset = 0; - int result; - visitTextSpan((TextSpan span) { - if (index - offset < span.text.length) { - result = span.text.codeUnitAt(index - offset); - return false; - } - offset += span.text.length; - return true; - }); - return result; + } + if (index - offset.value < text.length) { + return text.codeUnitAt(index - offset.value); + } + offset.increment(text.length); + return null; + } + + @override + void describeSemantics(Accumulator offset, List semanticsOffsets, List semanticsElements) { + if (recognizer != null && (recognizer is TapGestureRecognizer || recognizer is LongPressGestureRecognizer)) { + final int length = semanticsLabel?.length ?? text.length; + semanticsOffsets.add(offset.value); + semanticsOffsets.add(offset.value + length); + semanticsElements.add(recognizer); + } + offset.increment(text != null ? text.length : 0); } /// In checked mode, throws an exception if the object is not in a @@ -278,45 +289,39 @@ class TextSpan extends DiagnosticableTree { /// ```dart /// assert(myTextSpan.debugAssertIsValid()); /// ``` + @override bool debugAssertIsValid() { assert(() { - if (!visitTextSpan((TextSpan span) { - if (span.children != null) { - for (TextSpan child in span.children) { - if (child == null) - return false; - } + if (children != null) { + for (InlineSpan child in children) { + assert(child != null, + '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' + '${toStringDeep(prefixLineOne: ' ')}' + ); + assert(child.debugAssertIsValid()); } - return true; - })) { - throw 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' - '${toStringDeep(prefixLineOne: ' ')}' - ); } return true; }()); - return true; + return super.debugAssertIsValid(); } - /// Describe the difference between this text span and another, in terms of - /// how much damage it will make to the rendering. The comparison is deep. - /// - /// See also: - /// - /// * [TextStyle.compareTo], which does the same thing for [TextStyle]s. - RenderComparison compareTo(TextSpan other) { + @override + RenderComparison compareTo(InlineSpan other) { if (identical(this, other)) return RenderComparison.identical; - if (other.text != text || - children?.length != other.children?.length || - (style == null) != (other.style == null)) + if (other.runtimeType != runtimeType) return RenderComparison.layout; - RenderComparison result = recognizer == other.recognizer ? RenderComparison.identical : RenderComparison.metadata; + final TextSpan textSpan = other; + if (textSpan.text != text || + children?.length != textSpan.children?.length || + (style == null) != (textSpan.style == null)) + return RenderComparison.layout; + RenderComparison result = recognizer == textSpan.recognizer ? RenderComparison.identical : RenderComparison.metadata; if (style != null) { - final RenderComparison candidate = style.compareTo(other.style); + final RenderComparison candidate = style.compareTo(textSpan.style); if (candidate.index > result.index) result = candidate; if (result == RenderComparison.layout) @@ -324,7 +329,7 @@ class TextSpan extends DiagnosticableTree { } if (children != null) { for (int index = 0; index < children.length; index += 1) { - final RenderComparison candidate = children[index].compareTo(other.children[index]); + final RenderComparison candidate = children[index].compareTo(textSpan.children[index]); if (candidate.index > result.index) result = candidate; if (result == RenderComparison.layout) @@ -340,16 +345,17 @@ class TextSpan extends DiagnosticableTree { return true; if (other.runtimeType != runtimeType) return false; + if (super != other) + return false; final TextSpan typedOther = other; return typedOther.text == text - && typedOther.style == style && typedOther.recognizer == recognizer && typedOther.semanticsLabel == semanticsLabel - && listEquals(typedOther.children, children); + && listEquals(typedOther.children, children); } @override - int get hashCode => hashValues(style, text, recognizer, semanticsLabel, hashList(children)); + int get hashCode => hashValues(super.hashCode, text, recognizer, semanticsLabel, hashList(children)); @override String toStringShort() => '$runtimeType'; @@ -357,11 +363,10 @@ class TextSpan extends DiagnosticableTree { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.defaultDiagnosticsTreeStyle = DiagnosticsTreeStyle.whitespace; - // Properties on style are added as if they were properties directly on - // this TextSpan. - if (style != null) - style.debugFillProperties(properties); + + properties.add(StringProperty('text', text, showName: false, defaultValue: null)); + if (style == null && text == null && children == null) + properties.add(DiagnosticsNode.message('(empty)')); properties.add(DiagnosticsProperty( 'recognizer', recognizer, @@ -369,22 +374,16 @@ class TextSpan extends DiagnosticableTree { defaultValue: null, )); - if (semanticsLabel != null) { properties.add(StringProperty('semanticsLabel', semanticsLabel)); } - - - properties.add(StringProperty('text', text, showName: false, defaultValue: null)); - if (style == null && text == null && children == null) - properties.add(DiagnosticsNode.message('(empty)')); } @override List debugDescribeChildren() { if (children == null) return const []; - return children.map((TextSpan child) { + return children.map((InlineSpan child) { if (child != null) { return child.toDiagnosticsNode(); } else { diff --git a/packages/flutter/lib/src/rendering/box.dart b/packages/flutter/lib/src/rendering/box.dart index 0b829471f31..bcff74ce8f7 100644 --- a/packages/flutter/lib/src/rendering/box.dart +++ b/packages/flutter/lib/src/rendering/box.dart @@ -1728,7 +1728,10 @@ abstract class RenderBox extends RenderObject { return true; }()); _size = value; - assert(() { debugAssertDoesMeetConstraints(); return true; }()); + assert(() { + debugAssertDoesMeetConstraints(); + return true; + }()); } /// Claims ownership of the given [Size]. diff --git a/packages/flutter/lib/src/rendering/debug_overflow_indicator.dart b/packages/flutter/lib/src/rendering/debug_overflow_indicator.dart index d7a0d6d0f25..be8790a31da 100644 --- a/packages/flutter/lib/src/rendering/debug_overflow_indicator.dart +++ b/packages/flutter/lib/src/rendering/debug_overflow_indicator.dart @@ -284,8 +284,8 @@ mixin DebugOverflowIndicatorMixin on RenderObject { final List<_OverflowRegionData> overflowRegions = _calculateOverflowRegions(overflow, containerRect); for (_OverflowRegionData region in overflowRegions) { context.canvas.drawRect(region.rect.shift(offset), _indicatorPaint); - - if (_indicatorLabel[region.side.index].text?.text != region.label) { + final TextSpan textSpan = _indicatorLabel[region.side.index].text; + if (textSpan?.text != region.label) { _indicatorLabel[region.side.index].text = TextSpan( text: region.label, style: _indicatorTextStyle, diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index 151f0adfe32..b0f44bb2ef3 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -2,7 +2,7 @@ // 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 'dart:ui' as ui show Gradient, Shader, TextBox, PlaceholderAlignment; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -10,6 +10,7 @@ import 'package:flutter/painting.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'box.dart'; import 'debug.dart'; @@ -35,8 +36,27 @@ enum TextOverflow { const String _kEllipsis = '\u2026'; -/// A render object that displays a paragraph of text -class RenderParagraph extends RenderBox { +/// Parent data for use with [RenderParagraph]. +class TextParentData extends ContainerBoxParentData { + /// The scaling of the text. + double scale; + + @override + String toString() { + final List values = []; + if (offset != null) + values.add('offset=$offset'); + if (scale != null) + values.add('scale=$scale'); + values.add(super.toString()); + return values.join('; '); + } +} + +/// A render object that displays a paragraph of text. +class RenderParagraph extends RenderBox + with ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { /// Creates a paragraph render object. /// /// The [text], [textAlign], [textDirection], [overflow], [softWrap], and @@ -44,8 +64,7 @@ class RenderParagraph extends RenderBox { /// /// 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, { + RenderParagraph(InlineSpan text, { TextAlign textAlign = TextAlign.start, @required TextDirection textDirection, bool softWrap = true, @@ -55,6 +74,7 @@ class RenderParagraph extends RenderBox { TextWidthBasis textWidthBasis = TextWidthBasis.parent, Locale locale, StrutStyle strutStyle, + List children, }) : assert(text != null), assert(text.debugAssertIsValid()), assert(textAlign != null), @@ -76,13 +96,22 @@ class RenderParagraph extends RenderBox { locale: locale, strutStyle: strutStyle, textWidthBasis: textWidthBasis, - ); + ) { + addAll(children); + _extractPlaceholderSpans(text); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! TextParentData) + child.parentData = TextParentData(); + } final TextPainter _textPainter; /// The text to display - TextSpan get text => _textPainter.text; - set text(TextSpan value) { + InlineSpan get text => _textPainter.text; + set text(InlineSpan value) { assert(value != null); switch (_textPainter.text.compareTo(value)) { case RenderComparison.identical: @@ -90,17 +119,31 @@ class RenderParagraph extends RenderBox { return; case RenderComparison.paint: _textPainter.text = value; + _extractPlaceholderSpans(value); markNeedsPaint(); markNeedsSemanticsUpdate(); break; case RenderComparison.layout: _textPainter.text = value; _overflowShader = null; + _extractPlaceholderSpans(value); markNeedsLayout(); break; } } + List _placeholderSpans; + void _extractPlaceholderSpans(InlineSpan span) { + _placeholderSpans = []; + span.visitChildren((InlineSpan span) { + if (span is PlaceholderSpan) { + final PlaceholderSpan placeholderSpan = span; + _placeholderSpans.add(placeholderSpan); + } + return true; + }); + } + /// How the text should be aligned horizontally. TextAlign get textAlign => _textPainter.textAlign; set textAlign(TextAlign value) { @@ -229,28 +272,31 @@ class RenderParagraph extends RenderBox { 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(); + if (!_canComputeIntrinsics()) { + return 0.0; + } + _computeChildrenWidthWithMinIntrinsics(height); + _layoutText(); // layout with infinite width. return _textPainter.minIntrinsicWidth; } @override double computeMaxIntrinsicWidth(double height) { - _layoutText(); + if (!_canComputeIntrinsics()) { + return 0.0; + } + _computeChildrenWidthWithMaxIntrinsics(height); + _layoutText(); // layout with infinite width. return _textPainter.maxIntrinsicWidth; } double _computeIntrinsicHeight(double width) { + if (!_canComputeIntrinsics()) { + return 0.0; + } + _computeChildrenHeightWithMinIntrinsics(width); _layoutText(minWidth: width, maxWidth: width); return _textPainter.height; } @@ -274,9 +320,114 @@ class RenderParagraph extends RenderBox { return _textPainter.computeDistanceToActualBaseline(baseline); } + // Intrinsics cannot be calculated without a full layout for + // alignments that require the baseline (baseline, aboveBaseline, + // belowBaseline). + bool _canComputeIntrinsics() { + for (PlaceholderSpan span in _placeholderSpans) { + switch (span.alignment) { + case ui.PlaceholderAlignment.baseline: + case ui.PlaceholderAlignment.aboveBaseline: + case ui.PlaceholderAlignment.belowBaseline: { + assert(RenderObject.debugCheckingIntrinsics, + 'Intrinsics are not available for PlaceholderAlignment.baseline, ' + 'PlaceholderAlignment.aboveBaseline, or PlaceholderAlignment.belowBaseline,'); + return false; + } + case ui.PlaceholderAlignment.top: + case ui.PlaceholderAlignment.middle: + case ui.PlaceholderAlignment.bottom: { + continue; + } + } + } + return true; + } + + void _computeChildrenWidthWithMaxIntrinsics(double height) { + RenderBox child = firstChild; + final List placeholderDimensions = List(childCount); + int childIndex = 0; + while (child != null) { + // Height and baseline is irrelevant as all text will be laid + // out in a single line. + placeholderDimensions[childIndex] = PlaceholderDimensions( + size: Size(child.getMaxIntrinsicWidth(height), height), + alignment: _placeholderSpans[childIndex].alignment, + baseline: _placeholderSpans[childIndex].baseline, + ); + child = childAfter(child); + childIndex += 1; + } + _textPainter.setPlaceholderDimensions(placeholderDimensions); + } + + void _computeChildrenWidthWithMinIntrinsics(double height) { + RenderBox child = firstChild; + final List placeholderDimensions = List(childCount); + int childIndex = 0; + while (child != null) { + final double intrinsicWidth = child.getMinIntrinsicWidth(height); + final double intrinsicHeight = child.getMinIntrinsicHeight(intrinsicWidth); + placeholderDimensions[childIndex] = PlaceholderDimensions( + size: Size(intrinsicWidth, intrinsicHeight), + alignment: _placeholderSpans[childIndex].alignment, + baseline: _placeholderSpans[childIndex].baseline, + ); + child = childAfter(child); + childIndex += 1; + } + _textPainter.setPlaceholderDimensions(placeholderDimensions); + } + + void _computeChildrenHeightWithMinIntrinsics(double width) { + RenderBox child = firstChild; + final List placeholderDimensions = List(childCount); + int childIndex = 0; + while (child != null) { + final double intrinsicHeight = child.getMinIntrinsicHeight(width); + final double intrinsicWidth = child.getMinIntrinsicWidth(intrinsicHeight); + placeholderDimensions[childIndex] = PlaceholderDimensions( + size: Size(intrinsicWidth, intrinsicHeight), + alignment: _placeholderSpans[childIndex].alignment, + baseline: _placeholderSpans[childIndex].baseline, + ); + child = childAfter(child); + childIndex += 1; + } + _textPainter.setPlaceholderDimensions(placeholderDimensions); + } + @override bool hitTestSelf(Offset position) => true; + @override + bool hitTestChildren(BoxHitTestResult result, { Offset position }) { + RenderBox child = firstChild; + while (child != null) { + final TextParentData textParentData = child.parentData; + final Matrix4 transform = Matrix4.translationValues(textParentData.offset.dx, textParentData.offset.dy, 0.0) + ..scale(textParentData.scale, textParentData.scale, textParentData.scale); + final bool isHit = result.addWithPaintTransform( + transform: transform, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(() { + final Offset manualPosition = (position - textParentData.offset) / textParentData.scale; + return (transformed.dx - manualPosition.dx).abs() < precisionErrorTolerance + && (transformed.dy - manualPosition.dy).abs() < precisionErrorTolerance; + }()); + return child.hitTest(result, position: transformed); + }, + ); + if (isHit) { + return true; + } + child = childAfter(child); + } + return false; + } + @override void handleEvent(PointerEvent event, BoxHitTestEntry entry) { assert(debugHandleEvent(event, entry)); @@ -299,9 +450,81 @@ class RenderParagraph extends RenderBox { @visibleForTesting bool get debugHasOverflowShader => _overflowShader != null; + 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); + } + + // Layout the child inline widgets. We then pass the dimensions of the + // children to _textPainter so that appropriate placeholders can be inserted + // into the LibTxt layout. This does not do anything if no inline widgets were + // specified. + void _layoutChildren(BoxConstraints constraints) { + if (childCount == 0) { + return; + } + RenderBox child = firstChild; + final List placeholderDimensions = List(childCount); + int childIndex = 0; + while (child != null) { + // Only constrain the width to the maximum width of the paragraph. + // Leave height unconstrained, which will overflow if expanded past. + child.layout( + BoxConstraints( + maxWidth: constraints.maxWidth, + ), + parentUsesSize: true + ); + double baselineOffset; + switch (_placeholderSpans[childIndex].alignment) { + case ui.PlaceholderAlignment.baseline: { + baselineOffset = child.getDistanceToBaseline(_placeholderSpans[childIndex].baseline); + break; + } + default: { + baselineOffset = null; + break; + } + } + placeholderDimensions[childIndex] = PlaceholderDimensions( + size: child.size, + alignment: _placeholderSpans[childIndex].alignment, + baseline: _placeholderSpans[childIndex].baseline, + baselineOffset: baselineOffset, + ); + child = childAfter(child); + childIndex += 1; + } + _textPainter.setPlaceholderDimensions(placeholderDimensions); + } + + // Iterate through the laid-out children and set the parentData offsets based + // off of the placeholders inserted for each child. + void _setParentData() { + RenderBox child = firstChild; + int childIndex = 0; + while (child != null) { + final TextParentData textParentData = child.parentData; + textParentData.offset = Offset( + _textPainter.inlinePlaceholderBoxes[childIndex].left, + _textPainter.inlinePlaceholderBoxes[childIndex].top + ); + textParentData.scale = _textPainter.inlinePlaceholderScales[childIndex]; + child = childAfter(child); + childIndex += 1; + } + } + @override void performLayout() { + _layoutChildren(constraints); _layoutTextWithConstraints(constraints); + _setParentData(); + // 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 @@ -386,13 +609,12 @@ class RenderParagraph extends RenderBox { // 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); + context.canvas.drawRect(offset & size, paint); } return true; }()); @@ -402,22 +624,44 @@ class RenderParagraph extends RenderBox { 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()); + context.canvas.saveLayer(bounds, Paint()); } else { - canvas.save(); + context.canvas.save(); } - canvas.clipRect(bounds); + context.canvas.clipRect(bounds); + } + _textPainter.paint(context.canvas, offset); + + RenderBox child = firstChild; + int childIndex = 0; + while (child != null) { + assert(childIndex < _textPainter.inlinePlaceholderBoxes.length); + final TextParentData textParentData = child.parentData; + + final double scale = textParentData.scale; + context.pushTransform( + needsCompositing, + offset + textParentData.offset, + Matrix4.diagonal3Values(scale, scale, scale), + (PaintingContext context, Offset offset) { + context.paintChild( + child, + offset, + ); + }, + ); + child = childAfter(child); + childIndex += 1; } - _textPainter.paint(canvas, offset); if (_needsClipping) { if (_overflowShader != null) { - canvas.translate(offset.dx, offset.dy); + context.canvas.translate(offset.dx, offset.dy); final Paint paint = Paint() ..blendMode = BlendMode.modulate ..shader = _overflowShader; - canvas.drawRect(Offset.zero & size, paint); + context.canvas.drawRect(Offset.zero & size, paint); } - canvas.restore(); + context.canvas.restore(); } } @@ -481,26 +725,23 @@ class RenderParagraph extends RenderBox { return _textPainter.size; } - final List _recognizerOffsets = []; - final List _recognizers = []; + // The offsets for each span that requires custom semantics. + final List _inlineSemanticsOffsets = []; + // Holds either [GestureRecognizer] or null (for placeholders) to generate + // proper semnatics configurations. + final List _inlineSemanticsElements = []; @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; + _inlineSemanticsOffsets.clear(); + _inlineSemanticsElements.clear(); + final Accumulator offset = Accumulator(); + text.visitChildren((InlineSpan span) { + span.describeSemantics(offset, _inlineSemanticsOffsets, _inlineSemanticsElements); return true; }); - if (_recognizerOffsets.isNotEmpty) { + if (_inlineSemanticsOffsets.isNotEmpty) { config.explicitChildNodes = true; config.isSemanticBoundary = true; } else { @@ -511,10 +752,9 @@ class RenderParagraph extends RenderBox { @override void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable children) { - assert(_recognizerOffsets.isNotEmpty); - assert(_recognizerOffsets.length.isEven); - assert(_recognizers.isNotEmpty); - assert(children.isEmpty); + assert(_inlineSemanticsOffsets.isNotEmpty); + assert(_inlineSemanticsOffsets.length.isEven); + assert(_inlineSemanticsElements.isNotEmpty); final List newChildren = []; final String rawLabel = text.toPlainText(); int current = 0; @@ -522,7 +762,7 @@ class RenderParagraph extends RenderBox { TextDirection currentDirection = textDirection; Rect currentRect; - SemanticsConfiguration buildSemanticsConfig(int start, int end) { + SemanticsConfiguration buildSemanticsConfig(int start, int end, { bool includeText = true }) { final TextDirection initialDirection = currentDirection; final TextSelection selection = TextSelection(baseOffset: start, extentOffset: end); final List rects = getBoxesForSelection(selection); @@ -542,15 +782,21 @@ class RenderParagraph extends RenderBox { rect.bottom.ceilToDouble() + 4.0, ); order += 1; - return SemanticsConfiguration() + final SemanticsConfiguration configuration = SemanticsConfiguration() ..sortKey = OrdinalSortKey(order) - ..textDirection = initialDirection - ..label = rawLabel.substring(start, end); + ..textDirection = initialDirection; + if (includeText) { + configuration.label = rawLabel.substring(start, end); + } + return configuration; } - for (int i = 0, j = 0; i < _recognizerOffsets.length; i += 2, j++) { - final int start = _recognizerOffsets[i]; - final int end = _recognizerOffsets[i + 1]; + int childIndex = 0; + RenderBox child = firstChild; + for (int i = 0, j = 0; i < _inlineSemanticsOffsets.length; i += 2, j++) { + final int start = _inlineSemanticsOffsets[i]; + final int end = _inlineSemanticsOffsets[i + 1]; + // Add semantics for any text between the previous recognizer/widget and this one. if (current != start) { final SemanticsNode node = SemanticsNode(); final SemanticsConfiguration configuration = buildSemanticsConfig(current, start); @@ -558,19 +804,38 @@ class RenderParagraph extends RenderBox { 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); + final dynamic inlineElement = _inlineSemanticsElements[j]; + final SemanticsConfiguration configuration = buildSemanticsConfig(start, end, includeText: false); + if (inlineElement != null) { + // Add semantics for this recognizer. + final SemanticsNode node = SemanticsNode(); + if (inlineElement is TapGestureRecognizer) { + final TapGestureRecognizer recognizer = inlineElement; + configuration.onTap = recognizer.onTap; + } else if (inlineElement is LongPressGestureRecognizer) { + final LongPressGestureRecognizer recognizer = inlineElement; + configuration.onLongPress = recognizer.onLongPress; + } else { + assert(false); + } + node.updateWith(config: configuration); + node.rect = currentRect; + newChildren.add(node); + } else if (childIndex < children.length) { + // Add semantics for this placeholder. Semantics are precomputed in the children + // argument. + final SemanticsNode childNode = children.elementAt(childIndex); + final TextParentData parentData = child.parentData; + childNode.rect = Rect.fromLTWH( + childNode.rect.left, + childNode.rect.top, + childNode.rect.width * parentData.scale, + childNode.rect.height * parentData.scale, + ); + newChildren.add(children.elementAt(childIndex)); + childIndex += 1; + child = childAfter(child); } - node.updateWith(config: configuration); - node.rect = currentRect; - newChildren.add(node); current = end; } if (current < rawLabel.length) { diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index e627b3ea91b..596b883d057 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -12,6 +12,7 @@ import 'package:flutter/services.dart'; import 'debug.dart'; import 'framework.dart'; import 'localizations.dart'; +import 'widget_span.dart'; export 'package:flutter/animation.dart'; export 'package:flutter/foundation.dart' show @@ -4913,7 +4914,9 @@ class Flow extends MultiChildRenderObjectWidget { /// * [TextSpan], which is used to describe the text in a paragraph. /// * [Text], which automatically applies the ambient styles described by a /// [DefaultTextStyle] to a single string. -class RichText extends LeafRenderObjectWidget { +/// * [Text.rich], a const text widget that provides similar functionality +/// as [RichText]. [Text.rich] will inherit [TextStyle] from [DefaultTextStyle]. +class RichText extends MultiChildRenderObjectWidget { /// Creates a paragraph of rich text. /// /// The [text], [textAlign], [softWrap], [overflow], and [textScaleFactor] @@ -4924,7 +4927,7 @@ class RichText extends LeafRenderObjectWidget { /// /// The [textDirection], if null, defaults to the ambient [Directionality], /// which in that case must not be null. - const RichText({ + RichText({ Key key, @required this.text, this.textAlign = TextAlign.start, @@ -4943,10 +4946,23 @@ class RichText extends LeafRenderObjectWidget { assert(textScaleFactor != null), assert(maxLines == null || maxLines > 0), assert(textWidthBasis != null), - super(key: key); + super(key: key, children: _extractChildren(text)); + + // Traverses the InlineSpan tree and depth-first collects the list of + // child widgets that are created in WidgetSpans. + static List _extractChildren(InlineSpan span) { + final List result = []; + span.visitChildren((InlineSpan span) { + if (span is WidgetSpan) { + result.add(span.child); + } + return true; + }); + return result; + } /// The text to display in this widget. - final TextSpan text; + final InlineSpan text; /// How the text should be aligned horizontally. final TextAlign textAlign; diff --git a/packages/flutter/lib/src/widgets/text.dart b/packages/flutter/lib/src/widgets/text.dart index ee3233f8daa..18637dd8e99 100644 --- a/packages/flutter/lib/src/widgets/text.dart +++ b/packages/flutter/lib/src/widgets/text.dart @@ -256,9 +256,16 @@ class Text extends StatelessWidget { textSpan = null, super(key: key); - /// Creates a text widget with a [TextSpan]. + /// Creates a text widget with a [InlineSpan]. + /// + /// The following subclasses of [InlineSpan] may be used to build rich text: + /// + /// * [TextSpan]s define text and children [InlineSpan]s. + /// * [WidgetSpan]s define embedded inline widgets. /// /// The [textSpan] parameter must not be null. + /// + /// See [RichText] which provides a lower-level way to draw text. const Text.rich( this.textSpan, { Key key, @@ -285,10 +292,10 @@ class Text extends StatelessWidget { /// This will be null if a [textSpan] is provided instead. final String data; - /// The text to display as a [TextSpan]. + /// The text to display as a [InlineSpan]. /// /// This will be null if [data] is provided instead. - final TextSpan textSpan; + final InlineSpan textSpan; /// If non-null, the style to use for this text. /// diff --git a/packages/flutter/lib/src/widgets/widget_inspector.dart b/packages/flutter/lib/src/widgets/widget_inspector.dart index ab178a03c1e..ab9a1bee778 100644 --- a/packages/flutter/lib/src/widgets/widget_inspector.dart +++ b/packages/flutter/lib/src/widgets/widget_inspector.dart @@ -2752,7 +2752,8 @@ class _InspectorOverlayLayer extends Layer { ) { canvas.save(); final double maxWidth = size.width - 2 * (_kScreenEdgeMargin + _kTooltipPadding); - if (_textPainter == null || _textPainter.text.text != message || _textPainterMaxWidth != maxWidth) { + final TextSpan textSpan = _textPainter?.text; + if (_textPainter == null || textSpan.text != message || _textPainterMaxWidth != maxWidth) { _textPainterMaxWidth = maxWidth; _textPainter = TextPainter() ..maxLines = _kMaxTooltipLines diff --git a/packages/flutter/lib/src/widgets/widget_span.dart b/packages/flutter/lib/src/widgets/widget_span.dart new file mode 100644 index 00000000000..a24c04a0909 --- /dev/null +++ b/packages/flutter/lib/src/widgets/widget_span.dart @@ -0,0 +1,198 @@ +// 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 ParagraphBuilder, PlaceholderAlignment; + +import 'package:flutter/painting.dart'; + +import 'framework.dart'; + +/// An immutable widget that is embedded inline within text. +/// +/// The [child] property is the widget that will be embedded. Children are +/// constrained by the width of the paragraph. +/// +/// The [child] property may contain its own [Widget] children (if applicable), +/// including [Text] and [RichText] widgets which may include additional +/// [WidgetSpan]s. Child [Text] and [RichText] widgets will be laid out +/// independently and occupy a rectangular space in the parent text layout. +/// +/// [WidgetSpan]s will be ignored when passed into a [TextPainter] directly. +/// To properly layout and paint the [child] widget, [WidgetSpan] should be +/// passed into a [Text.rich] widget. +/// +/// {@tool sample} +/// +/// A card with `Hello World!` embedded inline within a TextSpan tree. +/// +/// ```dart +/// Text.rich( +/// TextSpan( +/// children: [ +/// TextSpan(text: 'Flutter is'), +/// WidgetSpan( +/// child: SizedBox( +/// width: 120, +/// height: 50, +/// child: Card( +/// child: Center( +/// child: Text('Hello World!') +/// ) +/// ), +/// ) +/// ), +/// TextSpan(text: 'the best!'), +/// ], +/// ) +/// ) +/// ``` +/// {@end-tool} +/// +/// [WidgetSpan] contributes the semantics of the [WidgetSpan.child] to the +/// semantics tree. +/// +/// See also: +/// +/// * [TextSpan], a node that represents text in an [InlineSpan] tree. +/// * [Text], a widget for showing uniformly-styled text. +/// * [RichText], a widget for finer control of text rendering. +/// * [TextPainter], a class for painting [InlineSpan] objects on a [Canvas]. +@immutable +class WidgetSpan extends PlaceholderSpan { + /// Creates a [WidgetSpan] with the given values. + /// + /// The [child] property must be non-null. [WidgetSpan] is a leaf node in + /// the [InlineSpan] tree. Child widgets are constrained by the width of the + /// paragraph they occupy. Child widget heights are unconstrained, and may + /// cause the text to overflow and be ellipsized/truncated. + /// + /// A [TextStyle] may be provided with the [style] property, but only the + /// decoration, foreground, background, and spacing options will be used. + const WidgetSpan({ + @required this.child, + ui.PlaceholderAlignment alignment = ui.PlaceholderAlignment.bottom, + TextBaseline baseline, + TextStyle style, + }) : assert(child != null), + assert((identical(alignment, ui.PlaceholderAlignment.aboveBaseline) || + identical(alignment, ui.PlaceholderAlignment.belowBaseline) || + identical(alignment, ui.PlaceholderAlignment.baseline)) ? baseline != null : true), + super( + alignment: alignment, + baseline: baseline, + style: style, + ); + + /// The widget to embed inline within text. + final Widget child; + + /// Adds a placeholder box to the paragraph builder if a size has been + /// calculated for the widget. + /// + /// Sizes are provided through `dimensions`, which should contain a 1:1 + /// in-order mapping of widget to laid-out dimensions. If no such dimension + /// is provided, the widget will be skipped. + /// + /// The `textScaleFactor` will be applied to the laid-out size of the widget. + @override + void build(ui.ParagraphBuilder builder, { double textScaleFactor = 1.0, @required List dimensions }) { + assert(debugAssertIsValid()); + assert(dimensions != null); + final bool hasStyle = style != null; + if (hasStyle) { + builder.pushStyle(style.getTextStyle(textScaleFactor: textScaleFactor)); + } + assert(builder.placeholderCount < dimensions.length); + final PlaceholderDimensions currentDimensions = dimensions[builder.placeholderCount]; + builder.addPlaceholder( + currentDimensions.size.width, + currentDimensions.size.height, + alignment, + scale: textScaleFactor, + baseline: currentDimensions.baseline, + baselineOffset: currentDimensions.baselineOffset, + ); + if (hasStyle) { + builder.pop(); + } + } + + /// Calls `visitor` on this [WidgetSpan]. There are no children spans to walk. + @override + bool visitChildren(InlineSpanVisitor visitor) { + return visitor(this); + } + + @override + InlineSpan getSpanForPositionVisitor(TextPosition position, Accumulator offset) { + return null; + } + + @override + int codeUnitAtVisitor(int index, Accumulator offset) { + return null; + } + + @override + RenderComparison compareTo(InlineSpan other) { + if (identical(this, other)) + return RenderComparison.identical; + if (other.runtimeType != runtimeType) + return RenderComparison.layout; + if ((style == null) != (other.style == null)) + return RenderComparison.layout; + final WidgetSpan typedOther = other; + if (child != typedOther.child || alignment != typedOther.alignment) { + return RenderComparison.layout; + } + RenderComparison result = RenderComparison.identical; + if (style != null) { + final RenderComparison candidate = style.compareTo(other.style); + if (candidate.index > result.index) + result = candidate; + if (result == RenderComparison.layout) + return result; + } + return result; + } + + @override + bool operator ==(dynamic other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + if (super != other) + return false; + final WidgetSpan typedOther = other; + return typedOther.child == child + && typedOther.alignment == alignment + && typedOther.baseline == baseline; + } + + @override + int get hashCode => hashValues(super.hashCode, child, alignment, baseline); + + /// Returns the text span that contains the given position in the text. + @override + InlineSpan getSpanForPosition(TextPosition position) { + assert(debugAssertIsValid()); + return null; + } + + /// In debug 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(myWidgetSpan.debugAssertIsValid()); + /// ``` + @override + bool debugAssertIsValid() { + // WidgetSpans are always valid as asserts prevent invalid WidgetSpans + // from being constructed. + return true; + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 744a3fba75e..d0871cfa1ae 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -108,4 +108,5 @@ export 'src/widgets/value_listenable_builder.dart'; export 'src/widgets/viewport.dart'; export 'src/widgets/visibility.dart'; export 'src/widgets/widget_inspector.dart'; +export 'src/widgets/widget_span.dart'; export 'src/widgets/will_pop_scope.dart'; diff --git a/packages/flutter/test/painting/text_painter_rtl_test.dart b/packages/flutter/test/painting/text_painter_rtl_test.dart index 5f3f02bc900..e44cfe09b90 100644 --- a/packages/flutter/test/painting/text_painter_rtl_test.dart +++ b/packages/flutter/test/painting/text_painter_rtl_test.dart @@ -43,7 +43,8 @@ void main() { // 0 12345678 9 101234567 18 90123456 27 style: TextStyle(fontFamily: 'Ahem', fontSize: 10.0), ); - expect(painter.text.text.length, 28); + TextSpan textSpan = painter.text; + expect(textSpan.text.length, 28); painter.layout(); // The skips here are because the old rendering code considers the bidi formatting characters @@ -127,7 +128,8 @@ void main() { ); final List> list = >[]; - for (int index = 0; index < painter.text.text.length; index += 1) + textSpan = painter.text; + for (int index = 0; index < textSpan.text.length; index += 1) list.add(painter.getBoxesForSelection(TextSelection(baseOffset: index, extentOffset: index + 1))); expect(list, const >[ [], // U+202E, non-printing Unicode bidi formatting character @@ -172,7 +174,8 @@ void main() { // 0 12345678 9 101234567 18 90123456 27 style: TextStyle(fontFamily: 'Ahem', fontSize: 10.0), ); - expect(painter.text.text.length, 28); + final TextSpan textSpan = painter.text; + expect(textSpan.text.length, 28); painter.layout(); final TextRange hebrew1 = painter.getWordBoundary(const TextPosition(offset: 4, affinity: TextAffinity.downstream)); @@ -261,7 +264,8 @@ void main() { text: 'A\u05D0', // A, Alef style: TextStyle(fontFamily: 'Ahem', fontSize: 10.0), ); - expect(painter.text.text.length, 2); + final TextSpan textSpan = painter.text; + expect(textSpan.text.length, 2); painter.layout(maxWidth: 10.0); for (int index = 0; index <= 2; index += 1) { diff --git a/packages/flutter/test/painting/text_painter_test.dart b/packages/flutter/test/painting/text_painter_test.dart index 80d0c2228a0..cfa5f56883f 100644 --- a/packages/flutter/test/painting/text_painter_test.dart +++ b/packages/flutter/test/painting/text_painter_test.dart @@ -5,6 +5,7 @@ import 'dart:ui' as ui; import 'package:flutter/painting.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -634,4 +635,98 @@ void main() { expect(caretOffset.dx, closeTo(0.0, 0.0001)); expect(caretOffset.dy, closeTo(0.0, 0.0001)); }); + + test('TextPainter widget span', () { + final TextPainter painter = TextPainter() + ..textDirection = TextDirection.ltr; + + const String text = 'test'; + painter.text = const TextSpan( + text: text, + children: [ + WidgetSpan(child: SizedBox(width: 50, height: 30)), + TextSpan(text: text), + WidgetSpan(child: SizedBox(width: 50, height: 30)), + WidgetSpan(child: SizedBox(width: 50, height: 30)), + TextSpan(text: text), + WidgetSpan(child: SizedBox(width: 50, height: 30)), + WidgetSpan(child: SizedBox(width: 50, height: 30)), + WidgetSpan(child: SizedBox(width: 50, height: 30)), + WidgetSpan(child: SizedBox(width: 50, height: 30)), + WidgetSpan(child: SizedBox(width: 50, height: 30)), + WidgetSpan(child: SizedBox(width: 50, height: 30)), + WidgetSpan(child: SizedBox(width: 50, height: 30)), + WidgetSpan(child: SizedBox(width: 50, height: 30)), + WidgetSpan(child: SizedBox(width: 50, height: 30)), + WidgetSpan(child: SizedBox(width: 50, height: 30)), + WidgetSpan(child: SizedBox(width: 50, height: 30)), + ] + ); + + // We provide dimensions for the widgets + painter.setPlaceholderDimensions(const [ + PlaceholderDimensions(size: Size(50, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), + PlaceholderDimensions(size: Size(50, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), + PlaceholderDimensions(size: Size(50, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), + PlaceholderDimensions(size: Size(50, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), + PlaceholderDimensions(size: Size(50, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), + PlaceholderDimensions(size: Size(50, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), + PlaceholderDimensions(size: Size(50, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), + PlaceholderDimensions(size: Size(50, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), + PlaceholderDimensions(size: Size(50, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), + PlaceholderDimensions(size: Size(50, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), + PlaceholderDimensions(size: Size(50, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), + PlaceholderDimensions(size: Size(50, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), + PlaceholderDimensions(size: Size(51, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), + PlaceholderDimensions(size: Size(50, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), + ]); + + painter.layout(maxWidth: 500); + + // Now, each of the WidgetSpans will have their own placeholder 'hole'. + Offset caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 1), ui.Rect.zero); + expect(caretOffset.dx, 14); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 4), ui.Rect.zero); + expect(caretOffset.dx, 56); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 5), ui.Rect.zero); + expect(caretOffset.dx, 106); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 6), ui.Rect.zero); + expect(caretOffset.dx, 120); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 10), ui.Rect.zero); + expect(caretOffset.dx, 212); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 11), ui.Rect.zero); + expect(caretOffset.dx, 262); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 12), ui.Rect.zero); + expect(caretOffset.dx, 276); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 13), ui.Rect.zero); + expect(caretOffset.dx, 290); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 14), ui.Rect.zero); + expect(caretOffset.dx, 304); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 15), ui.Rect.zero); + expect(caretOffset.dx, 318); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 16), ui.Rect.zero); + expect(caretOffset.dx, 368); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 17), ui.Rect.zero); + expect(caretOffset.dx, 418); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 18), ui.Rect.zero); + expect(caretOffset.dx, 0); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 19), ui.Rect.zero); + expect(caretOffset.dx, 50); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 23), ui.Rect.zero); + expect(caretOffset.dx, 250); + + expect(painter.inlinePlaceholderBoxes.length, 14); + expect(painter.inlinePlaceholderBoxes[0], const TextBox.fromLTRBD(56, 0, 106, 30, TextDirection.ltr)); + expect(painter.inlinePlaceholderBoxes[2], const TextBox.fromLTRBD(212, 0, 262, 30, TextDirection.ltr)); + expect(painter.inlinePlaceholderBoxes[3], const TextBox.fromLTRBD(318, 0, 368, 30, TextDirection.ltr)); + expect(painter.inlinePlaceholderBoxes[4], const TextBox.fromLTRBD(368, 0, 418, 30, TextDirection.ltr)); + expect(painter.inlinePlaceholderBoxes[5], const TextBox.fromLTRBD(418, 0, 468, 30, TextDirection.ltr)); + // line should break here + expect(painter.inlinePlaceholderBoxes[6], const TextBox.fromLTRBD(0, 30, 50, 60, TextDirection.ltr)); + expect(painter.inlinePlaceholderBoxes[7], const TextBox.fromLTRBD(50, 30, 100, 60, TextDirection.ltr)); + expect(painter.inlinePlaceholderBoxes[10], const TextBox.fromLTRBD(200, 30, 250, 60, TextDirection.ltr)); + expect(painter.inlinePlaceholderBoxes[11], const TextBox.fromLTRBD(250, 30, 300, 60, TextDirection.ltr)); + expect(painter.inlinePlaceholderBoxes[12], const TextBox.fromLTRBD(300, 30, 351, 60, TextDirection.ltr)); + expect(painter.inlinePlaceholderBoxes[13], const TextBox.fromLTRBD(351, 30, 401, 60, TextDirection.ltr)); + }); } diff --git a/packages/flutter/test/painting/text_span_test.dart b/packages/flutter/test/painting/text_span_test.dart index 072ea5f4cbf..2f7e9757c92 100644 --- a/packages/flutter/test/painting/text_span_test.dart +++ b/packages/flutter/test/painting/text_span_test.dart @@ -3,17 +3,17 @@ // found in the LICENSE file. import 'package:flutter/painting.dart'; -import 'package:flutter_test/flutter_test.dart' show nonconst; +import 'package:flutter/widgets.dart'; import '../flutter_test_alternative.dart'; void main() { test('TextSpan equals', () { - final TextSpan a1 = TextSpan(text: nonconst('a')); - final TextSpan a2 = TextSpan(text: nonconst('a')); - final TextSpan b1 = TextSpan(children: [ a1 ]); - final TextSpan b2 = TextSpan(children: [ a2 ]); - final TextSpan c1 = TextSpan(text: nonconst(null)); - final TextSpan c2 = TextSpan(text: nonconst(null)); + const TextSpan a1 = TextSpan(text: 'a'); + const TextSpan a2 = TextSpan(text: 'a'); + const TextSpan b1 = TextSpan(children: [ a1 ]); + const TextSpan b2 = TextSpan(children: [ a2 ]); + const TextSpan c1 = TextSpan(text: null); + const TextSpan c2 = TextSpan(text: null); expect(a1 == a2, isTrue); expect(b1 == b2, isTrue); @@ -73,6 +73,18 @@ void main() { expect(textSpan.toPlainText(), 'abc'); }); + test('WidgetSpan toPlainText', () { + const TextSpan textSpan = TextSpan( + text: 'a', + children: [ + TextSpan(text: 'b'), + WidgetSpan(child: SizedBox(width: 10, height: 10)), + TextSpan(text: 'c'), + ], + ); + expect(textSpan.toPlainText(), 'ab\uFFFCc'); + }); + test('TextSpan toPlainText with semanticsLabel', () { const TextSpan textSpan = TextSpan( text: 'a', @@ -84,4 +96,117 @@ void main() { expect(textSpan.toPlainText(), 'afooc'); expect(textSpan.toPlainText(includeSemanticsLabels: false), 'abc'); }); + + test('TextSpan widget change test', () { + const TextSpan textSpan1 = TextSpan( + text: 'a', + children: [ + TextSpan(text: 'b'), + WidgetSpan(child: SizedBox(width: 10, height: 10)), + TextSpan(text: 'c'), + ], + ); + + const TextSpan textSpan2 = TextSpan( + text: 'a', + children: [ + TextSpan(text: 'b'), + WidgetSpan(child: SizedBox(width: 10, height: 10)), + TextSpan(text: 'c'), + ], + ); + + const TextSpan textSpan3 = TextSpan( + text: 'a', + children: [ + TextSpan(text: 'b'), + WidgetSpan(child: SizedBox(width: 11, height: 10)), + TextSpan(text: 'c'), + ], + ); + + const TextSpan textSpan4 = TextSpan( + text: 'a', + children: [ + TextSpan(text: 'b'), + WidgetSpan(child: Text('test')), + TextSpan(text: 'c'), + ], + ); + + const TextSpan textSpan5 = TextSpan( + text: 'a', + children: [ + TextSpan(text: 'b'), + WidgetSpan(child: Text('different!')), + TextSpan(text: 'c'), + ], + ); + + const TextSpan textSpan6 = TextSpan( + text: 'a', + children: [ + TextSpan(text: 'b'), + WidgetSpan( + child: SizedBox(width: 10, height: 10), + alignment: PlaceholderAlignment.top, + ), + TextSpan(text: 'c'), + ], + ); + + expect(textSpan1.compareTo(textSpan3), RenderComparison.layout); + expect(textSpan1.compareTo(textSpan4), RenderComparison.layout); + expect(textSpan1.compareTo(textSpan1), RenderComparison.identical); + expect(textSpan2.compareTo(textSpan2), RenderComparison.identical); + expect(textSpan3.compareTo(textSpan3), RenderComparison.identical); + expect(textSpan2.compareTo(textSpan3), RenderComparison.layout); + expect(textSpan4.compareTo(textSpan5), RenderComparison.layout); + expect(textSpan3.compareTo(textSpan5), RenderComparison.layout); + expect(textSpan2.compareTo(textSpan5), RenderComparison.layout); + expect(textSpan1.compareTo(textSpan5), RenderComparison.layout); + expect(textSpan1.compareTo(textSpan6), RenderComparison.layout); + }); + + test('TextSpan nested widget change test', () { + const TextSpan textSpan1 = TextSpan( + text: 'a', + children: [ + TextSpan(text: 'b'), + WidgetSpan( + child: Text.rich( + TextSpan( + children: [ + WidgetSpan(child: SizedBox(width: 10, height: 10)), + TextSpan(text: 'The sky is falling :)') + ], + ) + ), + ), + TextSpan(text: 'c'), + ], + ); + + const TextSpan textSpan2 = TextSpan( + text: 'a', + children: [ + TextSpan(text: 'b'), + WidgetSpan( + child: Text.rich( + TextSpan( + children: [ + WidgetSpan(child: SizedBox(width: 10, height: 11)), + TextSpan(text: 'The sky is falling :)') + ], + ) + ), + ), + TextSpan(text: 'c'), + ], + ); + + expect(textSpan1.compareTo(textSpan2), RenderComparison.layout); + expect(textSpan1.compareTo(textSpan1), RenderComparison.identical); + expect(textSpan2.compareTo(textSpan2), RenderComparison.identical); + }); } diff --git a/packages/flutter/test/rendering/paragraph_test.dart b/packages/flutter/test/rendering/paragraph_test.dart index c998d4e684a..356b6d8cbe8 100644 --- a/packages/flutter/test/rendering/paragraph_test.dart +++ b/packages/flutter/test/rendering/paragraph_test.dart @@ -5,6 +5,7 @@ import 'dart:ui' as ui show TextBox; import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -324,4 +325,93 @@ void main() { expect(paragraph.locale, const Locale('ja', 'JP')); }); + test('inline widgets test', () { + const TextSpan text = TextSpan( + text: 'a', + style: TextStyle(fontSize: 10.0), + children: [ + WidgetSpan(child: SizedBox(width: 21, height: 21)), + WidgetSpan(child: SizedBox(width: 21, height: 21)), + TextSpan(text: 'a'), + WidgetSpan(child: SizedBox(width: 21, height: 21)), + ], + ); + // Fake the render boxes that correspond to the WidgetSpans. We use + // RenderParagraph to reduce dependencies this test has. + final List renderBoxes = []; + renderBoxes.add(RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr)); + renderBoxes.add(RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr)); + renderBoxes.add(RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr)); + + final RenderParagraph paragraph = RenderParagraph( + text, + textDirection: TextDirection.ltr, + children: renderBoxes, + ); + layout(paragraph, constraints: const BoxConstraints(maxWidth: 100.0)); + + final List boxes = paragraph.getBoxesForSelection( + const TextSelection(baseOffset: 0, extentOffset: 8) + ); + + expect(boxes.length, equals(5)); + expect(boxes[0], const TextBox.fromLTRBD(0.0, 4.0, 10.0, 14.0, TextDirection.ltr)); + expect(boxes[1], const TextBox.fromLTRBD(10.0, 0.0, 24.0, 14.0, TextDirection.ltr)); + expect(boxes[2], const TextBox.fromLTRBD(24.0, 0.0, 38.0, 14.0, TextDirection.ltr)); + expect(boxes[3], const TextBox.fromLTRBD(38.0, 4.0, 48.0, 14.0, TextDirection.ltr)); + expect(boxes[4], const TextBox.fromLTRBD(48.0, 0.0, 62.0, 14.0, TextDirection.ltr)); + // Ahem-based tests don't yet quite work on Windows or some MacOS environments + }, skip: isWindows || isMacOS); + + test('inline widgets multiline test', () { + const TextSpan text = TextSpan( + text: 'a', + style: TextStyle(fontSize: 10.0), + children: [ + WidgetSpan(child: SizedBox(width: 21, height: 21)), + WidgetSpan(child: SizedBox(width: 21, height: 21)), + TextSpan(text: 'a'), + WidgetSpan(child: SizedBox(width: 21, height: 21)), + WidgetSpan(child: SizedBox(width: 21, height: 21)), + WidgetSpan(child: SizedBox(width: 21, height: 21)), + WidgetSpan(child: SizedBox(width: 21, height: 21)), + WidgetSpan(child: SizedBox(width: 21, height: 21)), + ], + ); + // Fake the render boxes that correspond to the WidgetSpans. We use + // RenderParagraph to reduce dependencies this test has. + final List renderBoxes = []; + renderBoxes.add(RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr)); + renderBoxes.add(RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr)); + renderBoxes.add(RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr)); + renderBoxes.add(RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr)); + renderBoxes.add(RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr)); + renderBoxes.add(RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr)); + renderBoxes.add(RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr)); + + final RenderParagraph paragraph = RenderParagraph( + text, + textDirection: TextDirection.ltr, + children: renderBoxes, + ); + layout(paragraph, constraints: const BoxConstraints(maxWidth: 50.0)); + + final List boxes = paragraph.getBoxesForSelection( + const TextSelection(baseOffset: 0, extentOffset: 12) + ); + + expect(boxes.length, equals(9)); + expect(boxes[0], const TextBox.fromLTRBD(0.0, 4.0, 10.0, 14.0, TextDirection.ltr)); + expect(boxes[1], const TextBox.fromLTRBD(10.0, 0.0, 24.0, 14.0, TextDirection.ltr)); + expect(boxes[2], const TextBox.fromLTRBD(24.0, 0.0, 38.0, 14.0, TextDirection.ltr)); + expect(boxes[3], const TextBox.fromLTRBD(38.0, 4.0, 48.0, 14.0, TextDirection.ltr)); + // Wraps + expect(boxes[4], const TextBox.fromLTRBD(0.0, 14.0, 14.0, 28.0 , TextDirection.ltr)); + expect(boxes[5], const TextBox.fromLTRBD(14.0, 14.0, 28.0, 28.0, TextDirection.ltr)); + expect(boxes[6], const TextBox.fromLTRBD(28.0, 14.0, 42.0, 28.0, TextDirection.ltr)); + // Wraps + expect(boxes[7], const TextBox.fromLTRBD(0.0, 28.0, 14.0, 42.0, TextDirection.ltr)); + expect(boxes[8], const TextBox.fromLTRBD(14.0, 28.0, 28.0, 42.0 , TextDirection.ltr)); + // Ahem-based tests don't yet quite work on Windows or some MacOS environments + }, skip: isWindows || isMacOS); } diff --git a/packages/flutter/test/widgets/backdrop_filter_test.dart b/packages/flutter/test/widgets/backdrop_filter_test.dart index c0cff7310ae..aa27ee3ebd9 100644 --- a/packages/flutter/test/widgets/backdrop_filter_test.dart +++ b/packages/flutter/test/widgets/backdrop_filter_test.dart @@ -10,6 +10,7 @@ import 'package:flutter/material.dart'; void main() { testWidgets('BackdropFilter\'s cull rect does not shrink', (WidgetTester tester) async { + tester.binding.addTime(const Duration(seconds: 15)); await tester.pumpWidget( MaterialApp( home: Scaffold( diff --git a/packages/flutter/test/widgets/basic_test.dart b/packages/flutter/test/widgets/basic_test.dart index 41e218c4ad7..15c69966f81 100644 --- a/packages/flutter/test/widgets/basic_test.dart +++ b/packages/flutter/test/widgets/basic_test.dart @@ -8,7 +8,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter/rendering.dart'; - void main() { group('PhysicalShape', () { testWidgets('properties', (WidgetTester tester) async { diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index ec9748b752d..015795c489f 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -1870,8 +1870,9 @@ void main() { final RenderEditable renderEditable = findRenderEditable(tester); // The actual text span is split into 3 parts with the middle part underlined. expect(renderEditable.text.children.length, 3); - expect(renderEditable.text.children[1].text, 'composing'); - expect(renderEditable.text.children[1].style.decoration, TextDecoration.underline); + final TextSpan textSpan = renderEditable.text.children[1]; + expect(textSpan.text, 'composing'); + expect(textSpan.style.decoration, TextDecoration.underline); focusNode.unfocus(); await tester.pump(); diff --git a/packages/flutter/test/widgets/text_golden_test.dart b/packages/flutter/test/widgets/text_golden_test.dart index 5627dcf600c..bdc0294a46c 100644 --- a/packages/flutter/test/widgets/text_golden_test.dart +++ b/packages/flutter/test/widgets/text_golden_test.dart @@ -151,16 +151,15 @@ void main() { decoration: const BoxDecoration( color: Colors.green, ), - child: RichText( - textDirection: TextDirection.ltr, - text: TextSpan( + child: Text.rich( + TextSpan( text: 'text1 ', style: TextStyle( color: translucentGreen, background: Paint() ..color = red.withOpacity(0.5), ), - children: [ + children: [ TextSpan( text: 'text2', style: TextStyle( @@ -171,6 +170,7 @@ void main() { ), ], ), + textDirection: TextDirection.ltr, ), ), ), @@ -242,7 +242,7 @@ void main() { find.byType(Container), matchesGoldenFile('text_golden.StrutDefault.png'), ); - }, skip: true); // Should only be on linux (skip: !Platform.isLinux). + }, skip: true); // Should only be on linux (skip: !isLinux). // Disabled for now until font inconsistency is resolved. testWidgets('Strut text 1', (WidgetTester tester) async { @@ -270,7 +270,7 @@ void main() { find.byType(Container), matchesGoldenFile('text_golden.Strut.1.1.png'), ); - }, skip: true); // Should only be on linux (skip: !Platform.isLinux). + }, skip: true); // Should only be on linux (skip: !isLinux). // Disabled for now until font inconsistency is resolved. testWidgets('Strut text 2', (WidgetTester tester) async { @@ -299,7 +299,7 @@ void main() { find.byType(Container), matchesGoldenFile('text_golden.Strut.2.1.png'), ); - }, skip: true); // Should only be on linux (skip: !Platform.isLinux). + }, skip: true); // Should only be on linux (skip: !isLinux). // Disabled for now until font inconsistency is resolved. testWidgets('Strut text rich', (WidgetTester tester) async { @@ -319,7 +319,7 @@ void main() { color: Colors.red, fontSize: 30, ), - children: [ + children: [ TextSpan( text: 'Second line!\n', style: TextStyle( @@ -351,7 +351,7 @@ void main() { find.byType(Container), matchesGoldenFile('text_golden.Strut.3.1.png'), ); - }, skip: true); // Should only be on linux (skip: !Platform.isLinux). + }, skip: true); // Should only be on linux (skip: !isLinux). // Disabled for now until font inconsistency is resolved. testWidgets('Strut text font fallback', (WidgetTester tester) async { @@ -387,7 +387,7 @@ void main() { find.byType(Container), matchesGoldenFile('text_golden.Strut.4.1.png'), ); - }, skip: true); // Should only be on linux (skip: !Platform.isLinux). + }, skip: true); // Should only be on linux (skip: !isLinux). // Disabled for now until font inconsistency is resolved. testWidgets('Strut text rich forceStrutHeight', (WidgetTester tester) async { @@ -407,7 +407,7 @@ void main() { color: Colors.red, fontSize: 30, ), - children: [ + children: [ TextSpan( text: 'Second line!\n', style: TextStyle( @@ -439,7 +439,7 @@ void main() { find.byType(Container), matchesGoldenFile('text_golden.StrutForce.1.1.png'), ); - }, skip: true); // Should only be on linux (skip: !Platform.isLinux). + }, skip: true); // Should only be on linux (skip: !isLinux). // Disabled for now until font inconsistency is resolved. testWidgets('Decoration thickness', (WidgetTester tester) async { @@ -518,4 +518,807 @@ void main() { matchesGoldenFile('text_golden.DecorationThickness.1.0.png'), ); }, skip: !isLinux); // Coretext uses different thicknesses for decoration + + testWidgets('Text Inline widget', (WidgetTester tester) async { + await tester.pumpWidget( + Center( + child: RepaintBoundary( + child: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Container( + width: 400.0, + height: 200.0, + decoration: const BoxDecoration( + color: Color(0xff00ff00), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 100), + child: const Text.rich( + TextSpan( + text: 'C ', + style: TextStyle( + fontSize: 16, + ), + children: [ + WidgetSpan( + child: Checkbox(value: true, onChanged: null), + ), + WidgetSpan( + child: Checkbox(value: false, onChanged: null), + ), + TextSpan(text: 'He ', style: TextStyle(fontSize: 20)), + WidgetSpan( + child: SizedBox( + width: 50.0, + height: 55.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xffffff00), + ), + child: Center( + child:SizedBox( + width: 10.0, + height: 15.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xffff0000), + ), + ) + ), + ), + ) + ), + ), + TextSpan(text: 'hello world! sieze the day!'), + WidgetSpan( + child: Checkbox(value: false, onChanged: null), + ), + WidgetSpan( + child: SizedBox( + width: 20, + height: 20, + child: Checkbox(value: true, onChanged: null), + ) + ), + WidgetSpan( + child: Checkbox(value: false, onChanged: null), + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic + ), + WidgetSpan( + child: SizedBox( + width: 20, + height: 20, + child: Checkbox(value: true, onChanged: null), + ) + ), + WidgetSpan( + child: Text('embedded'), + ), + ], + ), + textDirection: TextDirection.ltr, + ), + ), + ), + ), + ), + ), + ), + ); + await expectLater( + find.byType(Container), + matchesGoldenFile('text_golden.TextInlineWidget.1.1.png'), + ); + }, skip: !isLinux); // Coretext uses different thicknesses for decoration + + testWidgets('Text Inline widget textfield', (WidgetTester tester) async { + await tester.pumpWidget( + Center( + child: MaterialApp( + home: RepaintBoundary( + child: Material( + child: Container( + width: 400.0, + height: 200.0, + decoration: const BoxDecoration( + color: Color(0xff00ff00), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 100), + child: const Text.rich( + TextSpan( + text: 'My name is: ', + style: TextStyle( + fontSize: 20, + ), + children: [ + WidgetSpan( + child: SizedBox(width: 70, height: 25, child: TextField()), + ), + TextSpan(text: ', and my favorite city is: ', style: TextStyle(fontSize: 20)), + WidgetSpan( + child: SizedBox(width: 70, height: 25, child: TextField()), + ), + ], + ), + textDirection: TextDirection.ltr, + ), + ), + ), + ), + ), + ), + ), + ); + await expectLater( + find.byType(Container), + matchesGoldenFile('text_golden.TextInlineWidget.2.2.png'), + ); + }, skip: !isLinux); // Coretext uses different thicknesses for decoration + + // This tests if multiple Text.rich widgets are able to inline nest within each other. + testWidgets('Text Inline widget nesting', (WidgetTester tester) async { + await tester.pumpWidget( + Center( + child: MaterialApp( + home: RepaintBoundary( + child: Material( + child: Container( + width: 400.0, + height: 200.0, + decoration: const BoxDecoration( + color: Color(0xff00ff00), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 100), + child: const Text.rich( + TextSpan( + text: 'outer', + style: TextStyle( + fontSize: 20, + ), + children: [ + WidgetSpan( + child: Text.rich( + TextSpan( + text: 'inner', + style: TextStyle(color: Color(0xff402f4ff)), + children: [ + WidgetSpan( + child: Text.rich( + TextSpan( + text: 'inner2', + style: TextStyle(color: Color(0xff003ffff)), + children: [ + WidgetSpan( + child: SizedBox( + width: 50.0, + height: 55.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xffffff30), + ), + child: Center( + child:SizedBox( + width: 10.0, + height: 15.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xff5f00f0), + ), + ) + ), + ), + ) + ), + ), + ], + ), + ), + ), + WidgetSpan( + child: SizedBox( + width: 50.0, + height: 55.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xff5fff00), + ), + child: Center( + child:SizedBox( + width: 10.0, + height: 15.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xff5f0000), + ), + ) + ), + ), + ) + ), + ), + ], + ), + ), + ), + TextSpan(text: 'outer', style: TextStyle(fontSize: 20)), + WidgetSpan( + child: SizedBox(width: 70, height: 25, child: TextField()), + ), + WidgetSpan( + child: SizedBox( + width: 50.0, + height: 55.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xffff00ff), + ), + child: Center( + child:SizedBox( + width: 10.0, + height: 15.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xff0000ff), + ), + ) + ), + ), + ) + ), + ), + ], + ), + textDirection: TextDirection.ltr, + ), + ), + ), + ), + ), + ), + ), + ); + await expectLater( + find.byType(Container), + matchesGoldenFile('text_golden.TextInlineWidgetNest.1.2.png'), + ); + }, skip: !isLinux); // Coretext uses different thicknesses for decoration + + testWidgets('Text Inline widget baseline', (WidgetTester tester) async { + await tester.pumpWidget( + Center( + child: RepaintBoundary( + child: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Container( + width: 400.0, + height: 200.0, + decoration: const BoxDecoration( + color: Color(0xff00ff00), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 100), + child: const Text.rich( + TextSpan( + text: 'C ', + style: TextStyle( + fontSize: 16, + ), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Checkbox(value: true, onChanged: null), + ), + WidgetSpan( + child: Checkbox(value: false, onChanged: null), + ), + TextSpan(text: 'He ', style: TextStyle(fontSize: 20)), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: SizedBox( + width: 50.0, + height: 55.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xffffff00), + ), + child: Center( + child:SizedBox( + width: 10.0, + height: 15.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xffff0000), + ), + ) + ), + ), + ) + ), + ), + TextSpan(text: 'hello world! sieze the day!'), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Checkbox(value: false, onChanged: null), + ), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: SizedBox( + width: 20, + height: 20, + child: Checkbox(value: true, onChanged: null), + ) + ), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Checkbox(value: false, onChanged: null), + ), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: SizedBox( + width: 20, + height: 20, + child: Checkbox(value: true, onChanged: null), + ) + ), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Text('embedded'), + ), + TextSpan(text: 'ref'), + ], + ), + textDirection: TextDirection.ltr, + ), + ), + ), + ), + ), + ), + ), + ); + await expectLater( + find.byType(Container), + matchesGoldenFile('text_golden.TextInlineWidgetBaseline.1.1.png'), + ); + }, skip: !isLinux); // Coretext uses different thicknesses for decoration + + testWidgets('Text Inline widget aboveBaseline', (WidgetTester tester) async { + await tester.pumpWidget( + Center( + child: RepaintBoundary( + child: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Container( + width: 400.0, + height: 200.0, + decoration: const BoxDecoration( + color: Color(0xff00ff00), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 100), + child: const Text.rich( + TextSpan( + text: 'C ', + style: TextStyle( + fontSize: 16, + ), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.aboveBaseline, + baseline: TextBaseline.alphabetic, + child: Checkbox(value: true, onChanged: null), + ), + WidgetSpan( + child: Checkbox(value: false, onChanged: null), + ), + TextSpan(text: 'He ', style: TextStyle(fontSize: 20)), + WidgetSpan( + alignment: PlaceholderAlignment.aboveBaseline, + baseline: TextBaseline.alphabetic, + child: SizedBox( + width: 50.0, + height: 55.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xffffff00), + ), + child: Center( + child:SizedBox( + width: 10.0, + height: 15.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xffff0000), + ), + ) + ), + ), + ) + ), + ), + TextSpan(text: 'hello world! sieze the day!'), + WidgetSpan( + alignment: PlaceholderAlignment.aboveBaseline, + baseline: TextBaseline.alphabetic, + child: Checkbox(value: false, onChanged: null), + ), + WidgetSpan( + alignment: PlaceholderAlignment.aboveBaseline, + baseline: TextBaseline.alphabetic, + child: SizedBox( + width: 20, + height: 20, + child: Checkbox(value: true, onChanged: null), + ) + ), + WidgetSpan( + alignment: PlaceholderAlignment.aboveBaseline, + baseline: TextBaseline.alphabetic, + child: Checkbox(value: false, onChanged: null), + ), + WidgetSpan( + alignment: PlaceholderAlignment.aboveBaseline, + baseline: TextBaseline.alphabetic, + child: SizedBox( + width: 20, + height: 20, + child: Checkbox(value: true, onChanged: null), + ) + ), + WidgetSpan( + alignment: PlaceholderAlignment.aboveBaseline, + baseline: TextBaseline.alphabetic, + child: Text('embedded'), + ), + TextSpan(text: 'ref'), + ], + ), + textDirection: TextDirection.ltr, + ), + ), + ), + ), + ), + ), + ), + ); + await expectLater( + find.byType(Container), + matchesGoldenFile('text_golden.TextInlineWidgetAboveBaseline.1.1.png'), + ); + }, skip: !isLinux); // Coretext uses different thicknesses for decoration + + testWidgets('Text Inline widget belowBaseline', (WidgetTester tester) async { + await tester.pumpWidget( + Center( + child: RepaintBoundary( + child: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Container( + width: 400.0, + height: 200.0, + decoration: const BoxDecoration( + color: Color(0xff00ff00), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 100), + child: const Text.rich( + TextSpan( + text: 'C ', + style: TextStyle( + fontSize: 16, + ), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.belowBaseline, + baseline: TextBaseline.alphabetic, + child: Checkbox(value: true, onChanged: null), + ), + WidgetSpan( + child: Checkbox(value: false, onChanged: null), + ), + TextSpan(text: 'He ', style: TextStyle(fontSize: 20)), + WidgetSpan( + alignment: PlaceholderAlignment.belowBaseline, + baseline: TextBaseline.alphabetic, + child: SizedBox( + width: 50.0, + height: 55.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xffffff00), + ), + child: Center( + child:SizedBox( + width: 10.0, + height: 15.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xffff0000), + ), + ) + ), + ), + ) + ), + ), + TextSpan(text: 'hello world! sieze the day!'), + WidgetSpan( + alignment: PlaceholderAlignment.belowBaseline, + baseline: TextBaseline.alphabetic, + child: Checkbox(value: false, onChanged: null), + ), + WidgetSpan( + alignment: PlaceholderAlignment.belowBaseline, + baseline: TextBaseline.alphabetic, + child: SizedBox( + width: 20, + height: 20, + child: Checkbox(value: true, onChanged: null), + ) + ), + WidgetSpan( + alignment: PlaceholderAlignment.belowBaseline, + baseline: TextBaseline.alphabetic, + child: Checkbox(value: false, onChanged: null), + ), + WidgetSpan( + alignment: PlaceholderAlignment.belowBaseline, + baseline: TextBaseline.alphabetic, + child: SizedBox( + width: 20, + height: 20, + child: Checkbox(value: true, onChanged: null), + ) + ), + WidgetSpan( + alignment: PlaceholderAlignment.belowBaseline, + baseline: TextBaseline.alphabetic, + child: Text('embedded'), + ), + TextSpan(text: 'ref'), + ], + ), + textDirection: TextDirection.ltr, + ), + ), + ), + ), + ), + ), + ), + ); + await expectLater( + find.byType(Container), + matchesGoldenFile('text_golden.TextInlineWidgetBelowBaseline.1.1.png'), + ); + }, skip: !isLinux); // Coretext uses different thicknesses for decoration + + testWidgets('Text Inline widget top', (WidgetTester tester) async { + await tester.pumpWidget( + Center( + child: RepaintBoundary( + child: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Container( + width: 400.0, + height: 200.0, + decoration: const BoxDecoration( + color: Color(0xff00ff00), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 100), + child: const Text.rich( + TextSpan( + text: 'C ', + style: TextStyle( + fontSize: 16, + ), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.top, + baseline: TextBaseline.alphabetic, + child: Checkbox(value: true, onChanged: null), + ), + WidgetSpan( + child: Checkbox(value: false, onChanged: null), + ), + TextSpan(text: 'He ', style: TextStyle(fontSize: 20)), + WidgetSpan( + alignment: PlaceholderAlignment.top, + baseline: TextBaseline.alphabetic, + child: SizedBox( + width: 50.0, + height: 55.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xffffff00), + ), + child: Center( + child:SizedBox( + width: 10.0, + height: 15.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xffff0000), + ), + ) + ), + ), + ) + ), + ), + TextSpan(text: 'hello world! sieze the day!'), + WidgetSpan( + alignment: PlaceholderAlignment.top, + baseline: TextBaseline.alphabetic, + child: Checkbox(value: false, onChanged: null), + ), + WidgetSpan( + alignment: PlaceholderAlignment.top, + baseline: TextBaseline.alphabetic, + child: SizedBox( + width: 20, + height: 20, + child: Checkbox(value: true, onChanged: null), + ) + ), + WidgetSpan( + alignment: PlaceholderAlignment.top, + baseline: TextBaseline.alphabetic, + child: Checkbox(value: false, onChanged: null), + ), + WidgetSpan( + alignment: PlaceholderAlignment.top, + baseline: TextBaseline.alphabetic, + child: SizedBox( + width: 20, + height: 20, + child: Checkbox(value: true, onChanged: null), + ) + ), + WidgetSpan( + alignment: PlaceholderAlignment.top, + baseline: TextBaseline.alphabetic, + child: Text('embedded'), + ), + TextSpan(text: 'ref'), + ], + ), + textDirection: TextDirection.ltr, + ), + ), + ), + ), + ), + ), + ), + ); + await expectLater( + find.byType(Container), + matchesGoldenFile('text_golden.TextInlineWidgetTop.1.1.png'), + ); + }, skip: !isLinux); // Coretext uses different thicknesses for decoration + + testWidgets('Text Inline widget middle', (WidgetTester tester) async { + await tester.pumpWidget( + Center( + child: RepaintBoundary( + child: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Container( + width: 400.0, + height: 200.0, + decoration: const BoxDecoration( + color: Color(0xff00ff00), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 100), + child: const Text.rich( + TextSpan( + text: 'C ', + style: TextStyle( + fontSize: 16, + ), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic, + child: Checkbox(value: true, onChanged: null), + ), + WidgetSpan( + child: Checkbox(value: false, onChanged: null), + ), + TextSpan(text: 'He ', style: TextStyle(fontSize: 20)), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic, + child: SizedBox( + width: 50.0, + height: 55.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xffffff00), + ), + child: Center( + child:SizedBox( + width: 10.0, + height: 15.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xffff0000), + ), + ) + ), + ), + ) + ), + ), + TextSpan(text: 'hello world! sieze the day!'), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic, + child: Checkbox(value: false, onChanged: null), + ), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic, + child: SizedBox( + width: 20, + height: 20, + child: Checkbox(value: true, onChanged: null), + ) + ), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic, + child: Checkbox(value: false, onChanged: null), + ), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic, + child: SizedBox( + width: 20, + height: 20, + child: Checkbox(value: true, onChanged: null), + ) + ), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic, + child: Text('embedded'), + ), + TextSpan(text: 'ref'), + ], + ), + textDirection: TextDirection.ltr, + ), + ), + ), + ), + ), + ), + ), + ); + await expectLater( + find.byType(Container), + matchesGoldenFile('text_golden.TextInlineWidgetMiddle.1.1.png'), + ); + }, skip: !isLinux); // Coretext uses different thicknesses for decoration } diff --git a/packages/flutter/test/widgets/text_test.dart b/packages/flutter/test/widgets/text_test.dart index 48a89ba1fdc..269263bd8bb 100644 --- a/packages/flutter/test/widgets/text_test.dart +++ b/packages/flutter/test/widgets/text_test.dart @@ -2,11 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/gestures.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import '../rendering/mock_canvas.dart'; import 'semantics_tester.dart'; @@ -294,6 +294,140 @@ void main() { semantics.dispose(); }, skip: true); // TODO(jonahwilliams): correct once https://github.com/flutter/flutter/issues/20891 is resolved. + testWidgets('inline widgets generate semantic nodes', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + const TextStyle textStyle = TextStyle(fontFamily: 'Ahem'); + await tester.pumpWidget( + Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'a '), + TextSpan(text: 'pebble', recognizer: TapGestureRecognizer()..onTap = () { }), + const TextSpan(text: ' in the '), + WidgetSpan( + child: SizedBox( + width: 20, + height: 40, + child: Card( + child: RichText( + text: const TextSpan(text: 'INTERRUPTION'), + textDirection: TextDirection.rtl, + ), + ), + ), + ), + const TextSpan(text: 'sky'), + ], + style: textStyle, + ), + textDirection: TextDirection.ltr, + ), + ); + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics.rootChild( + children: [ + TestSemantics( + label: 'a ', + textDirection: TextDirection.ltr, + ), + TestSemantics( + label: 'pebble', + textDirection: TextDirection.ltr, + actions: [ + SemanticsAction.tap, + ], + ), + TestSemantics( + label: ' in the ', + textDirection: TextDirection.ltr, + ), + TestSemantics( + label: 'INTERRUPTION', + textDirection: TextDirection.rtl, + ), + TestSemantics( + label: 'sky', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ); + expect(semantics, hasSemantics(expectedSemantics, ignoreTransform: true, ignoreId: true, ignoreRect: true)); + semantics.dispose(); + }); + + testWidgets('inline widgets semantic nodes scale', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + const TextStyle textStyle = TextStyle(fontFamily: 'Ahem'); + await tester.pumpWidget( + Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'a '), + TextSpan(text: 'pebble', recognizer: TapGestureRecognizer()..onTap = () { }), + const TextSpan(text: ' in the '), + WidgetSpan( + child: SizedBox( + width: 20, + height: 40, + child: Card( + child: RichText( + text: const TextSpan(text: 'INTERRUPTION'), + textDirection: TextDirection.rtl, + ), + ), + ), + ), + const TextSpan(text: 'sky'), + ], + style: textStyle, + ), + textDirection: TextDirection.ltr, + textScaleFactor: 2, + ), + ); + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics.rootChild( + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + children: [ + TestSemantics( + label: 'a ', + textDirection: TextDirection.ltr, + rect: const Rect.fromLTRB(-4.0, 48.0, 60.0, 84.0), + ), + TestSemantics( + label: 'pebble', + textDirection: TextDirection.ltr, + actions: [ + SemanticsAction.tap, + ], + rect: const Rect.fromLTRB(52.0, 48.0, 228.0, 84.0), + ), + TestSemantics( + label: ' in the ', + textDirection: TextDirection.ltr, + rect: const Rect.fromLTRB(220.0, 48.0, 452.0, 84.0), + ), + TestSemantics( + label: 'INTERRUPTION', + textDirection: TextDirection.rtl, + rect: const Rect.fromLTRB(448.0, 0.0, 488.0, 80.0), + ), + TestSemantics( + label: 'sky', + textDirection: TextDirection.ltr, + rect: const Rect.fromLTRB(484.0, 48.0, 576.0, 84.0), + ), + ], + ), + ], + ); + expect(semantics, hasSemantics(expectedSemantics, ignoreTransform: true, ignoreId: true,)); + semantics.dispose(); + }); testWidgets('Overflow is clipping correctly - short text with overflow: clip', (WidgetTester tester) async { await _pumpTextWidget( diff --git a/packages/flutter/test/widgets/widget_inspector_test.dart b/packages/flutter/test/widgets/widget_inspector_test.dart index 046528a64ac..8db54de1654 100644 --- a/packages/flutter/test/widgets/widget_inspector_test.dart +++ b/packages/flutter/test/widgets/widget_inspector_test.dart @@ -329,7 +329,10 @@ class TestWidgetInspectorService extends Object with WidgetInspectorService { } // State type is private, hence using dynamic. dynamic getInspectorState() => inspectorKey.currentState; - String paragraphText(RenderParagraph paragraph) => paragraph.text.text; + String paragraphText(RenderParagraph paragraph) { + final TextSpan textSpan = paragraph.text; + return textSpan.text; + } await tester.pumpWidget( Directionality(