// Copyright 2014 The Flutter 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:collection'; import 'dart:math' as math; import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, Gradient, LineMetrics, PlaceholderAlignment, Shader, TextBox, TextHeightBehavior; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'box.dart'; import 'debug.dart'; import 'editable.dart'; import 'layer.dart'; import 'object.dart'; import 'selection.dart'; const String _kEllipsis = '\u2026'; /// Parent data for use with [RenderParagraph] and [RenderEditable]. class TextParentData extends ContainerBoxParentData<RenderBox> { /// The scaling of the text. double? scale; @override String toString() { final List<String> values = <String>[ 'offset=$offset', if (scale != null) 'scale=$scale', super.toString(), ]; return values.join('; '); } } /// Used by the [RenderParagraph] to map its rendering children to their /// corresponding semantics nodes. /// /// The [RichText] uses this to tag the relation between its placeholder spans /// and their semantics nodes. @immutable class PlaceholderSpanIndexSemanticsTag extends SemanticsTag { /// Creates a semantics tag with the input `index`. /// /// Different [PlaceholderSpanIndexSemanticsTag]s with the same `index` are /// consider the same. const PlaceholderSpanIndexSemanticsTag(this.index) : super('PlaceholderSpanIndexSemanticsTag($index)'); /// The index of this tag. final int index; @override bool operator ==(Object other) { return other is PlaceholderSpanIndexSemanticsTag && other.index == index; } @override int get hashCode => Object.hash(PlaceholderSpanIndexSemanticsTag, index); } /// A render object that displays a paragraph of text. class RenderParagraph extends RenderBox with ContainerRenderObjectMixin<RenderBox, TextParentData>, RenderBoxContainerDefaultsMixin<RenderBox, TextParentData>, RelayoutWhenSystemFontsChangeMixin { /// Creates a paragraph render object. /// /// The [text], [textAlign], [textDirection], [overflow], [softWrap], and /// [textScaleFactor] arguments must not be null. /// /// The [maxLines] property may be null (and indeed defaults to null), but if /// it is not null, it must be greater than zero. RenderParagraph(InlineSpan text, { TextAlign textAlign = TextAlign.start, required TextDirection textDirection, bool softWrap = true, TextOverflow overflow = TextOverflow.clip, double textScaleFactor = 1.0, int? maxLines, Locale? locale, StrutStyle? strutStyle, TextWidthBasis textWidthBasis = TextWidthBasis.parent, ui.TextHeightBehavior? textHeightBehavior, List<RenderBox>? children, Color? selectionColor, SelectionRegistrar? registrar, }) : assert(text != null), assert(text.debugAssertIsValid()), assert(textAlign != null), assert(textDirection != null), assert(softWrap != null), assert(overflow != null), assert(textScaleFactor != null), assert(maxLines == null || maxLines > 0), assert(textWidthBasis != null), _softWrap = softWrap, _overflow = overflow, _selectionColor = selectionColor, _textPainter = TextPainter( text: text, textAlign: textAlign, textDirection: textDirection, textScaleFactor: textScaleFactor, maxLines: maxLines, ellipsis: overflow == TextOverflow.ellipsis ? _kEllipsis : null, locale: locale, strutStyle: strutStyle, textWidthBasis: textWidthBasis, textHeightBehavior: textHeightBehavior, ) { addAll(children); _extractPlaceholderSpans(text); this.registrar = registrar; } @override void setupParentData(RenderBox child) { if (child.parentData is! TextParentData) { child.parentData = TextParentData(); } } static final String _placeholderCharacter = String.fromCharCode(PlaceholderSpan.placeholderCodeUnit); final TextPainter _textPainter; AttributedString? _cachedAttributedLabel; List<InlineSpanSemanticsInformation>? _cachedCombinedSemanticsInfos; /// The text to display. InlineSpan get text => _textPainter.text!; set text(InlineSpan value) { assert(value != null); switch (_textPainter.text!.compareTo(value)) { case RenderComparison.identical: return; case RenderComparison.metadata: _textPainter.text = value; _cachedCombinedSemanticsInfos = null; markNeedsSemanticsUpdate(); break; case RenderComparison.paint: _textPainter.text = value; _cachedAttributedLabel = null; _cachedCombinedSemanticsInfos = null; _extractPlaceholderSpans(value); markNeedsPaint(); markNeedsSemanticsUpdate(); break; case RenderComparison.layout: _textPainter.text = value; _overflowShader = null; _cachedAttributedLabel = null; _cachedCombinedSemanticsInfos = null; _extractPlaceholderSpans(value); markNeedsLayout(); _removeSelectionRegistrarSubscription(); _disposeSelectableFragments(); _updateSelectionRegistrarSubscription(); break; } } /// The ongoing selections in this paragraph. /// /// The selection does not include selections in [PlaceholderSpan] if there /// are any. @visibleForTesting List<TextSelection> get selections { if (_lastSelectableFragments == null) { return const <TextSelection>[]; } final List<TextSelection> results = <TextSelection>[]; for (final _SelectableFragment fragment in _lastSelectableFragments!) { if (fragment._textSelectionStart != null && fragment._textSelectionEnd != null && fragment._textSelectionStart!.offset != fragment._textSelectionEnd!.offset) { results.add( TextSelection( baseOffset: fragment._textSelectionStart!.offset, extentOffset: fragment._textSelectionEnd!.offset ) ); } } return results; } // Should be null if selection is not enabled, i.e. _registrar = null. The // paragraph splits on [PlaceholderSpan.placeholderCodeUnit], and stores each // fragment in this list. List<_SelectableFragment>? _lastSelectableFragments; /// The [SelectionRegistrar] this paragraph will be, or is, registered to. SelectionRegistrar? get registrar => _registrar; SelectionRegistrar? _registrar; set registrar(SelectionRegistrar? value) { if (value == _registrar) { return; } _removeSelectionRegistrarSubscription(); _disposeSelectableFragments(); _registrar = value; _updateSelectionRegistrarSubscription(); } void _updateSelectionRegistrarSubscription() { if (_registrar == null) { return; } _lastSelectableFragments ??= _getSelectableFragments(); _lastSelectableFragments!.forEach(_registrar!.add); } void _removeSelectionRegistrarSubscription() { if (_registrar == null || _lastSelectableFragments == null) { return; } _lastSelectableFragments!.forEach(_registrar!.remove); } List<_SelectableFragment> _getSelectableFragments() { final String plainText = text.toPlainText(includeSemanticsLabels: false); final List<_SelectableFragment> result = <_SelectableFragment>[]; int start = 0; while (start < plainText.length) { int end = plainText.indexOf(_placeholderCharacter, start); if (start != end) { if (end == -1) { end = plainText.length; } result.add(_SelectableFragment(paragraph: this, range: TextRange(start: start, end: end), fullText: plainText)); start = end; } start += 1; } return result; } void _disposeSelectableFragments() { if (_lastSelectableFragments == null) { return; } for (final _SelectableFragment fragment in _lastSelectableFragments!) { fragment.dispose(); } _lastSelectableFragments = null; } @override void markNeedsLayout() { _lastSelectableFragments?.forEach((_SelectableFragment element) => element.didChangeParagraphLayout()); super.markNeedsLayout(); } @override void dispose() { _removeSelectionRegistrarSubscription(); // _lastSelectableFragments may hold references to this RenderParagraph. // Release them manually to avoid retain cycles. _lastSelectableFragments = null; _textPainter.dispose(); super.dispose(); } late List<PlaceholderSpan> _placeholderSpans; void _extractPlaceholderSpans(InlineSpan span) { _placeholderSpans = <PlaceholderSpan>[]; span.visitChildren((InlineSpan span) { if (span is PlaceholderSpan) { _placeholderSpans.add(span); } return true; }); } /// How the text should be aligned horizontally. TextAlign get textAlign => _textPainter.textAlign; set textAlign(TextAlign value) { assert(value != null); if (_textPainter.textAlign == value) { return; } _textPainter.textAlign = value; markNeedsPaint(); } /// The directionality of the text. /// /// This decides how the [TextAlign.start], [TextAlign.end], and /// [TextAlign.justify] values of [textAlign] are interpreted. /// /// This is also used to disambiguate how to render bidirectional text. For /// example, if the [text] is an English phrase followed by a Hebrew phrase, /// in a [TextDirection.ltr] context the English phrase will be on the left /// and the Hebrew phrase to its right, while in a [TextDirection.rtl] /// context, the English phrase will be on the right and the Hebrew phrase on /// its left. /// /// This must not be null. TextDirection get textDirection => _textPainter.textDirection!; set textDirection(TextDirection value) { assert(value != null); if (_textPainter.textDirection == value) { return; } _textPainter.textDirection = value; markNeedsLayout(); } /// Whether the text should break at soft line breaks. /// /// If false, the glyphs in the text will be positioned as if there was /// unlimited horizontal space. /// /// If [softWrap] is false, [overflow] and [textAlign] may have unexpected /// effects. bool get softWrap => _softWrap; bool _softWrap; set softWrap(bool value) { assert(value != null); if (_softWrap == value) { return; } _softWrap = value; markNeedsLayout(); } /// How visual overflow should be handled. TextOverflow get overflow => _overflow; TextOverflow _overflow; set overflow(TextOverflow value) { assert(value != null); if (_overflow == value) { return; } _overflow = value; _textPainter.ellipsis = value == TextOverflow.ellipsis ? _kEllipsis : null; markNeedsLayout(); } /// The number of font pixels for each logical pixel. /// /// For example, if the text scale factor is 1.5, text will be 50% larger than /// the specified font size. double get textScaleFactor => _textPainter.textScaleFactor; set textScaleFactor(double value) { assert(value != null); if (_textPainter.textScaleFactor == value) { return; } _textPainter.textScaleFactor = value; _overflowShader = null; markNeedsLayout(); } /// An optional maximum number of lines for the text to span, wrapping if /// necessary. If the text exceeds the given number of lines, it will be /// truncated according to [overflow] and [softWrap]. int? get maxLines => _textPainter.maxLines; /// The value may be null. If it is not null, then it must be greater than /// zero. set maxLines(int? value) { assert(value == null || value > 0); if (_textPainter.maxLines == value) { return; } _textPainter.maxLines = value; _overflowShader = null; markNeedsLayout(); } /// Used by this paragraph's internal [TextPainter] to select a /// locale-specific font. /// /// In some cases, the same Unicode character may be rendered differently /// depending on the locale. For example, the '骨' character is rendered /// differently in the Chinese and Japanese locales. In these cases, the /// [locale] may be used to select a locale-specific font. Locale? get locale => _textPainter.locale; /// The value may be null. set locale(Locale? value) { if (_textPainter.locale == value) { return; } _textPainter.locale = value; _overflowShader = null; markNeedsLayout(); } /// {@macro flutter.painting.textPainter.strutStyle} StrutStyle? get strutStyle => _textPainter.strutStyle; /// The value may be null. set strutStyle(StrutStyle? value) { if (_textPainter.strutStyle == value) { return; } _textPainter.strutStyle = value; _overflowShader = null; markNeedsLayout(); } /// {@macro flutter.painting.textPainter.textWidthBasis} TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis; set textWidthBasis(TextWidthBasis value) { assert(value != null); if (_textPainter.textWidthBasis == value) { return; } _textPainter.textWidthBasis = value; _overflowShader = null; markNeedsLayout(); } /// {@macro dart.ui.textHeightBehavior} ui.TextHeightBehavior? get textHeightBehavior => _textPainter.textHeightBehavior; set textHeightBehavior(ui.TextHeightBehavior? value) { if (_textPainter.textHeightBehavior == value) { return; } _textPainter.textHeightBehavior = value; _overflowShader = null; markNeedsLayout(); } /// The color to use when painting the selection. /// /// Ignored if the text is not selectable (e.g. if [registrar] is null). Color? get selectionColor => _selectionColor; Color? _selectionColor; set selectionColor(Color? value) { if (_selectionColor == value) { return; } _selectionColor = value; if (_lastSelectableFragments?.any((_SelectableFragment fragment) => fragment.value.hasSelection) ?? false) { markNeedsPaint(); } } Offset _getOffsetForPosition(TextPosition position) { return getOffsetForCaret(position, Rect.zero) + Offset(0, getFullHeightForCaret(position) ?? 0.0); } List<ui.LineMetrics> _computeLineMetrics() { return _textPainter.computeLineMetrics(); } @override double computeMinIntrinsicWidth(double height) { if (!_canComputeIntrinsics()) { return 0.0; } _computeChildrenWidthWithMinIntrinsics(height); _layoutText(); // layout with infinite width. return _textPainter.minIntrinsicWidth; } @override double computeMaxIntrinsicWidth(double height) { 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; } @override double computeMinIntrinsicHeight(double width) { return _computeIntrinsicHeight(width); } @override double computeMaxIntrinsicHeight(double width) { return _computeIntrinsicHeight(width); } @override double computeDistanceToActualBaseline(TextBaseline baseline) { assert(!debugNeedsLayout); assert(constraints != null); assert(constraints.debugAssertIsValid()); _layoutTextWithConstraints(constraints); // TODO(garyq): Since our metric for ideographic baseline is currently // inaccurate and the non-alphabetic baselines are based off of the // alphabetic baseline, we use the alphabetic for now to produce correct // layouts. We should eventually change this back to pass the `baseline` // property when the ideographic baseline is properly implemented // (https://github.com/flutter/flutter/issues/22625). return _textPainter.computeDistanceToActualBaseline(TextBaseline.alphabetic); } // Intrinsics cannot be calculated without a full layout for // alignments that require the baseline (baseline, aboveBaseline, // belowBaseline). bool _canComputeIntrinsics() { for (final 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> placeholderDimensions = List<PlaceholderDimensions>.filled(childCount, PlaceholderDimensions.empty); int childIndex = 0; while (child != null) { // Height and baseline is irrelevant as all text will be laid // out in a single line. Therefore, using 0.0 as a dummy for the height. placeholderDimensions[childIndex] = PlaceholderDimensions( size: Size(child.getMaxIntrinsicWidth(double.infinity), 0.0), 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> placeholderDimensions = List<PlaceholderDimensions>.filled(childCount, PlaceholderDimensions.empty); int childIndex = 0; while (child != null) { // Height and baseline is irrelevant; only looking for the widest word or // placeholder. Therefore, using 0.0 as a dummy for height. placeholderDimensions[childIndex] = PlaceholderDimensions( size: Size(child.getMinIntrinsicWidth(double.infinity), 0.0), 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> placeholderDimensions = List<PlaceholderDimensions>.filled(childCount, PlaceholderDimensions.empty); int childIndex = 0; // Takes textScaleFactor into account because the content of the placeholder // span will be scaled up when it paints. width = width / textScaleFactor; while (child != null) { final Size size = child.getDryLayout(BoxConstraints(maxWidth: width)); placeholderDimensions[childIndex] = PlaceholderDimensions( size: size, 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, { required Offset position }) { // Hit test text spans. bool hitText = false; final TextPosition textPosition = _textPainter.getPositionForOffset(position); final InlineSpan? span = _textPainter.text!.getSpanForPosition(textPosition); if (span != null && span is HitTestTarget) { result.add(HitTestEntry(span as HitTestTarget)); hitText = true; } // Hit test render object children RenderBox? child = firstChild; int childIndex = 0; while (child != null && childIndex < _textPainter.inlinePlaceholderBoxes!.length) { final TextParentData textParentData = child.parentData! as TextParentData; 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); childIndex += 1; } return hitText; } bool _needsClipping = false; ui.Shader? _overflowShader; /// Whether this paragraph currently has a [dart:ui.Shader] for its overflow /// effect. /// /// Used to test this object. Not for use in production. @visibleForTesting bool get debugHasOverflowShader => _overflowShader != null; 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, ); } @override void systemFontsDidChange() { super.systemFontsDidChange(); _textPainter.markNeedsLayout(); } // Placeholder dimensions representing the sizes of child inline widgets. // // These need to be cached because the text painter's placeholder dimensions // will be overwritten during intrinsic width/height calculations and must be // restored to the original values before final layout and painting. List<PlaceholderDimensions>? _placeholderDimensions; void _layoutTextWithConstraints(BoxConstraints constraints) { _textPainter.setPlaceholderDimensions(_placeholderDimensions); _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. List<PlaceholderDimensions> _layoutChildren(BoxConstraints constraints, {bool dry = false}) { if (childCount == 0) { return <PlaceholderDimensions>[]; } RenderBox? child = firstChild; final List<PlaceholderDimensions> placeholderDimensions = List<PlaceholderDimensions>.filled(childCount, PlaceholderDimensions.empty); int childIndex = 0; // Only constrain the width to the maximum width of the paragraph. // Leave height unconstrained, which will overflow if expanded past. BoxConstraints boxConstraints = BoxConstraints(maxWidth: constraints.maxWidth); // The content will be enlarged by textScaleFactor during painting phase. // We reduce constraints by textScaleFactor, so that the content will fit // into the box once it is enlarged. boxConstraints = boxConstraints / textScaleFactor; while (child != null) { double? baselineOffset; final Size childSize; if (!dry) { child.layout( boxConstraints, parentUsesSize: true, ); childSize = child.size; switch (_placeholderSpans[childIndex].alignment) { case ui.PlaceholderAlignment.baseline: baselineOffset = child.getDistanceToBaseline( _placeholderSpans[childIndex].baseline!, ); break; case ui.PlaceholderAlignment.aboveBaseline: case ui.PlaceholderAlignment.belowBaseline: case ui.PlaceholderAlignment.bottom: case ui.PlaceholderAlignment.middle: case ui.PlaceholderAlignment.top: baselineOffset = null; break; } } else { assert(_placeholderSpans[childIndex].alignment != ui.PlaceholderAlignment.baseline); childSize = child.getDryLayout(boxConstraints); } placeholderDimensions[childIndex] = PlaceholderDimensions( size: childSize, alignment: _placeholderSpans[childIndex].alignment, baseline: _placeholderSpans[childIndex].baseline, baselineOffset: baselineOffset, ); child = childAfter(child); childIndex += 1; } return 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 && childIndex < _textPainter.inlinePlaceholderBoxes!.length) { final TextParentData textParentData = child.parentData! as TextParentData; textParentData.offset = Offset( _textPainter.inlinePlaceholderBoxes![childIndex].left, _textPainter.inlinePlaceholderBoxes![childIndex].top, ); textParentData.scale = _textPainter.inlinePlaceholderScales![childIndex]; child = childAfter(child); childIndex += 1; } } bool _canComputeDryLayout() { // Dry layout cannot be calculated without a full layout for // alignments that require the baseline (baseline, aboveBaseline, // belowBaseline). for (final PlaceholderSpan span in _placeholderSpans) { switch (span.alignment) { case ui.PlaceholderAlignment.baseline: case ui.PlaceholderAlignment.aboveBaseline: case ui.PlaceholderAlignment.belowBaseline: return false; case ui.PlaceholderAlignment.top: case ui.PlaceholderAlignment.middle: case ui.PlaceholderAlignment.bottom: continue; } } return true; } @override Size computeDryLayout(BoxConstraints constraints) { if (!_canComputeDryLayout()) { assert(debugCannotComputeDryLayout( reason: 'Dry layout not available for alignments that require baseline.', )); return Size.zero; } _textPainter.setPlaceholderDimensions(_layoutChildren(constraints, dry: true)); _layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); return constraints.constrain(_textPainter.size); } @override void performLayout() { final BoxConstraints constraints = this.constraints; _placeholderDimensions = _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 // calculations are destructive. Other _textPainter state will also be // affected. See also RenderEditable which has a similar issue. final Size textSize = _textPainter.size; final bool textDidExceedMaxLines = _textPainter.didExceedMaxLines; size = constraints.constrain(textSize); final bool didOverflowHeight = size.height < textSize.height || textDidExceedMaxLines; final bool didOverflowWidth = size.width < textSize.width; // TODO(abarth): We're only measuring the sizes of the line boxes here. If // the glyphs draw outside the line boxes, we might think that there isn't // visual overflow when there actually is visual overflow. This can become // a problem if we start having horizontal overflow and introduce a clip // that affects the actual (but undetected) vertical overflow. final bool hasVisualOverflow = didOverflowWidth || didOverflowHeight; if (hasVisualOverflow) { switch (_overflow) { case TextOverflow.visible: _needsClipping = false; _overflowShader = null; break; case TextOverflow.clip: case TextOverflow.ellipsis: _needsClipping = true; _overflowShader = null; break; case TextOverflow.fade: assert(textDirection != null); _needsClipping = true; final TextPainter fadeSizePainter = TextPainter( text: TextSpan(style: _textPainter.text!.style, text: '\u2026'), textDirection: textDirection, textScaleFactor: textScaleFactor, locale: locale, )..layout(); if (didOverflowWidth) { double fadeEnd, fadeStart; switch (textDirection) { case TextDirection.rtl: fadeEnd = 0.0; fadeStart = fadeSizePainter.width; break; case TextDirection.ltr: fadeEnd = size.width; fadeStart = fadeEnd - fadeSizePainter.width; break; } _overflowShader = ui.Gradient.linear( Offset(fadeStart, 0.0), Offset(fadeEnd, 0.0), <Color>[const Color(0xFFFFFFFF), const Color(0x00FFFFFF)], ); } else { final double fadeEnd = size.height; final double fadeStart = fadeEnd - fadeSizePainter.height / 2.0; _overflowShader = ui.Gradient.linear( Offset(0.0, fadeStart), Offset(0.0, fadeEnd), <Color>[const Color(0xFFFFFFFF), const Color(0x00FFFFFF)], ); } fadeSizePainter.dispose(); break; } } else { _needsClipping = false; _overflowShader = null; } } @override void paint(PaintingContext context, Offset offset) { // Ideally we could compute the min/max intrinsic width/height with a // non-destructive operation. However, currently, computing these values // will destroy state inside the painter. If that happens, we need to get // back the correct state by calling _layout again. // // TODO(abarth): Make computing the min/max intrinsic width/height a // non-destructive operation. // // If you remove this call, make sure that changing the textAlign still // works properly. _layoutTextWithConstraints(constraints); assert(() { if (debugRepaintTextRainbowEnabled) { final Paint paint = Paint() ..color = debugCurrentRepaintColor.toColor(); context.canvas.drawRect(offset & size, paint); } return true; }()); if (_needsClipping) { final Rect bounds = offset & size; if (_overflowShader != null) { // This layer limits what the shader below blends with to be just the // text (as opposed to the text and its background). context.canvas.saveLayer(bounds, Paint()); } else { context.canvas.save(); } context.canvas.clipRect(bounds); } _textPainter.paint(context.canvas, offset); RenderBox? child = firstChild; int childIndex = 0; // childIndex might be out of index of placeholder boxes. This can happen // if engine truncates children due to ellipsis. Sadly, we would not know // it until we finish layout, and RenderObject is in immutable state at // this point. while (child != null && childIndex < _textPainter.inlinePlaceholderBoxes!.length) { final TextParentData textParentData = child.parentData! as TextParentData; 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; } if (_needsClipping) { if (_overflowShader != null) { context.canvas.translate(offset.dx, offset.dy); final Paint paint = Paint() ..blendMode = BlendMode.modulate ..shader = _overflowShader; context.canvas.drawRect(Offset.zero & size, paint); } context.canvas.restore(); } if (_lastSelectableFragments != null) { for (final _SelectableFragment fragment in _lastSelectableFragments!) { fragment.paint(context, offset); } } super.paint(context, offset); } /// Returns the offset at which to paint the caret. /// /// Valid only after [layout]. Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) { assert(!debugNeedsLayout); _layoutTextWithConstraints(constraints); return _textPainter.getOffsetForCaret(position, caretPrototype); } /// {@macro flutter.painting.textPainter.getFullHeightForCaret} /// /// Valid only after [layout]. double? getFullHeightForCaret(TextPosition position) { assert(!debugNeedsLayout); _layoutTextWithConstraints(constraints); return _textPainter.getFullHeightForCaret(position, Rect.zero); } /// Returns a list of rects that bound the given selection. /// /// The [boxHeightStyle] and [boxWidthStyle] arguments may be used to select /// the shape of the [TextBox]es. These properties default to /// [ui.BoxHeightStyle.tight] and [ui.BoxWidthStyle.tight] respectively and /// must not be null. /// /// A given selection might have more than one rect if the [RenderParagraph] /// contains multiple [InlineSpan]s or bidirectional text, because logically /// contiguous text might not be visually contiguous. /// /// Valid only after [layout]. /// /// See also: /// /// * [TextPainter.getBoxesForSelection], the method in TextPainter to get /// the equivalent boxes. List<ui.TextBox> getBoxesForSelection( TextSelection selection, { ui.BoxHeightStyle boxHeightStyle = ui.BoxHeightStyle.tight, ui.BoxWidthStyle boxWidthStyle = ui.BoxWidthStyle.tight, }) { assert(!debugNeedsLayout); assert(boxHeightStyle != null); assert(boxWidthStyle != null); _layoutTextWithConstraints(constraints); return _textPainter.getBoxesForSelection( selection, boxHeightStyle: boxHeightStyle, boxWidthStyle: boxWidthStyle, ); } /// Returns the position within the text for the given pixel offset. /// /// Valid only after [layout]. TextPosition getPositionForOffset(Offset offset) { assert(!debugNeedsLayout); _layoutTextWithConstraints(constraints); return _textPainter.getPositionForOffset(offset); } /// Returns the text range of the word at the given offset. Characters not /// part of a word, such as spaces, symbols, and punctuation, have word breaks /// on both sides. In such cases, this method will return a text range that /// contains the given text position. /// /// Word boundaries are defined more precisely in Unicode Standard Annex #29 /// <http://www.unicode.org/reports/tr29/#Word_Boundaries>. /// /// Valid only after [layout]. TextRange getWordBoundary(TextPosition position) { assert(!debugNeedsLayout); _layoutTextWithConstraints(constraints); return _textPainter.getWordBoundary(position); } TextRange _getLineAtOffset(TextPosition position) => _textPainter.getLineBoundary(position); TextPosition _getTextPositionAbove(TextPosition position) { // -0.5 of preferredLineHeight points to the middle of the line above. final double preferredLineHeight = _textPainter.preferredLineHeight; final double verticalOffset = -0.5 * preferredLineHeight; return _getTextPositionVertical(position, verticalOffset); } TextPosition _getTextPositionBelow(TextPosition position) { // 1.5 of preferredLineHeight points to the middle of the line below. final double preferredLineHeight = _textPainter.preferredLineHeight; final double verticalOffset = 1.5 * preferredLineHeight; return _getTextPositionVertical(position, verticalOffset); } TextPosition _getTextPositionVertical(TextPosition position, double verticalOffset) { final Offset caretOffset = _textPainter.getOffsetForCaret(position, Rect.zero); final Offset caretOffsetTranslated = caretOffset.translate(0.0, verticalOffset); return _textPainter.getPositionForOffset(caretOffsetTranslated); } /// Returns the size of the text as laid out. /// /// This can differ from [size] if the text overflowed or if the [constraints] /// provided by the parent [RenderObject] forced the layout to be bigger than /// necessary for the given [text]. /// /// This returns the [TextPainter.size] of the underlying [TextPainter]. /// /// Valid only after [layout]. Size get textSize { assert(!debugNeedsLayout); return _textPainter.size; } /// Collected during [describeSemanticsConfiguration], used by /// [assembleSemanticsNode] and [_combineSemanticsInfo]. List<InlineSpanSemanticsInformation>? _semanticsInfo; @override void describeSemanticsConfiguration(SemanticsConfiguration config) { super.describeSemanticsConfiguration(config); _semanticsInfo = text.getSemanticsInformation(); if (_semanticsInfo!.any((InlineSpanSemanticsInformation info) => info.recognizer != null)) { config.explicitChildNodes = true; config.isSemanticBoundary = true; } else { if (_cachedAttributedLabel == null) { final StringBuffer buffer = StringBuffer(); int offset = 0; final List<StringAttribute> attributes = <StringAttribute>[]; for (final InlineSpanSemanticsInformation info in _semanticsInfo!) { final String label = info.semanticsLabel ?? info.text; for (final StringAttribute infoAttribute in info.stringAttributes) { final TextRange originalRange = infoAttribute.range; attributes.add( infoAttribute.copy( range: TextRange(start: offset + originalRange.start, end: offset + originalRange.end) ), ); } buffer.write(label); offset += label.length; } _cachedAttributedLabel = AttributedString(buffer.toString(), attributes: attributes); } config.attributedLabel = _cachedAttributedLabel!; config.textDirection = textDirection; } } // Caches [SemanticsNode]s created during [assembleSemanticsNode] so they // can be re-used when [assembleSemanticsNode] is called again. This ensures // stable ids for the [SemanticsNode]s of [TextSpan]s across // [assembleSemanticsNode] invocations. LinkedHashMap<Key, SemanticsNode>? _cachedChildNodes; @override void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) { assert(_semanticsInfo != null && _semanticsInfo!.isNotEmpty); final List<SemanticsNode> newChildren = <SemanticsNode>[]; TextDirection currentDirection = textDirection; Rect currentRect; double ordinal = 0.0; int start = 0; int placeholderIndex = 0; int childIndex = 0; RenderBox? child = firstChild; final LinkedHashMap<Key, SemanticsNode> newChildCache = LinkedHashMap<Key, SemanticsNode>(); _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!); for (final InlineSpanSemanticsInformation info in _cachedCombinedSemanticsInfos!) { final TextSelection selection = TextSelection( baseOffset: start, extentOffset: start + info.text.length, ); start += info.text.length; if (info.isPlaceholder) { // A placeholder span may have 0 to multiple semantics nodes, we need // to annotate all of the semantics nodes belong to this span. while (children.length > childIndex && children.elementAt(childIndex).isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) { final SemanticsNode childNode = children.elementAt(childIndex); final TextParentData parentData = child!.parentData! as TextParentData; assert(parentData.scale != null || parentData.offset == Offset.zero); // parentData.scale may be null if the render object is truncated. if (parentData.scale != null) { childNode.rect = Rect.fromLTWH( childNode.rect.left, childNode.rect.top, childNode.rect.width * parentData.scale!, childNode.rect.height * parentData.scale!, ); newChildren.add(childNode); } childIndex += 1; } child = childAfter(child!); placeholderIndex += 1; } else { final TextDirection initialDirection = currentDirection; final List<ui.TextBox> rects = getBoxesForSelection(selection); if (rects.isEmpty) { continue; } Rect rect = rects.first.toRect(); currentDirection = rects.first.direction; for (final ui.TextBox textBox in rects.skip(1)) { rect = rect.expandToInclude(textBox.toRect()); currentDirection = textBox.direction; } // Any of the text boxes may have had infinite dimensions. // We shouldn't pass infinite dimensions up to the bridges. rect = Rect.fromLTWH( math.max(0.0, rect.left), math.max(0.0, rect.top), math.min(rect.width, constraints.maxWidth), math.min(rect.height, constraints.maxHeight), ); // round the current rectangle to make this API testable and add some // padding so that the accessibility rects do not overlap with the text. currentRect = Rect.fromLTRB( rect.left.floorToDouble() - 4.0, rect.top.floorToDouble() - 4.0, rect.right.ceilToDouble() + 4.0, rect.bottom.ceilToDouble() + 4.0, ); final SemanticsConfiguration configuration = SemanticsConfiguration() ..sortKey = OrdinalSortKey(ordinal++) ..textDirection = initialDirection ..attributedLabel = AttributedString(info.semanticsLabel ?? info.text, attributes: info.stringAttributes); final GestureRecognizer? recognizer = info.recognizer; if (recognizer != null) { if (recognizer is TapGestureRecognizer) { if (recognizer.onTap != null) { configuration.onTap = recognizer.onTap; configuration.isLink = true; } } else if (recognizer is DoubleTapGestureRecognizer) { if (recognizer.onDoubleTap != null) { configuration.onTap = recognizer.onDoubleTap; configuration.isLink = true; } } else if (recognizer is LongPressGestureRecognizer) { if (recognizer.onLongPress != null) { configuration.onLongPress = recognizer.onLongPress; } } else { assert(false, '${recognizer.runtimeType} is not supported.'); } } if (node.parentPaintClipRect != null) { final Rect paintRect = node.parentPaintClipRect!.intersect(currentRect); configuration.isHidden = paintRect.isEmpty && !currentRect.isEmpty; } late final SemanticsNode newChild; if (_cachedChildNodes?.isNotEmpty ?? false) { newChild = _cachedChildNodes!.remove(_cachedChildNodes!.keys.first)!; } else { final UniqueKey key = UniqueKey(); newChild = SemanticsNode( key: key, showOnScreen: _createShowOnScreenFor(key), ); } newChild ..updateWith(config: configuration) ..rect = currentRect; newChildCache[newChild.key!] = newChild; newChildren.add(newChild); } } // Makes sure we annotated all of the semantics children. assert(childIndex == children.length); assert(child == null); _cachedChildNodes = newChildCache; node.updateWith(config: config, childrenInInversePaintOrder: newChildren); } VoidCallback? _createShowOnScreenFor(Key key) { return () { final SemanticsNode node = _cachedChildNodes![key]!; showOnScreen(descendant: this, rect: node.rect); }; } @override void clearSemantics() { super.clearSemantics(); _cachedChildNodes = null; } @override List<DiagnosticsNode> debugDescribeChildren() { return <DiagnosticsNode>[ text.toDiagnosticsNode( name: 'text', style: DiagnosticsTreeStyle.transition, ), ]; } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(EnumProperty<TextAlign>('textAlign', textAlign)); properties.add(EnumProperty<TextDirection>('textDirection', textDirection)); properties.add( FlagProperty( 'softWrap', value: softWrap, ifTrue: 'wrapping at box width', ifFalse: 'no wrapping except at line break characters', showName: true, ), ); properties.add(EnumProperty<TextOverflow>('overflow', overflow)); properties.add( DoubleProperty( 'textScaleFactor', textScaleFactor, defaultValue: 1.0, ), ); properties.add( DiagnosticsProperty<Locale>( 'locale', locale, defaultValue: null, ), ); properties.add(IntProperty('maxLines', maxLines, ifNull: 'unlimited')); } } /// A continuous, selectable piece of paragraph. /// /// Since the selections in [PlaceHolderSpan] are handled independently in its /// subtree, a selection in [RenderParagraph] can't continue across a /// [PlaceHolderSpan]. The [RenderParagraph] splits itself on [PlaceHolderSpan] /// to create multiple `_SelectableFragment`s so that they can be selected /// separately. class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutMetrics { _SelectableFragment({ required this.paragraph, required this.fullText, required this.range, }) : assert(range.isValid && !range.isCollapsed && range.isNormalized) { _selectionGeometry = _getSelectionGeometry(); } final TextRange range; final RenderParagraph paragraph; final String fullText; TextPosition? _textSelectionStart; TextPosition? _textSelectionEnd; LayerLink? _startHandleLayerLink; LayerLink? _endHandleLayerLink; @override SelectionGeometry get value => _selectionGeometry; late SelectionGeometry _selectionGeometry; void _updateSelectionGeometry() { final SelectionGeometry newValue = _getSelectionGeometry(); if (_selectionGeometry == newValue) { return; } _selectionGeometry = newValue; notifyListeners(); } SelectionGeometry _getSelectionGeometry() { if (_textSelectionStart == null || _textSelectionEnd == null) { return const SelectionGeometry( status: SelectionStatus.none, hasContent: true, ); } final int selectionStart = _textSelectionStart!.offset; final int selectionEnd = _textSelectionEnd!.offset; final bool isReversed = selectionStart > selectionEnd; final Offset startOffsetInParagraphCoordinates = paragraph._getOffsetForPosition(TextPosition(offset: selectionStart)); final Offset endOffsetInParagraphCoordinates = selectionStart == selectionEnd ? startOffsetInParagraphCoordinates : paragraph._getOffsetForPosition(TextPosition(offset: selectionEnd)); final bool flipHandles = isReversed != (TextDirection.rtl == paragraph.textDirection); final Matrix4 paragraphToFragmentTransform = getTransformToParagraph()..invert(); return SelectionGeometry( startSelectionPoint: SelectionPoint( localPosition: MatrixUtils.transformPoint(paragraphToFragmentTransform, startOffsetInParagraphCoordinates), lineHeight: paragraph._textPainter.preferredLineHeight, handleType: flipHandles ? TextSelectionHandleType.right : TextSelectionHandleType.left ), endSelectionPoint: SelectionPoint( localPosition: MatrixUtils.transformPoint(paragraphToFragmentTransform, endOffsetInParagraphCoordinates), lineHeight: paragraph._textPainter.preferredLineHeight, handleType: flipHandles ? TextSelectionHandleType.left : TextSelectionHandleType.right, ), status: _textSelectionStart!.offset == _textSelectionEnd!.offset ? SelectionStatus.collapsed : SelectionStatus.uncollapsed, hasContent: true, ); } @override SelectionResult dispatchSelectionEvent(SelectionEvent event) { late final SelectionResult result; final TextPosition? existingSelectionStart = _textSelectionStart; final TextPosition? existingSelectionEnd = _textSelectionEnd; switch (event.type) { case SelectionEventType.startEdgeUpdate: case SelectionEventType.endEdgeUpdate: final SelectionEdgeUpdateEvent edgeUpdate = event as SelectionEdgeUpdateEvent; result = _updateSelectionEdge(edgeUpdate.globalPosition, isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate); break; case SelectionEventType.clear: result = _handleClearSelection(); break; case SelectionEventType.selectAll: result = _handleSelectAll(); break; case SelectionEventType.selectWord: final SelectWordSelectionEvent selectWord = event as SelectWordSelectionEvent; result = _handleSelectWord(selectWord.globalPosition); break; case SelectionEventType.granularlyExtendSelection: final GranularlyExtendSelectionEvent granularlyExtendSelection = event as GranularlyExtendSelectionEvent; result = _handleGranularlyExtendSelection( granularlyExtendSelection.forward, granularlyExtendSelection.isEnd, granularlyExtendSelection.granularity, ); break; case SelectionEventType.directionallyExtendSelection: final DirectionallyExtendSelectionEvent directionallyExtendSelection = event as DirectionallyExtendSelectionEvent; result = _handleDirectionallyExtendSelection( directionallyExtendSelection.dx, directionallyExtendSelection.isEnd, directionallyExtendSelection.direction, ); break; } if (existingSelectionStart != _textSelectionStart || existingSelectionEnd != _textSelectionEnd) { _didChangeSelection(); } return result; } @override SelectedContent? getSelectedContent() { if (_textSelectionStart == null || _textSelectionEnd == null) { return null; } final int start = math.min(_textSelectionStart!.offset, _textSelectionEnd!.offset); final int end = math.max(_textSelectionStart!.offset, _textSelectionEnd!.offset); return SelectedContent( plainText: fullText.substring(start, end), ); } void _didChangeSelection() { paragraph.markNeedsPaint(); _updateSelectionGeometry(); } SelectionResult _updateSelectionEdge(Offset globalPosition, {required bool isEnd}) { _setSelectionPosition(null, isEnd: isEnd); final Matrix4 transform = paragraph.getTransformTo(null); transform.invert(); final Offset localPosition = MatrixUtils.transformPoint(transform, globalPosition); if (_rect.isEmpty) { return SelectionUtils.getResultBasedOnRect(_rect, localPosition); } final Offset adjustedOffset = SelectionUtils.adjustDragOffset( _rect, localPosition, direction: paragraph.textDirection, ); final TextPosition position = _clampTextPosition(paragraph.getPositionForOffset(adjustedOffset)); _setSelectionPosition(position, isEnd: isEnd); if (position.offset == range.end) { return SelectionResult.next; } if (position.offset == range.start) { return SelectionResult.previous; } // TODO(chunhtai): The geometry information should not be used to determine // selection result. This is a workaround to RenderParagraph, where it does // not have a way to get accurate text length if its text is truncated due to // layout constraint. return SelectionUtils.getResultBasedOnRect(_rect, localPosition); } TextPosition _clampTextPosition(TextPosition position) { // Affinity of range.end is upstream. if (position.offset > range.end || (position.offset == range.end && position.affinity == TextAffinity.downstream)) { return TextPosition(offset: range.end, affinity: TextAffinity.upstream); } if (position.offset < range.start) { return TextPosition(offset: range.start); } return position; } void _setSelectionPosition(TextPosition? position, {required bool isEnd}) { if (isEnd) { _textSelectionEnd = position; } else { _textSelectionStart = position; } } SelectionResult _handleClearSelection() { _textSelectionStart = null; _textSelectionEnd = null; return SelectionResult.none; } SelectionResult _handleSelectAll() { _textSelectionStart = TextPosition(offset: range.start); _textSelectionEnd = TextPosition(offset: range.end, affinity: TextAffinity.upstream); return SelectionResult.none; } SelectionResult _handleSelectWord(Offset globalPosition) { final TextPosition position = paragraph.getPositionForOffset(paragraph.globalToLocal(globalPosition)); if (_positionIsWithinCurrentSelection(position)) { return SelectionResult.end; } final TextRange word = paragraph.getWordBoundary(position); assert(word.isNormalized); // Fragments are separated by placeholder span, the word boundary shouldn't // expand across fragments. assert(word.start >= range.start && word.end <= range.end); late TextPosition start; late TextPosition end; if (position.offset >= word.end) { start = end = TextPosition(offset: position.offset); } else { start = TextPosition(offset: word.start); end = TextPosition(offset: word.end, affinity: TextAffinity.upstream); } _textSelectionStart = start; _textSelectionEnd = end; return SelectionResult.end; } SelectionResult _handleDirectionallyExtendSelection(double horizontalBaseline, bool isExtent, SelectionExtendDirection movement) { final Matrix4 transform = paragraph.getTransformTo(null); if (transform.invert() == 0.0) { switch(movement) { case SelectionExtendDirection.previousLine: case SelectionExtendDirection.backward: return SelectionResult.previous; case SelectionExtendDirection.nextLine: case SelectionExtendDirection.forward: return SelectionResult.next; } } final double baselineInParagraphCoordinates = MatrixUtils.transformPoint(transform, Offset(horizontalBaseline, 0)).dx; assert(!baselineInParagraphCoordinates.isNaN); final TextPosition newPosition; final SelectionResult result; switch(movement) { case SelectionExtendDirection.previousLine: case SelectionExtendDirection.nextLine: assert(_textSelectionEnd != null && _textSelectionStart != null); final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!; final MapEntry<TextPosition, SelectionResult> moveResult = _handleVerticalMovement( targetedEdge, horizontalBaselineInParagraphCoordinates: baselineInParagraphCoordinates, below: movement == SelectionExtendDirection.nextLine, ); newPosition = moveResult.key; result = moveResult.value; break; case SelectionExtendDirection.forward: case SelectionExtendDirection.backward: _textSelectionEnd ??= movement == SelectionExtendDirection.forward ? TextPosition(offset: range.start) : TextPosition(offset: range.end, affinity: TextAffinity.upstream); _textSelectionStart ??= _textSelectionEnd; final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!; final Offset edgeOffsetInParagraphCoordinates = paragraph._getOffsetForPosition(targetedEdge); final Offset baselineOffsetInParagraphCoordinates = Offset( baselineInParagraphCoordinates, // Use half of line height to point to the middle of the line. edgeOffsetInParagraphCoordinates.dy - paragraph._textPainter.preferredLineHeight / 2, ); newPosition = paragraph.getPositionForOffset(baselineOffsetInParagraphCoordinates); result = SelectionResult.end; break; } if (isExtent) { _textSelectionEnd = newPosition; } else { _textSelectionStart = newPosition; } return result; } SelectionResult _handleGranularlyExtendSelection(bool forward, bool isExtent, TextGranularity granularity) { _textSelectionEnd ??= forward ? TextPosition(offset: range.start) : TextPosition(offset: range.end, affinity: TextAffinity.upstream); _textSelectionStart ??= _textSelectionEnd; final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!; if (forward && (targetedEdge.offset == range.end)) { return SelectionResult.next; } if (!forward && (targetedEdge.offset == range.start)) { return SelectionResult.previous; } final SelectionResult result; final TextPosition newPosition; switch (granularity) { case TextGranularity.character: final String text = range.textInside(fullText); newPosition = _getNextPosition(CharacterBoundary(text), targetedEdge, forward); result = SelectionResult.end; break; case TextGranularity.word: final String text = range.textInside(fullText); newPosition = _getNextPosition(WhitespaceBoundary(text) + WordBoundary(this), targetedEdge, forward); result = SelectionResult.end; break; case TextGranularity.line: newPosition = _getNextPosition(LineBreak(this), targetedEdge, forward); result = SelectionResult.end; break; case TextGranularity.document: final String text = range.textInside(fullText); newPosition = _getNextPosition(DocumentBoundary(text), targetedEdge, forward); if (forward && newPosition.offset == range.end) { result = SelectionResult.next; } else if (!forward && newPosition.offset == range.start) { result = SelectionResult.previous; } else { result = SelectionResult.end; } break; } if (isExtent) { _textSelectionEnd = newPosition; } else { _textSelectionStart = newPosition; } return result; } TextPosition _getNextPosition(TextBoundary boundary, TextPosition position, bool forward) { if (forward) { return _clampTextPosition( (PushTextPosition.forward + boundary).getTrailingTextBoundaryAt(position) ); } return _clampTextPosition( (PushTextPosition.backward + boundary).getLeadingTextBoundaryAt(position), ); } MapEntry<TextPosition, SelectionResult> _handleVerticalMovement(TextPosition position, {required double horizontalBaselineInParagraphCoordinates, required bool below}) { final List<ui.LineMetrics> lines = paragraph._computeLineMetrics(); final Offset offset = paragraph.getOffsetForCaret(position, Rect.zero); int currentLine = lines.length - 1; for (final ui.LineMetrics lineMetrics in lines) { if (lineMetrics.baseline > offset.dy) { currentLine = lineMetrics.lineNumber; break; } } final TextPosition newPosition; if (below && currentLine == lines.length - 1) { newPosition = TextPosition(offset: range.end, affinity: TextAffinity.upstream); } else if (!below && currentLine == 0) { newPosition = TextPosition(offset: range.start); } else { final int newLine = below ? currentLine + 1 : currentLine - 1; newPosition = _clampTextPosition( paragraph.getPositionForOffset(Offset(horizontalBaselineInParagraphCoordinates, lines[newLine].baseline)) ); } final SelectionResult result; if (newPosition.offset == range.start) { result = SelectionResult.previous; } else if (newPosition.offset == range.end) { result = SelectionResult.next; } else { result = SelectionResult.end; } assert(result != SelectionResult.next || below); assert(result != SelectionResult.previous || !below); return MapEntry<TextPosition, SelectionResult>(newPosition, result); } /// Whether the given text position is contained in current selection /// range. /// /// The parameter `start` must be smaller than `end`. bool _positionIsWithinCurrentSelection(TextPosition position) { if (_textSelectionStart == null || _textSelectionEnd == null) { return false; } // Normalize current selection. late TextPosition currentStart; late TextPosition currentEnd; if (_compareTextPositions(_textSelectionStart!, _textSelectionEnd!) > 0) { currentStart = _textSelectionStart!; currentEnd = _textSelectionEnd!; } else { currentStart = _textSelectionEnd!; currentEnd = _textSelectionStart!; } return _compareTextPositions(currentStart, position) >= 0 && _compareTextPositions(currentEnd, position) <= 0; } /// Compares two text positions. /// /// Returns 1 if `position` < `otherPosition`, -1 if `position` > `otherPosition`, /// or 0 if they are equal. static int _compareTextPositions(TextPosition position, TextPosition otherPosition) { if (position.offset < otherPosition.offset) { return 1; } else if (position.offset > otherPosition.offset) { return -1; } else if (position.affinity == otherPosition.affinity){ return 0; } else { return position.affinity == TextAffinity.upstream ? 1 : -1; } } Matrix4 getTransformToParagraph() { return Matrix4.translationValues(_rect.left, _rect.top, 0.0); } @override Matrix4 getTransformTo(RenderObject? ancestor) { return getTransformToParagraph()..multiply(paragraph.getTransformTo(ancestor)); } @override void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { if (!paragraph.attached) { assert(startHandle == null && endHandle == null, 'Only clean up can be called.'); return; } if (_startHandleLayerLink != startHandle) { _startHandleLayerLink = startHandle; paragraph.markNeedsPaint(); } if (_endHandleLayerLink != endHandle) { _endHandleLayerLink = endHandle; paragraph.markNeedsPaint(); } } Rect get _rect { if (_cachedRect == null) { final List<TextBox> boxes = paragraph.getBoxesForSelection( TextSelection(baseOffset: range.start, extentOffset: range.end), ); if (boxes.isNotEmpty) { Rect result = boxes.first.toRect(); for (int index = 1; index < boxes.length; index += 1) { result = result.expandToInclude(boxes[index].toRect()); } _cachedRect = result; } else { final Offset offset = paragraph._getOffsetForPosition(TextPosition(offset: range.start)); _cachedRect = Rect.fromPoints(offset, offset.translate(0, - paragraph._textPainter.preferredLineHeight)); } } return _cachedRect!; } Rect? _cachedRect; void didChangeParagraphLayout() { _cachedRect = null; } @override Size get size { return _rect.size; } void paint(PaintingContext context, Offset offset) { if (_textSelectionStart == null || _textSelectionEnd == null) { return; } if (paragraph.selectionColor != null) { final TextSelection selection = TextSelection( baseOffset: _textSelectionStart!.offset, extentOffset: _textSelectionEnd!.offset, ); final Paint selectionPaint = Paint() ..style = PaintingStyle.fill ..color = paragraph.selectionColor!; for (final TextBox textBox in paragraph.getBoxesForSelection(selection)) { context.canvas.drawRect( textBox.toRect().shift(offset), selectionPaint); } } final Matrix4 transform = getTransformToParagraph(); if (_startHandleLayerLink != null && value.startSelectionPoint != null) { context.pushLayer( LeaderLayer( link: _startHandleLayerLink!, offset: offset + MatrixUtils.transformPoint(transform, value.startSelectionPoint!.localPosition), ), (PaintingContext context, Offset offset) { }, Offset.zero, ); } if (_endHandleLayerLink != null && value.endSelectionPoint != null) { context.pushLayer( LeaderLayer( link: _endHandleLayerLink!, offset: offset + MatrixUtils.transformPoint(transform, value.endSelectionPoint!.localPosition), ), (PaintingContext context, Offset offset) { }, Offset.zero, ); } } @override TextSelection getLineAtOffset(TextPosition position) { final TextRange line = paragraph._getLineAtOffset(position); final int start = line.start.clamp(range.start, range.end); // ignore_clamp_double_lint final int end = line.end.clamp(range.start, range.end); // ignore_clamp_double_lint return TextSelection(baseOffset: start, extentOffset: end); } @override TextPosition getTextPositionAbove(TextPosition position) { return _clampTextPosition(paragraph._getTextPositionAbove(position)); } @override TextPosition getTextPositionBelow(TextPosition position) { return _clampTextPosition(paragraph._getTextPositionBelow(position)); } @override TextRange getWordBoundary(TextPosition position) => paragraph.getWordBoundary(position); }