blob: 13f3338bb82b0929017b65b982473780adff2a00 [file] [log] [blame]
// 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 'layer.dart';
import 'layout_helper.dart';
import 'object.dart';
import 'selection.dart';
/// The start and end positions for a text boundary.
typedef _TextBoundaryRecord = ({TextPosition boundaryStart, TextPosition boundaryEnd});
/// Signature for a function that determines the [_TextBoundaryRecord] at the given
/// [TextPosition].
typedef _TextBoundaryAtPosition = _TextBoundaryRecord Function(TextPosition position);
/// Signature for a function that determines the [_TextBoundaryRecord] at the given
/// [TextPosition], for the given [String].
typedef _TextBoundaryAtPositionInText = _TextBoundaryRecord Function(TextPosition position, String text);
const String _kEllipsis = '\u2026';
/// 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);
}
/// Parent data used by [RenderParagraph] and [RenderEditable] to annotate
/// inline contents (such as [WidgetSpan]s) with.
class TextParentData extends ParentData with ContainerParentDataMixin<RenderBox> {
/// The offset at which to paint the child in the parent's coordinate system.
///
/// A `null` value indicates this inline widget is not laid out. For instance,
/// when the inline widget has never been laid out, or the inline widget is
/// ellipsized away.
Offset? get offset => _offset;
Offset? _offset;
/// The [PlaceholderSpan] associated with this render child.
///
/// This field is usually set by a [ParentDataWidget], and is typically not
/// null when `performLayout` is called.
PlaceholderSpan? span;
@override
void detach() {
span = null;
_offset = null;
super.detach();
}
@override
String toString() => 'widget: $span, ${offset == null ? "not laid out" : "offset: $offset"}';
}
/// A mixin that provides useful default behaviors for text [RenderBox]es
/// ([RenderParagraph] and [RenderEditable] for example) with inline content
/// children managed by the [ContainerRenderObjectMixin] mixin.
///
/// This mixin assumes every child managed by the [ContainerRenderObjectMixin]
/// mixin corresponds to a [PlaceholderSpan], and they are organized in logical
/// order of the text (the order each [PlaceholderSpan] is encountered when the
/// user reads the text).
///
/// To use this mixin in a [RenderBox] class:
///
/// * Call [layoutInlineChildren] in the `performLayout` and `computeDryLayout`
/// implementation, and during intrinsic size calculations, to get the size
/// information of the inline widgets as a `List` of `PlaceholderDimensions`.
/// Determine the positioning of the inline widgets (which is usually done by
/// a [TextPainter] using its line break algorithm).
///
/// * Call [positionInlineChildren] with the positioning information of the
/// inline widgets.
///
/// * Implement [RenderBox.applyPaintTransform], optionally with
/// [defaultApplyPaintTransform].
///
/// * Call [paintInlineChildren] in [RenderBox.paint] to paint the inline widgets.
///
/// * Call [hitTestInlineChildren] in [RenderBox.hitTestChildren] to hit test the
/// inline widgets.
///
/// See also:
///
/// * [WidgetSpan.extractFromInlineSpan], a helper function for extracting
/// [WidgetSpan]s from an [InlineSpan] tree.
mixin RenderInlineChildrenContainerDefaults on RenderBox, ContainerRenderObjectMixin<RenderBox, TextParentData> {
@override
void setupParentData(RenderBox child) {
if (child.parentData is! TextParentData) {
child.parentData = TextParentData();
}
}
static PlaceholderDimensions _layoutChild(RenderBox child, BoxConstraints childConstraints, ChildLayouter layoutChild, ChildBaselineGetter getBaseline) {
final TextParentData parentData = child.parentData! as TextParentData;
final PlaceholderSpan? span = parentData.span;
assert(span != null);
return span == null
? PlaceholderDimensions.empty
: PlaceholderDimensions(
size: layoutChild(child, childConstraints),
alignment: span.alignment,
baseline: span.baseline,
baselineOffset: switch (span.alignment) {
ui.PlaceholderAlignment.aboveBaseline ||
ui.PlaceholderAlignment.belowBaseline ||
ui.PlaceholderAlignment.bottom ||
ui.PlaceholderAlignment.middle ||
ui.PlaceholderAlignment.top => null,
ui.PlaceholderAlignment.baseline => getBaseline(child, childConstraints, span.baseline!),
},
);
}
/// Computes the layout for every inline child using the `maxWidth` constraint.
///
/// Returns a list of [PlaceholderDimensions], representing the layout results
/// for each child managed by the [ContainerRenderObjectMixin] mixin.
///
/// The `getChildBaseline` parameter and the `layoutChild` parameter must be
/// consistent: if `layoutChild` computes the size of the child without
/// modifying the actual layout of that child, then `getChildBaseline` must
/// also be "dry", and vice versa.
///
/// Since this method does not impose a maximum height constraint on the
/// inline children, some children may become taller than this [RenderBox].
///
/// See also:
///
/// * [TextPainter.setPlaceholderDimensions], the method that usually takes
/// the layout results from this method as the input.
@protected
List<PlaceholderDimensions> layoutInlineChildren(double maxWidth, ChildLayouter layoutChild, ChildBaselineGetter getChildBaseline) {
final BoxConstraints constraints = BoxConstraints(maxWidth: maxWidth);
return <PlaceholderDimensions>[
for (RenderBox? child = firstChild; child != null; child = childAfter(child))
_layoutChild(child, constraints, layoutChild, getChildBaseline),
];
}
/// Positions each inline child according to the coordinates provided in the
/// `boxes` list.
///
/// The `boxes` list must be in logical order, which is the order each child
/// is encountered when the user reads the text. Usually the length of the
/// list equals [childCount], but it can be less than that, when some children
/// are omitted due to ellipsing. It never exceeds [childCount].
///
/// See also:
///
/// * [TextPainter.inlinePlaceholderBoxes], the method that can be used to
/// get the input `boxes`.
@protected
void positionInlineChildren(List<ui.TextBox> boxes) {
RenderBox? child = firstChild;
for (final ui.TextBox box in boxes) {
if (child == null) {
assert(false, 'The length of boxes (${boxes.length}) should be greater than childCount ($childCount)');
return;
}
final TextParentData textParentData = child.parentData! as TextParentData;
textParentData._offset = Offset(box.left, box.top);
child = childAfter(child);
}
while (child != null) {
final TextParentData textParentData = child.parentData! as TextParentData;
textParentData._offset = null;
child = childAfter(child);
}
}
/// Applies the transform that would be applied when painting the given child
/// to the given matrix.
///
/// Render children whose [TextParentData.offset] is null zeros out the
/// `transform` to indicate they're invisible thus should not be painted.
@protected
void defaultApplyPaintTransform(RenderBox child, Matrix4 transform) {
final TextParentData childParentData = child.parentData! as TextParentData;
final Offset? offset = childParentData.offset;
if (offset == null) {
transform.setZero();
} else {
transform.translate(offset.dx, offset.dy);
}
}
/// Paints each inline child.
///
/// Render children whose [TextParentData.offset] is null will be skipped by
/// this method.
@protected
void paintInlineChildren(PaintingContext context, Offset offset) {
RenderBox? child = firstChild;
while (child != null) {
final TextParentData childParentData = child.parentData! as TextParentData;
final Offset? childOffset = childParentData.offset;
if (childOffset == null) {
return;
}
context.paintChild(child, childOffset + offset);
child = childAfter(child);
}
}
/// Performs a hit test on each inline child.
///
/// Render children whose [TextParentData.offset] is null will be skipped by
/// this method.
@protected
bool hitTestInlineChildren(BoxHitTestResult result, Offset position) {
RenderBox? child = firstChild;
while (child != null) {
final TextParentData childParentData = child.parentData! as TextParentData;
final Offset? childOffset = childParentData.offset;
if (childOffset == null) {
return false;
}
final bool isHit = result.addWithPaintOffset(
offset: childOffset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) => child!.hitTest(result, position: transformed),
);
if (isHit) {
return true;
}
child = childAfter(child);
}
return false;
}
}
/// A render object that displays a paragraph of text.
class RenderParagraph extends RenderBox with ContainerRenderObjectMixin<RenderBox, TextParentData>, RenderInlineChildrenContainerDefaults, RelayoutWhenSystemFontsChangeMixin {
/// Creates a paragraph render object.
///
/// 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,
@Deprecated(
'Use textScaler instead. '
'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. '
'This feature was deprecated after v3.12.0-2.0.pre.',
)
double textScaleFactor = 1.0,
TextScaler textScaler = TextScaler.noScaling,
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),
assert(
identical(textScaler, TextScaler.noScaling) || textScaleFactor == 1.0,
'textScaleFactor is deprecated and cannot be specified when textScaler is specified.',
),
_softWrap = softWrap,
_overflow = overflow,
_selectionColor = selectionColor,
_textPainter = TextPainter(
text: text,
textAlign: textAlign,
textDirection: textDirection,
textScaler: textScaler == TextScaler.noScaling ? TextScaler.linear(textScaleFactor) : textScaler,
maxLines: maxLines,
ellipsis: overflow == TextOverflow.ellipsis ? _kEllipsis : null,
locale: locale,
strutStyle: strutStyle,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior,
) {
addAll(children);
this.registrar = registrar;
}
static final String _placeholderCharacter = String.fromCharCode(PlaceholderSpan.placeholderCodeUnit);
final TextPainter _textPainter;
// Currently, computing min/max intrinsic width/height will destroy state
// inside the painter. Instead of calling _layout again to get back the correct
// state, use a separate TextPainter for intrinsics calculation.
//
// TODO(abarth): Make computing the min/max intrinsic width/height a
// non-destructive operation.
TextPainter? _textIntrinsicsCache;
TextPainter get _textIntrinsics {
return (_textIntrinsicsCache ??= TextPainter())
..text = _textPainter.text
..textAlign = _textPainter.textAlign
..textDirection = _textPainter.textDirection
..textScaler = _textPainter.textScaler
..maxLines = _textPainter.maxLines
..ellipsis = _textPainter.ellipsis
..locale = _textPainter.locale
..strutStyle = _textPainter.strutStyle
..textWidthBasis = _textPainter.textWidthBasis
..textHeightBehavior = _textPainter.textHeightBehavior;
}
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();
case RenderComparison.paint:
_textPainter.text = value;
_cachedAttributedLabels = null;
_cachedCombinedSemanticsInfos = null;
markNeedsPaint();
markNeedsSemanticsUpdate();
case RenderComparison.layout:
_textPainter.text = value;
_overflowShader = null;
_cachedAttributedLabels = null;
_cachedCombinedSemanticsInfos = null;
markNeedsLayout();
_removeSelectionRegistrarSubscription();
_disposeSelectableFragments();
_updateSelectionRegistrarSubscription();
}
}
/// 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) {
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);
if (_lastSelectableFragments!.isNotEmpty) {
markNeedsCompositingBitsUpdate();
}
}
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;
}
/// Determines whether the given [Selectable] was created by this
/// [RenderParagraph].
bool selectableBelongsToParagraph(Selectable selectable) {
if (_lastSelectableFragments == null) {
return false;
}
return _lastSelectableFragments!.contains(selectable);
}
void _disposeSelectableFragments() {
if (_lastSelectableFragments == null) {
return;
}
for (final _SelectableFragment fragment in _lastSelectableFragments!) {
fragment.dispose();
}
_lastSelectableFragments = null;
}
@override
bool get alwaysNeedsCompositing => _lastSelectableFragments?.isNotEmpty ?? false;
@override
void markNeedsLayout() {
_lastSelectableFragments?.forEach((_SelectableFragment element) => element.didChangeParagraphLayout());
super.markNeedsLayout();
}
@override
void dispose() {
_removeSelectionRegistrarSubscription();
_disposeSelectableFragments();
_textPainter.dispose();
_textIntrinsicsCache?.dispose();
super.dispose();
}
/// 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.
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();
}
/// Deprecated. Will be removed in a future version of Flutter. Use
/// [textScaler] instead.
///
/// 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.
@Deprecated(
'Use textScaler instead. '
'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. '
'This feature was deprecated after v3.12.0-2.0.pre.',
)
double get textScaleFactor => _textPainter.textScaleFactor;
@Deprecated(
'Use textScaler instead. '
'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. '
'This feature was deprecated after v3.12.0-2.0.pre.',
)
set textScaleFactor(double value) {
textScaler = TextScaler.linear(value);
}
/// {@macro flutter.painting.textPainter.textScaler}
TextScaler get textScaler => _textPainter.textScaler;
set textScaler(TextScaler value) {
if (_textPainter.textScaler == value) {
return;
}
_textPainter.textScaler = 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));
}
@override
double computeMinIntrinsicWidth(double height) {
final List<PlaceholderDimensions> placeholderDimensions = layoutInlineChildren(
double.infinity,
(RenderBox child, BoxConstraints constraints) => Size(child.getMinIntrinsicWidth(double.infinity), 0.0),
ChildLayoutHelper.getDryBaseline,
);
return (_textIntrinsics..setPlaceholderDimensions(placeholderDimensions)..layout())
.minIntrinsicWidth;
}
@override
double computeMaxIntrinsicWidth(double height) {
final List<PlaceholderDimensions> placeholderDimensions = layoutInlineChildren(
double.infinity,
// 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.
(RenderBox child, BoxConstraints constraints) => Size(child.getMaxIntrinsicWidth(double.infinity), 0.0),
ChildLayoutHelper.getDryBaseline,
);
return (_textIntrinsics..setPlaceholderDimensions(placeholderDimensions)..layout())
.maxIntrinsicWidth;
}
double _computeIntrinsicHeight(double width) {
return (_textIntrinsics
..setPlaceholderDimensions(layoutInlineChildren(width, ChildLayoutHelper.dryLayoutChild, ChildLayoutHelper.getDryBaseline))
..layout(minWidth: width, maxWidth: _adjustMaxWidth(width)))
.height;
}
@override
double computeMinIntrinsicHeight(double width) {
return _computeIntrinsicHeight(width);
}
@override
double computeMaxIntrinsicHeight(double width) {
return _computeIntrinsicHeight(width);
}
@override
bool hitTestSelf(Offset position) => true;
@override
@protected
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
final GlyphInfo? glyph = _textPainter.getClosestGlyphForOffset(position);
// The hit-test can't fall through the horizontal gaps between visually
// adjacent characters on the same line, even with a large letter-spacing or
// text justification, as graphemeClusterLayoutBounds.width is the advance
// width to the next character, so there's no gap between their
// graphemeClusterLayoutBounds rects.
final InlineSpan? spanHit = glyph != null && glyph.graphemeClusterLayoutBounds.contains(position)
? _textPainter.text!.getSpanForPosition(TextPosition(offset: glyph.graphemeClusterCodeUnitRange.start))
: null;
switch (spanHit) {
case final HitTestTarget span:
result.add(HitTestEntry(span));
return true;
case _:
return hitTestInlineChildren(result, position);
}
}
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;
@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;
double _adjustMaxWidth(double maxWidth) {
return softWrap || overflow == TextOverflow.ellipsis ? maxWidth : double.infinity;
}
void _layoutTextWithConstraints(BoxConstraints constraints) {
_textPainter
..setPlaceholderDimensions(_placeholderDimensions)
..layout(minWidth: constraints.minWidth, maxWidth: _adjustMaxWidth(constraints.maxWidth));
}
@override
@protected
Size computeDryLayout(covariant BoxConstraints constraints) {
final Size size = (_textIntrinsics
..setPlaceholderDimensions(layoutInlineChildren(constraints.maxWidth, ChildLayoutHelper.dryLayoutChild, ChildLayoutHelper.getDryBaseline))
..layout(minWidth: constraints.minWidth, maxWidth: _adjustMaxWidth(constraints.maxWidth)))
.size;
return constraints.constrain(size);
}
@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);
}
@override
double computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) {
assert(constraints.debugAssertIsValid());
_textIntrinsics
..setPlaceholderDimensions(layoutInlineChildren(constraints.maxWidth, ChildLayoutHelper.dryLayoutChild, ChildLayoutHelper.getDryBaseline))
..layout(minWidth: constraints.minWidth, maxWidth: _adjustMaxWidth(constraints.maxWidth));
return _textIntrinsics.computeDistanceToActualBaseline(TextBaseline.alphabetic);
}
@override
void performLayout() {
final BoxConstraints constraints = this.constraints;
_placeholderDimensions = layoutInlineChildren(constraints.maxWidth, ChildLayoutHelper.layoutChild, ChildLayoutHelper.getBaseline);
_layoutTextWithConstraints(constraints);
positionInlineChildren(_textPainter.inlinePlaceholderBoxes!);
final Size textSize = _textPainter.size;
size = constraints.constrain(textSize);
final bool didOverflowHeight = size.height < textSize.height || _textPainter.didExceedMaxLines;
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;
case TextOverflow.clip:
case TextOverflow.ellipsis:
_needsClipping = true;
_overflowShader = null;
case TextOverflow.fade:
_needsClipping = true;
final TextPainter fadeSizePainter = TextPainter(
text: TextSpan(style: _textPainter.text!.style, text: '\u2026'),
textDirection: textDirection,
textScaler: textScaler,
locale: locale,
)..layout();
if (didOverflowWidth) {
final (double fadeStart, double fadeEnd) = switch (textDirection) {
TextDirection.rtl => (fadeSizePainter.width, 0.0),
TextDirection.ltr => (size.width - fadeSizePainter.width, size.width),
};
_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();
}
} else {
_needsClipping = false;
_overflowShader = null;
}
}
@override
void applyPaintTransform(RenderBox child, Matrix4 transform) {
defaultApplyPaintTransform(child, transform);
}
@override
void paint(PaintingContext context, Offset offset) {
// Text alignment only triggers repaint so it's possible the text layout has
// been invalidated but performLayout wasn't called at this point. Make sure
// the TextPainter has a valid layout.
_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);
}
if (_lastSelectableFragments != null) {
for (final _SelectableFragment fragment in _lastSelectableFragments!) {
fragment.paint(context, offset);
}
}
_textPainter.paint(context.canvas, offset);
paintInlineChildren(context, offset);
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();
}
}
/// 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.
///
/// 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;
}
/// Whether the text was truncated or ellipsized as laid out.
///
/// This returns the [TextPainter.didExceedMaxLines] of the underlying [TextPainter].
///
/// Valid only after [layout].
bool get didExceedMaxLines {
assert(!debugNeedsLayout);
return _textPainter.didExceedMaxLines;
}
/// 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 needsChildConfigurationsDelegate = false;
for (final InlineSpanSemanticsInformation info in _semanticsInfo!) {
if (info.recognizer != null) {
needsAssembleSemanticsNode = true;
break;
}
needsChildConfigurationsDelegate = needsChildConfigurationsDelegate || info.isPlaceholder;
}
if (needsAssembleSemanticsNode) {
config.explicitChildNodes = true;
config.isSemanticBoundary = true;
} else if (needsChildConfigurationsDelegate) {
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;
// parentData.scale may be null if the render object is truncated.
if (parentData.offset != null) {
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;
}
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(
DiagnosticsProperty<TextScaler>('textScaler', textScaler, defaultValue: TextScaler.noScaling),
);
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, Diagnosticable, ChangeNotifier implements TextLayoutMetrics {
_SelectableFragment({
required this.paragraph,
required this.fullText,
required this.range,
}) : assert(range.isValid && !range.isCollapsed && range.isNormalized) {
if (kFlutterMemoryAllocationsEnabled) {
ChangeNotifier.maybeDispatchObjectCreation(this);
}
_selectionGeometry = _getSelectionGeometry();
}
final TextRange range;
final RenderParagraph paragraph;
final String fullText;
TextPosition? _textSelectionStart;
TextPosition? _textSelectionEnd;
bool _selectableContainsOriginTextBoundary = false;
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 TextSelection selection = TextSelection(
baseOffset: selectionStart,
extentOffset: selectionEnd,
);
final List<Rect> selectionRects = <Rect>[];
for (final TextBox textBox in paragraph.getBoxesForSelection(selection)) {
selectionRects.add(textBox.toRect());
}
return SelectionGeometry(
startSelectionPoint: SelectionPoint(
localPosition: startOffsetInParagraphCoordinates,
lineHeight: paragraph._textPainter.preferredLineHeight,
handleType: flipHandles ? TextSelectionHandleType.right : TextSelectionHandleType.left
),
endSelectionPoint: SelectionPoint(
localPosition: endOffsetInParagraphCoordinates,
lineHeight: paragraph._textPainter.preferredLineHeight,
handleType: flipHandles ? TextSelectionHandleType.left : TextSelectionHandleType.right,
),
selectionRects: selectionRects,
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;
final TextGranularity granularity = event.granularity;
switch (granularity) {
case TextGranularity.character:
result = _updateSelectionEdge(edgeUpdate.globalPosition, isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate);
case TextGranularity.word:
result = _updateSelectionEdgeByTextBoundary(edgeUpdate.globalPosition, isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate, getTextBoundary: _getWordBoundaryAtPosition);
case TextGranularity.paragraph:
result = _updateSelectionEdgeByMultiSelectableTextBoundary(edgeUpdate.globalPosition, isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate, getTextBoundary: _getParagraphBoundaryAtPosition, getClampedTextBoundary: _getClampedParagraphBoundaryAtPosition);
case TextGranularity.document:
case TextGranularity.line:
assert(false, 'Moving the selection edge by line or document is not supported.');
}
case SelectionEventType.clear:
result = _handleClearSelection();
case SelectionEventType.selectAll:
result = _handleSelectAll();
case SelectionEventType.selectWord:
final SelectWordSelectionEvent selectWord = event as SelectWordSelectionEvent;
result = _handleSelectWord(selectWord.globalPosition);
case SelectionEventType.selectParagraph:
final SelectParagraphSelectionEvent selectParagraph = event as SelectParagraphSelectionEvent;
if (selectParagraph.absorb) {
_handleSelectAll();
result = SelectionResult.next;
_selectableContainsOriginTextBoundary = true;
} else {
result = _handleSelectParagraph(selectParagraph.globalPosition);
}
case SelectionEventType.granularlyExtendSelection:
final GranularlyExtendSelectionEvent granularlyExtendSelection = event as GranularlyExtendSelectionEvent;
result = _handleGranularlyExtendSelection(
granularlyExtendSelection.forward,
granularlyExtendSelection.isEnd,
granularlyExtendSelection.granularity,
);
case SelectionEventType.directionallyExtendSelection:
final DirectionallyExtendSelectionEvent directionallyExtendSelection = event as DirectionallyExtendSelectionEvent;
result = _handleDirectionallyExtendSelection(
directionallyExtendSelection.dx,
directionallyExtendSelection.isEnd,
directionallyExtendSelection.direction,
);
}
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();
}
TextPosition _updateSelectionStartEdgeByTextBoundary(
_TextBoundaryRecord? textBoundary,
_TextBoundaryAtPosition getTextBoundary,
TextPosition position,
TextPosition? existingSelectionStart,
TextPosition? existingSelectionEnd,
) {
TextPosition? targetPosition;
if (textBoundary != null) {
assert(textBoundary.boundaryStart.offset >= range.start && textBoundary.boundaryEnd.offset <= range.end);
if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) {
final bool isSamePosition = position.offset == existingSelectionEnd.offset;
final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset;
final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset > existingSelectionEnd.offset));
if (shouldSwapEdges) {
if (position.offset < existingSelectionEnd.offset) {
targetPosition = textBoundary.boundaryStart;
} else {
targetPosition = textBoundary.boundaryEnd;
}
// When the selection is inverted by the new position it is necessary to
// swap the start edge (moving edge) with the end edge (static edge) to
// maintain the origin text boundary within the selection.
final _TextBoundaryRecord localTextBoundary = getTextBoundary(existingSelectionEnd);
assert(localTextBoundary.boundaryStart.offset >= range.start && localTextBoundary.boundaryEnd.offset <= range.end);
_setSelectionPosition(existingSelectionEnd.offset == localTextBoundary.boundaryStart.offset ? localTextBoundary.boundaryEnd : localTextBoundary.boundaryStart, isEnd: true);
} else {
if (position.offset < existingSelectionEnd.offset) {
targetPosition = textBoundary.boundaryStart;
} else if (position.offset > existingSelectionEnd.offset) {
targetPosition = textBoundary.boundaryEnd;
} else {
// Keep the origin text boundary in bounds when position is at the static edge.
targetPosition = existingSelectionStart;
}
}
} else {
if (existingSelectionEnd != null) {
// If the end edge exists and the start edge is being moved, then the
// start edge is moved to encompass the entire text boundary at the new position.
if (position.offset < existingSelectionEnd.offset) {
targetPosition = textBoundary.boundaryStart;
} else {
targetPosition = textBoundary.boundaryEnd;
}
} else {
// Move the start edge to the closest text boundary.
targetPosition = _closestTextBoundary(textBoundary, position);
}
}
} else {
// The position is not contained within the current rect. The targetPosition
// will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset]
// for a more in depth explanation on this adjustment.
if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) {
// When the selection is inverted by the new position it is necessary to
// swap the start edge (moving edge) with the end edge (static edge) to
// maintain the origin text boundary within the selection.
final bool isSamePosition = position.offset == existingSelectionEnd.offset;
final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset;
final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset > existingSelectionEnd.offset));
if (shouldSwapEdges) {
final _TextBoundaryRecord localTextBoundary = getTextBoundary(existingSelectionEnd);
assert(localTextBoundary.boundaryStart.offset >= range.start && localTextBoundary.boundaryEnd.offset <= range.end);
_setSelectionPosition(isSelectionInverted ? localTextBoundary.boundaryEnd : localTextBoundary.boundaryStart, isEnd: true);
}
}
}
return targetPosition ?? position;
}
TextPosition _updateSelectionEndEdgeByTextBoundary(
_TextBoundaryRecord? textBoundary,
_TextBoundaryAtPosition getTextBoundary,
TextPosition position,
TextPosition? existingSelectionStart,
TextPosition? existingSelectionEnd,
) {
TextPosition? targetPosition;
if (textBoundary != null) {
assert(textBoundary.boundaryStart.offset >= range.start && textBoundary.boundaryEnd.offset <= range.end);
if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) {
final bool isSamePosition = position.offset == existingSelectionStart.offset;
final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset;
final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset < existingSelectionStart.offset));
if (shouldSwapEdges) {
if (position.offset < existingSelectionStart.offset) {
targetPosition = textBoundary.boundaryStart;
} else {
targetPosition = textBoundary.boundaryEnd;
}
// When the selection is inverted by the new position it is necessary to
// swap the end edge (moving edge) with the start edge (static edge) to
// maintain the origin text boundary within the selection.
final _TextBoundaryRecord localTextBoundary = getTextBoundary(existingSelectionStart);
assert(localTextBoundary.boundaryStart.offset >= range.start && localTextBoundary.boundaryEnd.offset <= range.end);
_setSelectionPosition(existingSelectionStart.offset == localTextBoundary.boundaryStart.offset ? localTextBoundary.boundaryEnd : localTextBoundary.boundaryStart, isEnd: false);
} else {
if (position.offset < existingSelectionStart.offset) {
targetPosition = textBoundary.boundaryStart;
} else if (position.offset > existingSelectionStart.offset) {
targetPosition = textBoundary.boundaryEnd;
} else {
// Keep the origin text boundary in bounds when position is at the static edge.
targetPosition = existingSelectionEnd;
}
}
} else {
if (existingSelectionStart != null) {
// If the start edge exists and the end edge is being moved, then the
// end edge is moved to encompass the entire text boundary at the new position.
if (position.offset < existingSelectionStart.offset) {
targetPosition = textBoundary.boundaryStart;
} else {
targetPosition = textBoundary.boundaryEnd;
}
} else {
// Move the end edge to the closest text boundary.
targetPosition = _closestTextBoundary(textBoundary, position);
}
}
} else {
// The position is not contained within the current rect. The targetPosition
// will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset]
// for a more in depth explanation on this adjustment.
if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) {
// When the selection is inverted by the new position it is necessary to
// swap the end edge (moving edge) with the start edge (static edge) to
// maintain the origin text boundary within the selection.
final bool isSamePosition = position.offset == existingSelectionStart.offset;
final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset;
final bool shouldSwapEdges = isSelectionInverted != (position.offset < existingSelectionStart.offset) || isSamePosition;
if (shouldSwapEdges) {
final _TextBoundaryRecord localTextBoundary = getTextBoundary(existingSelectionStart);
assert(localTextBoundary.boundaryStart.offset >= range.start && localTextBoundary.boundaryEnd.offset <= range.end);
_setSelectionPosition(isSelectionInverted ? localTextBoundary.boundaryStart : localTextBoundary.boundaryEnd, isEnd: false);
}
}
}
return targetPosition ?? position;
}
SelectionResult _updateSelectionEdgeByTextBoundary(Offset globalPosition, {required bool isEnd, required _TextBoundaryAtPosition getTextBoundary}) {
// When the start/end edges are swapped, i.e. the start is after the end, and
// the scrollable synthesizes an event for the opposite edge, this will potentially
// move the opposite edge outside of the origin text boundary and we are unable to recover.
final TextPosition? existingSelectionStart = _textSelectionStart;
final TextPosition? existingSelectionEnd = _textSelectionEnd;
_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 = paragraph.getPositionForOffset(adjustedOffset);
// Check if the original local position is within the rect, if it is not then
// we do not need to look up the text boundary for that position. This is to
// maintain a selectables selection collapsed at 0 when the local position is
// not located inside its rect.
_TextBoundaryRecord? textBoundary = _rect.contains(localPosition) ? getTextBoundary(position) : null;
if (textBoundary != null
&& (textBoundary.boundaryStart.offset < range.start && textBoundary.boundaryEnd.offset <= range.start
|| textBoundary.boundaryStart.offset >= range.end && textBoundary.boundaryEnd.offset > range.end)) {
// When the position is located at a placeholder inside of the text, then we may compute
// a text boundary that does not belong to the current selectable fragment. In this case
// we should invalidate the text boundary so that it is not taken into account when
// computing the target position.
textBoundary = null;
}
final TextPosition targetPosition = _clampTextPosition(isEnd ? _updateSelectionEndEdgeByTextBoundary(textBoundary, getTextBoundary, position, existingSelectionStart, existingSelectionEnd) : _updateSelectionStartEdgeByTextBoundary(textBoundary, getTextBoundary, position, existingSelectionStart, existingSelectionEnd));
_setSelectionPosition(targetPosition, isEnd: isEnd);
if (targetPosition.offset == range.end) {
return SelectionResult.next;
}
if (targetPosition.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);
}
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);
}
// This method handles updating the start edge by a text boundary that may
// not be contained within this selectable fragment. It is possible
// that a boundary spans multiple selectable fragments when the text contains
// [WidgetSpan]s.
//
// This method differs from [_updateSelectionStartEdgeByTextBoundary] in that
// to pivot offset used to swap selection edges and maintain the origin
// text boundary selected may be located outside of this selectable fragment.
//
// See [_updateSelectionEndEdgeByMultiSelectableTextBoundary] for the method
// that handles updating the end edge.
SelectionResult? _updateSelectionStartEdgeByMultiSelectableTextBoundary(
_TextBoundaryAtPositionInText getTextBoundary,
bool paragraphContainsPosition,
TextPosition position,
TextPosition? existingSelectionStart,
TextPosition? existingSelectionEnd,
) {
const bool isEnd = false;
if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) {
// If this selectable contains the origin boundary, maintain the existing
// selection.
final bool forwardSelection = existingSelectionEnd.offset >= existingSelectionStart.offset;
if (paragraphContainsPosition) {
// When the position is within the root paragraph, swap the start and end
// edges when the selection is inverted.
final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(position, fullText);
// To accurately retrieve the origin text boundary when the selection
// is forward, use existingSelectionEnd.offset - 1. This is necessary
// because in a forwards selection, existingSelectionEnd marks the end
// of the origin text boundary. Using the unmodified offset incorrectly
// targets the subsequent text boundary.
final _TextBoundaryRecord originTextBoundary = getTextBoundary(
forwardSelection
? TextPosition(
offset: existingSelectionEnd.offset - 1,
affinity: existingSelectionEnd.affinity,
)
: existingSelectionEnd,
fullText,
);
final TextPosition targetPosition;
final int pivotOffset = forwardSelection ? originTextBoundary.boundaryEnd.offset : originTextBoundary.boundaryStart.offset;
final bool shouldSwapEdges = !forwardSelection != (position.offset > pivotOffset);
if (position.offset < pivotOffset) {
targetPosition = boundaryAtPosition.boundaryStart;
} else if (position.offset > pivotOffset) {
targetPosition = boundaryAtPosition.boundaryEnd;
} else {
// Keep the origin text boundary in bounds when position is at the static edge.
targetPosition = forwardSelection ? existingSelectionStart : existingSelectionEnd;
}
if (shouldSwapEdges) {
_setSelectionPosition(
_clampTextPosition(forwardSelection ? originTextBoundary.boundaryStart : originTextBoundary.boundaryEnd),
isEnd: true,
);
}
_setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd);
final bool finalSelectionIsForward = _textSelectionEnd!.offset >= _textSelectionStart!.offset;
if (boundaryAtPosition.boundaryStart.offset > range.end && boundaryAtPosition.boundaryEnd.offset > range.end) {
return SelectionResult.next;
}
if (boundaryAtPosition.boundaryStart.offset < range.start && boundaryAtPosition.boundaryEnd.offset < range.start) {
return SelectionResult.previous;
}
if (finalSelectionIsForward) {
if (boundaryAtPosition.boundaryStart.offset >= originTextBoundary.boundaryStart.offset) {
return SelectionResult.end;
}
if (boundaryAtPosition.boundaryStart.offset < originTextBoundary.boundaryStart.offset) {
return SelectionResult.previous;
}
} else {
if (boundaryAtPosition.boundaryEnd.offset <= originTextBoundary.boundaryEnd.offset) {
return SelectionResult.end;
}
if (boundaryAtPosition.boundaryEnd.offset > originTextBoundary.boundaryEnd.offset) {
return SelectionResult.next;
}
}
} else {
// When the drag position is not contained within the root paragraph,
// swap the edges when the selection changes direction.
final TextPosition clampedPosition = _clampTextPosition(position);
// To accurately retrieve the origin text boundary when the selection
// is forward, use existingSelectionEnd.offset - 1. This is necessary
// because in a forwards selection, existingSelectionEnd marks the end
// of the origin text boundary. Using the unmodified offset incorrectly
// targets the subsequent text boundary.
final _TextBoundaryRecord originTextBoundary = getTextBoundary(
forwardSelection
? TextPosition(
offset: existingSelectionEnd.offset - 1,
affinity: existingSelectionEnd.affinity,
)
: existingSelectionEnd,
fullText,
);
if (forwardSelection && clampedPosition.offset == range.start) {
_setSelectionPosition(clampedPosition, isEnd: isEnd);
return SelectionResult.previous;
}
if (!forwardSelection && clampedPosition.offset == range.end) {
_setSelectionPosition(clampedPosition, isEnd: isEnd);
return SelectionResult.next;
}
if (forwardSelection && clampedPosition.offset == range.end) {
_setSelectionPosition(_clampTextPosition(originTextBoundary.boundaryStart), isEnd: true);
_setSelectionPosition(clampedPosition, isEnd: isEnd);
return SelectionResult.next;
}
if (!forwardSelection && clampedPosition.offset == range.start) {
_setSelectionPosition(_clampTextPosition(originTextBoundary.boundaryEnd), isEnd: true);
_setSelectionPosition(clampedPosition, isEnd: isEnd);
return SelectionResult.previous;
}
}
} else {
// A paragraph boundary may not be completely contained within this root
// selectable fragment. Keep searching until we find the end of the
// boundary. Do not search when the current drag position is on a placeholder
// to allow traversal to reach that placeholder.
final bool positionOnPlaceholder = paragraph.getWordBoundary(position).textInside(fullText) == _placeholderCharacter;
if (!paragraphContainsPosition || positionOnPlaceholder) {
return null;
}
if (existingSelectionEnd != null) {
final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(position, fullText);
final bool backwardSelection = existingSelectionStart == null && existingSelectionEnd.offset == range.start
|| existingSelectionStart == existingSelectionEnd && existingSelectionEnd.offset == range.start
|| existingSelectionStart != null && existingSelectionStart.offset > existingSelectionEnd.offset;
if (boundaryAtPosition.boundaryStart.offset < range.start && boundaryAtPosition.boundaryEnd.offset < range.start) {
_setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd);
return SelectionResult.previous;
}
if (boundaryAtPosition.boundaryStart.offset > range.end && boundaryAtPosition.boundaryEnd.offset > range.end) {
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
return SelectionResult.next;
}
if (backwardSelection) {
if (boundaryAtPosition.boundaryEnd.offset <= range.end) {
_setSelectionPosition(_clampTextPosition(boundaryAtPosition.boundaryEnd), isEnd: isEnd);
return SelectionResult.end;
}
if (boundaryAtPosition.boundaryEnd.offset > range.end) {
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
return SelectionResult.next;
}
} else {
_setSelectionPosition(_clampTextPosition(boundaryAtPosition.boundaryStart), isEnd: isEnd);
if (boundaryAtPosition.boundaryStart.offset < range.start) {
return SelectionResult.previous;
}
if (boundaryAtPosition.boundaryStart.offset >= range.start) {
return SelectionResult.end;
}
}
}
}
return null;
}
// This method handles updating the end edge by a text boundary that may
// not be contained within this selectable fragment. It is possible
// that a boundary spans multiple selectable fragments when the text contains
// [WidgetSpan]s.
//
// This method differs from [_updateSelectionEndEdgeByTextBoundary] in that
// to pivot offset used to swap selection edges and maintain the origin
// text boundary selected may be located outside of this selectable fragment.
//
// See [_updateSelectionStartEdgeByMultiSelectableTextBoundary] for the method
// that handles updating the end edge.
SelectionResult? _updateSelectionEndEdgeByMultiSelectableTextBoundary(
_TextBoundaryAtPositionInText getTextBoundary,
bool paragraphContainsPosition,
TextPosition position,
TextPosition? existingSelectionStart,
TextPosition? existingSelectionEnd,
) {
const bool isEnd = true;
if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) {
// If this selectable contains the origin boundary, maintain the existing
// selection.
final bool forwardSelection = existingSelectionEnd.offset >= existingSelectionStart.offset;
if (paragraphContainsPosition) {
// When the position is within the root paragraph, swap the start and end
// edges when the selection is inverted.
final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(position, fullText);
// To accurately retrieve the origin text boundary when the selection
// is backwards, use existingSelectionStart.offset - 1. This is necessary
// because in a backwards selection, existingSelectionStart marks the end
// of the origin text boundary. Using the unmodified offset incorrectly
// targets the subsequent text boundary.
final _TextBoundaryRecord originTextBoundary = getTextBoundary(
forwardSelection
? existingSelectionStart
: TextPosition(
offset: existingSelectionStart.offset - 1,
affinity: existingSelectionStart.affinity,
),
fullText,
);
final TextPosition targetPosition;
final int pivotOffset = forwardSelection ? originTextBoundary.boundaryStart.offset : originTextBoundary.boundaryEnd.offset;
final bool shouldSwapEdges = !forwardSelection != (position.offset < pivotOffset);
if (position.offset < pivotOffset) {
targetPosition = boundaryAtPosition.boundaryStart;
} else if (position.offset > pivotOffset) {
targetPosition = boundaryAtPosition.boundaryEnd;
} else {
// Keep the origin text boundary in bounds when position is at the static edge.
targetPosition = forwardSelection ? existingSelectionEnd : existingSelectionStart;
}
if (shouldSwapEdges) {
_setSelectionPosition(
_clampTextPosition(forwardSelection ? originTextBoundary.boundaryEnd : originTextBoundary.boundaryStart),
isEnd: false,
);
}
_setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd);
final bool finalSelectionIsForward = _textSelectionEnd!.offset >= _textSelectionStart!.offset;
if (boundaryAtPosition.boundaryStart.offset > range.end && boundaryAtPosition.boundaryEnd.offset > range.end) {
return SelectionResult.next;
}
if (boundaryAtPosition.boundaryStart.offset < range.start && boundaryAtPosition.boundaryEnd.offset < range.start) {
return SelectionResult.previous;
}
if (finalSelectionIsForward) {
if (boundaryAtPosition.boundaryEnd.offset <= originTextBoundary.boundaryEnd.offset) {
return SelectionResult.end;
}
if (boundaryAtPosition.boundaryEnd.offset > originTextBoundary.boundaryEnd.offset) {
return SelectionResult.next;
}
} else {
if (boundaryAtPosition.boundaryStart.offset >= originTextBoundary.boundaryStart.offset) {
return SelectionResult.end;
}
if (boundaryAtPosition.boundaryStart.offset < originTextBoundary.boundaryStart.offset) {
return SelectionResult.previous;
}
}
} else {
// When the drag position is not contained within the root paragraph,
// swap the edges when the selection changes direction.
final TextPosition clampedPosition = _clampTextPosition(position);
// To accurately retrieve the origin text boundary when the selection
// is backwards, use existingSelectionStart.offset - 1. This is necessary
// because in a backwards selection, existingSelectionStart marks the end
// of the origin text boundary. Using the unmodified offset incorrectly
// targets the subsequent text boundary.
final _TextBoundaryRecord originTextBoundary = getTextBoundary(
forwardSelection
? existingSelectionStart
: TextPosition(
offset: existingSelectionStart.offset - 1,
affinity: existingSelectionStart.affinity,
),
fullText,
);
if (forwardSelection && clampedPosition.offset == range.start) {
_setSelectionPosition(_clampTextPosition(originTextBoundary.boundaryEnd), isEnd: false);
_setSelectionPosition(clampedPosition, isEnd: isEnd);
return SelectionResult.previous;
}
if (!forwardSelection && clampedPosition.offset == range.end) {
_setSelectionPosition(_clampTextPosition(originTextBoundary.boundaryStart), isEnd: false);
_setSelectionPosition(clampedPosition, isEnd: isEnd);
return SelectionResult.next;
}
if (forwardSelection && clampedPosition.offset == range.end) {
_setSelectionPosition(clampedPosition, isEnd: isEnd);
return SelectionResult.next;
}
if (!forwardSelection && clampedPosition.offset == range.start) {
_setSelectionPosition(clampedPosition, isEnd: isEnd);
return SelectionResult.previous;
}
}
} else {
// A paragraph boundary may not be completely contained within this root
// selectable fragment. Keep searching until we find the end of the
// boundary. Do not search when the current drag position is on a placeholder
// to allow traversal to reach that placeholder.
final bool positionOnPlaceholder = paragraph.getWordBoundary(position).textInside(fullText) == _placeholderCharacter;
if (!paragraphContainsPosition || positionOnPlaceholder) {
return null;
}
if (existingSelectionStart != null) {
final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(position, fullText);
final bool backwardSelection = existingSelectionEnd == null && existingSelectionStart.offset == range.end
|| existingSelectionStart == existingSelectionEnd && existingSelectionStart.offset == range.end
|| existingSelectionEnd != null && existingSelectionStart.offset > existingSelectionEnd.offset;
if (boundaryAtPosition.boundaryStart.offset < range.start && boundaryAtPosition.boundaryEnd.offset < range.start) {
_setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd);
return SelectionResult.previous;
}
if (boundaryAtPosition.boundaryStart.offset > range.end && boundaryAtPosition.boundaryEnd.offset > range.end) {
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
return SelectionResult.next;
}
if (backwardSelection) {
_setSelectionPosition(_clampTextPosition(boundaryAtPosition.boundaryStart), isEnd: isEnd);
if (boundaryAtPosition.boundaryStart.offset < range.start) {
return SelectionResult.previous;
}
if (boundaryAtPosition.boundaryStart.offset >= range.start) {
return SelectionResult.end;
}
} else {
if (boundaryAtPosition.boundaryEnd.offset <= range.end) {
_setSelectionPosition(_clampTextPosition(boundaryAtPosition.boundaryEnd), isEnd: isEnd);
return SelectionResult.end;
}
if (boundaryAtPosition.boundaryEnd.offset > range.end) {
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
return SelectionResult.next;
}
}
}
}
return null;
}
// The placeholder character used by [RenderParagraph].
static final String _placeholderCharacter = String.fromCharCode(PlaceholderSpan.placeholderCodeUnit);
static final int _placeholderLength = _placeholderCharacter.length;
// This method handles updating the start edge by a text boundary that may
// not be contained within this selectable fragment. It is possible
// that a boundary spans multiple selectable fragments when the text contains
// [WidgetSpan]s.
//
// This method differs from [_updateSelectionStartEdgeByMultiSelectableBoundary]
// in that to maintain the origin text boundary selected at a placeholder,
// this selectable fragment must be aware of the [RenderParagraph] that closely
// encompasses the complete origin text boundary.
//
// See [_updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary] for the method
// that handles updating the end edge.
SelectionResult? _updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary(
_TextBoundaryAtPositionInText getTextBoundary,
Offset globalPosition,
bool paragraphContainsPosition,
TextPosition position,
TextPosition? existingSelectionStart,
TextPosition? existingSelectionEnd,
) {
const bool isEnd = false;
if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) {
// If this selectable contains the origin boundary, maintain the existing
// selection.
final bool forwardSelection = existingSelectionEnd.offset >= existingSelectionStart.offset;
final RenderParagraph originParagraph = _getOriginParagraph();
final bool fragmentBelongsToOriginParagraph = originParagraph == paragraph;
if (fragmentBelongsToOriginParagraph) {
return _updateSelectionStartEdgeByMultiSelectableTextBoundary(
getTextBoundary,
paragraphContainsPosition,
position,
existingSelectionStart,
existingSelectionEnd,
);
}
final Matrix4 originTransform = originParagraph.getTransformTo(null);
originTransform.invert();
final Offset originParagraphLocalPosition = MatrixUtils.transformPoint(originTransform, globalPosition);
final bool positionWithinOriginParagraph = originParagraph.paintBounds.contains(originParagraphLocalPosition);
final TextPosition positionRelativeToOriginParagraph = originParagraph.getPositionForOffset(originParagraphLocalPosition);
if (positionWithinOriginParagraph) {
// When the selection is inverted by the new position it is necessary to
// swap the start edge (moving edge) with the end edge (static edge) to
// maintain the origin text boundary within the selection.
final String originText = originParagraph.text.toPlainText(includeSemanticsLabels: false);
final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(positionRelativeToOriginParagraph, originText);
final _TextBoundaryRecord originTextBoundary = getTextBoundary(_getPositionInParagraph(originParagraph), originText);
final TextPosition targetPosition;
final int pivotOffset = forwardSelection ? originTextBoundary.boundaryEnd.offset : originTextBoundary.boundaryStart.offset;
final bool shouldSwapEdges = !forwardSelection != (positionRelativeToOriginParagraph.offset > pivotOffset);
if (positionRelativeToOriginParagraph.offset < pivotOffset) {
targetPosition = boundaryAtPosition.boundaryStart;
} else if (positionRelativeToOriginParagraph.offset > pivotOffset) {
targetPosition = boundaryAtPosition.boundaryEnd;
} else {
// Keep the origin text boundary in bounds when position is at the static edge.
targetPosition = existingSelectionStart;
}
if (shouldSwapEdges) {
_setSelectionPosition(existingSelectionStart, isEnd: true);
}
_setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd);
final bool finalSelectionIsForward = _textSelectionEnd!.offset >= _textSelectionStart!.offset;
final TextPosition originParagraphPlaceholderTextPosition = _getPositionInParagraph(originParagraph);
final TextRange originParagraphPlaceholderRange = TextRange(start: originParagraphPlaceholderTextPosition.offset, end: originParagraphPlaceholderTextPosition.offset + _placeholderLength);
if (boundaryAtPosition.boundaryStart.offset > originParagraphPlaceholderRange.end && boundaryAtPosition.boundaryEnd.offset > originParagraphPlaceholderRange.end) {
return SelectionResult.next;
}
if (boundaryAtPosition.boundaryStart.offset < originParagraphPlaceholderRange.start && boundaryAtPosition.boundaryEnd.offset < originParagraphPlaceholderRange.start) {
return SelectionResult.previous;
}
if (finalSelectionIsForward) {
if (boundaryAtPosition.boundaryEnd.offset <= originTextBoundary.boundaryEnd.offset) {
return SelectionResult.end;
}
if (boundaryAtPosition.boundaryEnd.offset > originTextBoundary.boundaryEnd.offset) {
return SelectionResult.next;
}
} else {
if (boundaryAtPosition.boundaryStart.offset >= originTextBoundary.boundaryStart.offset) {
return SelectionResult.end;
}
if (boundaryAtPosition.boundaryStart.offset < originTextBoundary.boundaryStart.offset) {
return SelectionResult.previous;
}
}
} else {
// When the drag position is not contained within the origin paragraph,
// swap the edges when the selection changes direction.
//
// [SelectionUtils.adjustDragOffset] will adjust the given [Offset] to the
// beginning or end of the provided [Rect] based on whether the [Offset]
// is located within the given [Rect].
final Offset adjustedOffset = SelectionUtils.adjustDragOffset(
originParagraph.paintBounds,
originParagraphLocalPosition,
direction: paragraph.textDirection,
);
final TextPosition adjustedPositionRelativeToOriginParagraph = originParagraph.getPositionForOffset(adjustedOffset);
final TextPosition originParagraphPlaceholderTextPosition = _getPositionInParagraph(originParagraph);
final TextRange originParagraphPlaceholderRange = TextRange(start: originParagraphPlaceholderTextPosition.offset, end: originParagraphPlaceholderTextPosition.offset + _placeholderLength);
if (forwardSelection && adjustedPositionRelativeToOriginParagraph.offset <= originParagraphPlaceholderRange.start) {
_setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd);
return SelectionResult.previous;
}
if (!forwardSelection && adjustedPositionRelativeToOriginParagraph.offset >= originParagraphPlaceholderRange.end) {
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
return SelectionResult.next;
}
if (forwardSelection && adjustedPositionRelativeToOriginParagraph.offset >= originParagraphPlaceholderRange.end) {
_setSelectionPosition(existingSelectionStart, isEnd: true);
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
return SelectionResult.next;
}
if (!forwardSelection && adjustedPositionRelativeToOriginParagraph.offset <= originParagraphPlaceholderRange.start) {
_setSelectionPosition(existingSelectionStart, isEnd: true);
_setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd);
return SelectionResult.previous;
}
}
} else {
// When the drag position is somewhere on the root text and not a placeholder,
// traverse the selectable fragments relative to the [RenderParagraph] that
// contains the drag position.
if (paragraphContainsPosition) {
return _updateSelectionStartEdgeByMultiSelectableTextBoundary(
getTextBoundary,
paragraphContainsPosition,
position,
existingSelectionStart,
existingSelectionEnd,
);
}
if (existingSelectionEnd != null) {
final ({RenderParagraph paragraph, Offset localPosition})? targetDetails = _getParagraphContainingPosition(globalPosition);
if (targetDetails == null) {
return null;
}
final RenderParagraph targetParagraph = targetDetails.paragraph;
final TextPosition positionRelativeToTargetParagraph = targetParagraph.getPositionForOffset(targetDetails.localPosition);
final String targetText = targetParagraph.text.toPlainText(includeSemanticsLabels: false);
final bool positionOnPlaceholder = targetParagraph.getWordBoundary(positionRelativeToTargetParagraph).textInside(targetText) == _placeholderCharacter;
if (positionOnPlaceholder) {
return null;
}
final bool backwardSelection = existingSelectionStart == null && existingSelectionEnd.offset == range.start
|| existingSelectionStart == existingSelectionEnd && existingSelectionEnd.offset == range.start
|| existingSelectionStart != null && existingSelectionStart.offset > existingSelectionEnd.offset;
final _TextBoundaryRecord boundaryAtPositionRelativeToTargetParagraph = getTextBoundary(positionRelativeToTargetParagraph, targetText);
final TextPosition targetParagraphPlaceholderTextPosition = _getPositionInParagraph(targetParagraph);
final TextRange targetParagraphPlaceholderRange = TextRange(start: targetParagraphPlaceholderTextPosition.offset, end: targetParagraphPlaceholderTextPosition.offset + _placeholderLength);
if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < targetParagraphPlaceholderRange.start && boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset < targetParagraphPlaceholderRange.start) {
_setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd);
return SelectionResult.previous;
}
if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset > targetParagraphPlaceholderRange.end && boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > targetParagraphPlaceholderRange.end) {
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
return SelectionResult.next;
}
if (backwardSelection) {
if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset <= targetParagraphPlaceholderRange.end) {
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
return SelectionResult.end;
}
if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > targetParagraphPlaceholderRange.end) {
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
return SelectionResult.next;
}
} else {
if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset >= targetParagraphPlaceholderRange.start) {
_setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd);
return SelectionResult.end;
}
if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < targetParagraphPlaceholderRange.start) {
_setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd);
return SelectionResult.previous;
}
}
}
}
return null;
}
// This method handles updating the end edge by a text boundary that may
// not be contained within this selectable fragment. It is possible
// that a boundary spans multiple selectable fragments when the text contains
// [WidgetSpan]s.
//
// This method differs from [_updateSelectionEndEdgeByMultiSelectableBoundary]
// in that to maintain the origin text boundary selected at a placeholder, this
// selectable fragment must be aware of the [RenderParagraph] that closely
// encompasses the complete origin text boundary.
//
// See [_updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary]
// for the method that handles updating the start edge.
SelectionResult? _updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary(
_TextBoundaryAtPositionInText getTextBoundary,
Offset globalPosition,
bool paragraphContainsPosition,
TextPosition position,
TextPosition? existingSelectionStart,
TextPosition? existingSelectionEnd,
) {
const bool isEnd = true;
if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) {
// If this selectable contains the origin boundary, maintain the existing
// selection.
final bool forwardSelection = existingSelectionEnd.offset >= existingSelectionStart.offset;
final RenderParagraph originParagraph = _getOriginParagraph();
final bool fragmentBelongsToOriginParagraph = originParagraph == paragraph;
if (fragmentBelongsToOriginParagraph) {
return _updateSelectionEndEdgeByMultiSelectableTextBoundary(
getTextBoundary,
paragraphContainsPosition,
position,
existingSelectionStart,
existingSelectionEnd,
);
}
final Matrix4 originTransform = originParagraph.getTransformTo(null);
originTransform.invert();
final Offset originParagraphLocalPosition = MatrixUtils.transformPoint(originTransform, globalPosition);
final bool positionWithinOriginParagraph = originParagraph.paintBounds.contains(originParagraphLocalPosition);
final TextPosition positionRelativeToOriginParagraph = originParagraph.getPositionForOffset(originParagraphLocalPosition);
if (positionWithinOriginParagraph) {
// When the selection is inverted by the new position it is necessary to
// swap the end edge (moving edge) with the start edge (static edge) to
// maintain the origin text boundary within the selection.
final String originText = originParagraph.text.toPlainText(includeSemanticsLabels: false);
final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(positionRelativeToOriginParagraph, originText);
final _TextBoundaryRecord originTextBoundary = getTextBoundary(_getPositionInParagraph(originParagraph), originText);
final TextPosition targetPosition;
final int pivotOffset = forwardSelection ? originTextBoundary.boundaryStart.offset : originTextBoundary.boundaryEnd.offset;
final bool shouldSwapEdges = !forwardSelection != (positionRelativeToOriginParagraph.offset < pivotOffset);
if (positionRelativeToOriginParagraph.offset < pivotOffset) {
targetPosition = boundaryAtPosition.boundaryStart;
} else if (positionRelativeToOriginParagraph.offset > pivotOffset) {
targetPosition = boundaryAtPosition.boundaryEnd;
} else {
// Keep the origin text boundary in bounds when position is at the static edge.
targetPosition = existingSelectionEnd;
}
if (shouldSwapEdges) {
_setSelectionPosition(existingSelectionEnd, isEnd: false);
}
_setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd);
final bool finalSelectionIsForward = _textSelectionEnd!.offset >= _textSelectionStart!.offset;
final TextPosition originParagraphPlaceholderTextPosition = _getPositionInParagraph(originParagraph);
final TextRange originParagraphPlaceholderRange = TextRange(start: originParagraphPlaceholderTextPosition.offset, end: originParagraphPlaceholderTextPosition.offset + _placeholderLength);
if (boundaryAtPosition.boundaryStart.offset > originParagraphPlaceholderRange.end && boundaryAtPosition.boundaryEnd.offset > originParagraphPlaceholderRange.end) {
return SelectionResult.next;
}
if (boundaryAtPosition.boundaryStart.offset < originParagraphPlaceholderRange.start && boundaryAtPosition.boundaryEnd.offset < originParagraphPlaceholderRange.start) {
return SelectionResult.previous;
}
if (finalSelectionIsForward) {
if (boundaryAtPosition.boundaryEnd.offset <= originTextBoundary.boundaryEnd.offset) {
return SelectionResult.end;
}
if (boundaryAtPosition.boundaryEnd.offset > originTextBoundary.boundaryEnd.offset) {
return SelectionResult.next;
}
} else {
if (boundaryAtPosition.boundaryStart.offset >= originTextBoundary.boundaryStart.offset) {
return SelectionResult.end;
}
if (boundaryAtPosition.boundaryStart.offset < originTextBoundary.boundaryStart.offset) {
return SelectionResult.previous;
}
}
} else {
// When the drag position is not contained within the origin paragraph,
// swap the edges when the selection changes direction.
//
// [SelectionUtils.adjustDragOffset] will adjust the given [Offset] to the
// beginning or end of the provided [Rect] based on whether the [Offset]
// is located within the given [Rect].
final Offset adjustedOffset = SelectionUtils.adjustDragOffset(
originParagraph.paintBounds,
originParagraphLocalPosition,
direction: paragraph.textDirection,
);
final TextPosition adjustedPositionRelativeToOriginParagraph = originParagraph.getPositionForOffset(adjustedOffset);
final TextPosition originParagraphPlaceholderTextPosition = _getPositionInParagraph(originParagraph);
final TextRange originParagraphPlaceholderRange = TextRange(start: originParagraphPlaceholderTextPosition.offset, end: originParagraphPlaceholderTextPosition.offset + _placeholderLength);
if (forwardSelection && adjustedPositionRelativeToOriginParagraph.offset <= originParagraphPlaceholderRange.start) {
_setSelectionPosition(existingSelectionEnd, isEnd: false);
_setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd);
return SelectionResult.previous;
}
if (!forwardSelection && adjustedPositionRelativeToOriginParagraph.offset >= originParagraphPlaceholderRange.end) {
_setSelectionPosition(existingSelectionEnd, isEnd: false);
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
return SelectionResult.next;
}
if (forwardSelection && adjustedPositionRelativeToOriginParagraph.offset >= originParagraphPlaceholderRange.end) {
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
return SelectionResult.next;
}
if (!forwardSelection && adjustedPositionRelativeToOriginParagraph.offset <= originParagraphPlaceholderRange.start) {
_setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd);
return SelectionResult.previous;
}
}
} else {
// When the drag position is somewhere on the root text and not a placeholder,
// traverse the selectable fragments relative to the [RenderParagraph] that
// contains the drag position.
if (paragraphContainsPosition) {
return _updateSelectionEndEdgeByMultiSelectableTextBoundary(
getTextBoundary,
paragraphContainsPosition,
position,
existingSelectionStart,
existingSelectionEnd,
);
}
if (existingSelectionStart != null) {
final ({RenderParagraph paragraph, Offset localPosition})? targetDetails = _getParagraphContainingPosition(globalPosition);
if (targetDetails == null) {
return null;
}
final RenderParagraph targetParagraph = targetDetails.paragraph;
final TextPosition positionRelativeToTargetParagraph = targetParagraph.getPositionForOffset(targetDetails.localPosition);
final String targetText = targetParagraph.text.toPlainText(includeSemanticsLabels: false);
final bool positionOnPlaceholder = targetParagraph.getWordBoundary(positionRelativeToTargetParagraph).textInside(targetText) == _placeholderCharacter;
if (positionOnPlaceholder) {
return null;
}
final bool backwardSelection = existingSelectionEnd == null && existingSelectionStart.offset == range.end
|| existingSelectionStart == existingSelectionEnd && existingSelectionStart.offset == range.end
|| existingSelectionEnd != null && existingSelectionStart.offset > existingSelectionEnd.offset;
final _TextBoundaryRecord boundaryAtPositionRelativeToTargetParagraph = getTextBoundary(positionRelativeToTargetParagraph, targetText);
final TextPosition targetParagraphPlaceholderTextPosition = _getPositionInParagraph(targetParagraph);
final TextRange targetParagraphPlaceholderRange = TextRange(start: targetParagraphPlaceholderTextPosition.offset, end: targetParagraphPlaceholderTextPosition.offset + _placeholderLength);
if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < targetParagraphPlaceholderRange.start && boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset < targetParagraphPlaceholderRange.start) {
_setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd);
return SelectionResult.previous;
}
if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset > targetParagraphPlaceholderRange.end && boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > targetParagraphPlaceholderRange.end) {
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
return SelectionResult.next;
}
if (backwardSelection) {
if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset >= targetParagraphPlaceholderRange.start) {
_setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd);
return SelectionResult.end;
}
if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < targetParagraphPlaceholderRange.start) {
_setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd);
return SelectionResult.previous;
}
} else {
if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset <= targetParagraphPlaceholderRange.end) {
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
return SelectionResult.end;
}
if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > targetParagraphPlaceholderRange.end) {
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
return SelectionResult.next;
}
}
}
}
return null;
}
SelectionResult _updateSelectionEdgeByMultiSelectableTextBoundary(
Offset globalPosition,
{
required bool isEnd,
required _TextBoundaryAtPositionInText getTextBoundary,
required _TextBoundaryAtPosition getClampedTextBoundary,
}
) {
// When the start/end edges are swapped, i.e. the start is after the end, and
// the scrollable synthesizes an event for the opposite edge, this will potentially
// move the opposite edge outside of the origin text boundary and we are unable to recover.
final TextPosition? existingSelectionStart = _textSelectionStart;
final TextPosition? existingSelectionEnd = _textSelectionEnd;
_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 Offset adjustedOffsetRelativeToParagraph = SelectionUtils.adjustDragOffset(
paragraph.paintBounds,
localPosition,
direction: paragraph.textDirection,
);
final TextPosition position = paragraph.getPositionForOffset(adjustedOffset);
final TextPosition positionInFullText = paragraph.getPositionForOffset(adjustedOffsetRelativeToParagraph);
final SelectionResult? result;
if (_isPlaceholder()) {
result = isEnd
? _updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary(
getTextBoundary,
globalPosition,
paragraph.paintBounds.contains(localPosition),
positionInFullText,
existingSelectionStart,
existingSelectionEnd,
)
: _updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary(
getTextBoundary,
globalPosition,
paragraph.paintBounds.contains(localPosition),
positionInFullText,
existingSelectionStart,
existingSelectionEnd,
);
} else {
result = isEnd
? _updateSelectionEndEdgeByMultiSelectableTextBoundary(
getTextBoundary,
paragraph.paintBounds.contains(localPosition),
positionInFullText,
existingSelectionStart,
existingSelectionEnd,
)
: _updateSelectionStartEdgeByMultiSelectableTextBoundary(
getTextBoundary,
paragraph.paintBounds.contains(localPosition),
positionInFullText,
existingSelectionStart,
existingSelectionEnd,
);
}
if (result != null) {
return result;
}
// Check if the original local position is within the rect, if it is not then
// we do not need to look up the text boundary for that position. This is to
// maintain a selectables selection collapsed at 0 when the local position is
// not located inside its rect.
_TextBoundaryRecord? textBoundary = _boundingBoxesContains(localPosition) ? getClampedTextBoundary(position) : null;
if (textBoundary != null
&& (textBoundary.boundaryStart.offset < range.start && textBoundary.boundaryEnd.offset <= range.start
|| textBoundary.boundaryStart.offset >= range.end && textBoundary.boundaryEnd.offset > range.end)) {
// When the position is located at a placeholder inside of the text, then we may compute
// a text boundary that does not belong to the current selectable fragment. In this case
// we should invalidate the text boundary so that it is not taken into account when
// computing the target position.
textBoundary = null;
}
final TextPosition targetPosition = _clampTextPosition(
isEnd
? _updateSelectionEndEdgeByTextBoundary(
textBoundary,
getClampedTextBoundary,
position,
existingSelectionStart,
existingSelectionEnd,
)
: _updateSelectionStartEdgeByTextBoundary(
textBoundary,
getClampedTextBoundary,
position,
existingSelectionStart,
existingSelectionEnd,
),
);
_setSelectionPosition(targetPosition, isEnd: isEnd);
if (targetPosition.offset == range.end) {
return SelectionResult.next;
}
if (targetPosition.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 _closestTextBoundary(
_TextBoundaryRecord textBoundary,
TextPosition position,
) {
final int differenceA = (position.offset - textBoundary.boundaryStart.offset).abs();
final int differenceB = (position.offset - textBoundary.boundaryEnd.offset).abs();
return differenceA < differenceB ? textBoundary.boundaryStart : textBoundary.boundaryEnd;
}
bool _isPlaceholder() {
// Determine whether this selectable fragment is a placeholder.
RenderObject? current = paragraph.parent;
while (current != null) {
if (current is RenderParagraph) {
return true;
}
current = current.parent;
}
return false;
}
RenderParagraph _getOriginParagraph() {
// This method should only be called from a fragment that contains
// the origin boundary. By traversing up the RenderTree, determine the
// highest RenderParagraph that contains the origin text boundary.
assert(_selectableContainsOriginTextBoundary);
// Begin at the parent because it is guaranteed the paragraph containing
// this selectable fragment contains the origin boundary.
RenderObject? current = paragraph.parent;
RenderParagraph? originParagraph;
while (current != null) {
if (current is RenderParagraph) {
if (current._lastSelectableFragments != null) {
bool paragraphContainsOriginTextBoundary = false;
for (final _SelectableFragment fragment in current._lastSelectableFragments!) {
if (fragment._selectableContainsOriginTextBoundary) {
paragraphContainsOriginTextBoundary = true;
originParagraph = current;
break;
}
}
if (!paragraphContainsOriginTextBoundary) {
return originParagraph ?? paragraph;
}
}
}
current = current.parent;
}
return originParagraph ?? paragraph;
}
({RenderParagraph paragraph, Offset localPosition})? _getParagraphContainingPosition(Offset globalPosition) {
// This method will return the closest [RenderParagraph] whose rect
// contains the given `globalPosition` and the given `globalPosition`
// relative to that [RenderParagraph]. If no ancestor [RenderParagraph]
// contains the given `globalPosition` then this method will return null.
RenderObject? current = paragraph;
while (current != null) {
if (current is RenderParagraph) {
final Matrix4 currentTransform = current.getTransformTo(null);
currentTransform.invert();
final Offset currentParagraphLocalPosition = MatrixUtils.transformPoint(currentTransform, globalPosition);
final bool positionWithinCurrentParagraph = current.paintBounds.contains(currentParagraphLocalPosition);
if (positionWithinCurrentParagraph) {
return (paragraph: current, localPosition: currentParagraphLocalPosition);
}
}
current = current.parent;
}
return null;
}
bool _boundingBoxesContains(Offset position) {
for (final Rect rect in boundingBoxes) {
if (rect.contains(position)) {
return true;
}
}
return false;
}
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;
_selectableContainsOriginTextBoundary = false;
return SelectionResult.none;
}
SelectionResult _handleSelectAll() {
_textSelectionStart = TextPosition(offset: range.start);
_textSelectionEnd = TextPosition(offset: range.end, affinity: TextAffinity.upstream);
return SelectionResult.none;
}
SelectionResult _handleSelectTextBoundary(_TextBoundaryRecord textBoundary) {
// This fragment may not contain the boundary, decide what direction the target
// fragment is located in. Because fragments are separated by placeholder
// spans, we also check if the beginning or end of the boundary is touching
// either edge of this fragment.
if (textBoundary.boundaryStart.offset < range.start && textBoundary.boundaryEnd.offset <= range.start) {
return SelectionResult.previous;
} else if (textBoundary.boundaryStart.offset >= range.end && textBoundary.boundaryEnd.offset > range.end) {
return SelectionResult.next;
}
// Fragments are separated by placeholder span, the text boundary shouldn't
// expand across fragments.
assert(textBoundary.boundaryStart.offset >= range.start && textBoundary.boundaryEnd.offset <= range.end);
_textSelectionStart = textBoundary.boundaryStart;
_textSelectionEnd = textBoundary.boundaryEnd;
_selectableContainsOriginTextBoundary = true;
return SelectionResult.end;
}
TextRange? _intersect(TextRange a, TextRange b) {
assert(a.isNormalized);
assert(b.isNormalized);
final int startMax = math.max(a.start, b.start);
final int endMin = math.min(a.end, b.end);
if (startMax <= endMin) {
// Intersection.
return TextRange(start: startMax, end: endMin);
}
return null;
}
SelectionResult _handleSelectMultiFragmentTextBoundary(_TextBoundaryRecord textBoundary) {
// This fragment may not contain the boundary, decide what direction the target
// fragment is located in. Because fragments are separated by placeholder
// spans, we also check if the beginning or end of the boundary is touching
// either edge of this fragment.
if (textBoundary.boundaryStart.offset < range.start && textBoundary.boundaryEnd.offset <= range.start) {
return SelectionResult.previous;
} else if (textBoundary.boundaryStart.offset >= range.end && textBoundary.boundaryEnd.offset > range.end) {
return SelectionResult.next;
}
final TextRange boundaryAsRange = TextRange(start: textBoundary.boundaryStart.offset, end: textBoundary.boundaryEnd.offset);
final TextRange? intersectRange = _intersect(range, boundaryAsRange);
if (intersectRange != null) {
_textSelectionStart = TextPosition(offset: intersectRange.start);
_textSelectionEnd = TextPosition(offset: intersectRange.end);
_selectableContainsOriginTextBoundary = true;
if (range.end < textBoundary.boundaryEnd.offset) {
return SelectionResult.next;
}
return SelectionResult.end;
}
return SelectionResult.none;
}
_TextBoundaryRecord _adjustTextBoundaryAtPosition(TextRange textBoundary, TextPosition position) {
late final TextPosition start;
late final TextPosition end;
if (position.offset > textBoundary.end) {
start = end = TextPosition(offset: position.offset);
} else {
start = TextPosition(offset: textBoundary.start);
end = TextPosition(offset: textBoundary.end, affinity: TextAffinity.upstream);
}
return (boundaryStart: start, boundaryEnd: end);
}
SelectionResult _handleSelectWord(Offset globalPosition) {
final TextPosition position = paragraph.getPositionForOffset(paragraph.globalToLocal(globalPosition));
if (_positionIsWithinCurrentSelection(position) && _textSelectionStart != _textSelectionEnd) {
return SelectionResult.end;
}
final _TextBoundaryRecord wordBoundary = _getWordBoundaryAtPosition(position);
return _handleSelectTextBoundary(wordBoundary);
}
_TextBoundaryRecord _getWordBoundaryAtPosition(TextPosition position) {
final TextRange word = paragraph.getWordBoundary(position);
assert(word.isNormalized);
return _adjustTextBoundaryAtPosition(word, position);
}
SelectionResult _handleSelectParagraph(Offset globalPosition) {
final Offset localPosition = paragraph.globalToLocal(globalPosition);
final TextPosition position = paragraph.getPositionForOffset(localPosition);
final _TextBoundaryRecord paragraphBoundary = _getParagraphBoundaryAtPosition(position, fullText);
return _handleSelectMultiFragmentTextBoundary(paragraphBoundary);
}
TextPosition _getPositionInParagraph(RenderParagraph targetParagraph) {
final Matrix4 transform = paragraph.getTransformTo(targetParagraph);
final Offset localCenter = paragraph.paintBounds.centerLeft;
final Offset localPos = MatrixUtils.transformPoint(transform, localCenter);
final TextPosition position = targetParagraph.getPositionForOffset(localPos);
return position;
}
_TextBoundaryRecord _getParagraphBoundaryAtPosition(TextPosition position, String text) {
final ParagraphBoundary paragraphBoundary = ParagraphBoundary(text);
// Use position.offset - 1 when `position` is at the end of the selectable to retrieve
// the previous text boundary's location.
final int paragraphStart = paragraphBoundary.getLeadingTextBoundaryAt(position.offset == text.length || position.affinity == TextAffinity.upstream ? position.offset - 1 : position.offset) ?? 0;
final int paragraphEnd = paragraphBoundary.getTrailingTextBoundaryAt(position.offset) ?? text.length;
final TextRange paragraphRange = TextRange(start: paragraphStart, end: paragraphEnd);
assert(paragraphRange.isNormalized);
return _adjustTextBoundaryAtPosition(paragraphRange, position);
}
_TextBoundaryRecord _getClampedParagraphBoundaryAtPosition(TextPosition position) {
final ParagraphBoundary paragraphBoundary = ParagraphBoundary(fullText);
// Use position.offset - 1 when `position` is at the end of the selectable to retrieve
// the previous text boundary's location.
int paragraphStart = paragraphBoundary.getLeadingTextBoundaryAt(position.offset == fullText.length || position.affinity == TextAffinity.upstream ? position.offset - 1 : position.offset) ?? 0;
int paragraphEnd = paragraphBoundary.getTrailingTextBoundaryAt(position.offset) ?? fullText.length;
paragraphStart = paragraphStart < range.start ? range.start : paragraphStart > range.end ? range.end : paragraphStart;
paragraphEnd = paragraphEnd > range.end ? range.end : paragraphEnd < range.start ? range.start : paragraphEnd;
final TextRange paragraphRange = TextRange(start: paragraphStart, end: paragraphEnd);
assert(paragraphRange.isNormalized);
return _adjustTextBoundaryAtPosition(paragraphRange, position);
}
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;
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;
}
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;
case TextGranularity.word:
final TextBoundary textBoundary = paragraph._textPainter.wordBoundaries.moveByWordBoundary;
newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, textBoundary);
result = SelectionResult.end;
case TextGranularity.paragraph:
final String text = range.textInside(fullText);
newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, ParagraphBoundary(text));
result = SelectionResult.end;
case TextGranularity.line:
newPosition = _moveToTextBoundaryAtDirection(targetedEdge, forward, LineBoundary(this));
result = SelectionResult.end;
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;
}
}
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;
case TextAffinity.downstream:
caretOffset = end.offset;
}
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._textPainter.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;
}
}
@override
Matrix4 getTransformTo(RenderObject? ancestor) {
return 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();
}
}
List<Rect>? _cachedBoundingBoxes;
@override
List<Rect> get boundingBoxes {
if (_cachedBoundingBoxes == null) {
final List<TextBox> boxes = paragraph.getBoxesForSelection(
TextSelection(baseOffset: range.start, extentOffset: range.end),
);
if (boxes.isNotEmpty) {
_cachedBoundingBoxes = <Rect>[];
for (final TextBox textBox in boxes) {
_cachedBoundingBoxes!.add(textBox.toRect());
}
} else {
final Offset offset = paragraph._getOffsetForPosition(TextPosition(offset: range.start));
final Rect rect = Rect.fromPoints(offset, offset.translate(0, - paragraph._textPainter.preferredLineHeight));
_cachedBoundingBoxes = <Rect>[rect];
}
}
return _cachedBoundingBoxes!;
}
Rect? _cachedRect;
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!;
}
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);
}
}
if (_startHandleLayerLink != null && value.startSelectionPoint != null) {
context.pushLayer(
LeaderLayer(
link: _startHandleLayerLink!,
offset: offset + value.startSelectionPoint!.localPosition,
),
(PaintingContext context, Offset offset) { },
Offset.zero,
);
}
if (_endHandleLayerLink != null && value.endSelectionPoint != null) {
context.pushLayer(
LeaderLayer(
link: _endHandleLayerLink!,
offset: offset + 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);
final int end = line.end.clamp(range.start, range.end);
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);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<String>('textInsideRange', range.textInside(fullText)));
properties.add(DiagnosticsProperty<TextRange>('range', range));
properties.add(DiagnosticsProperty<String>('fullText', fullText));
}
}