// 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. // ignore_for_file: uri_does_not_exist_in_doc_import /// @docImport 'package:flutter/gestures.dart'; /// @docImport 'package:flutter/material.dart'; /// /// @docImport 'editable_text.dart'; /// @docImport 'gesture_detector.dart'; /// @docImport 'implicit_animations.dart'; /// @docImport 'transitions.dart'; /// @docImport 'widget_span.dart'; library; import 'dart:math'; import 'dart:ui' as ui show TextHeightBehavior; import 'package:PiliPlus/common/widgets/text/paragraph.dart'; import 'package:PiliPlus/common/widgets/text/rich_text.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide RichText; import 'package:flutter/rendering.dart' hide RenderParagraph; /// A run of text with a single style. /// /// The [Text] widget displays a string of text with single style. The string /// might break across multiple lines or might all be displayed on the same line /// depending on the layout constraints. /// /// The [style] argument is optional. When omitted, the text will use the style /// from the closest enclosing [DefaultTextStyle]. If the given style's /// [TextStyle.inherit] property is true (the default), the given style will /// be merged with the closest enclosing [DefaultTextStyle]. This merging /// behavior is useful, for example, to make the text bold while using the /// default font family and size. /// /// {@tool snippet} /// /// This example shows how to display text using the [Text] widget with the /// [overflow] set to [TextOverflow.ellipsis]. /// /// ![If the text overflows, the Text widget displays an ellipsis to trim the overflowing text](https://flutter.github.io/assets-for-api-docs/assets/widgets/text_ellipsis.png) /// /// ```dart /// Container( /// width: 100, /// decoration: BoxDecoration(border: Border.all()), /// child: Text(overflow: TextOverflow.ellipsis, 'Hello $_name, how are you?')) /// ``` /// {@end-tool} /// /// {@tool snippet} /// /// Setting [maxLines] to `1` is not equivalent to disabling soft wrapping with /// [softWrap]. This is apparent when using [TextOverflow.fade] as the following /// examples show. /// /// ![If a second line overflows the Text widget displays a horizontal fade](https://flutter.github.io/assets-for-api-docs/assets/widgets/text_fade_max_lines.png) /// /// ```dart /// Text( /// overflow: TextOverflow.fade, /// maxLines: 1, /// 'Hello $_name, how are you?') /// ``` /// /// Here soft wrapping is enabled and the [Text] widget tries to wrap the words /// "how are you?" to a second line. This is prevented by the [maxLines] value /// of `1`. The result is that a second line overflows and the fade appears in a /// horizontal direction at the bottom. /// /// ![If a single line overflows the Text widget displays a horizontal fade](https://flutter.github.io/assets-for-api-docs/assets/widgets/text_fade_soft_wrap.png) /// /// ```dart /// Text( /// overflow: TextOverflow.fade, /// softWrap: false, /// 'Hello $_name, how are you?') /// ``` /// /// Here soft wrapping is disabled with `softWrap: false` and the [Text] widget /// attempts to display its text in a single unbroken line. The result is that /// the single line overflows and the fade appears in a vertical direction at /// the right. /// /// {@end-tool} /// /// Using the [Text.rich] constructor, the [Text] widget can /// display a paragraph with differently styled [TextSpan]s. The sample /// that follows displays "Hello beautiful world" with different styles /// for each word. /// /// {@tool snippet} /// /// ![The word "Hello" is shown with the default text styles. The word "beautiful" is italicized. The word "world" is bold.](https://flutter.github.io/assets-for-api-docs/assets/widgets/text_rich.png) /// /// ```dart /// const Text.rich( /// TextSpan( /// text: 'Hello', // default text style /// children: [ /// TextSpan(text: ' beautiful ', style: TextStyle(fontStyle: FontStyle.italic)), /// TextSpan(text: 'world', style: TextStyle(fontWeight: FontWeight.bold)), /// ], /// ), /// ) /// ``` /// {@end-tool} /// /// ## Interactivity /// /// To make [Text] react to touch events, wrap it in a [GestureDetector] widget /// with a [GestureDetector.onTap] handler. /// /// In a Material Design application, consider using a [TextButton] instead, or /// if that isn't appropriate, at least using an [InkWell] instead of /// [GestureDetector]. /// /// To make sections of the text interactive, use [RichText] and specify a /// [TapGestureRecognizer] as the [TextSpan.recognizer] of the relevant part of /// the text. /// /// ## Selection /// /// [Text] is not selectable by default. To make a [Text] selectable, one can /// wrap a subtree with a [SelectionArea] widget. To exclude a part of a subtree /// under [SelectionArea] from selection, once can also wrap that part of the /// subtree with [SelectionContainer.disabled]. /// /// {@tool dartpad} /// This sample demonstrates how to disable selection for a Text under a /// SelectionArea. /// /// ** See code in examples/api/lib/material/selection_container/selection_container_disabled.0.dart ** /// {@end-tool} /// /// See also: /// /// * [RichText], which gives you more control over the text styles. /// * [DefaultTextStyle], which sets default styles for [Text] widgets. /// * [SelectableRegion], which provides an overview of the selection system. class Text extends StatelessWidget { /// Creates a text widget. /// /// If the [style] argument is null, the text will use the style from the /// closest enclosing [DefaultTextStyle]. /// /// The [overflow] property's behavior is affected by the [softWrap] argument. /// If the [softWrap] is true or null, the glyph causing overflow, and those /// that follow, will not be rendered. Otherwise, it will be shown with the /// given overflow option. const Text( String this.data, { super.key, this.style, this.strutStyle, this.textAlign, this.textDirection, this.locale, this.softWrap, this.overflow, @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.', ) this.textScaleFactor, this.textScaler, this.maxLines, this.semanticsLabel, this.semanticsIdentifier, this.textWidthBasis, this.textHeightBehavior, this.selectionColor, this.onShowMore, required this.primary, }) : textSpan = null, assert( textScaler == null || textScaleFactor == null, 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', ); /// Creates a text widget with a [InlineSpan]. /// /// The following subclasses of [InlineSpan] may be used to build rich text: /// /// * [TextSpan]s define text and children [InlineSpan]s. /// * [WidgetSpan]s define embedded inline widgets. /// /// See [RichText] which provides a lower-level way to draw text. const Text.rich( InlineSpan this.textSpan, { super.key, this.style, this.strutStyle, this.textAlign, this.textDirection, this.locale, this.softWrap, this.overflow, @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.', ) this.textScaleFactor, this.textScaler, this.maxLines, this.semanticsLabel, this.semanticsIdentifier, this.textWidthBasis, this.textHeightBehavior, this.selectionColor, this.onShowMore, required this.primary, }) : data = null, assert( textScaler == null || textScaleFactor == null, 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', ); /// The text to display. /// /// This will be null if a [textSpan] is provided instead. final String? data; /// The text to display as a [InlineSpan]. /// /// This will be null if [data] is provided instead. final InlineSpan? textSpan; /// If non-null, the style to use for this text. /// /// If the style's "inherit" property is true, the style will be merged with /// the closest enclosing [DefaultTextStyle]. Otherwise, the style will /// replace the closest enclosing [DefaultTextStyle]. final TextStyle? style; /// {@macro flutter.painting.textPainter.strutStyle} final StrutStyle? strutStyle; /// How the text should be aligned horizontally. final TextAlign? textAlign; /// The directionality of the text. /// /// This decides how [textAlign] values like [TextAlign.start] and /// [TextAlign.end] are interpreted. /// /// This is also used to disambiguate how to render bidirectional text. For /// example, if the [data] 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. /// /// Defaults to the ambient [Directionality], if any. final TextDirection? textDirection; /// Used to select a font when the same Unicode character can /// be rendered differently, depending on the locale. /// /// It's rarely necessary to set this property. By default its value /// is inherited from the enclosing app with `Localizations.localeOf(context)`. /// /// See [RenderParagraph.locale] for more information. final Locale? locale; /// 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. final bool? softWrap; /// How visual overflow should be handled. /// /// If this is null [TextStyle.overflow] will be used, otherwise the value /// from the nearest [DefaultTextStyle] ancestor will be used. final TextOverflow? overflow; /// 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. /// /// The value given to the constructor as textScaleFactor. If null, will /// use the [MediaQueryData.textScaleFactor] obtained from the ambient /// [MediaQuery], or 1.0 if there is no [MediaQuery] in scope. @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.', ) final double? textScaleFactor; /// {@macro flutter.painting.textPainter.textScaler} final TextScaler? textScaler; /// 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]. /// /// If this is 1, text will not wrap. Otherwise, text will be wrapped at the /// edge of the box. /// /// If this is null, but there is an ambient [DefaultTextStyle] that specifies /// an explicit number for its [DefaultTextStyle.maxLines], then the /// [DefaultTextStyle] value will take precedence. You can use a [RichText] /// widget directly to entirely override the [DefaultTextStyle]. final int? maxLines; /// {@template flutter.widgets.Text.semanticsLabel} /// An alternative semantics label for this text. /// /// If present, the semantics of this widget will contain this value instead /// of the actual text. This will overwrite any of the semantics labels applied /// directly to the [TextSpan]s. /// /// This is useful for replacing abbreviations or shorthands with the full /// text value: /// /// ```dart /// const Text(r'$$', semanticsLabel: 'Double dollars') /// ``` /// {@endtemplate} final String? semanticsLabel; /// A unique identifier for the semantics node for this widget. /// /// This is useful for cases where the text widget needs to have a uniquely /// identifiable ID that is recognized through the automation tools without /// having a dependency on the actual content of the text that can possibly be /// dynamic in nature. final String? semanticsIdentifier; /// {@macro flutter.painting.textPainter.textWidthBasis} final TextWidthBasis? textWidthBasis; /// {@macro dart.ui.textHeightBehavior} final ui.TextHeightBehavior? textHeightBehavior; /// The color to use when painting the selection. /// /// This is ignored if [SelectionContainer.maybeOf] returns null /// in the [BuildContext] of the [Text] widget. /// /// If null, the ambient [DefaultSelectionStyle] is used (if any); failing /// that, the selection color defaults to [DefaultSelectionStyle.defaultColor] /// (semi-transparent grey). final Color? selectionColor; final Color primary; final VoidCallback? onShowMore; @override Widget build(BuildContext context) { final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context); TextStyle? effectiveTextStyle = style; if (style == null || style!.inherit) { effectiveTextStyle = defaultTextStyle.style.merge(style); } if (MediaQuery.boldTextOf(context)) { effectiveTextStyle = effectiveTextStyle!.merge( const TextStyle(fontWeight: FontWeight.bold), ); } final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context); final TextScaler textScaler = switch ((this.textScaler, textScaleFactor)) { (final TextScaler textScaler, _) => textScaler, // For unmigrated apps, fall back to textScaleFactor. (null, final double textScaleFactor) => TextScaler.linear( textScaleFactor, ), (null, null) => MediaQuery.textScalerOf(context), }; late Widget result; if (registrar != null) { result = MouseRegion( cursor: DefaultSelectionStyle.of(context).mouseCursor ?? SystemMouseCursors.text, child: _SelectableTextContainer( textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start, textDirection: textDirection, // RichText uses Directionality.of to obtain a default if this is null. locale: locale, // RichText uses Localizations.localeOf to obtain a default if this is null softWrap: softWrap ?? defaultTextStyle.softWrap, overflow: overflow ?? effectiveTextStyle?.overflow ?? defaultTextStyle.overflow, textScaler: textScaler, maxLines: maxLines ?? defaultTextStyle.maxLines, strutStyle: strutStyle, textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis, textHeightBehavior: textHeightBehavior ?? defaultTextStyle.textHeightBehavior ?? DefaultTextHeightBehavior.maybeOf(context), selectionColor: selectionColor ?? DefaultSelectionStyle.of(context).selectionColor ?? DefaultSelectionStyle.defaultColor, text: TextSpan( style: effectiveTextStyle, text: data, children: textSpan != null ? [textSpan!] : null, ), primary: primary, ), ); } else { result = RichText( textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start, textDirection: textDirection, // RichText uses Directionality.of to obtain a default if this is null. locale: locale, // RichText uses Localizations.localeOf to obtain a default if this is null softWrap: softWrap ?? defaultTextStyle.softWrap, overflow: overflow ?? effectiveTextStyle?.overflow ?? defaultTextStyle.overflow, textScaler: textScaler, maxLines: maxLines ?? defaultTextStyle.maxLines, strutStyle: strutStyle, textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis, textHeightBehavior: textHeightBehavior ?? defaultTextStyle.textHeightBehavior ?? DefaultTextHeightBehavior.maybeOf(context), selectionColor: selectionColor ?? DefaultSelectionStyle.of(context).selectionColor ?? DefaultSelectionStyle.defaultColor, text: TextSpan( style: effectiveTextStyle, text: data, children: textSpan != null ? [textSpan!] : null, ), onShowMore: onShowMore, primary: primary, ); } if (semanticsLabel != null || semanticsIdentifier != null) { result = Semantics( textDirection: textDirection, label: semanticsLabel, identifier: semanticsIdentifier, child: ExcludeSemantics( excluding: semanticsLabel != null, child: result, ), ); } return result; } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(StringProperty('data', data, showName: false)); if (textSpan != null) { properties.add( textSpan!.toDiagnosticsNode( name: 'textSpan', style: DiagnosticsTreeStyle.transition, ), ); } style?.debugFillProperties(properties); properties.add( EnumProperty('textAlign', textAlign, defaultValue: null), ); properties.add( EnumProperty( 'textDirection', textDirection, defaultValue: null, ), ); properties.add( DiagnosticsProperty('locale', locale, defaultValue: null), ); properties.add( FlagProperty( 'softWrap', value: softWrap, ifTrue: 'wrapping at box width', ifFalse: 'no wrapping except at line break characters', showName: true, ), ); properties.add( EnumProperty('overflow', overflow, defaultValue: null), ); properties.add( DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null), ); properties.add(IntProperty('maxLines', maxLines, defaultValue: null)); properties.add( EnumProperty( 'textWidthBasis', textWidthBasis, defaultValue: null, ), ); properties.add( DiagnosticsProperty( 'textHeightBehavior', textHeightBehavior, defaultValue: null, ), ); if (semanticsLabel != null) { properties.add(StringProperty('semanticsLabel', semanticsLabel)); } if (semanticsIdentifier != null) { properties.add( StringProperty('semanticsIdentifier', semanticsIdentifier), ); } } } class _SelectableTextContainer extends StatefulWidget { const _SelectableTextContainer({ required this.text, required this.textAlign, this.textDirection, required this.softWrap, required this.overflow, required this.textScaler, this.maxLines, this.locale, this.strutStyle, required this.textWidthBasis, this.textHeightBehavior, required this.selectionColor, required this.primary, }); final TextSpan text; final TextAlign textAlign; final TextDirection? textDirection; final bool softWrap; final TextOverflow overflow; final TextScaler textScaler; final int? maxLines; final Locale? locale; final StrutStyle? strutStyle; final TextWidthBasis textWidthBasis; final ui.TextHeightBehavior? textHeightBehavior; final Color selectionColor; final Color primary; @override State<_SelectableTextContainer> createState() => _SelectableTextContainerState(); } class _SelectableTextContainerState extends State<_SelectableTextContainer> { late final _SelectableTextContainerDelegate _selectionDelegate; final GlobalKey _textKey = GlobalKey(); @override void initState() { super.initState(); _selectionDelegate = _SelectableTextContainerDelegate(_textKey); } @override void dispose() { _selectionDelegate.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SelectionContainer( delegate: _selectionDelegate, // Use [_RichText] wrapper so the underlying [RenderParagraph] can register // its [Selectable]s to the [SelectionContainer] created by this widget. child: _RichText( textKey: _textKey, textAlign: widget.textAlign, textDirection: widget.textDirection, locale: widget.locale, softWrap: widget.softWrap, overflow: widget.overflow, textScaler: widget.textScaler, maxLines: widget.maxLines, strutStyle: widget.strutStyle, textWidthBasis: widget.textWidthBasis, textHeightBehavior: widget.textHeightBehavior, selectionColor: widget.selectionColor, text: widget.text, primary: widget.primary, ), ); } } class _RichText extends StatelessWidget { const _RichText({ this.textKey, required this.text, required this.textAlign, this.textDirection, required this.softWrap, required this.overflow, required this.textScaler, this.maxLines, this.locale, this.strutStyle, required this.textWidthBasis, this.textHeightBehavior, required this.selectionColor, required this.primary, }); final GlobalKey? textKey; final InlineSpan text; final TextAlign textAlign; final TextDirection? textDirection; final bool softWrap; final TextOverflow overflow; final TextScaler textScaler; final int? maxLines; final Locale? locale; final StrutStyle? strutStyle; final TextWidthBasis textWidthBasis; final ui.TextHeightBehavior? textHeightBehavior; final Color selectionColor; final Color primary; @override Widget build(BuildContext context) { final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context); return RichText( key: textKey, textAlign: textAlign, textDirection: textDirection, locale: locale, softWrap: softWrap, overflow: overflow, textScaler: textScaler, maxLines: maxLines, strutStyle: strutStyle, textWidthBasis: textWidthBasis, textHeightBehavior: textHeightBehavior, selectionRegistrar: registrar, selectionColor: selectionColor, text: text, primary: primary, ); } } // In practice some selectables like widgetspan shift several pixels. So when // the vertical position diff is within the threshold, compare the horizontal // position to make the compareScreenOrder function more robust. const double _kSelectableVerticalComparingThreshold = 3.0; class _SelectableTextContainerDelegate extends StaticSelectionContainerDelegate { _SelectableTextContainerDelegate(GlobalKey textKey) : _textKey = textKey; final GlobalKey _textKey; RenderParagraph get paragraph => _textKey.currentContext!.findRenderObject()! as RenderParagraph; @override SelectionResult handleSelectParagraph(SelectParagraphSelectionEvent event) { final SelectionResult result = _handleSelectParagraph(event); super.didReceiveSelectionBoundaryEvents(); return result; } SelectionResult _handleSelectParagraph(SelectParagraphSelectionEvent event) { if (event.absorb) { for (int index = 0; index < selectables.length; index += 1) { dispatchSelectionEventToChild(selectables[index], event); } currentSelectionStartIndex = 0; currentSelectionEndIndex = selectables.length - 1; return SelectionResult.next; } // First pass, if the position is on a placeholder then dispatch the selection // event to the [Selectable] at the location and terminate. for (int index = 0; index < selectables.length; index += 1) { final bool selectableIsPlaceholder = !paragraph .selectableBelongsToParagraph(selectables[index]); if (selectableIsPlaceholder && selectables[index].boundingBoxes.isNotEmpty) { for (final Rect rect in selectables[index].boundingBoxes) { final Rect globalRect = MatrixUtils.transformRect( selectables[index].getTransformTo(null), rect, ); if (globalRect.contains(event.globalPosition)) { currentSelectionStartIndex = currentSelectionEndIndex = index; return dispatchSelectionEventToChild(selectables[index], event); } } } } SelectionResult? lastSelectionResult; bool foundStart = false; int? lastNextIndex; for (int index = 0; index < selectables.length; index += 1) { if (!paragraph.selectableBelongsToParagraph(selectables[index])) { if (foundStart) { final SelectionEvent synthesizedEvent = SelectParagraphSelectionEvent( globalPosition: event.globalPosition, absorb: true, ); final SelectionResult result = dispatchSelectionEventToChild( selectables[index], synthesizedEvent, ); if (selectables.length - 1 == index) { currentSelectionEndIndex = index; _flushInactiveSelections(); return result; } } continue; } final SelectionGeometry existingGeometry = selectables[index].value; lastSelectionResult = dispatchSelectionEventToChild( selectables[index], event, ); if (index == selectables.length - 1 && lastSelectionResult == SelectionResult.next) { if (foundStart) { currentSelectionEndIndex = index; } else { currentSelectionStartIndex = currentSelectionEndIndex = index; } return SelectionResult.next; } if (lastSelectionResult == SelectionResult.next) { if (selectables[index].value == existingGeometry && !foundStart) { lastNextIndex = index; } if (selectables[index].value != existingGeometry && !foundStart) { assert(selectables[index].boundingBoxes.isNotEmpty); assert(selectables[index].value.selectionRects.isNotEmpty); final bool selectionAtStartOfSelectable = selectables[index] .boundingBoxes[0] .overlaps( selectables[index].value.selectionRects[0], ); int startIndex = 0; if (lastNextIndex != null && selectionAtStartOfSelectable) { startIndex = lastNextIndex + 1; } else { startIndex = lastNextIndex == null && selectionAtStartOfSelectable ? 0 : index; } for (int i = startIndex; i < index; i += 1) { final SelectionEvent synthesizedEvent = SelectParagraphSelectionEvent( globalPosition: event.globalPosition, absorb: true, ); dispatchSelectionEventToChild(selectables[i], synthesizedEvent); } currentSelectionStartIndex = startIndex; foundStart = true; } continue; } if (index == 0 && lastSelectionResult == SelectionResult.previous) { return SelectionResult.previous; } if (selectables[index].value != existingGeometry) { if (!foundStart && lastNextIndex == null) { currentSelectionStartIndex = 0; for (int i = 0; i < index; i += 1) { final SelectionEvent synthesizedEvent = SelectParagraphSelectionEvent( globalPosition: event.globalPosition, absorb: true, ); dispatchSelectionEventToChild(selectables[i], synthesizedEvent); } } currentSelectionEndIndex = index; // Geometry has changed as a result of select paragraph, need to clear the // selection of other selectables to keep selection in sync. _flushInactiveSelections(); } return SelectionResult.end; } assert(lastSelectionResult == null); return SelectionResult.end; } /// Initializes the selection of the selectable children. /// /// The goal is to find the selectable child that contains the selection edge. /// Returns [SelectionResult.end] if the selection edge ends on any of the /// children. Otherwise, it returns [SelectionResult.previous] if the selection /// does not reach any of its children. Returns [SelectionResult.next] /// if the selection reaches the end of its children. /// /// Ideally, this method should only be called twice at the beginning of the /// drag selection, once for start edge update event, once for end edge update /// event. SelectionResult _initSelection( SelectionEdgeUpdateEvent event, { required bool isEnd, }) { assert( (isEnd && currentSelectionEndIndex == -1) || (!isEnd && currentSelectionStartIndex == -1), ); SelectionResult? finalResult; // Begin the search for the selection edge at the opposite edge if it exists. final bool hasOppositeEdge = isEnd ? currentSelectionStartIndex != -1 : currentSelectionEndIndex != -1; int newIndex = switch ((isEnd, hasOppositeEdge)) { (true, true) => currentSelectionStartIndex, (true, false) => 0, (false, true) => currentSelectionEndIndex, (false, false) => 0, }; bool? forward; late SelectionResult currentSelectableResult; // This loop sends the selection event to one of the following to determine // the direction of the search. // - The opposite edge index if it exists. // - Index 0 if the opposite edge index does not exist. // // If the result is `SelectionResult.next`, this loop look backward. // Otherwise, it looks forward. // // The terminate condition are: // 1. the selectable returns end, pending, none. // 2. the selectable returns previous when looking forward. // 2. the selectable returns next when looking backward. while (newIndex < selectables.length && newIndex >= 0 && finalResult == null) { currentSelectableResult = dispatchSelectionEventToChild( selectables[newIndex], event, ); switch (currentSelectableResult) { case SelectionResult.end: case SelectionResult.pending: case SelectionResult.none: finalResult = currentSelectableResult; case SelectionResult.next: if (forward == false) { newIndex += 1; finalResult = SelectionResult.end; } else if (newIndex == selectables.length - 1) { finalResult = currentSelectableResult; } else { forward = true; newIndex += 1; } case SelectionResult.previous: if (forward ?? false) { newIndex -= 1; finalResult = SelectionResult.end; } else if (newIndex == 0) { finalResult = currentSelectableResult; } else { forward = false; newIndex -= 1; } } } if (isEnd) { currentSelectionEndIndex = newIndex; } else { currentSelectionStartIndex = newIndex; } _flushInactiveSelections(); return finalResult!; } SelectionResult _adjustSelection( SelectionEdgeUpdateEvent event, { required bool isEnd, }) { assert(() { if (isEnd) { assert( currentSelectionEndIndex < selectables.length && currentSelectionEndIndex >= 0, ); return true; } assert( currentSelectionStartIndex < selectables.length && currentSelectionStartIndex >= 0, ); return true; }()); SelectionResult? finalResult; // Determines if the edge being adjusted is within the current viewport. // - If so, we begin the search for the new selection edge position at the // currentSelectionEndIndex/currentSelectionStartIndex. // - If not, we attempt to locate the new selection edge starting from // the opposite end. // - If neither edge is in the current viewport, the search for the new // selection edge position begins at 0. // // This can happen when there is a scrollable child and the edge being adjusted // has been scrolled out of view. final bool isCurrentEdgeWithinViewport = isEnd ? value.endSelectionPoint != null : value.startSelectionPoint != null; final bool isOppositeEdgeWithinViewport = isEnd ? value.startSelectionPoint != null : value.endSelectionPoint != null; int newIndex = switch (( isEnd, isCurrentEdgeWithinViewport, isOppositeEdgeWithinViewport, )) { (true, true, true) => currentSelectionEndIndex, (true, true, false) => currentSelectionEndIndex, (true, false, true) => currentSelectionStartIndex, (true, false, false) => 0, (false, true, true) => currentSelectionStartIndex, (false, true, false) => currentSelectionStartIndex, (false, false, true) => currentSelectionEndIndex, (false, false, false) => 0, }; bool? forward; late SelectionResult currentSelectableResult; // This loop sends the selection event to one of the following to determine // the direction of the search. // - currentSelectionEndIndex/currentSelectionStartIndex if the current edge // is in the current viewport. // - The opposite edge index if the current edge is not in the current viewport. // - Index 0 if neither edge is in the current viewport. // // If the result is `SelectionResult.next`, this loop look backward. // Otherwise, it looks forward. // // The terminate condition are: // 1. the selectable returns end, pending, none. // 2. the selectable returns previous when looking forward. // 2. the selectable returns next when looking backward. while (newIndex < selectables.length && newIndex >= 0 && finalResult == null) { currentSelectableResult = dispatchSelectionEventToChild( selectables[newIndex], event, ); switch (currentSelectableResult) { case SelectionResult.end: case SelectionResult.pending: case SelectionResult.none: finalResult = currentSelectableResult; case SelectionResult.next: if (forward == false) { newIndex += 1; finalResult = SelectionResult.end; } else if (newIndex == selectables.length - 1) { finalResult = currentSelectableResult; } else { forward = true; newIndex += 1; } case SelectionResult.previous: if (forward ?? false) { newIndex -= 1; finalResult = SelectionResult.end; } else if (newIndex == 0) { finalResult = currentSelectableResult; } else { forward = false; newIndex -= 1; } } } if (isEnd) { final bool forwardSelection = currentSelectionEndIndex >= currentSelectionStartIndex; if (forward != null && ((!forwardSelection && forward && newIndex >= currentSelectionStartIndex) || (forwardSelection && !forward && newIndex <= currentSelectionStartIndex))) { currentSelectionStartIndex = currentSelectionEndIndex; } currentSelectionEndIndex = newIndex; } else { final bool forwardSelection = currentSelectionEndIndex >= currentSelectionStartIndex; if (forward != null && ((!forwardSelection && !forward && newIndex <= currentSelectionEndIndex) || (forwardSelection && forward && newIndex >= currentSelectionEndIndex))) { currentSelectionEndIndex = currentSelectionStartIndex; } currentSelectionStartIndex = newIndex; } _flushInactiveSelections(); return finalResult!; } /// The compare function this delegate used for determining the selection /// order of the [Selectable]s. /// /// Sorts the [Selectable]s by their top left [Rect]. @override Comparator get compareOrder => _compareScreenOrder; static int _compareScreenOrder(Selectable a, Selectable b) { // Attempt to sort the selectables under a [_SelectableTextContainerDelegate] // by the top left rect. final Rect rectA = MatrixUtils.transformRect( a.getTransformTo(null), a.boundingBoxes.first, ); final Rect rectB = MatrixUtils.transformRect( b.getTransformTo(null), b.boundingBoxes.first, ); final int result = _compareVertically(rectA, rectB); if (result != 0) { return result; } return _compareHorizontally(rectA, rectB); } /// Compares two rectangles in the screen order solely by their vertical /// positions. /// /// Returns positive if a is lower, negative if a is higher, 0 if their /// order can't be determine solely by their vertical position. static int _compareVertically(Rect a, Rect b) { // The rectangles overlap so defer to horizontal comparison. if ((a.top - b.top < _kSelectableVerticalComparingThreshold && a.bottom - b.bottom > -_kSelectableVerticalComparingThreshold) || (b.top - a.top < _kSelectableVerticalComparingThreshold && b.bottom - a.bottom > -_kSelectableVerticalComparingThreshold)) { return 0; } if ((a.top - b.top).abs() > _kSelectableVerticalComparingThreshold) { return a.top > b.top ? 1 : -1; } return a.bottom > b.bottom ? 1 : -1; } /// Compares two rectangles in the screen order by their horizontal positions /// assuming one of the rectangles enclose the other rect vertically. /// /// Returns positive if a is lower, negative if a is higher. static int _compareHorizontally(Rect a, Rect b) { // a encloses b. if (a.left - b.left < precisionErrorTolerance && a.right - b.right > -precisionErrorTolerance) { return -1; } // b encloses a. if (b.left - a.left < precisionErrorTolerance && b.right - a.right > -precisionErrorTolerance) { return 1; } if ((a.left - b.left).abs() > precisionErrorTolerance) { return a.left > b.left ? 1 : -1; } return a.right > b.right ? 1 : -1; } /// This method calculates a local [SelectedContentRange] based on the list /// of [selections] that are accumulated from the [Selectable] children under this /// delegate. This calculation takes into account the accumulated content /// length before the active selection, and returns null when either selection /// edge has not been set. SelectedContentRange? _calculateLocalRange(List<_SelectionInfo> selections) { if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) { return null; } int startOffset = 0; int endOffset = 0; bool foundStart = false; bool forwardSelection = currentSelectionEndIndex >= currentSelectionStartIndex; if (currentSelectionEndIndex == currentSelectionStartIndex) { // Determining selection direction is innacurate if currentSelectionStartIndex == currentSelectionEndIndex. // Use the range from the selectable within the selection as the source of truth for selection direction. final SelectedContentRange rangeAtSelectableInSelection = selectables[currentSelectionStartIndex].getSelection()!; forwardSelection = rangeAtSelectableInSelection.endOffset >= rangeAtSelectableInSelection.startOffset; } for (int index = 0; index < selections.length; index++) { final _SelectionInfo selection = selections[index]; if (selection.range == null) { if (foundStart) { return SelectedContentRange( startOffset: forwardSelection ? startOffset : endOffset, endOffset: forwardSelection ? endOffset : startOffset, ); } startOffset += selection.contentLength; endOffset = startOffset; continue; } final int selectionStartNormalized = min( selection.range!.startOffset, selection.range!.endOffset, ); final int selectionEndNormalized = max( selection.range!.startOffset, selection.range!.endOffset, ); if (!foundStart) { // Because a RenderParagraph may split its content into multiple selectables // we have to consider at what offset a selectable starts at relative // to the RenderParagraph, when the selectable is not the start of the content. final bool shouldConsiderContentStart = index > 0 && paragraph.selectableBelongsToParagraph(selectables[index]); startOffset += (selectionStartNormalized - (shouldConsiderContentStart ? paragraph .getPositionForOffset( selectables[index] .boundingBoxes .first .centerLeft, ) .offset : 0)) .abs(); endOffset = startOffset + (selectionEndNormalized - selectionStartNormalized).abs(); foundStart = true; } else { endOffset += (selectionEndNormalized - selectionStartNormalized).abs(); } } assert( foundStart, 'The start of the selection has not been found despite this selection delegate having an existing currentSelectionStartIndex and currentSelectionEndIndex.', ); return SelectedContentRange( startOffset: forwardSelection ? startOffset : endOffset, endOffset: forwardSelection ? endOffset : startOffset, ); } /// Returns a [SelectedContentRange] considering the [SelectedContentRange] /// from each [Selectable] child managed under this delegate. /// /// When nothing is selected or either selection edge has not been set, /// this method will return `null`. @override SelectedContentRange? getSelection() { final List<_SelectionInfo> selections = <_SelectionInfo>[ for (final Selectable selectable in selectables) ( contentLength: selectable.contentLength, range: selectable.getSelection(), ), ]; return _calculateLocalRange(selections); } // From [SelectableRegion]. // Clears the selection on all selectables not in the range of // currentSelectionStartIndex..currentSelectionEndIndex. // // If one of the edges does not exist, then this method will clear the selection // in all selectables except the existing edge. // // If neither of the edges exist this method immediately returns. void _flushInactiveSelections() { if (currentSelectionStartIndex == -1 && currentSelectionEndIndex == -1) { return; } if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) { final int skipIndex = currentSelectionStartIndex == -1 ? currentSelectionEndIndex : currentSelectionStartIndex; selectables .where((Selectable target) => target != selectables[skipIndex]) .forEach( (Selectable target) => dispatchSelectionEventToChild( target, const ClearSelectionEvent(), ), ); return; } final int skipStart = min( currentSelectionStartIndex, currentSelectionEndIndex, ); final int skipEnd = max( currentSelectionStartIndex, currentSelectionEndIndex, ); for (int index = 0; index < selectables.length; index += 1) { if (index >= skipStart && index <= skipEnd) { continue; } dispatchSelectionEventToChild( selectables[index], const ClearSelectionEvent(), ); } } @override SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) { if (event.granularity != TextGranularity.paragraph) { return super.handleSelectionEdgeUpdate(event); } updateLastSelectionEdgeLocation( globalSelectionEdgeLocation: event.globalPosition, forEnd: event.type == SelectionEventType.endEdgeUpdate, ); if (event.type == SelectionEventType.endEdgeUpdate) { return currentSelectionEndIndex == -1 ? _initSelection(event, isEnd: true) : _adjustSelection(event, isEnd: true); } return currentSelectionStartIndex == -1 ? _initSelection(event, isEnd: false) : _adjustSelection(event, isEnd: false); } } /// The length of the content that can be selected, and the range that is /// selected. typedef _SelectionInfo = ({int contentLength, SelectedContentRange? range});