| // 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.debugAssertIsValid()), | |
| assert(maxLines == null || maxLines > 0), | |
| _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; | |
| List<AttributedString>? _cachedAttributedLabels; | |
| List<InlineSpanSemanticsInformation>? _cachedCombinedSemanticsInfos; | |
| /// The text to display. | |
| InlineSpan get text => _textPainter.text!; | |
| set text(InlineSpan value) { | |
| 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; | |
| _cachedAttributedLabels = null; | |
| _cachedCombinedSemanticsInfos = null; | |
| _extractPlaceholderSpans(value); | |
| markNeedsPaint(); | |
| markNeedsSemanticsUpdate(); | |
| break; | |
| case RenderComparison.layout: | |
| _textPainter.text = value; | |
| _overflowShader = null; | |
| _cachedAttributedLabels = 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) { | |
| 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) { | |
| 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) { | |
| if (_softWrap == value) { | |
| return; | |
| } | |
| _softWrap = value; | |
| markNeedsLayout(); | |
| } | |
| /// How visual overflow should be handled. | |
| TextOverflow get overflow => _overflow; | |
| TextOverflow _overflow; | |
| set overflow(TextOverflow value) { | |
| 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) { | |
| 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) { | |
| 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.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: | |
| _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); | |
| _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(); | |
| bool needsAssembleSemanticsNode = false; | |
| bool needsChildConfigrationsDelegate = false; | |
| for (final InlineSpanSemanticsInformation info in _semanticsInfo!) { | |
| if (info.recognizer != null) { | |
| needsAssembleSemanticsNode = true; | |
| break; | |
| } | |
| needsChildConfigrationsDelegate = needsChildConfigrationsDelegate || info.isPlaceholder; | |
| } | |
| if (needsAssembleSemanticsNode) { | |
| config.explicitChildNodes = true; | |
| config.isSemanticBoundary = true; | |
| } else if (needsChildConfigrationsDelegate) { | |
| config.childConfigurationsDelegate = _childSemanticsConfigurationsDelegate; | |
| } else { | |
| if (_cachedAttributedLabels == 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; | |
| } | |
| _cachedAttributedLabels = <AttributedString>[AttributedString(buffer.toString(), attributes: attributes)]; | |
| } | |
| config.attributedLabel = _cachedAttributedLabels![0]; | |
| config.textDirection = textDirection; | |
| } | |
| } | |
| ChildSemanticsConfigurationsResult _childSemanticsConfigurationsDelegate(List<SemanticsConfiguration> childConfigs) { | |
| final ChildSemanticsConfigurationsResultBuilder builder = ChildSemanticsConfigurationsResultBuilder(); | |
| int placeholderIndex = 0; | |
| int childConfigsIndex = 0; | |
| int attributedLabelCacheIndex = 0; | |
| InlineSpanSemanticsInformation? seenTextInfo; | |
| _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!); | |
| for (final InlineSpanSemanticsInformation info in _cachedCombinedSemanticsInfos!) { | |
| if (info.isPlaceholder) { | |
| if (seenTextInfo != null) { | |
| builder.markAsMergeUp(_createSemanticsConfigForTextInfo(seenTextInfo, attributedLabelCacheIndex)); | |
| attributedLabelCacheIndex += 1; | |
| } | |
| // Mark every childConfig belongs to this placeholder to merge up group. | |
| while (childConfigsIndex < childConfigs.length && | |
| childConfigs[childConfigsIndex].tagsChildrenWith(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) { | |
| builder.markAsMergeUp(childConfigs[childConfigsIndex]); | |
| childConfigsIndex += 1; | |
| } | |
| placeholderIndex += 1; | |
| } else { | |
| seenTextInfo = info; | |
| } | |
| } | |
| // Handle plain text info at the end. | |
| if (seenTextInfo != null) { | |
| builder.markAsMergeUp(_createSemanticsConfigForTextInfo(seenTextInfo, attributedLabelCacheIndex)); | |
| } | |
| return builder.build(); | |
| } | |
| SemanticsConfiguration _createSemanticsConfigForTextInfo(InlineSpanSemanticsInformation textInfo, int cacheIndex) { | |
| assert(!textInfo.requiresOwnNode); | |
| final List<AttributedString> cachedStrings = _cachedAttributedLabels ??= <AttributedString>[]; | |
| assert(cacheIndex <= cachedStrings.length); | |
| final bool hasCache = cacheIndex < cachedStrings.length; | |
| late AttributedString attributedLabel; | |
| if (hasCache) { | |
| attributedLabel = cachedStrings[cacheIndex]; | |
| } else { | |
| assert(cachedStrings.length == cacheIndex); | |
| attributedLabel = AttributedString( | |
| textInfo.semanticsLabel ?? textInfo.text, | |
| attributes: textInfo.stringAttributes, | |
| ); | |
| cachedStrings.add(attributedLabel); | |
| } | |
| return SemanticsConfiguration() | |
| ..textDirection = textDirection | |
| ..attributedLabel = attributedLabel; | |
| } | |
| // 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 = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, CharacterBoundary(text)); | |
| result = SelectionResult.end; | |
| break; | |
| case TextGranularity.word: | |
| final TextBoundary textBoundary = paragraph._textPainter.wordBoundaries.moveByWordBoundary; | |
| newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, textBoundary); | |
| result = SelectionResult.end; | |
| break; | |
| case TextGranularity.line: | |
| newPosition = _moveToTextBoundaryAtDirection(targetedEdge, forward, LineBoundary(this)); | |
| result = SelectionResult.end; | |
| break; | |
| case TextGranularity.document: | |
| final String text = range.textInside(fullText); | |
| newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, DocumentBoundary(text)); | |
| 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; | |
| } | |
| // Move **beyond** the local boundary of the given type (unless range.start or | |
| // range.end is reached). Used for most TextGranularity types except for | |
| // TextGranularity.line, to ensure the selection movement doesn't get stuck at | |
| // a local fixed point. | |
| TextPosition _moveBeyondTextBoundaryAtDirection(TextPosition end, bool forward, TextBoundary textBoundary) { | |
| final int newOffset = forward | |
| ? textBoundary.getTrailingTextBoundaryAt(end.offset) ?? range.end | |
| : textBoundary.getLeadingTextBoundaryAt(end.offset - 1) ?? range.start; | |
| return TextPosition(offset: newOffset); | |
| } | |
| // Move **to** the local boundary of the given type. Typically used for line | |
| // boundaries, such that performing "move to line start" more than once never | |
| // moves the selection to the previous line. | |
| TextPosition _moveToTextBoundaryAtDirection(TextPosition end, bool forward, TextBoundary textBoundary) { | |
| assert(end.offset >= 0); | |
| final int caretOffset; | |
| switch (end.affinity) { | |
| case TextAffinity.upstream: | |
| if (end.offset < 1 && !forward) { | |
| assert (end.offset == 0); | |
| return const TextPosition(offset: 0); | |
| } | |
| final CharacterBoundary characterBoundary = CharacterBoundary(fullText); | |
| caretOffset = math.max( | |
| 0, | |
| characterBoundary.getLeadingTextBoundaryAt(range.start + end.offset) ?? range.start, | |
| ) - 1; | |
| break; | |
| case TextAffinity.downstream: | |
| caretOffset = end.offset; | |
| break; | |
| } | |
| final int offset = forward | |
| ? textBoundary.getTrailingTextBoundaryAt(caretOffset) ?? range.end | |
| : textBoundary.getLeadingTextBoundaryAt(caretOffset) ?? range.start; | |
| return TextPosition(offset: offset); | |
| } | |
| 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); | |
| } |