1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
---|---|
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | /// @docImport 'package:flutter/widgets.dart'; |
6 | /// |
7 | /// @docImport 'editable.dart'; |
8 | library; |
9 | |
10 | import 'dart:collection'; |
11 | import 'dart:math' as math; |
12 | import 'dart:ui' |
13 | as ui |
14 | show |
15 | BoxHeightStyle, |
16 | BoxWidthStyle, |
17 | Gradient, |
18 | LineMetrics, |
19 | PlaceholderAlignment, |
20 | Shader, |
21 | TextBox, |
22 | TextHeightBehavior; |
23 | |
24 | import 'package:flutter/foundation.dart'; |
25 | import 'package:flutter/gestures.dart'; |
26 | import 'package:flutter/semantics.dart'; |
27 | import 'package:flutter/services.dart'; |
28 | |
29 | import 'box.dart'; |
30 | import 'debug.dart'; |
31 | import 'layer.dart'; |
32 | import 'layout_helper.dart'; |
33 | import 'object.dart'; |
34 | import 'selection.dart'; |
35 | |
36 | /// The start and end positions for a text boundary. |
37 | typedef _TextBoundaryRecord = ({TextPosition boundaryStart, TextPosition boundaryEnd}); |
38 | |
39 | /// Signature for a function that determines the [_TextBoundaryRecord] at the given |
40 | /// [TextPosition]. |
41 | typedef _TextBoundaryAtPosition = _TextBoundaryRecord Function(TextPosition position); |
42 | |
43 | /// Signature for a function that determines the [_TextBoundaryRecord] at the given |
44 | /// [TextPosition], for the given [String]. |
45 | typedef _TextBoundaryAtPositionInText = |
46 | _TextBoundaryRecord Function(TextPosition position, String text); |
47 | |
48 | const String _kEllipsis = '\u2026'; |
49 | |
50 | /// Used by the [RenderParagraph] to map its rendering children to their |
51 | /// corresponding semantics nodes. |
52 | /// |
53 | /// The [RichText] uses this to tag the relation between its placeholder spans |
54 | /// and their semantics nodes. |
55 | @immutable |
56 | class PlaceholderSpanIndexSemanticsTag extends SemanticsTag { |
57 | /// Creates a semantics tag with the input `index`. |
58 | /// |
59 | /// Different [PlaceholderSpanIndexSemanticsTag]s with the same `index` are |
60 | /// consider the same. |
61 | const PlaceholderSpanIndexSemanticsTag(this.index) |
62 | : super('PlaceholderSpanIndexSemanticsTag($index )'); |
63 | |
64 | /// The index of this tag. |
65 | final int index; |
66 | |
67 | @override |
68 | bool operator ==(Object other) { |
69 | return other is PlaceholderSpanIndexSemanticsTag && other.index == index; |
70 | } |
71 | |
72 | @override |
73 | int get hashCode => Object.hash(PlaceholderSpanIndexSemanticsTag, index); |
74 | } |
75 | |
76 | /// Parent data used by [RenderParagraph] and [RenderEditable] to annotate |
77 | /// inline contents (such as [WidgetSpan]s) with. |
78 | class TextParentData extends ParentData with ContainerParentDataMixin<RenderBox> { |
79 | /// The offset at which to paint the child in the parent's coordinate system. |
80 | /// |
81 | /// A `null` value indicates this inline widget is not laid out. For instance, |
82 | /// when the inline widget has never been laid out, or the inline widget is |
83 | /// ellipsized away. |
84 | Offset? get offset => _offset; |
85 | Offset? _offset; |
86 | |
87 | /// The [PlaceholderSpan] associated with this render child. |
88 | /// |
89 | /// This field is usually set by a [ParentDataWidget], and is typically not |
90 | /// null when `performLayout` is called. |
91 | PlaceholderSpan? span; |
92 | |
93 | @override |
94 | void detach() { |
95 | span = null; |
96 | _offset = null; |
97 | super.detach(); |
98 | } |
99 | |
100 | @override |
101 | String toString() => 'widget:$span ,${offset == null ? "not laid out": "offset:$offset "} '; |
102 | } |
103 | |
104 | /// A mixin that provides useful default behaviors for text [RenderBox]es |
105 | /// ([RenderParagraph] and [RenderEditable] for example) with inline content |
106 | /// children managed by the [ContainerRenderObjectMixin] mixin. |
107 | /// |
108 | /// This mixin assumes every child managed by the [ContainerRenderObjectMixin] |
109 | /// mixin corresponds to a [PlaceholderSpan], and they are organized in logical |
110 | /// order of the text (the order each [PlaceholderSpan] is encountered when the |
111 | /// user reads the text). |
112 | /// |
113 | /// To use this mixin in a [RenderBox] class: |
114 | /// |
115 | /// * Call [layoutInlineChildren] in the `performLayout` and `computeDryLayout` |
116 | /// implementation, and during intrinsic size calculations, to get the size |
117 | /// information of the inline widgets as a `List` of `PlaceholderDimensions`. |
118 | /// Determine the positioning of the inline widgets (which is usually done by |
119 | /// a [TextPainter] using its line break algorithm). |
120 | /// |
121 | /// * Call [positionInlineChildren] with the positioning information of the |
122 | /// inline widgets. |
123 | /// |
124 | /// * Implement [RenderBox.applyPaintTransform], optionally with |
125 | /// [defaultApplyPaintTransform]. |
126 | /// |
127 | /// * Call [paintInlineChildren] in [RenderBox.paint] to paint the inline widgets. |
128 | /// |
129 | /// * Call [hitTestInlineChildren] in [RenderBox.hitTestChildren] to hit test the |
130 | /// inline widgets. |
131 | /// |
132 | /// See also: |
133 | /// |
134 | /// * [WidgetSpan.extractFromInlineSpan], a helper function for extracting |
135 | /// [WidgetSpan]s from an [InlineSpan] tree. |
136 | mixin RenderInlineChildrenContainerDefaults |
137 | on RenderBox, ContainerRenderObjectMixin<RenderBox, TextParentData> { |
138 | @override |
139 | void setupParentData(RenderBox child) { |
140 | if (child.parentData is! TextParentData) { |
141 | child.parentData = TextParentData(); |
142 | } |
143 | } |
144 | |
145 | static PlaceholderDimensions _layoutChild( |
146 | RenderBox child, |
147 | BoxConstraints childConstraints, |
148 | ChildLayouter layoutChild, |
149 | ChildBaselineGetter getBaseline, |
150 | ) { |
151 | final TextParentData parentData = child.parentData! as TextParentData; |
152 | final PlaceholderSpan? span = parentData.span; |
153 | assert(span != null); |
154 | return span == null |
155 | ? PlaceholderDimensions.empty |
156 | : PlaceholderDimensions( |
157 | size: layoutChild(child, childConstraints), |
158 | alignment: span.alignment, |
159 | baseline: span.baseline, |
160 | baselineOffset: switch (span.alignment) { |
161 | ui.PlaceholderAlignment.aboveBaseline || |
162 | ui.PlaceholderAlignment.belowBaseline || |
163 | ui.PlaceholderAlignment.bottom || |
164 | ui.PlaceholderAlignment.middle || |
165 | ui.PlaceholderAlignment.top => null, |
166 | ui.PlaceholderAlignment.baseline => getBaseline( |
167 | child, |
168 | childConstraints, |
169 | span.baseline!, |
170 | ), |
171 | }, |
172 | ); |
173 | } |
174 | |
175 | /// Computes the layout for every inline child using the `maxWidth` constraint. |
176 | /// |
177 | /// Returns a list of [PlaceholderDimensions], representing the layout results |
178 | /// for each child managed by the [ContainerRenderObjectMixin] mixin. |
179 | /// |
180 | /// The `getChildBaseline` parameter and the `layoutChild` parameter must be |
181 | /// consistent: if `layoutChild` computes the size of the child without |
182 | /// modifying the actual layout of that child, then `getChildBaseline` must |
183 | /// also be "dry", and vice versa. |
184 | /// |
185 | /// Since this method does not impose a maximum height constraint on the |
186 | /// inline children, some children may become taller than this [RenderBox]. |
187 | /// |
188 | /// See also: |
189 | /// |
190 | /// * [TextPainter.setPlaceholderDimensions], the method that usually takes |
191 | /// the layout results from this method as the input. |
192 | @protected |
193 | List<PlaceholderDimensions> layoutInlineChildren( |
194 | double maxWidth, |
195 | ChildLayouter layoutChild, |
196 | ChildBaselineGetter getChildBaseline, |
197 | ) { |
198 | final BoxConstraints constraints = BoxConstraints(maxWidth: maxWidth); |
199 | return <PlaceholderDimensions>[ |
200 | for (RenderBox? child = firstChild; child != null; child = childAfter(child)) |
201 | _layoutChild(child, constraints, layoutChild, getChildBaseline), |
202 | ]; |
203 | } |
204 | |
205 | /// Positions each inline child according to the coordinates provided in the |
206 | /// `boxes` list. |
207 | /// |
208 | /// The `boxes` list must be in logical order, which is the order each child |
209 | /// is encountered when the user reads the text. Usually the length of the |
210 | /// list equals [childCount], but it can be less than that, when some children |
211 | /// are omitted due to ellipsing. It never exceeds [childCount]. |
212 | /// |
213 | /// See also: |
214 | /// |
215 | /// * [TextPainter.inlinePlaceholderBoxes], the method that can be used to |
216 | /// get the input `boxes`. |
217 | @protected |
218 | void positionInlineChildren(List<ui.TextBox> boxes) { |
219 | RenderBox? child = firstChild; |
220 | for (final ui.TextBox box in boxes) { |
221 | if (child == null) { |
222 | assert( |
223 | false, |
224 | 'The length of boxes (${boxes.length} ) should be greater than childCount ($childCount )', |
225 | ); |
226 | return; |
227 | } |
228 | final TextParentData textParentData = child.parentData! as TextParentData; |
229 | textParentData._offset = Offset(box.left, box.top); |
230 | child = childAfter(child); |
231 | } |
232 | while (child != null) { |
233 | final TextParentData textParentData = child.parentData! as TextParentData; |
234 | textParentData._offset = null; |
235 | child = childAfter(child); |
236 | } |
237 | } |
238 | |
239 | /// Applies the transform that would be applied when painting the given child |
240 | /// to the given matrix. |
241 | /// |
242 | /// Render children whose [TextParentData.offset] is null zeros out the |
243 | /// `transform` to indicate they're invisible thus should not be painted. |
244 | @protected |
245 | void defaultApplyPaintTransform(RenderBox child, Matrix4 transform) { |
246 | final TextParentData childParentData = child.parentData! as TextParentData; |
247 | final Offset? offset = childParentData.offset; |
248 | if (offset == null) { |
249 | transform.setZero(); |
250 | } else { |
251 | transform.translate(offset.dx, offset.dy); |
252 | } |
253 | } |
254 | |
255 | /// Paints each inline child. |
256 | /// |
257 | /// Render children whose [TextParentData.offset] is null will be skipped by |
258 | /// this method. |
259 | @protected |
260 | void paintInlineChildren(PaintingContext context, Offset offset) { |
261 | RenderBox? child = firstChild; |
262 | while (child != null) { |
263 | final TextParentData childParentData = child.parentData! as TextParentData; |
264 | final Offset? childOffset = childParentData.offset; |
265 | if (childOffset == null) { |
266 | return; |
267 | } |
268 | context.paintChild(child, childOffset + offset); |
269 | child = childAfter(child); |
270 | } |
271 | } |
272 | |
273 | /// Performs a hit test on each inline child. |
274 | /// |
275 | /// Render children whose [TextParentData.offset] is null will be skipped by |
276 | /// this method. |
277 | @protected |
278 | bool hitTestInlineChildren(BoxHitTestResult result, Offset position) { |
279 | RenderBox? child = firstChild; |
280 | while (child != null) { |
281 | final TextParentData childParentData = child.parentData! as TextParentData; |
282 | final Offset? childOffset = childParentData.offset; |
283 | if (childOffset == null) { |
284 | return false; |
285 | } |
286 | final bool isHit = result.addWithPaintOffset( |
287 | offset: childOffset, |
288 | position: position, |
289 | hitTest: |
290 | (BoxHitTestResult result, Offset transformed) => |
291 | child!.hitTest(result, position: transformed), |
292 | ); |
293 | if (isHit) { |
294 | return true; |
295 | } |
296 | child = childAfter(child); |
297 | } |
298 | return false; |
299 | } |
300 | } |
301 | |
302 | /// A render object that displays a paragraph of text. |
303 | class RenderParagraph extends RenderBox |
304 | with |
305 | ContainerRenderObjectMixin<RenderBox, TextParentData>, |
306 | RenderInlineChildrenContainerDefaults, |
307 | RelayoutWhenSystemFontsChangeMixin { |
308 | /// Creates a paragraph render object. |
309 | /// |
310 | /// The [maxLines] property may be null (and indeed defaults to null), but if |
311 | /// it is not null, it must be greater than zero. |
312 | RenderParagraph( |
313 | InlineSpan text, { |
314 | TextAlign textAlign = TextAlign.start, |
315 | required TextDirection textDirection, |
316 | bool softWrap = true, |
317 | TextOverflow overflow = TextOverflow.clip, |
318 | @Deprecated( |
319 | 'Use textScaler instead. ' |
320 | 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' |
321 | 'This feature was deprecated after v3.12.0-2.0.pre.', |
322 | ) |
323 | double textScaleFactor = 1.0, |
324 | TextScaler textScaler = TextScaler.noScaling, |
325 | int? maxLines, |
326 | Locale? locale, |
327 | StrutStyle? strutStyle, |
328 | TextWidthBasis textWidthBasis = TextWidthBasis.parent, |
329 | ui.TextHeightBehavior? textHeightBehavior, |
330 | List<RenderBox>? children, |
331 | Color? selectionColor, |
332 | SelectionRegistrar? registrar, |
333 | }) : assert(text.debugAssertIsValid()), |
334 | assert(maxLines == null || maxLines > 0), |
335 | assert( |
336 | identical(textScaler, TextScaler.noScaling) || textScaleFactor == 1.0, |
337 | 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', |
338 | ), |
339 | _softWrap = softWrap, |
340 | _overflow = overflow, |
341 | _selectionColor = selectionColor, |
342 | _textPainter = TextPainter( |
343 | text: text, |
344 | textAlign: textAlign, |
345 | textDirection: textDirection, |
346 | textScaler: |
347 | textScaler == TextScaler.noScaling ? TextScaler.linear(textScaleFactor) : textScaler, |
348 | maxLines: maxLines, |
349 | ellipsis: overflow == TextOverflow.ellipsis ? _kEllipsis : null, |
350 | locale: locale, |
351 | strutStyle: strutStyle, |
352 | textWidthBasis: textWidthBasis, |
353 | textHeightBehavior: textHeightBehavior, |
354 | ) { |
355 | addAll(children); |
356 | this.registrar = registrar; |
357 | } |
358 | |
359 | static final String _placeholderCharacter = String.fromCharCode( |
360 | PlaceholderSpan.placeholderCodeUnit, |
361 | ); |
362 | |
363 | final TextPainter _textPainter; |
364 | |
365 | // Currently, computing min/max intrinsic width/height will destroy state |
366 | // inside the painter. Instead of calling _layout again to get back the correct |
367 | // state, use a separate TextPainter for intrinsics calculation. |
368 | // |
369 | // TODO(abarth): Make computing the min/max intrinsic width/height a |
370 | // non-destructive operation. |
371 | TextPainter? _textIntrinsicsCache; |
372 | TextPainter get _textIntrinsics { |
373 | return (_textIntrinsicsCache ??= TextPainter()) |
374 | ..text = _textPainter.text |
375 | ..textAlign = _textPainter.textAlign |
376 | ..textDirection = _textPainter.textDirection |
377 | ..textScaler = _textPainter.textScaler |
378 | ..maxLines = _textPainter.maxLines |
379 | ..ellipsis = _textPainter.ellipsis |
380 | ..locale = _textPainter.locale |
381 | ..strutStyle = _textPainter.strutStyle |
382 | ..textWidthBasis = _textPainter.textWidthBasis |
383 | ..textHeightBehavior = _textPainter.textHeightBehavior; |
384 | } |
385 | |
386 | List<AttributedString>? _cachedAttributedLabels; |
387 | |
388 | List<InlineSpanSemanticsInformation>? _cachedCombinedSemanticsInfos; |
389 | |
390 | /// The text to display. |
391 | InlineSpan get text => _textPainter.text!; |
392 | set text(InlineSpan value) { |
393 | switch (_textPainter.text!.compareTo(value)) { |
394 | case RenderComparison.identical: |
395 | return; |
396 | case RenderComparison.metadata: |
397 | _textPainter.text = value; |
398 | _cachedCombinedSemanticsInfos = null; |
399 | markNeedsSemanticsUpdate(); |
400 | case RenderComparison.paint: |
401 | _textPainter.text = value; |
402 | _cachedAttributedLabels = null; |
403 | _cachedCombinedSemanticsInfos = null; |
404 | markNeedsPaint(); |
405 | markNeedsSemanticsUpdate(); |
406 | case RenderComparison.layout: |
407 | _textPainter.text = value; |
408 | _overflowShader = null; |
409 | _cachedAttributedLabels = null; |
410 | _cachedCombinedSemanticsInfos = null; |
411 | markNeedsLayout(); |
412 | _removeSelectionRegistrarSubscription(); |
413 | _disposeSelectableFragments(); |
414 | _updateSelectionRegistrarSubscription(); |
415 | } |
416 | } |
417 | |
418 | /// The ongoing selections in this paragraph. |
419 | /// |
420 | /// The selection does not include selections in [PlaceholderSpan] if there |
421 | /// are any. |
422 | @visibleForTesting |
423 | List<TextSelection> get selections { |
424 | if (_lastSelectableFragments == null) { |
425 | return const <TextSelection>[]; |
426 | } |
427 | final List<TextSelection> results = <TextSelection>[]; |
428 | for (final _SelectableFragment fragment in _lastSelectableFragments!) { |
429 | if (fragment._textSelectionStart != null && fragment._textSelectionEnd != null) { |
430 | results.add( |
431 | TextSelection( |
432 | baseOffset: fragment._textSelectionStart!.offset, |
433 | extentOffset: fragment._textSelectionEnd!.offset, |
434 | ), |
435 | ); |
436 | } |
437 | } |
438 | return results; |
439 | } |
440 | |
441 | // Should be null if selection is not enabled, i.e. _registrar = null. The |
442 | // paragraph splits on [PlaceholderSpan.placeholderCodeUnit], and stores each |
443 | // fragment in this list. |
444 | List<_SelectableFragment>? _lastSelectableFragments; |
445 | |
446 | /// The [SelectionRegistrar] this paragraph will be, or is, registered to. |
447 | SelectionRegistrar? get registrar => _registrar; |
448 | SelectionRegistrar? _registrar; |
449 | set registrar(SelectionRegistrar? value) { |
450 | if (value == _registrar) { |
451 | return; |
452 | } |
453 | _removeSelectionRegistrarSubscription(); |
454 | _disposeSelectableFragments(); |
455 | _registrar = value; |
456 | _updateSelectionRegistrarSubscription(); |
457 | } |
458 | |
459 | void _updateSelectionRegistrarSubscription() { |
460 | if (_registrar == null) { |
461 | return; |
462 | } |
463 | _lastSelectableFragments ??= _getSelectableFragments(); |
464 | _lastSelectableFragments!.forEach(_registrar!.add); |
465 | if (_lastSelectableFragments!.isNotEmpty) { |
466 | markNeedsCompositingBitsUpdate(); |
467 | } |
468 | } |
469 | |
470 | void _removeSelectionRegistrarSubscription() { |
471 | if (_registrar == null || _lastSelectableFragments == null) { |
472 | return; |
473 | } |
474 | _lastSelectableFragments!.forEach(_registrar!.remove); |
475 | } |
476 | |
477 | List<_SelectableFragment> _getSelectableFragments() { |
478 | final String plainText = text.toPlainText(includeSemanticsLabels: false); |
479 | final List<_SelectableFragment> result = <_SelectableFragment>[]; |
480 | int start = 0; |
481 | while (start < plainText.length) { |
482 | int end = plainText.indexOf(_placeholderCharacter, start); |
483 | if (start != end) { |
484 | if (end == -1) { |
485 | end = plainText.length; |
486 | } |
487 | result.add( |
488 | _SelectableFragment( |
489 | paragraph: this, |
490 | range: TextRange(start: start, end: end), |
491 | fullText: plainText, |
492 | ), |
493 | ); |
494 | start = end; |
495 | } |
496 | start += 1; |
497 | } |
498 | return result; |
499 | } |
500 | |
501 | /// Determines whether the given [Selectable] was created by this |
502 | /// [RenderParagraph]. |
503 | /// |
504 | /// The [RenderParagraph] splits its text into multiple [Selectable]s, |
505 | /// delimited by [PlaceholderSpan]s or [WidgetSpan]s. |
506 | bool selectableBelongsToParagraph(Selectable selectable) { |
507 | if (_lastSelectableFragments == null) { |
508 | return false; |
509 | } |
510 | return _lastSelectableFragments!.contains(selectable); |
511 | } |
512 | |
513 | void _disposeSelectableFragments() { |
514 | if (_lastSelectableFragments == null) { |
515 | return; |
516 | } |
517 | for (final _SelectableFragment fragment in _lastSelectableFragments!) { |
518 | fragment.dispose(); |
519 | } |
520 | _lastSelectableFragments = null; |
521 | } |
522 | |
523 | @override |
524 | bool get alwaysNeedsCompositing => _lastSelectableFragments?.isNotEmpty ?? false; |
525 | |
526 | @override |
527 | void markNeedsLayout() { |
528 | _lastSelectableFragments?.forEach( |
529 | (_SelectableFragment element) => element.didChangeParagraphLayout(), |
530 | ); |
531 | super.markNeedsLayout(); |
532 | } |
533 | |
534 | @override |
535 | void dispose() { |
536 | _removeSelectionRegistrarSubscription(); |
537 | _disposeSelectableFragments(); |
538 | _textPainter.dispose(); |
539 | _textIntrinsicsCache?.dispose(); |
540 | super.dispose(); |
541 | } |
542 | |
543 | /// How the text should be aligned horizontally. |
544 | TextAlign get textAlign => _textPainter.textAlign; |
545 | set textAlign(TextAlign value) { |
546 | if (_textPainter.textAlign == value) { |
547 | return; |
548 | } |
549 | _textPainter.textAlign = value; |
550 | markNeedsPaint(); |
551 | } |
552 | |
553 | /// The directionality of the text. |
554 | /// |
555 | /// This decides how the [TextAlign.start], [TextAlign.end], and |
556 | /// [TextAlign.justify] values of [textAlign] are interpreted. |
557 | /// |
558 | /// This is also used to disambiguate how to render bidirectional text. For |
559 | /// example, if the [text] is an English phrase followed by a Hebrew phrase, |
560 | /// in a [TextDirection.ltr] context the English phrase will be on the left |
561 | /// and the Hebrew phrase to its right, while in a [TextDirection.rtl] |
562 | /// context, the English phrase will be on the right and the Hebrew phrase on |
563 | /// its left. |
564 | TextDirection get textDirection => _textPainter.textDirection!; |
565 | set textDirection(TextDirection value) { |
566 | if (_textPainter.textDirection == value) { |
567 | return; |
568 | } |
569 | _textPainter.textDirection = value; |
570 | markNeedsLayout(); |
571 | } |
572 | |
573 | /// Whether the text should break at soft line breaks. |
574 | /// |
575 | /// If false, the glyphs in the text will be positioned as if there was |
576 | /// unlimited horizontal space. |
577 | /// |
578 | /// If [softWrap] is false, [overflow] and [textAlign] may have unexpected |
579 | /// effects. |
580 | bool get softWrap => _softWrap; |
581 | bool _softWrap; |
582 | set softWrap(bool value) { |
583 | if (_softWrap == value) { |
584 | return; |
585 | } |
586 | _softWrap = value; |
587 | markNeedsLayout(); |
588 | } |
589 | |
590 | /// How visual overflow should be handled. |
591 | TextOverflow get overflow => _overflow; |
592 | TextOverflow _overflow; |
593 | set overflow(TextOverflow value) { |
594 | if (_overflow == value) { |
595 | return; |
596 | } |
597 | _overflow = value; |
598 | _textPainter.ellipsis = value == TextOverflow.ellipsis ? _kEllipsis : null; |
599 | markNeedsLayout(); |
600 | } |
601 | |
602 | /// Deprecated. Will be removed in a future version of Flutter. Use |
603 | /// [textScaler] instead. |
604 | /// |
605 | /// The number of font pixels for each logical pixel. |
606 | /// |
607 | /// For example, if the text scale factor is 1.5, text will be 50% larger than |
608 | /// the specified font size. |
609 | @Deprecated( |
610 | 'Use textScaler instead. ' |
611 | 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' |
612 | 'This feature was deprecated after v3.12.0-2.0.pre.', |
613 | ) |
614 | double get textScaleFactor => _textPainter.textScaleFactor; |
615 | @Deprecated( |
616 | 'Use textScaler instead. ' |
617 | 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' |
618 | 'This feature was deprecated after v3.12.0-2.0.pre.', |
619 | ) |
620 | set textScaleFactor(double value) { |
621 | textScaler = TextScaler.linear(value); |
622 | } |
623 | |
624 | /// {@macro flutter.painting.textPainter.textScaler} |
625 | TextScaler get textScaler => _textPainter.textScaler; |
626 | set textScaler(TextScaler value) { |
627 | if (_textPainter.textScaler == value) { |
628 | return; |
629 | } |
630 | _textPainter.textScaler = value; |
631 | _overflowShader = null; |
632 | markNeedsLayout(); |
633 | } |
634 | |
635 | /// An optional maximum number of lines for the text to span, wrapping if |
636 | /// necessary. If the text exceeds the given number of lines, it will be |
637 | /// truncated according to [overflow] and [softWrap]. |
638 | int? get maxLines => _textPainter.maxLines; |
639 | |
640 | /// The value may be null. If it is not null, then it must be greater than |
641 | /// zero. |
642 | set maxLines(int? value) { |
643 | assert(value == null || value > 0); |
644 | if (_textPainter.maxLines == value) { |
645 | return; |
646 | } |
647 | _textPainter.maxLines = value; |
648 | _overflowShader = null; |
649 | markNeedsLayout(); |
650 | } |
651 | |
652 | /// Used by this paragraph's internal [TextPainter] to select a |
653 | /// locale-specific font. |
654 | /// |
655 | /// In some cases, the same Unicode character may be rendered differently |
656 | /// depending on the locale. For example, the '骨' character is rendered |
657 | /// differently in the Chinese and Japanese locales. In these cases, the |
658 | /// [locale] may be used to select a locale-specific font. |
659 | Locale? get locale => _textPainter.locale; |
660 | |
661 | /// The value may be null. |
662 | set locale(Locale? value) { |
663 | if (_textPainter.locale == value) { |
664 | return; |
665 | } |
666 | _textPainter.locale = value; |
667 | _overflowShader = null; |
668 | markNeedsLayout(); |
669 | } |
670 | |
671 | /// {@macro flutter.painting.textPainter.strutStyle} |
672 | StrutStyle? get strutStyle => _textPainter.strutStyle; |
673 | |
674 | /// The value may be null. |
675 | set strutStyle(StrutStyle? value) { |
676 | if (_textPainter.strutStyle == value) { |
677 | return; |
678 | } |
679 | _textPainter.strutStyle = value; |
680 | _overflowShader = null; |
681 | markNeedsLayout(); |
682 | } |
683 | |
684 | /// {@macro flutter.painting.textPainter.textWidthBasis} |
685 | TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis; |
686 | set textWidthBasis(TextWidthBasis value) { |
687 | if (_textPainter.textWidthBasis == value) { |
688 | return; |
689 | } |
690 | _textPainter.textWidthBasis = value; |
691 | _overflowShader = null; |
692 | markNeedsLayout(); |
693 | } |
694 | |
695 | /// {@macro dart.ui.textHeightBehavior} |
696 | ui.TextHeightBehavior? get textHeightBehavior => _textPainter.textHeightBehavior; |
697 | set textHeightBehavior(ui.TextHeightBehavior? value) { |
698 | if (_textPainter.textHeightBehavior == value) { |
699 | return; |
700 | } |
701 | _textPainter.textHeightBehavior = value; |
702 | _overflowShader = null; |
703 | markNeedsLayout(); |
704 | } |
705 | |
706 | /// The color to use when painting the selection. |
707 | /// |
708 | /// Ignored if the text is not selectable (e.g. if [registrar] is null). |
709 | Color? get selectionColor => _selectionColor; |
710 | Color? _selectionColor; |
711 | set selectionColor(Color? value) { |
712 | if (_selectionColor == value) { |
713 | return; |
714 | } |
715 | _selectionColor = value; |
716 | if (_lastSelectableFragments?.any( |
717 | (_SelectableFragment fragment) => fragment.value.hasSelection, |
718 | ) ?? |
719 | false) { |
720 | markNeedsPaint(); |
721 | } |
722 | } |
723 | |
724 | Offset _getOffsetForPosition(TextPosition position) { |
725 | return getOffsetForCaret(position, Rect.zero) + Offset(0, getFullHeightForCaret(position)); |
726 | } |
727 | |
728 | @override |
729 | double computeMinIntrinsicWidth(double height) { |
730 | final List<PlaceholderDimensions> placeholderDimensions = layoutInlineChildren( |
731 | double.infinity, |
732 | (RenderBox child, BoxConstraints constraints) => |
733 | Size(child.getMinIntrinsicWidth(double.infinity), 0.0), |
734 | ChildLayoutHelper.getDryBaseline, |
735 | ); |
736 | return (_textIntrinsics |
737 | ..setPlaceholderDimensions(placeholderDimensions) |
738 | ..layout()) |
739 | .minIntrinsicWidth; |
740 | } |
741 | |
742 | @override |
743 | double computeMaxIntrinsicWidth(double height) { |
744 | final List<PlaceholderDimensions> placeholderDimensions = layoutInlineChildren( |
745 | double.infinity, |
746 | // Height and baseline is irrelevant as all text will be laid |
747 | // out in a single line. Therefore, using 0.0 as a dummy for the height. |
748 | (RenderBox child, BoxConstraints constraints) => |
749 | Size(child.getMaxIntrinsicWidth(double.infinity), 0.0), |
750 | ChildLayoutHelper.getDryBaseline, |
751 | ); |
752 | return (_textIntrinsics |
753 | ..setPlaceholderDimensions(placeholderDimensions) |
754 | ..layout()) |
755 | .maxIntrinsicWidth; |
756 | } |
757 | |
758 | /// An estimate of the height of a line in the text. See [TextPainter.preferredLineHeight]. |
759 | /// |
760 | /// This does not require the layout to be updated. |
761 | @visibleForTesting |
762 | double get preferredLineHeight => _textPainter.preferredLineHeight; |
763 | |
764 | double _computeIntrinsicHeight(double width) { |
765 | return (_textIntrinsics |
766 | ..setPlaceholderDimensions( |
767 | layoutInlineChildren( |
768 | width, |
769 | ChildLayoutHelper.dryLayoutChild, |
770 | ChildLayoutHelper.getDryBaseline, |
771 | ), |
772 | ) |
773 | ..layout(minWidth: width, maxWidth: _adjustMaxWidth(width))) |
774 | .height; |
775 | } |
776 | |
777 | @override |
778 | double computeMinIntrinsicHeight(double width) { |
779 | return _computeIntrinsicHeight(width); |
780 | } |
781 | |
782 | @override |
783 | double computeMaxIntrinsicHeight(double width) { |
784 | return _computeIntrinsicHeight(width); |
785 | } |
786 | |
787 | @override |
788 | bool hitTestSelf(Offset position) => true; |
789 | |
790 | @override |
791 | @protected |
792 | bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { |
793 | final GlyphInfo? glyph = _textPainter.getClosestGlyphForOffset(position); |
794 | // The hit-test can't fall through the horizontal gaps between visually |
795 | // adjacent characters on the same line, even with a large letter-spacing or |
796 | // text justification, as graphemeClusterLayoutBounds.width is the advance |
797 | // width to the next character, so there's no gap between their |
798 | // graphemeClusterLayoutBounds rects. |
799 | final InlineSpan? spanHit = |
800 | glyph != null && glyph.graphemeClusterLayoutBounds.contains(position) |
801 | ? _textPainter.text!.getSpanForPosition( |
802 | TextPosition(offset: glyph.graphemeClusterCodeUnitRange.start), |
803 | ) |
804 | : null; |
805 | switch (spanHit) { |
806 | case final HitTestTarget span: |
807 | result.add(HitTestEntry(span)); |
808 | return true; |
809 | case _: |
810 | return hitTestInlineChildren(result, position); |
811 | } |
812 | } |
813 | |
814 | bool _needsClipping = false; |
815 | ui.Shader? _overflowShader; |
816 | |
817 | /// Whether this paragraph currently has a [dart:ui.Shader] for its overflow |
818 | /// effect. |
819 | /// |
820 | /// Used to test this object. Not for use in production. |
821 | @visibleForTesting |
822 | bool get debugHasOverflowShader => _overflowShader != null; |
823 | |
824 | @override |
825 | void systemFontsDidChange() { |
826 | super.systemFontsDidChange(); |
827 | _textPainter.markNeedsLayout(); |
828 | } |
829 | |
830 | // Placeholder dimensions representing the sizes of child inline widgets. |
831 | // |
832 | // These need to be cached because the text painter's placeholder dimensions |
833 | // will be overwritten during intrinsic width/height calculations and must be |
834 | // restored to the original values before final layout and painting. |
835 | List<PlaceholderDimensions>? _placeholderDimensions; |
836 | |
837 | double _adjustMaxWidth(double maxWidth) { |
838 | return softWrap || overflow == TextOverflow.ellipsis ? maxWidth : double.infinity; |
839 | } |
840 | |
841 | void _layoutTextWithConstraints(BoxConstraints constraints) { |
842 | _textPainter |
843 | ..setPlaceholderDimensions(_placeholderDimensions) |
844 | ..layout(minWidth: constraints.minWidth, maxWidth: _adjustMaxWidth(constraints.maxWidth)); |
845 | } |
846 | |
847 | @override |
848 | @protected |
849 | Size computeDryLayout(covariant BoxConstraints constraints) { |
850 | final Size size = |
851 | (_textIntrinsics |
852 | ..setPlaceholderDimensions( |
853 | layoutInlineChildren( |
854 | constraints.maxWidth, |
855 | ChildLayoutHelper.dryLayoutChild, |
856 | ChildLayoutHelper.getDryBaseline, |
857 | ), |
858 | ) |
859 | ..layout( |
860 | minWidth: constraints.minWidth, |
861 | maxWidth: _adjustMaxWidth(constraints.maxWidth), |
862 | )) |
863 | .size; |
864 | return constraints.constrain(size); |
865 | } |
866 | |
867 | @override |
868 | double computeDistanceToActualBaseline(TextBaseline baseline) { |
869 | assert(!debugNeedsLayout); |
870 | assert(constraints.debugAssertIsValid()); |
871 | _layoutTextWithConstraints(constraints); |
872 | // TODO(garyq): Since our metric for ideographic baseline is currently |
873 | // inaccurate and the non-alphabetic baselines are based off of the |
874 | // alphabetic baseline, we use the alphabetic for now to produce correct |
875 | // layouts. We should eventually change this back to pass the `baseline` |
876 | // property when the ideographic baseline is properly implemented |
877 | // (https://github.com/flutter/flutter/issues/22625). |
878 | return _textPainter.computeDistanceToActualBaseline(TextBaseline.alphabetic); |
879 | } |
880 | |
881 | @override |
882 | double computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { |
883 | assert(constraints.debugAssertIsValid()); |
884 | _textIntrinsics |
885 | ..setPlaceholderDimensions( |
886 | layoutInlineChildren( |
887 | constraints.maxWidth, |
888 | ChildLayoutHelper.dryLayoutChild, |
889 | ChildLayoutHelper.getDryBaseline, |
890 | ), |
891 | ) |
892 | ..layout(minWidth: constraints.minWidth, maxWidth: _adjustMaxWidth(constraints.maxWidth)); |
893 | return _textIntrinsics.computeDistanceToActualBaseline(TextBaseline.alphabetic); |
894 | } |
895 | |
896 | @override |
897 | void performLayout() { |
898 | _lastSelectableFragments?.forEach( |
899 | (_SelectableFragment element) => element.didChangeParagraphLayout(), |
900 | ); |
901 | final BoxConstraints constraints = this.constraints; |
902 | _placeholderDimensions = layoutInlineChildren( |
903 | constraints.maxWidth, |
904 | ChildLayoutHelper.layoutChild, |
905 | ChildLayoutHelper.getBaseline, |
906 | ); |
907 | _layoutTextWithConstraints(constraints); |
908 | positionInlineChildren(_textPainter.inlinePlaceholderBoxes!); |
909 | |
910 | final Size textSize = _textPainter.size; |
911 | size = constraints.constrain(textSize); |
912 | |
913 | final bool didOverflowHeight = size.height < textSize.height || _textPainter.didExceedMaxLines; |
914 | final bool didOverflowWidth = size.width < textSize.width; |
915 | // TODO(abarth): We're only measuring the sizes of the line boxes here. If |
916 | // the glyphs draw outside the line boxes, we might think that there isn't |
917 | // visual overflow when there actually is visual overflow. This can become |
918 | // a problem if we start having horizontal overflow and introduce a clip |
919 | // that affects the actual (but undetected) vertical overflow. |
920 | final bool hasVisualOverflow = didOverflowWidth || didOverflowHeight; |
921 | if (hasVisualOverflow) { |
922 | switch (_overflow) { |
923 | case TextOverflow.visible: |
924 | _needsClipping = false; |
925 | _overflowShader = null; |
926 | case TextOverflow.clip: |
927 | case TextOverflow.ellipsis: |
928 | _needsClipping = true; |
929 | _overflowShader = null; |
930 | case TextOverflow.fade: |
931 | _needsClipping = true; |
932 | final TextPainter fadeSizePainter = TextPainter( |
933 | text: TextSpan(style: _textPainter.text!.style, text: '\u2026'), |
934 | textDirection: textDirection, |
935 | textScaler: textScaler, |
936 | locale: locale, |
937 | )..layout(); |
938 | if (didOverflowWidth) { |
939 | final (double fadeStart, double fadeEnd) = switch (textDirection) { |
940 | TextDirection.rtl => (fadeSizePainter.width, 0.0), |
941 | TextDirection.ltr => (size.width - fadeSizePainter.width, size.width), |
942 | }; |
943 | _overflowShader = ui.Gradient.linear( |
944 | Offset(fadeStart, 0.0), |
945 | Offset(fadeEnd, 0.0), |
946 | <Color>[const Color(0xFFFFFFFF), const Color(0x00FFFFFF)], |
947 | ); |
948 | } else { |
949 | final double fadeEnd = size.height; |
950 | final double fadeStart = fadeEnd - fadeSizePainter.height / 2.0; |
951 | _overflowShader = ui.Gradient.linear( |
952 | Offset(0.0, fadeStart), |
953 | Offset(0.0, fadeEnd), |
954 | <Color>[const Color(0xFFFFFFFF), const Color(0x00FFFFFF)], |
955 | ); |
956 | } |
957 | fadeSizePainter.dispose(); |
958 | } |
959 | } else { |
960 | _needsClipping = false; |
961 | _overflowShader = null; |
962 | } |
963 | } |
964 | |
965 | @override |
966 | void applyPaintTransform(RenderBox child, Matrix4 transform) { |
967 | defaultApplyPaintTransform(child, transform); |
968 | } |
969 | |
970 | @override |
971 | void paint(PaintingContext context, Offset offset) { |
972 | // Text alignment only triggers repaint so it's possible the text layout has |
973 | // been invalidated but performLayout wasn't called at this point. Make sure |
974 | // the TextPainter has a valid layout. |
975 | _layoutTextWithConstraints(constraints); |
976 | assert(() { |
977 | if (debugRepaintTextRainbowEnabled) { |
978 | final Paint paint = Paint()..color = debugCurrentRepaintColor.toColor(); |
979 | context.canvas.drawRect(offset & size, paint); |
980 | } |
981 | return true; |
982 | }()); |
983 | |
984 | if (_needsClipping) { |
985 | final Rect bounds = offset & size; |
986 | if (_overflowShader != null) { |
987 | // This layer limits what the shader below blends with to be just the |
988 | // text (as opposed to the text and its background). |
989 | context.canvas.saveLayer(bounds, Paint()); |
990 | } else { |
991 | context.canvas.save(); |
992 | } |
993 | context.canvas.clipRect(bounds); |
994 | } |
995 | |
996 | if (_lastSelectableFragments != null) { |
997 | for (final _SelectableFragment fragment in _lastSelectableFragments!) { |
998 | fragment.paint(context, offset); |
999 | } |
1000 | } |
1001 | |
1002 | _textPainter.paint(context.canvas, offset); |
1003 | |
1004 | paintInlineChildren(context, offset); |
1005 | |
1006 | if (_needsClipping) { |
1007 | if (_overflowShader != null) { |
1008 | context.canvas.translate(offset.dx, offset.dy); |
1009 | final Paint paint = |
1010 | Paint() |
1011 | ..blendMode = BlendMode.modulate |
1012 | ..shader = _overflowShader; |
1013 | context.canvas.drawRect(Offset.zero & size, paint); |
1014 | } |
1015 | context.canvas.restore(); |
1016 | } |
1017 | } |
1018 | |
1019 | /// Returns the offset at which to paint the caret. |
1020 | /// |
1021 | /// Valid only after [layout]. |
1022 | Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) { |
1023 | assert(!debugNeedsLayout); |
1024 | _layoutTextWithConstraints(constraints); |
1025 | return _textPainter.getOffsetForCaret(position, caretPrototype); |
1026 | } |
1027 | |
1028 | /// {@macro flutter.painting.textPainter.getFullHeightForCaret} |
1029 | /// |
1030 | /// Valid only after [layout]. |
1031 | double getFullHeightForCaret(TextPosition position) { |
1032 | assert(!debugNeedsLayout); |
1033 | _layoutTextWithConstraints(constraints); |
1034 | return _textPainter.getFullHeightForCaret(position, Rect.zero); |
1035 | } |
1036 | |
1037 | /// Returns a list of rects that bound the given selection. |
1038 | /// |
1039 | /// The [boxHeightStyle] and [boxWidthStyle] arguments may be used to select |
1040 | /// the shape of the [TextBox]es. These properties default to |
1041 | /// [ui.BoxHeightStyle.tight] and [ui.BoxWidthStyle.tight] respectively. |
1042 | /// |
1043 | /// A given selection might have more than one rect if the [RenderParagraph] |
1044 | /// contains multiple [InlineSpan]s or bidirectional text, because logically |
1045 | /// contiguous text might not be visually contiguous. |
1046 | /// |
1047 | /// Valid only after [layout]. |
1048 | /// |
1049 | /// See also: |
1050 | /// |
1051 | /// * [TextPainter.getBoxesForSelection], the method in TextPainter to get |
1052 | /// the equivalent boxes. |
1053 | List<ui.TextBox> getBoxesForSelection( |
1054 | TextSelection selection, { |
1055 | ui.BoxHeightStyle boxHeightStyle = ui.BoxHeightStyle.tight, |
1056 | ui.BoxWidthStyle boxWidthStyle = ui.BoxWidthStyle.tight, |
1057 | }) { |
1058 | assert(!debugNeedsLayout); |
1059 | _layoutTextWithConstraints(constraints); |
1060 | return _textPainter.getBoxesForSelection( |
1061 | selection, |
1062 | boxHeightStyle: boxHeightStyle, |
1063 | boxWidthStyle: boxWidthStyle, |
1064 | ); |
1065 | } |
1066 | |
1067 | /// Returns the position within the text for the given pixel offset. |
1068 | /// |
1069 | /// Valid only after [layout]. |
1070 | TextPosition getPositionForOffset(Offset offset) { |
1071 | assert(!debugNeedsLayout); |
1072 | _layoutTextWithConstraints(constraints); |
1073 | return _textPainter.getPositionForOffset(offset); |
1074 | } |
1075 | |
1076 | /// Returns the text range of the word at the given offset. Characters not |
1077 | /// part of a word, such as spaces, symbols, and punctuation, have word breaks |
1078 | /// on both sides. In such cases, this method will return a text range that |
1079 | /// contains the given text position. |
1080 | /// |
1081 | /// Word boundaries are defined more precisely in Unicode Standard Annex #29 |
1082 | /// <http://www.unicode.org/reports/tr29/#Word_Boundaries>. |
1083 | /// |
1084 | /// Valid only after [layout]. |
1085 | TextRange getWordBoundary(TextPosition position) { |
1086 | assert(!debugNeedsLayout); |
1087 | _layoutTextWithConstraints(constraints); |
1088 | return _textPainter.getWordBoundary(position); |
1089 | } |
1090 | |
1091 | TextRange _getLineAtOffset(TextPosition position) => _textPainter.getLineBoundary(position); |
1092 | |
1093 | TextPosition _getTextPositionAbove(TextPosition position) { |
1094 | // -0.5 of preferredLineHeight points to the middle of the line above. |
1095 | final double preferredLineHeight = _textPainter.preferredLineHeight; |
1096 | final double verticalOffset = -0.5 * preferredLineHeight; |
1097 | return _getTextPositionVertical(position, verticalOffset); |
1098 | } |
1099 | |
1100 | TextPosition _getTextPositionBelow(TextPosition position) { |
1101 | // 1.5 of preferredLineHeight points to the middle of the line below. |
1102 | final double preferredLineHeight = _textPainter.preferredLineHeight; |
1103 | final double verticalOffset = 1.5 * preferredLineHeight; |
1104 | return _getTextPositionVertical(position, verticalOffset); |
1105 | } |
1106 | |
1107 | TextPosition _getTextPositionVertical(TextPosition position, double verticalOffset) { |
1108 | final Offset caretOffset = _textPainter.getOffsetForCaret(position, Rect.zero); |
1109 | final Offset caretOffsetTranslated = caretOffset.translate(0.0, verticalOffset); |
1110 | return _textPainter.getPositionForOffset(caretOffsetTranslated); |
1111 | } |
1112 | |
1113 | /// Returns the size of the text as laid out. |
1114 | /// |
1115 | /// This can differ from [size] if the text overflowed or if the [constraints] |
1116 | /// provided by the parent [RenderObject] forced the layout to be bigger than |
1117 | /// necessary for the given [text]. |
1118 | /// |
1119 | /// This returns the [TextPainter.size] of the underlying [TextPainter]. |
1120 | /// |
1121 | /// Valid only after [layout]. |
1122 | Size get textSize { |
1123 | assert(!debugNeedsLayout); |
1124 | return _textPainter.size; |
1125 | } |
1126 | |
1127 | /// Whether the text was truncated or ellipsized as laid out. |
1128 | /// |
1129 | /// This returns the [TextPainter.didExceedMaxLines] of the underlying [TextPainter]. |
1130 | /// |
1131 | /// Valid only after [layout]. |
1132 | bool get didExceedMaxLines { |
1133 | assert(!debugNeedsLayout); |
1134 | return _textPainter.didExceedMaxLines; |
1135 | } |
1136 | |
1137 | /// Collected during [describeSemanticsConfiguration], used by |
1138 | /// [assembleSemanticsNode]. |
1139 | List<InlineSpanSemanticsInformation>? _semanticsInfo; |
1140 | |
1141 | @override |
1142 | void describeSemanticsConfiguration(SemanticsConfiguration config) { |
1143 | super.describeSemanticsConfiguration(config); |
1144 | _semanticsInfo = text.getSemanticsInformation(); |
1145 | bool needsAssembleSemanticsNode = false; |
1146 | bool needsChildConfigurationsDelegate = false; |
1147 | for (final InlineSpanSemanticsInformation info in _semanticsInfo!) { |
1148 | if (info.recognizer != null || info.semanticsIdentifier != null) { |
1149 | needsAssembleSemanticsNode = true; |
1150 | break; |
1151 | } |
1152 | needsChildConfigurationsDelegate = needsChildConfigurationsDelegate || info.isPlaceholder; |
1153 | } |
1154 | |
1155 | if (needsAssembleSemanticsNode) { |
1156 | config.explicitChildNodes = true; |
1157 | config.isSemanticBoundary = true; |
1158 | } else if (needsChildConfigurationsDelegate) { |
1159 | config.childConfigurationsDelegate = _childSemanticsConfigurationsDelegate; |
1160 | } else { |
1161 | if (_cachedAttributedLabels == null) { |
1162 | final StringBuffer buffer = StringBuffer(); |
1163 | int offset = 0; |
1164 | final List<StringAttribute> attributes = <StringAttribute>[]; |
1165 | for (final InlineSpanSemanticsInformation info in _semanticsInfo!) { |
1166 | final String label = info.semanticsLabel ?? info.text; |
1167 | for (final StringAttribute infoAttribute in info.stringAttributes) { |
1168 | final TextRange originalRange = infoAttribute.range; |
1169 | attributes.add( |
1170 | infoAttribute.copy( |
1171 | range: TextRange( |
1172 | start: offset + originalRange.start, |
1173 | end: offset + originalRange.end, |
1174 | ), |
1175 | ), |
1176 | ); |
1177 | } |
1178 | buffer.write(label); |
1179 | offset += label.length; |
1180 | } |
1181 | _cachedAttributedLabels = <AttributedString>[ |
1182 | AttributedString(buffer.toString(), attributes: attributes), |
1183 | ]; |
1184 | } |
1185 | config.attributedLabel = _cachedAttributedLabels![0]; |
1186 | config.textDirection = textDirection; |
1187 | } |
1188 | } |
1189 | |
1190 | ChildSemanticsConfigurationsResult _childSemanticsConfigurationsDelegate( |
1191 | List<SemanticsConfiguration> childConfigs, |
1192 | ) { |
1193 | final ChildSemanticsConfigurationsResultBuilder builder = |
1194 | ChildSemanticsConfigurationsResultBuilder(); |
1195 | int placeholderIndex = 0; |
1196 | int childConfigsIndex = 0; |
1197 | int attributedLabelCacheIndex = 0; |
1198 | InlineSpanSemanticsInformation? seenTextInfo; |
1199 | _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!); |
1200 | for (final InlineSpanSemanticsInformation info in _cachedCombinedSemanticsInfos!) { |
1201 | if (info.isPlaceholder) { |
1202 | if (seenTextInfo != null) { |
1203 | builder.markAsMergeUp( |
1204 | _createSemanticsConfigForTextInfo(seenTextInfo, attributedLabelCacheIndex), |
1205 | ); |
1206 | attributedLabelCacheIndex += 1; |
1207 | } |
1208 | // Mark every childConfig belongs to this placeholder to merge up group. |
1209 | while (childConfigsIndex < childConfigs.length && |
1210 | childConfigs[childConfigsIndex].tagsChildrenWith( |
1211 | PlaceholderSpanIndexSemanticsTag(placeholderIndex), |
1212 | )) { |
1213 | builder.markAsMergeUp(childConfigs[childConfigsIndex]); |
1214 | childConfigsIndex += 1; |
1215 | } |
1216 | placeholderIndex += 1; |
1217 | } else { |
1218 | seenTextInfo = info; |
1219 | } |
1220 | } |
1221 | |
1222 | // Handle plain text info at the end. |
1223 | if (seenTextInfo != null) { |
1224 | builder.markAsMergeUp( |
1225 | _createSemanticsConfigForTextInfo(seenTextInfo, attributedLabelCacheIndex), |
1226 | ); |
1227 | } |
1228 | return builder.build(); |
1229 | } |
1230 | |
1231 | SemanticsConfiguration _createSemanticsConfigForTextInfo( |
1232 | InlineSpanSemanticsInformation textInfo, |
1233 | int cacheIndex, |
1234 | ) { |
1235 | assert(!textInfo.requiresOwnNode); |
1236 | final List<AttributedString> cachedStrings = _cachedAttributedLabels ??= <AttributedString>[]; |
1237 | assert(cacheIndex <= cachedStrings.length); |
1238 | final bool hasCache = cacheIndex < cachedStrings.length; |
1239 | |
1240 | late AttributedString attributedLabel; |
1241 | if (hasCache) { |
1242 | attributedLabel = cachedStrings[cacheIndex]; |
1243 | } else { |
1244 | assert(cachedStrings.length == cacheIndex); |
1245 | attributedLabel = AttributedString( |
1246 | textInfo.semanticsLabel ?? textInfo.text, |
1247 | attributes: textInfo.stringAttributes, |
1248 | ); |
1249 | cachedStrings.add(attributedLabel); |
1250 | } |
1251 | return SemanticsConfiguration() |
1252 | ..textDirection = textDirection |
1253 | ..attributedLabel = attributedLabel; |
1254 | } |
1255 | |
1256 | // Caches [SemanticsNode]s created during [assembleSemanticsNode] so they |
1257 | // can be re-used when [assembleSemanticsNode] is called again. This ensures |
1258 | // stable ids for the [SemanticsNode]s of [TextSpan]s across |
1259 | // [assembleSemanticsNode] invocations. |
1260 | LinkedHashMap<Key, SemanticsNode>? _cachedChildNodes; |
1261 | |
1262 | @override |
1263 | void assembleSemanticsNode( |
1264 | SemanticsNode node, |
1265 | SemanticsConfiguration config, |
1266 | Iterable<SemanticsNode> children, |
1267 | ) { |
1268 | assert(_semanticsInfo != null && _semanticsInfo!.isNotEmpty); |
1269 | final List<SemanticsNode> newChildren = <SemanticsNode>[]; |
1270 | TextDirection currentDirection = textDirection; |
1271 | Rect currentRect; |
1272 | double ordinal = 0.0; |
1273 | int start = 0; |
1274 | int placeholderIndex = 0; |
1275 | int childIndex = 0; |
1276 | RenderBox? child = firstChild; |
1277 | final LinkedHashMap<Key, SemanticsNode> newChildCache = LinkedHashMap<Key, SemanticsNode>(); |
1278 | _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!); |
1279 | for (final InlineSpanSemanticsInformation info in _cachedCombinedSemanticsInfos!) { |
1280 | final TextSelection selection = TextSelection( |
1281 | baseOffset: start, |
1282 | extentOffset: start + info.text.length, |
1283 | ); |
1284 | start += info.text.length; |
1285 | |
1286 | if (info.isPlaceholder) { |
1287 | // A placeholder span may have 0 to multiple semantics nodes, we need |
1288 | // to annotate all of the semantics nodes belong to this span. |
1289 | while (children.length > childIndex && |
1290 | children |
1291 | .elementAt(childIndex) |
1292 | .isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) { |
1293 | final SemanticsNode childNode = children.elementAt(childIndex); |
1294 | final TextParentData parentData = child!.parentData! as TextParentData; |
1295 | // parentData.scale may be null if the render object is truncated. |
1296 | if (parentData.offset != null) { |
1297 | newChildren.add(childNode); |
1298 | } |
1299 | childIndex += 1; |
1300 | } |
1301 | child = childAfter(child!); |
1302 | placeholderIndex += 1; |
1303 | } else { |
1304 | final TextDirection initialDirection = currentDirection; |
1305 | final List<ui.TextBox> rects = getBoxesForSelection(selection); |
1306 | if (rects.isEmpty) { |
1307 | continue; |
1308 | } |
1309 | Rect rect = rects.first.toRect(); |
1310 | currentDirection = rects.first.direction; |
1311 | for (final ui.TextBox textBox in rects.skip(1)) { |
1312 | rect = rect.expandToInclude(textBox.toRect()); |
1313 | currentDirection = textBox.direction; |
1314 | } |
1315 | // Any of the text boxes may have had infinite dimensions. |
1316 | // We shouldn't pass infinite dimensions up to the bridges. |
1317 | rect = Rect.fromLTWH( |
1318 | math.max(0.0, rect.left), |
1319 | math.max(0.0, rect.top), |
1320 | math.min(rect.width, constraints.maxWidth), |
1321 | math.min(rect.height, constraints.maxHeight), |
1322 | ); |
1323 | // round the current rectangle to make this API testable and add some |
1324 | // padding so that the accessibility rects do not overlap with the text. |
1325 | currentRect = Rect.fromLTRB( |
1326 | rect.left.floorToDouble() - 4.0, |
1327 | rect.top.floorToDouble() - 4.0, |
1328 | rect.right.ceilToDouble() + 4.0, |
1329 | rect.bottom.ceilToDouble() + 4.0, |
1330 | ); |
1331 | final SemanticsConfiguration configuration = |
1332 | SemanticsConfiguration() |
1333 | ..sortKey = OrdinalSortKey(ordinal++) |
1334 | ..textDirection = initialDirection |
1335 | ..identifier = info.semanticsIdentifier ?? '' |
1336 | ..attributedLabel = AttributedString( |
1337 | info.semanticsLabel ?? info.text, |
1338 | attributes: info.stringAttributes, |
1339 | ); |
1340 | switch (info.recognizer) { |
1341 | case TapGestureRecognizer(onTap: final VoidCallback? handler): |
1342 | case DoubleTapGestureRecognizer(onDoubleTap: final VoidCallback? handler): |
1343 | if (handler != null) { |
1344 | configuration.onTap = handler; |
1345 | configuration.isLink = true; |
1346 | } |
1347 | case LongPressGestureRecognizer(onLongPress: final GestureLongPressCallback? onLongPress): |
1348 | if (onLongPress != null) { |
1349 | configuration.onLongPress = onLongPress; |
1350 | } |
1351 | case null: |
1352 | break; |
1353 | default: |
1354 | assert(false, '${info.recognizer.runtimeType} is not supported.'); |
1355 | } |
1356 | if (node.parentPaintClipRect != null) { |
1357 | final Rect paintRect = node.parentPaintClipRect!.intersect(currentRect); |
1358 | configuration.isHidden = paintRect.isEmpty && !currentRect.isEmpty; |
1359 | } |
1360 | final SemanticsNode newChild; |
1361 | if (_cachedChildNodes?.isNotEmpty ?? false) { |
1362 | newChild = _cachedChildNodes!.remove(_cachedChildNodes!.keys.first)!; |
1363 | } else { |
1364 | final UniqueKey key = UniqueKey(); |
1365 | newChild = SemanticsNode(key: key, showOnScreen: _createShowOnScreenFor(key)); |
1366 | } |
1367 | newChild |
1368 | ..updateWith(config: configuration) |
1369 | ..rect = currentRect; |
1370 | newChildCache[newChild.key!] = newChild; |
1371 | newChildren.add(newChild); |
1372 | } |
1373 | } |
1374 | // Makes sure we annotated all of the semantics children. |
1375 | assert(childIndex == children.length); |
1376 | assert(child == null); |
1377 | |
1378 | _cachedChildNodes = newChildCache; |
1379 | node.updateWith(config: config, childrenInInversePaintOrder: newChildren); |
1380 | } |
1381 | |
1382 | VoidCallback? _createShowOnScreenFor(Key key) { |
1383 | return () { |
1384 | final SemanticsNode node = _cachedChildNodes![key]!; |
1385 | showOnScreen(descendant: this, rect: node.rect); |
1386 | }; |
1387 | } |
1388 | |
1389 | @override |
1390 | void clearSemantics() { |
1391 | super.clearSemantics(); |
1392 | _cachedChildNodes = null; |
1393 | } |
1394 | |
1395 | @override |
1396 | List<DiagnosticsNode> debugDescribeChildren() { |
1397 | return <DiagnosticsNode>[ |
1398 | text.toDiagnosticsNode(name: 'text', style: DiagnosticsTreeStyle.transition), |
1399 | ]; |
1400 | } |
1401 | |
1402 | @override |
1403 | void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
1404 | super.debugFillProperties(properties); |
1405 | properties.add(EnumProperty<TextAlign>('textAlign', textAlign)); |
1406 | properties.add(EnumProperty<TextDirection>('textDirection', textDirection)); |
1407 | properties.add( |
1408 | FlagProperty( |
1409 | 'softWrap', |
1410 | value: softWrap, |
1411 | ifTrue: 'wrapping at box width', |
1412 | ifFalse: 'no wrapping except at line break characters', |
1413 | showName: true, |
1414 | ), |
1415 | ); |
1416 | properties.add(EnumProperty<TextOverflow>('overflow', overflow)); |
1417 | properties.add( |
1418 | DiagnosticsProperty<TextScaler>('textScaler', textScaler, defaultValue: TextScaler.noScaling), |
1419 | ); |
1420 | properties.add(DiagnosticsProperty<Locale>('locale', locale, defaultValue: null)); |
1421 | properties.add(IntProperty('maxLines', maxLines, ifNull: 'unlimited')); |
1422 | } |
1423 | } |
1424 | |
1425 | /// A continuous, selectable piece of paragraph. |
1426 | /// |
1427 | /// Since the selections in [PlaceholderSpan] are handled independently in its |
1428 | /// subtree, a selection in [RenderParagraph] can't continue across a |
1429 | /// [PlaceholderSpan]. The [RenderParagraph] splits itself on [PlaceholderSpan] |
1430 | /// to create multiple `_SelectableFragment`s so that they can be selected |
1431 | /// separately. |
1432 | class _SelectableFragment |
1433 | with Selectable, Diagnosticable, ChangeNotifier |
1434 | implements TextLayoutMetrics { |
1435 | _SelectableFragment({required this.paragraph, required this.fullText, required this.range}) |
1436 | : assert(range.isValid && !range.isCollapsed && range.isNormalized) { |
1437 | if (kFlutterMemoryAllocationsEnabled) { |
1438 | ChangeNotifier.maybeDispatchObjectCreation(this); |
1439 | } |
1440 | _selectionGeometry = _getSelectionGeometry(); |
1441 | } |
1442 | |
1443 | final TextRange range; |
1444 | final RenderParagraph paragraph; |
1445 | final String fullText; |
1446 | |
1447 | TextPosition? _textSelectionStart; |
1448 | TextPosition? _textSelectionEnd; |
1449 | |
1450 | bool _selectableContainsOriginTextBoundary = false; |
1451 | |
1452 | LayerLink? _startHandleLayerLink; |
1453 | LayerLink? _endHandleLayerLink; |
1454 | |
1455 | @override |
1456 | SelectionGeometry get value => _selectionGeometry; |
1457 | late SelectionGeometry _selectionGeometry; |
1458 | void _updateSelectionGeometry() { |
1459 | final SelectionGeometry newValue = _getSelectionGeometry(); |
1460 | |
1461 | if (_selectionGeometry == newValue) { |
1462 | return; |
1463 | } |
1464 | _selectionGeometry = newValue; |
1465 | notifyListeners(); |
1466 | } |
1467 | |
1468 | SelectionGeometry _getSelectionGeometry() { |
1469 | if (_textSelectionStart == null || _textSelectionEnd == null) { |
1470 | return const SelectionGeometry(status: SelectionStatus.none, hasContent: true); |
1471 | } |
1472 | |
1473 | final int selectionStart = _textSelectionStart!.offset; |
1474 | final int selectionEnd = _textSelectionEnd!.offset; |
1475 | final bool isReversed = selectionStart > selectionEnd; |
1476 | final Offset startOffsetInParagraphCoordinates = paragraph._getOffsetForPosition( |
1477 | TextPosition(offset: selectionStart), |
1478 | ); |
1479 | final Offset endOffsetInParagraphCoordinates = |
1480 | selectionStart == selectionEnd |
1481 | ? startOffsetInParagraphCoordinates |
1482 | : paragraph._getOffsetForPosition(TextPosition(offset: selectionEnd)); |
1483 | final bool flipHandles = isReversed != (TextDirection.rtl == paragraph.textDirection); |
1484 | final TextSelection selection = TextSelection( |
1485 | baseOffset: selectionStart, |
1486 | extentOffset: selectionEnd, |
1487 | ); |
1488 | final List<Rect> selectionRects = <Rect>[]; |
1489 | for (final TextBox textBox in paragraph.getBoxesForSelection(selection)) { |
1490 | selectionRects.add(textBox.toRect()); |
1491 | } |
1492 | final bool selectionCollapsed = selectionStart == selectionEnd; |
1493 | final ( |
1494 | TextSelectionHandleType startSelectionHandleType, |
1495 | TextSelectionHandleType endSelectionHandleType, |
1496 | ) = switch ((selectionCollapsed, flipHandles)) { |
1497 | // Always prefer collapsed handle when selection is collapsed. |
1498 | (true, _) => (TextSelectionHandleType.collapsed, TextSelectionHandleType.collapsed), |
1499 | (false, true) => (TextSelectionHandleType.right, TextSelectionHandleType.left), |
1500 | (false, false) => (TextSelectionHandleType.left, TextSelectionHandleType.right), |
1501 | }; |
1502 | return SelectionGeometry( |
1503 | startSelectionPoint: SelectionPoint( |
1504 | localPosition: startOffsetInParagraphCoordinates, |
1505 | lineHeight: paragraph._textPainter.preferredLineHeight, |
1506 | handleType: startSelectionHandleType, |
1507 | ), |
1508 | endSelectionPoint: SelectionPoint( |
1509 | localPosition: endOffsetInParagraphCoordinates, |
1510 | lineHeight: paragraph._textPainter.preferredLineHeight, |
1511 | handleType: endSelectionHandleType, |
1512 | ), |
1513 | selectionRects: selectionRects, |
1514 | status: selectionCollapsed ? SelectionStatus.collapsed : SelectionStatus.uncollapsed, |
1515 | hasContent: true, |
1516 | ); |
1517 | } |
1518 | |
1519 | @override |
1520 | SelectionResult dispatchSelectionEvent(SelectionEvent event) { |
1521 | late final SelectionResult result; |
1522 | final TextPosition? existingSelectionStart = _textSelectionStart; |
1523 | final TextPosition? existingSelectionEnd = _textSelectionEnd; |
1524 | switch (event.type) { |
1525 | case SelectionEventType.startEdgeUpdate: |
1526 | case SelectionEventType.endEdgeUpdate: |
1527 | final SelectionEdgeUpdateEvent edgeUpdate = event as SelectionEdgeUpdateEvent; |
1528 | final TextGranularity granularity = event.granularity; |
1529 | |
1530 | switch (granularity) { |
1531 | case TextGranularity.character: |
1532 | result = _updateSelectionEdge( |
1533 | edgeUpdate.globalPosition, |
1534 | isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate, |
1535 | ); |
1536 | case TextGranularity.word: |
1537 | result = _updateSelectionEdgeByTextBoundary( |
1538 | edgeUpdate.globalPosition, |
1539 | isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate, |
1540 | getTextBoundary: _getWordBoundaryAtPosition, |
1541 | ); |
1542 | case TextGranularity.paragraph: |
1543 | result = _updateSelectionEdgeByMultiSelectableTextBoundary( |
1544 | edgeUpdate.globalPosition, |
1545 | isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate, |
1546 | getTextBoundary: _getParagraphBoundaryAtPosition, |
1547 | getClampedTextBoundary: _getClampedParagraphBoundaryAtPosition, |
1548 | ); |
1549 | case TextGranularity.document: |
1550 | case TextGranularity.line: |
1551 | assert(false, 'Moving the selection edge by line or document is not supported.'); |
1552 | } |
1553 | case SelectionEventType.clear: |
1554 | result = _handleClearSelection(); |
1555 | case SelectionEventType.selectAll: |
1556 | result = _handleSelectAll(); |
1557 | case SelectionEventType.selectWord: |
1558 | final SelectWordSelectionEvent selectWord = event as SelectWordSelectionEvent; |
1559 | result = _handleSelectWord(selectWord.globalPosition); |
1560 | case SelectionEventType.selectParagraph: |
1561 | final SelectParagraphSelectionEvent selectParagraph = |
1562 | event as SelectParagraphSelectionEvent; |
1563 | if (selectParagraph.absorb) { |
1564 | _handleSelectAll(); |
1565 | result = SelectionResult.next; |
1566 | _selectableContainsOriginTextBoundary = true; |
1567 | } else { |
1568 | result = _handleSelectParagraph(selectParagraph.globalPosition); |
1569 | } |
1570 | case SelectionEventType.granularlyExtendSelection: |
1571 | final GranularlyExtendSelectionEvent granularlyExtendSelection = |
1572 | event as GranularlyExtendSelectionEvent; |
1573 | result = _handleGranularlyExtendSelection( |
1574 | granularlyExtendSelection.forward, |
1575 | granularlyExtendSelection.isEnd, |
1576 | granularlyExtendSelection.granularity, |
1577 | ); |
1578 | case SelectionEventType.directionallyExtendSelection: |
1579 | final DirectionallyExtendSelectionEvent directionallyExtendSelection = |
1580 | event as DirectionallyExtendSelectionEvent; |
1581 | result = _handleDirectionallyExtendSelection( |
1582 | directionallyExtendSelection.dx, |
1583 | directionallyExtendSelection.isEnd, |
1584 | directionallyExtendSelection.direction, |
1585 | ); |
1586 | } |
1587 | |
1588 | if (existingSelectionStart != _textSelectionStart || |
1589 | existingSelectionEnd != _textSelectionEnd) { |
1590 | _didChangeSelection(); |
1591 | } |
1592 | return result; |
1593 | } |
1594 | |
1595 | @override |
1596 | SelectedContent? getSelectedContent() { |
1597 | if (_textSelectionStart == null || _textSelectionEnd == null) { |
1598 | return null; |
1599 | } |
1600 | final int start = math.min(_textSelectionStart!.offset, _textSelectionEnd!.offset); |
1601 | final int end = math.max(_textSelectionStart!.offset, _textSelectionEnd!.offset); |
1602 | return SelectedContent(plainText: fullText.substring(start, end)); |
1603 | } |
1604 | |
1605 | @override |
1606 | SelectedContentRange? getSelection() { |
1607 | if (_textSelectionStart == null || _textSelectionEnd == null) { |
1608 | return null; |
1609 | } |
1610 | return SelectedContentRange( |
1611 | startOffset: _textSelectionStart!.offset, |
1612 | endOffset: _textSelectionEnd!.offset, |
1613 | ); |
1614 | } |
1615 | |
1616 | void _didChangeSelection() { |
1617 | paragraph.markNeedsPaint(); |
1618 | _updateSelectionGeometry(); |
1619 | } |
1620 | |
1621 | TextPosition _updateSelectionStartEdgeByTextBoundary( |
1622 | _TextBoundaryRecord? textBoundary, |
1623 | _TextBoundaryAtPosition getTextBoundary, |
1624 | TextPosition position, |
1625 | TextPosition? existingSelectionStart, |
1626 | TextPosition? existingSelectionEnd, |
1627 | ) { |
1628 | TextPosition? targetPosition; |
1629 | if (textBoundary != null) { |
1630 | assert( |
1631 | textBoundary.boundaryStart.offset >= range.start && |
1632 | textBoundary.boundaryEnd.offset <= range.end, |
1633 | ); |
1634 | if (_selectableContainsOriginTextBoundary && |
1635 | existingSelectionStart != null && |
1636 | existingSelectionEnd != null) { |
1637 | final bool isSamePosition = position.offset == existingSelectionEnd.offset; |
1638 | final bool isSelectionInverted = |
1639 | existingSelectionStart.offset > existingSelectionEnd.offset; |
1640 | final bool shouldSwapEdges = |
1641 | !isSamePosition && |
1642 | (isSelectionInverted != (position.offset > existingSelectionEnd.offset)); |
1643 | if (shouldSwapEdges) { |
1644 | if (position.offset < existingSelectionEnd.offset) { |
1645 | targetPosition = textBoundary.boundaryStart; |
1646 | } else { |
1647 | targetPosition = textBoundary.boundaryEnd; |
1648 | } |
1649 | // When the selection is inverted by the new position it is necessary to |
1650 | // swap the start edge (moving edge) with the end edge (static edge) to |
1651 | // maintain the origin text boundary within the selection. |
1652 | final _TextBoundaryRecord localTextBoundary = getTextBoundary(existingSelectionEnd); |
1653 | assert( |
1654 | localTextBoundary.boundaryStart.offset >= range.start && |
1655 | localTextBoundary.boundaryEnd.offset <= range.end, |
1656 | ); |
1657 | _setSelectionPosition( |
1658 | existingSelectionEnd.offset == localTextBoundary.boundaryStart.offset |
1659 | ? localTextBoundary.boundaryEnd |
1660 | : localTextBoundary.boundaryStart, |
1661 | isEnd: true, |
1662 | ); |
1663 | } else { |
1664 | if (position.offset < existingSelectionEnd.offset) { |
1665 | targetPosition = textBoundary.boundaryStart; |
1666 | } else if (position.offset > existingSelectionEnd.offset) { |
1667 | targetPosition = textBoundary.boundaryEnd; |
1668 | } else { |
1669 | // Keep the origin text boundary in bounds when position is at the static edge. |
1670 | targetPosition = existingSelectionStart; |
1671 | } |
1672 | } |
1673 | } else { |
1674 | if (existingSelectionEnd != null) { |
1675 | // If the end edge exists and the start edge is being moved, then the |
1676 | // start edge is moved to encompass the entire text boundary at the new position. |
1677 | if (position.offset < existingSelectionEnd.offset) { |
1678 | targetPosition = textBoundary.boundaryStart; |
1679 | } else { |
1680 | targetPosition = textBoundary.boundaryEnd; |
1681 | } |
1682 | } else { |
1683 | // Move the start edge to the closest text boundary. |
1684 | targetPosition = _closestTextBoundary(textBoundary, position); |
1685 | } |
1686 | } |
1687 | } else { |
1688 | // The position is not contained within the current rect. The targetPosition |
1689 | // will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset] |
1690 | // for a more in depth explanation on this adjustment. |
1691 | if (_selectableContainsOriginTextBoundary && |
1692 | existingSelectionStart != null && |
1693 | existingSelectionEnd != null) { |
1694 | // When the selection is inverted by the new position it is necessary to |
1695 | // swap the start edge (moving edge) with the end edge (static edge) to |
1696 | // maintain the origin text boundary within the selection. |
1697 | final bool isSamePosition = position.offset == existingSelectionEnd.offset; |
1698 | final bool isSelectionInverted = |
1699 | existingSelectionStart.offset > existingSelectionEnd.offset; |
1700 | final bool shouldSwapEdges = |
1701 | !isSamePosition && |
1702 | (isSelectionInverted != (position.offset > existingSelectionEnd.offset)); |
1703 | |
1704 | if (shouldSwapEdges) { |
1705 | final _TextBoundaryRecord localTextBoundary = getTextBoundary(existingSelectionEnd); |
1706 | assert( |
1707 | localTextBoundary.boundaryStart.offset >= range.start && |
1708 | localTextBoundary.boundaryEnd.offset <= range.end, |
1709 | ); |
1710 | _setSelectionPosition( |
1711 | isSelectionInverted ? localTextBoundary.boundaryEnd : localTextBoundary.boundaryStart, |
1712 | isEnd: true, |
1713 | ); |
1714 | } |
1715 | } |
1716 | } |
1717 | return targetPosition ?? position; |
1718 | } |
1719 | |
1720 | TextPosition _updateSelectionEndEdgeByTextBoundary( |
1721 | _TextBoundaryRecord? textBoundary, |
1722 | _TextBoundaryAtPosition getTextBoundary, |
1723 | TextPosition position, |
1724 | TextPosition? existingSelectionStart, |
1725 | TextPosition? existingSelectionEnd, |
1726 | ) { |
1727 | TextPosition? targetPosition; |
1728 | if (textBoundary != null) { |
1729 | assert( |
1730 | textBoundary.boundaryStart.offset >= range.start && |
1731 | textBoundary.boundaryEnd.offset <= range.end, |
1732 | ); |
1733 | if (_selectableContainsOriginTextBoundary && |
1734 | existingSelectionStart != null && |
1735 | existingSelectionEnd != null) { |
1736 | final bool isSamePosition = position.offset == existingSelectionStart.offset; |
1737 | final bool isSelectionInverted = |
1738 | existingSelectionStart.offset > existingSelectionEnd.offset; |
1739 | final bool shouldSwapEdges = |
1740 | !isSamePosition && |
1741 | (isSelectionInverted != (position.offset < existingSelectionStart.offset)); |
1742 | if (shouldSwapEdges) { |
1743 | if (position.offset < existingSelectionStart.offset) { |
1744 | targetPosition = textBoundary.boundaryStart; |
1745 | } else { |
1746 | targetPosition = textBoundary.boundaryEnd; |
1747 | } |
1748 | // When the selection is inverted by the new position it is necessary to |
1749 | // swap the end edge (moving edge) with the start edge (static edge) to |
1750 | // maintain the origin text boundary within the selection. |
1751 | final _TextBoundaryRecord localTextBoundary = getTextBoundary(existingSelectionStart); |
1752 | assert( |
1753 | localTextBoundary.boundaryStart.offset >= range.start && |
1754 | localTextBoundary.boundaryEnd.offset <= range.end, |
1755 | ); |
1756 | _setSelectionPosition( |
1757 | existingSelectionStart.offset == localTextBoundary.boundaryStart.offset |
1758 | ? localTextBoundary.boundaryEnd |
1759 | : localTextBoundary.boundaryStart, |
1760 | isEnd: false, |
1761 | ); |
1762 | } else { |
1763 | if (position.offset < existingSelectionStart.offset) { |
1764 | targetPosition = textBoundary.boundaryStart; |
1765 | } else if (position.offset > existingSelectionStart.offset) { |
1766 | targetPosition = textBoundary.boundaryEnd; |
1767 | } else { |
1768 | // Keep the origin text boundary in bounds when position is at the static edge. |
1769 | targetPosition = existingSelectionEnd; |
1770 | } |
1771 | } |
1772 | } else { |
1773 | if (existingSelectionStart != null) { |
1774 | // If the start edge exists and the end edge is being moved, then the |
1775 | // end edge is moved to encompass the entire text boundary at the new position. |
1776 | if (position.offset < existingSelectionStart.offset) { |
1777 | targetPosition = textBoundary.boundaryStart; |
1778 | } else { |
1779 | targetPosition = textBoundary.boundaryEnd; |
1780 | } |
1781 | } else { |
1782 | // Move the end edge to the closest text boundary. |
1783 | targetPosition = _closestTextBoundary(textBoundary, position); |
1784 | } |
1785 | } |
1786 | } else { |
1787 | // The position is not contained within the current rect. The targetPosition |
1788 | // will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset] |
1789 | // for a more in depth explanation on this adjustment. |
1790 | if (_selectableContainsOriginTextBoundary && |
1791 | existingSelectionStart != null && |
1792 | existingSelectionEnd != null) { |
1793 | // When the selection is inverted by the new position it is necessary to |
1794 | // swap the end edge (moving edge) with the start edge (static edge) to |
1795 | // maintain the origin text boundary within the selection. |
1796 | final bool isSamePosition = position.offset == existingSelectionStart.offset; |
1797 | final bool isSelectionInverted = |
1798 | existingSelectionStart.offset > existingSelectionEnd.offset; |
1799 | final bool shouldSwapEdges = |
1800 | isSelectionInverted != (position.offset < existingSelectionStart.offset) || |
1801 | isSamePosition; |
1802 | if (shouldSwapEdges) { |
1803 | final _TextBoundaryRecord localTextBoundary = getTextBoundary(existingSelectionStart); |
1804 | assert( |
1805 | localTextBoundary.boundaryStart.offset >= range.start && |
1806 | localTextBoundary.boundaryEnd.offset <= range.end, |
1807 | ); |
1808 | _setSelectionPosition( |
1809 | isSelectionInverted ? localTextBoundary.boundaryStart : localTextBoundary.boundaryEnd, |
1810 | isEnd: false, |
1811 | ); |
1812 | } |
1813 | } |
1814 | } |
1815 | return targetPosition ?? position; |
1816 | } |
1817 | |
1818 | SelectionResult _updateSelectionEdgeByTextBoundary( |
1819 | Offset globalPosition, { |
1820 | required bool isEnd, |
1821 | required _TextBoundaryAtPosition getTextBoundary, |
1822 | }) { |
1823 | // When the start/end edges are swapped, i.e. the start is after the end, and |
1824 | // the scrollable synthesizes an event for the opposite edge, this will potentially |
1825 | // move the opposite edge outside of the origin text boundary and we are unable to recover. |
1826 | final TextPosition? existingSelectionStart = _textSelectionStart; |
1827 | final TextPosition? existingSelectionEnd = _textSelectionEnd; |
1828 | |
1829 | _setSelectionPosition(null, isEnd: isEnd); |
1830 | final Matrix4 transform = paragraph.getTransformTo(null); |
1831 | transform.invert(); |
1832 | final Offset localPosition = MatrixUtils.transformPoint(transform, globalPosition); |
1833 | if (_rect.isEmpty) { |
1834 | return SelectionUtils.getResultBasedOnRect(_rect, localPosition); |
1835 | } |
1836 | final Offset adjustedOffset = SelectionUtils.adjustDragOffset( |
1837 | _rect, |
1838 | localPosition, |
1839 | direction: paragraph.textDirection, |
1840 | ); |
1841 | |
1842 | final TextPosition position = paragraph.getPositionForOffset(adjustedOffset); |
1843 | // Check if the original local position is within the rect, if it is not then |
1844 | // we do not need to look up the text boundary for that position. This is to |
1845 | // maintain a selectables selection collapsed at 0 when the local position is |
1846 | // not located inside its rect. |
1847 | _TextBoundaryRecord? textBoundary = |
1848 | _rect.contains(localPosition) ? getTextBoundary(position) : null; |
1849 | if (textBoundary != null && |
1850 | (textBoundary.boundaryStart.offset < range.start && |
1851 | textBoundary.boundaryEnd.offset <= range.start || |
1852 | textBoundary.boundaryStart.offset >= range.end && |
1853 | textBoundary.boundaryEnd.offset > range.end)) { |
1854 | // When the position is located at a placeholder inside of the text, then we may compute |
1855 | // a text boundary that does not belong to the current selectable fragment. In this case |
1856 | // we should invalidate the text boundary so that it is not taken into account when |
1857 | // computing the target position. |
1858 | textBoundary = null; |
1859 | } |
1860 | final TextPosition targetPosition = _clampTextPosition( |
1861 | isEnd |
1862 | ? _updateSelectionEndEdgeByTextBoundary( |
1863 | textBoundary, |
1864 | getTextBoundary, |
1865 | position, |
1866 | existingSelectionStart, |
1867 | existingSelectionEnd, |
1868 | ) |
1869 | : _updateSelectionStartEdgeByTextBoundary( |
1870 | textBoundary, |
1871 | getTextBoundary, |
1872 | position, |
1873 | existingSelectionStart, |
1874 | existingSelectionEnd, |
1875 | ), |
1876 | ); |
1877 | |
1878 | _setSelectionPosition(targetPosition, isEnd: isEnd); |
1879 | if (targetPosition.offset == range.end) { |
1880 | return SelectionResult.next; |
1881 | } |
1882 | |
1883 | if (targetPosition.offset == range.start) { |
1884 | return SelectionResult.previous; |
1885 | } |
1886 | // TODO(chunhtai): The geometry information should not be used to determine |
1887 | // selection result. This is a workaround to RenderParagraph, where it does |
1888 | // not have a way to get accurate text length if its text is truncated due to |
1889 | // layout constraint. |
1890 | return SelectionUtils.getResultBasedOnRect(_rect, localPosition); |
1891 | } |
1892 | |
1893 | SelectionResult _updateSelectionEdge(Offset globalPosition, {required bool isEnd}) { |
1894 | _setSelectionPosition(null, isEnd: isEnd); |
1895 | final Matrix4 transform = paragraph.getTransformTo(null); |
1896 | transform.invert(); |
1897 | final Offset localPosition = MatrixUtils.transformPoint(transform, globalPosition); |
1898 | if (_rect.isEmpty) { |
1899 | return SelectionUtils.getResultBasedOnRect(_rect, localPosition); |
1900 | } |
1901 | final Offset adjustedOffset = SelectionUtils.adjustDragOffset( |
1902 | _rect, |
1903 | localPosition, |
1904 | direction: paragraph.textDirection, |
1905 | ); |
1906 | |
1907 | final TextPosition position = _clampTextPosition( |
1908 | paragraph.getPositionForOffset(adjustedOffset), |
1909 | ); |
1910 | _setSelectionPosition(position, isEnd: isEnd); |
1911 | if (position.offset == range.end) { |
1912 | return SelectionResult.next; |
1913 | } |
1914 | if (position.offset == range.start) { |
1915 | return SelectionResult.previous; |
1916 | } |
1917 | // TODO(chunhtai): The geometry information should not be used to determine |
1918 | // selection result. This is a workaround to RenderParagraph, where it does |
1919 | // not have a way to get accurate text length if its text is truncated due to |
1920 | // layout constraint. |
1921 | return SelectionUtils.getResultBasedOnRect(_rect, localPosition); |
1922 | } |
1923 | |
1924 | // This method handles updating the start edge by a text boundary that may |
1925 | // not be contained within this selectable fragment. It is possible |
1926 | // that a boundary spans multiple selectable fragments when the text contains |
1927 | // [WidgetSpan]s. |
1928 | // |
1929 | // This method differs from [_updateSelectionStartEdgeByTextBoundary] in that |
1930 | // to pivot offset used to swap selection edges and maintain the origin |
1931 | // text boundary selected may be located outside of this selectable fragment. |
1932 | // |
1933 | // See [_updateSelectionEndEdgeByMultiSelectableTextBoundary] for the method |
1934 | // that handles updating the end edge. |
1935 | SelectionResult? _updateSelectionStartEdgeByMultiSelectableTextBoundary( |
1936 | _TextBoundaryAtPositionInText getTextBoundary, |
1937 | bool paragraphContainsPosition, |
1938 | TextPosition position, |
1939 | TextPosition? existingSelectionStart, |
1940 | TextPosition? existingSelectionEnd, |
1941 | ) { |
1942 | const bool isEnd = false; |
1943 | if (_selectableContainsOriginTextBoundary && |
1944 | existingSelectionStart != null && |
1945 | existingSelectionEnd != null) { |
1946 | // If this selectable contains the origin boundary, maintain the existing |
1947 | // selection. |
1948 | final bool forwardSelection = existingSelectionEnd.offset >= existingSelectionStart.offset; |
1949 | if (paragraphContainsPosition) { |
1950 | // When the position is within the root paragraph, swap the start and end |
1951 | // edges when the selection is inverted. |
1952 | final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(position, fullText); |
1953 | // To accurately retrieve the origin text boundary when the selection |
1954 | // is forward, use existingSelectionEnd.offset - 1. This is necessary |
1955 | // because in a forwards selection, existingSelectionEnd marks the end |
1956 | // of the origin text boundary. Using the unmodified offset incorrectly |
1957 | // targets the subsequent text boundary. |
1958 | final _TextBoundaryRecord originTextBoundary = getTextBoundary( |
1959 | forwardSelection |
1960 | ? TextPosition( |
1961 | offset: existingSelectionEnd.offset - 1, |
1962 | affinity: existingSelectionEnd.affinity, |
1963 | ) |
1964 | : existingSelectionEnd, |
1965 | fullText, |
1966 | ); |
1967 | final TextPosition targetPosition; |
1968 | final int pivotOffset = |
1969 | forwardSelection |
1970 | ? originTextBoundary.boundaryEnd.offset |
1971 | : originTextBoundary.boundaryStart.offset; |
1972 | final bool shouldSwapEdges = !forwardSelection != (position.offset > pivotOffset); |
1973 | if (position.offset < pivotOffset) { |
1974 | targetPosition = boundaryAtPosition.boundaryStart; |
1975 | } else if (position.offset > pivotOffset) { |
1976 | targetPosition = boundaryAtPosition.boundaryEnd; |
1977 | } else { |
1978 | // Keep the origin text boundary in bounds when position is at the static edge. |
1979 | targetPosition = forwardSelection ? existingSelectionStart : existingSelectionEnd; |
1980 | } |
1981 | if (shouldSwapEdges) { |
1982 | _setSelectionPosition( |
1983 | _clampTextPosition( |
1984 | forwardSelection ? originTextBoundary.boundaryStart : originTextBoundary.boundaryEnd, |
1985 | ), |
1986 | isEnd: true, |
1987 | ); |
1988 | } |
1989 | _setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd); |
1990 | final bool finalSelectionIsForward = |
1991 | _textSelectionEnd!.offset >= _textSelectionStart!.offset; |
1992 | if (boundaryAtPosition.boundaryStart.offset > range.end && |
1993 | boundaryAtPosition.boundaryEnd.offset > range.end) { |
1994 | return SelectionResult.next; |
1995 | } |
1996 | if (boundaryAtPosition.boundaryStart.offset < range.start && |
1997 | boundaryAtPosition.boundaryEnd.offset < range.start) { |
1998 | return SelectionResult.previous; |
1999 | } |
2000 | if (finalSelectionIsForward) { |
2001 | if (boundaryAtPosition.boundaryStart.offset >= originTextBoundary.boundaryStart.offset) { |
2002 | return SelectionResult.end; |
2003 | } |
2004 | if (boundaryAtPosition.boundaryStart.offset < originTextBoundary.boundaryStart.offset) { |
2005 | return SelectionResult.previous; |
2006 | } |
2007 | } else { |
2008 | if (boundaryAtPosition.boundaryEnd.offset <= originTextBoundary.boundaryEnd.offset) { |
2009 | return SelectionResult.end; |
2010 | } |
2011 | if (boundaryAtPosition.boundaryEnd.offset > originTextBoundary.boundaryEnd.offset) { |
2012 | return SelectionResult.next; |
2013 | } |
2014 | } |
2015 | } else { |
2016 | // When the drag position is not contained within the root paragraph, |
2017 | // swap the edges when the selection changes direction. |
2018 | final TextPosition clampedPosition = _clampTextPosition(position); |
2019 | // To accurately retrieve the origin text boundary when the selection |
2020 | // is forward, use existingSelectionEnd.offset - 1. This is necessary |
2021 | // because in a forwards selection, existingSelectionEnd marks the end |
2022 | // of the origin text boundary. Using the unmodified offset incorrectly |
2023 | // targets the subsequent text boundary. |
2024 | final _TextBoundaryRecord originTextBoundary = getTextBoundary( |
2025 | forwardSelection |
2026 | ? TextPosition( |
2027 | offset: existingSelectionEnd.offset - 1, |
2028 | affinity: existingSelectionEnd.affinity, |
2029 | ) |
2030 | : existingSelectionEnd, |
2031 | fullText, |
2032 | ); |
2033 | if (forwardSelection && clampedPosition.offset == range.start) { |
2034 | _setSelectionPosition(clampedPosition, isEnd: isEnd); |
2035 | return SelectionResult.previous; |
2036 | } |
2037 | if (!forwardSelection && clampedPosition.offset == range.end) { |
2038 | _setSelectionPosition(clampedPosition, isEnd: isEnd); |
2039 | return SelectionResult.next; |
2040 | } |
2041 | if (forwardSelection && clampedPosition.offset == range.end) { |
2042 | _setSelectionPosition(_clampTextPosition(originTextBoundary.boundaryStart), isEnd: true); |
2043 | _setSelectionPosition(clampedPosition, isEnd: isEnd); |
2044 | return SelectionResult.next; |
2045 | } |
2046 | if (!forwardSelection && clampedPosition.offset == range.start) { |
2047 | _setSelectionPosition(_clampTextPosition(originTextBoundary.boundaryEnd), isEnd: true); |
2048 | _setSelectionPosition(clampedPosition, isEnd: isEnd); |
2049 | return SelectionResult.previous; |
2050 | } |
2051 | } |
2052 | } else { |
2053 | // A paragraph boundary may not be completely contained within this root |
2054 | // selectable fragment. Keep searching until we find the end of the |
2055 | // boundary. Do not search when the current drag position is on a placeholder |
2056 | // to allow traversal to reach that placeholder. |
2057 | final bool positionOnPlaceholder = |
2058 | paragraph.getWordBoundary(position).textInside(fullText) == _placeholderCharacter; |
2059 | if (!paragraphContainsPosition || positionOnPlaceholder) { |
2060 | return null; |
2061 | } |
2062 | if (existingSelectionEnd != null) { |
2063 | final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(position, fullText); |
2064 | final bool backwardSelection = |
2065 | existingSelectionStart == null && existingSelectionEnd.offset == range.start || |
2066 | existingSelectionStart == existingSelectionEnd && |
2067 | existingSelectionEnd.offset == range.start || |
2068 | existingSelectionStart != null && |
2069 | existingSelectionStart.offset > existingSelectionEnd.offset; |
2070 | if (boundaryAtPosition.boundaryStart.offset < range.start && |
2071 | boundaryAtPosition.boundaryEnd.offset < range.start) { |
2072 | _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); |
2073 | return SelectionResult.previous; |
2074 | } |
2075 | if (boundaryAtPosition.boundaryStart.offset > range.end && |
2076 | boundaryAtPosition.boundaryEnd.offset > range.end) { |
2077 | _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); |
2078 | return SelectionResult.next; |
2079 | } |
2080 | if (backwardSelection) { |
2081 | if (boundaryAtPosition.boundaryEnd.offset <= range.end) { |
2082 | _setSelectionPosition(_clampTextPosition(boundaryAtPosition.boundaryEnd), isEnd: isEnd); |
2083 | return SelectionResult.end; |
2084 | } |
2085 | if (boundaryAtPosition.boundaryEnd.offset > range.end) { |
2086 | _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); |
2087 | return SelectionResult.next; |
2088 | } |
2089 | } else { |
2090 | _setSelectionPosition(_clampTextPosition(boundaryAtPosition.boundaryStart), isEnd: isEnd); |
2091 | if (boundaryAtPosition.boundaryStart.offset < range.start) { |
2092 | return SelectionResult.previous; |
2093 | } |
2094 | if (boundaryAtPosition.boundaryStart.offset >= range.start) { |
2095 | return SelectionResult.end; |
2096 | } |
2097 | } |
2098 | } |
2099 | } |
2100 | return null; |
2101 | } |
2102 | |
2103 | // This method handles updating the end edge by a text boundary that may |
2104 | // not be contained within this selectable fragment. It is possible |
2105 | // that a boundary spans multiple selectable fragments when the text contains |
2106 | // [WidgetSpan]s. |
2107 | // |
2108 | // This method differs from [_updateSelectionEndEdgeByTextBoundary] in that |
2109 | // to pivot offset used to swap selection edges and maintain the origin |
2110 | // text boundary selected may be located outside of this selectable fragment. |
2111 | // |
2112 | // See [_updateSelectionStartEdgeByMultiSelectableTextBoundary] for the method |
2113 | // that handles updating the end edge. |
2114 | SelectionResult? _updateSelectionEndEdgeByMultiSelectableTextBoundary( |
2115 | _TextBoundaryAtPositionInText getTextBoundary, |
2116 | bool paragraphContainsPosition, |
2117 | TextPosition position, |
2118 | TextPosition? existingSelectionStart, |
2119 | TextPosition? existingSelectionEnd, |
2120 | ) { |
2121 | const bool isEnd = true; |
2122 | if (_selectableContainsOriginTextBoundary && |
2123 | existingSelectionStart != null && |
2124 | existingSelectionEnd != null) { |
2125 | // If this selectable contains the origin boundary, maintain the existing |
2126 | // selection. |
2127 | final bool forwardSelection = existingSelectionEnd.offset >= existingSelectionStart.offset; |
2128 | if (paragraphContainsPosition) { |
2129 | // When the position is within the root paragraph, swap the start and end |
2130 | // edges when the selection is inverted. |
2131 | final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(position, fullText); |
2132 | // To accurately retrieve the origin text boundary when the selection |
2133 | // is backwards, use existingSelectionStart.offset - 1. This is necessary |
2134 | // because in a backwards selection, existingSelectionStart marks the end |
2135 | // of the origin text boundary. Using the unmodified offset incorrectly |
2136 | // targets the subsequent text boundary. |
2137 | final _TextBoundaryRecord originTextBoundary = getTextBoundary( |
2138 | forwardSelection |
2139 | ? existingSelectionStart |
2140 | : TextPosition( |
2141 | offset: existingSelectionStart.offset - 1, |
2142 | affinity: existingSelectionStart.affinity, |
2143 | ), |
2144 | fullText, |
2145 | ); |
2146 | final TextPosition targetPosition; |
2147 | final int pivotOffset = |
2148 | forwardSelection |
2149 | ? originTextBoundary.boundaryStart.offset |
2150 | : originTextBoundary.boundaryEnd.offset; |
2151 | final bool shouldSwapEdges = !forwardSelection != (position.offset < pivotOffset); |
2152 | if (position.offset < pivotOffset) { |
2153 | targetPosition = boundaryAtPosition.boundaryStart; |
2154 | } else if (position.offset > pivotOffset) { |
2155 | targetPosition = boundaryAtPosition.boundaryEnd; |
2156 | } else { |
2157 | // Keep the origin text boundary in bounds when position is at the static edge. |
2158 | targetPosition = forwardSelection ? existingSelectionEnd : existingSelectionStart; |
2159 | } |
2160 | if (shouldSwapEdges) { |
2161 | _setSelectionPosition( |
2162 | _clampTextPosition( |
2163 | forwardSelection ? originTextBoundary.boundaryEnd : originTextBoundary.boundaryStart, |
2164 | ), |
2165 | isEnd: false, |
2166 | ); |
2167 | } |
2168 | _setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd); |
2169 | final bool finalSelectionIsForward = |
2170 | _textSelectionEnd!.offset >= _textSelectionStart!.offset; |
2171 | if (boundaryAtPosition.boundaryStart.offset > range.end && |
2172 | boundaryAtPosition.boundaryEnd.offset > range.end) { |
2173 | return SelectionResult.next; |
2174 | } |
2175 | if (boundaryAtPosition.boundaryStart.offset < range.start && |
2176 | boundaryAtPosition.boundaryEnd.offset < range.start) { |
2177 | return SelectionResult.previous; |
2178 | } |
2179 | if (finalSelectionIsForward) { |
2180 | if (boundaryAtPosition.boundaryEnd.offset <= originTextBoundary.boundaryEnd.offset) { |
2181 | return SelectionResult.end; |
2182 | } |
2183 | if (boundaryAtPosition.boundaryEnd.offset > originTextBoundary.boundaryEnd.offset) { |
2184 | return SelectionResult.next; |
2185 | } |
2186 | } else { |
2187 | if (boundaryAtPosition.boundaryStart.offset >= originTextBoundary.boundaryStart.offset) { |
2188 | return SelectionResult.end; |
2189 | } |
2190 | if (boundaryAtPosition.boundaryStart.offset < originTextBoundary.boundaryStart.offset) { |
2191 | return SelectionResult.previous; |
2192 | } |
2193 | } |
2194 | } else { |
2195 | // When the drag position is not contained within the root paragraph, |
2196 | // swap the edges when the selection changes direction. |
2197 | final TextPosition clampedPosition = _clampTextPosition(position); |
2198 | // To accurately retrieve the origin text boundary when the selection |
2199 | // is backwards, use existingSelectionStart.offset - 1. This is necessary |
2200 | // because in a backwards selection, existingSelectionStart marks the end |
2201 | // of the origin text boundary. Using the unmodified offset incorrectly |
2202 | // targets the subsequent text boundary. |
2203 | final _TextBoundaryRecord originTextBoundary = getTextBoundary( |
2204 | forwardSelection |
2205 | ? existingSelectionStart |
2206 | : TextPosition( |
2207 | offset: existingSelectionStart.offset - 1, |
2208 | affinity: existingSelectionStart.affinity, |
2209 | ), |
2210 | fullText, |
2211 | ); |
2212 | if (forwardSelection && clampedPosition.offset == range.start) { |
2213 | _setSelectionPosition(_clampTextPosition(originTextBoundary.boundaryEnd), isEnd: false); |
2214 | _setSelectionPosition(clampedPosition, isEnd: isEnd); |
2215 | return SelectionResult.previous; |
2216 | } |
2217 | if (!forwardSelection && clampedPosition.offset == range.end) { |
2218 | _setSelectionPosition(_clampTextPosition(originTextBoundary.boundaryStart), isEnd: false); |
2219 | _setSelectionPosition(clampedPosition, isEnd: isEnd); |
2220 | return SelectionResult.next; |
2221 | } |
2222 | if (forwardSelection && clampedPosition.offset == range.end) { |
2223 | _setSelectionPosition(clampedPosition, isEnd: isEnd); |
2224 | return SelectionResult.next; |
2225 | } |
2226 | if (!forwardSelection && clampedPosition.offset == range.start) { |
2227 | _setSelectionPosition(clampedPosition, isEnd: isEnd); |
2228 | return SelectionResult.previous; |
2229 | } |
2230 | } |
2231 | } else { |
2232 | // A paragraph boundary may not be completely contained within this root |
2233 | // selectable fragment. Keep searching until we find the end of the |
2234 | // boundary. Do not search when the current drag position is on a placeholder |
2235 | // to allow traversal to reach that placeholder. |
2236 | final bool positionOnPlaceholder = |
2237 | paragraph.getWordBoundary(position).textInside(fullText) == _placeholderCharacter; |
2238 | if (!paragraphContainsPosition || positionOnPlaceholder) { |
2239 | return null; |
2240 | } |
2241 | if (existingSelectionStart != null) { |
2242 | final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(position, fullText); |
2243 | final bool backwardSelection = |
2244 | existingSelectionEnd == null && existingSelectionStart.offset == range.end || |
2245 | existingSelectionStart == existingSelectionEnd && |
2246 | existingSelectionStart.offset == range.end || |
2247 | existingSelectionEnd != null && |
2248 | existingSelectionStart.offset > existingSelectionEnd.offset; |
2249 | if (boundaryAtPosition.boundaryStart.offset < range.start && |
2250 | boundaryAtPosition.boundaryEnd.offset < range.start) { |
2251 | _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); |
2252 | return SelectionResult.previous; |
2253 | } |
2254 | if (boundaryAtPosition.boundaryStart.offset > range.end && |
2255 | boundaryAtPosition.boundaryEnd.offset > range.end) { |
2256 | _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); |
2257 | return SelectionResult.next; |
2258 | } |
2259 | if (backwardSelection) { |
2260 | _setSelectionPosition(_clampTextPosition(boundaryAtPosition.boundaryStart), isEnd: isEnd); |
2261 | if (boundaryAtPosition.boundaryStart.offset < range.start) { |
2262 | return SelectionResult.previous; |
2263 | } |
2264 | if (boundaryAtPosition.boundaryStart.offset >= range.start) { |
2265 | return SelectionResult.end; |
2266 | } |
2267 | } else { |
2268 | if (boundaryAtPosition.boundaryEnd.offset <= range.end) { |
2269 | _setSelectionPosition(_clampTextPosition(boundaryAtPosition.boundaryEnd), isEnd: isEnd); |
2270 | return SelectionResult.end; |
2271 | } |
2272 | if (boundaryAtPosition.boundaryEnd.offset > range.end) { |
2273 | _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); |
2274 | return SelectionResult.next; |
2275 | } |
2276 | } |
2277 | } |
2278 | } |
2279 | return null; |
2280 | } |
2281 | |
2282 | // The placeholder character used by [RenderParagraph]. |
2283 | static final String _placeholderCharacter = String.fromCharCode( |
2284 | PlaceholderSpan.placeholderCodeUnit, |
2285 | ); |
2286 | static final int _placeholderLength = _placeholderCharacter.length; |
2287 | // This method handles updating the start edge by a text boundary that may |
2288 | // not be contained within this selectable fragment. It is possible |
2289 | // that a boundary spans multiple selectable fragments when the text contains |
2290 | // [WidgetSpan]s. |
2291 | // |
2292 | // This method differs from [_updateSelectionStartEdgeByMultiSelectableBoundary] |
2293 | // in that to maintain the origin text boundary selected at a placeholder, |
2294 | // this selectable fragment must be aware of the [RenderParagraph] that closely |
2295 | // encompasses the complete origin text boundary. |
2296 | // |
2297 | // See [_updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary] for the method |
2298 | // that handles updating the end edge. |
2299 | SelectionResult? _updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary( |
2300 | _TextBoundaryAtPositionInText getTextBoundary, |
2301 | Offset globalPosition, |
2302 | bool paragraphContainsPosition, |
2303 | TextPosition position, |
2304 | TextPosition? existingSelectionStart, |
2305 | TextPosition? existingSelectionEnd, |
2306 | ) { |
2307 | const bool isEnd = false; |
2308 | if (_selectableContainsOriginTextBoundary && |
2309 | existingSelectionStart != null && |
2310 | existingSelectionEnd != null) { |
2311 | // If this selectable contains the origin boundary, maintain the existing |
2312 | // selection. |
2313 | final bool forwardSelection = existingSelectionEnd.offset >= existingSelectionStart.offset; |
2314 | final RenderParagraph originParagraph = _getOriginParagraph(); |
2315 | final bool fragmentBelongsToOriginParagraph = originParagraph == paragraph; |
2316 | if (fragmentBelongsToOriginParagraph) { |
2317 | return _updateSelectionStartEdgeByMultiSelectableTextBoundary( |
2318 | getTextBoundary, |
2319 | paragraphContainsPosition, |
2320 | position, |
2321 | existingSelectionStart, |
2322 | existingSelectionEnd, |
2323 | ); |
2324 | } |
2325 | final Matrix4 originTransform = originParagraph.getTransformTo(null); |
2326 | originTransform.invert(); |
2327 | final Offset originParagraphLocalPosition = MatrixUtils.transformPoint( |
2328 | originTransform, |
2329 | globalPosition, |
2330 | ); |
2331 | final bool positionWithinOriginParagraph = originParagraph.paintBounds.contains( |
2332 | originParagraphLocalPosition, |
2333 | ); |
2334 | final TextPosition positionRelativeToOriginParagraph = originParagraph.getPositionForOffset( |
2335 | originParagraphLocalPosition, |
2336 | ); |
2337 | if (positionWithinOriginParagraph) { |
2338 | // When the selection is inverted by the new position it is necessary to |
2339 | // swap the start edge (moving edge) with the end edge (static edge) to |
2340 | // maintain the origin text boundary within the selection. |
2341 | final String originText = originParagraph.text.toPlainText(includeSemanticsLabels: false); |
2342 | final _TextBoundaryRecord boundaryAtPosition = getTextBoundary( |
2343 | positionRelativeToOriginParagraph, |
2344 | originText, |
2345 | ); |
2346 | final _TextBoundaryRecord originTextBoundary = getTextBoundary( |
2347 | _getPositionInParagraph(originParagraph), |
2348 | originText, |
2349 | ); |
2350 | final TextPosition targetPosition; |
2351 | final int pivotOffset = |
2352 | forwardSelection |
2353 | ? originTextBoundary.boundaryEnd.offset |
2354 | : originTextBoundary.boundaryStart.offset; |
2355 | final bool shouldSwapEdges = |
2356 | !forwardSelection != (positionRelativeToOriginParagraph.offset > pivotOffset); |
2357 | if (positionRelativeToOriginParagraph.offset < pivotOffset) { |
2358 | targetPosition = boundaryAtPosition.boundaryStart; |
2359 | } else if (positionRelativeToOriginParagraph.offset > pivotOffset) { |
2360 | targetPosition = boundaryAtPosition.boundaryEnd; |
2361 | } else { |
2362 | // Keep the origin text boundary in bounds when position is at the static edge. |
2363 | targetPosition = existingSelectionStart; |
2364 | } |
2365 | if (shouldSwapEdges) { |
2366 | _setSelectionPosition(existingSelectionStart, isEnd: true); |
2367 | } |
2368 | _setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd); |
2369 | final bool finalSelectionIsForward = |
2370 | _textSelectionEnd!.offset >= _textSelectionStart!.offset; |
2371 | final TextPosition originParagraphPlaceholderTextPosition = _getPositionInParagraph( |
2372 | originParagraph, |
2373 | ); |
2374 | final TextRange originParagraphPlaceholderRange = TextRange( |
2375 | start: originParagraphPlaceholderTextPosition.offset, |
2376 | end: originParagraphPlaceholderTextPosition.offset + _placeholderLength, |
2377 | ); |
2378 | if (boundaryAtPosition.boundaryStart.offset > originParagraphPlaceholderRange.end && |
2379 | boundaryAtPosition.boundaryEnd.offset > originParagraphPlaceholderRange.end) { |
2380 | return SelectionResult.next; |
2381 | } |
2382 | if (boundaryAtPosition.boundaryStart.offset < originParagraphPlaceholderRange.start && |
2383 | boundaryAtPosition.boundaryEnd.offset < originParagraphPlaceholderRange.start) { |
2384 | return SelectionResult.previous; |
2385 | } |
2386 | if (finalSelectionIsForward) { |
2387 | if (boundaryAtPosition.boundaryEnd.offset <= originTextBoundary.boundaryEnd.offset) { |
2388 | return SelectionResult.end; |
2389 | } |
2390 | if (boundaryAtPosition.boundaryEnd.offset > originTextBoundary.boundaryEnd.offset) { |
2391 | return SelectionResult.next; |
2392 | } |
2393 | } else { |
2394 | if (boundaryAtPosition.boundaryStart.offset >= originTextBoundary.boundaryStart.offset) { |
2395 | return SelectionResult.end; |
2396 | } |
2397 | if (boundaryAtPosition.boundaryStart.offset < originTextBoundary.boundaryStart.offset) { |
2398 | return SelectionResult.previous; |
2399 | } |
2400 | } |
2401 | } else { |
2402 | // When the drag position is not contained within the origin paragraph, |
2403 | // swap the edges when the selection changes direction. |
2404 | // |
2405 | // [SelectionUtils.adjustDragOffset] will adjust the given [Offset] to the |
2406 | // beginning or end of the provided [Rect] based on whether the [Offset] |
2407 | // is located within the given [Rect]. |
2408 | final Offset adjustedOffset = SelectionUtils.adjustDragOffset( |
2409 | originParagraph.paintBounds, |
2410 | originParagraphLocalPosition, |
2411 | direction: paragraph.textDirection, |
2412 | ); |
2413 | final TextPosition adjustedPositionRelativeToOriginParagraph = originParagraph |
2414 | .getPositionForOffset(adjustedOffset); |
2415 | final TextPosition originParagraphPlaceholderTextPosition = _getPositionInParagraph( |
2416 | originParagraph, |
2417 | ); |
2418 | final TextRange originParagraphPlaceholderRange = TextRange( |
2419 | start: originParagraphPlaceholderTextPosition.offset, |
2420 | end: originParagraphPlaceholderTextPosition.offset + _placeholderLength, |
2421 | ); |
2422 | if (forwardSelection && |
2423 | adjustedPositionRelativeToOriginParagraph.offset <= |
2424 | originParagraphPlaceholderRange.start) { |
2425 | _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); |
2426 | return SelectionResult.previous; |
2427 | } |
2428 | if (!forwardSelection && |
2429 | adjustedPositionRelativeToOriginParagraph.offset >= |
2430 | originParagraphPlaceholderRange.end) { |
2431 | _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); |
2432 | return SelectionResult.next; |
2433 | } |
2434 | if (forwardSelection && |
2435 | adjustedPositionRelativeToOriginParagraph.offset >= |
2436 | originParagraphPlaceholderRange.end) { |
2437 | _setSelectionPosition(existingSelectionStart, isEnd: true); |
2438 | _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); |
2439 | return SelectionResult.next; |
2440 | } |
2441 | if (!forwardSelection && |
2442 | adjustedPositionRelativeToOriginParagraph.offset <= |
2443 | originParagraphPlaceholderRange.start) { |
2444 | _setSelectionPosition(existingSelectionStart, isEnd: true); |
2445 | _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); |
2446 | return SelectionResult.previous; |
2447 | } |
2448 | } |
2449 | } else { |
2450 | // When the drag position is somewhere on the root text and not a placeholder, |
2451 | // traverse the selectable fragments relative to the [RenderParagraph] that |
2452 | // contains the drag position. |
2453 | if (paragraphContainsPosition) { |
2454 | return _updateSelectionStartEdgeByMultiSelectableTextBoundary( |
2455 | getTextBoundary, |
2456 | paragraphContainsPosition, |
2457 | position, |
2458 | existingSelectionStart, |
2459 | existingSelectionEnd, |
2460 | ); |
2461 | } |
2462 | if (existingSelectionEnd != null) { |
2463 | final ({RenderParagraph paragraph, Offset localPosition})? targetDetails = |
2464 | _getParagraphContainingPosition(globalPosition); |
2465 | if (targetDetails == null) { |
2466 | return null; |
2467 | } |
2468 | final RenderParagraph targetParagraph = targetDetails.paragraph; |
2469 | final TextPosition positionRelativeToTargetParagraph = targetParagraph.getPositionForOffset( |
2470 | targetDetails.localPosition, |
2471 | ); |
2472 | final String targetText = targetParagraph.text.toPlainText(includeSemanticsLabels: false); |
2473 | final bool positionOnPlaceholder = |
2474 | targetParagraph |
2475 | .getWordBoundary(positionRelativeToTargetParagraph) |
2476 | .textInside(targetText) == |
2477 | _placeholderCharacter; |
2478 | if (positionOnPlaceholder) { |
2479 | return null; |
2480 | } |
2481 | final bool backwardSelection = |
2482 | existingSelectionStart == null && existingSelectionEnd.offset == range.start || |
2483 | existingSelectionStart == existingSelectionEnd && |
2484 | existingSelectionEnd.offset == range.start || |
2485 | existingSelectionStart != null && |
2486 | existingSelectionStart.offset > existingSelectionEnd.offset; |
2487 | final _TextBoundaryRecord boundaryAtPositionRelativeToTargetParagraph = getTextBoundary( |
2488 | positionRelativeToTargetParagraph, |
2489 | targetText, |
2490 | ); |
2491 | final TextPosition targetParagraphPlaceholderTextPosition = _getPositionInParagraph( |
2492 | targetParagraph, |
2493 | ); |
2494 | final TextRange targetParagraphPlaceholderRange = TextRange( |
2495 | start: targetParagraphPlaceholderTextPosition.offset, |
2496 | end: targetParagraphPlaceholderTextPosition.offset + _placeholderLength, |
2497 | ); |
2498 | if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < |
2499 | targetParagraphPlaceholderRange.start && |
2500 | boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset < |
2501 | targetParagraphPlaceholderRange.start) { |
2502 | _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); |
2503 | return SelectionResult.previous; |
2504 | } |
2505 | if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset > |
2506 | targetParagraphPlaceholderRange.end && |
2507 | boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > |
2508 | targetParagraphPlaceholderRange.end) { |
2509 | _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); |
2510 | return SelectionResult.next; |
2511 | } |
2512 | if (backwardSelection) { |
2513 | if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset <= |
2514 | targetParagraphPlaceholderRange.end) { |
2515 | _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); |
2516 | return SelectionResult.end; |
2517 | } |
2518 | if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > |
2519 | targetParagraphPlaceholderRange.end) { |
2520 | _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); |
2521 | return SelectionResult.next; |
2522 | } |
2523 | } else { |
2524 | if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset >= |
2525 | targetParagraphPlaceholderRange.start) { |
2526 | _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); |
2527 | return SelectionResult.end; |
2528 | } |
2529 | if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < |
2530 | targetParagraphPlaceholderRange.start) { |
2531 | _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); |
2532 | return SelectionResult.previous; |
2533 | } |
2534 | } |
2535 | } |
2536 | } |
2537 | return null; |
2538 | } |
2539 | |
2540 | // This method handles updating the end edge by a text boundary that may |
2541 | // not be contained within this selectable fragment. It is possible |
2542 | // that a boundary spans multiple selectable fragments when the text contains |
2543 | // [WidgetSpan]s. |
2544 | // |
2545 | // This method differs from [_updateSelectionEndEdgeByMultiSelectableBoundary] |
2546 | // in that to maintain the origin text boundary selected at a placeholder, this |
2547 | // selectable fragment must be aware of the [RenderParagraph] that closely |
2548 | // encompasses the complete origin text boundary. |
2549 | // |
2550 | // See [_updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary] |
2551 | // for the method that handles updating the start edge. |
2552 | SelectionResult? _updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary( |
2553 | _TextBoundaryAtPositionInText getTextBoundary, |
2554 | Offset globalPosition, |
2555 | bool paragraphContainsPosition, |
2556 | TextPosition position, |
2557 | TextPosition? existingSelectionStart, |
2558 | TextPosition? existingSelectionEnd, |
2559 | ) { |
2560 | const bool isEnd = true; |
2561 | if (_selectableContainsOriginTextBoundary && |
2562 | existingSelectionStart != null && |
2563 | existingSelectionEnd != null) { |
2564 | // If this selectable contains the origin boundary, maintain the existing |
2565 | // selection. |
2566 | final bool forwardSelection = existingSelectionEnd.offset >= existingSelectionStart.offset; |
2567 | final RenderParagraph originParagraph = _getOriginParagraph(); |
2568 | final bool fragmentBelongsToOriginParagraph = originParagraph == paragraph; |
2569 | if (fragmentBelongsToOriginParagraph) { |
2570 | return _updateSelectionEndEdgeByMultiSelectableTextBoundary( |
2571 | getTextBoundary, |
2572 | paragraphContainsPosition, |
2573 | position, |
2574 | existingSelectionStart, |
2575 | existingSelectionEnd, |
2576 | ); |
2577 | } |
2578 | final Matrix4 originTransform = originParagraph.getTransformTo(null); |
2579 | originTransform.invert(); |
2580 | final Offset originParagraphLocalPosition = MatrixUtils.transformPoint( |
2581 | originTransform, |
2582 | globalPosition, |
2583 | ); |
2584 | final bool positionWithinOriginParagraph = originParagraph.paintBounds.contains( |
2585 | originParagraphLocalPosition, |
2586 | ); |
2587 | final TextPosition positionRelativeToOriginParagraph = originParagraph.getPositionForOffset( |
2588 | originParagraphLocalPosition, |
2589 | ); |
2590 | if (positionWithinOriginParagraph) { |
2591 | // When the selection is inverted by the new position it is necessary to |
2592 | // swap the end edge (moving edge) with the start edge (static edge) to |
2593 | // maintain the origin text boundary within the selection. |
2594 | final String originText = originParagraph.text.toPlainText(includeSemanticsLabels: false); |
2595 | final _TextBoundaryRecord boundaryAtPosition = getTextBoundary( |
2596 | positionRelativeToOriginParagraph, |
2597 | originText, |
2598 | ); |
2599 | final _TextBoundaryRecord originTextBoundary = getTextBoundary( |
2600 | _getPositionInParagraph(originParagraph), |
2601 | originText, |
2602 | ); |
2603 | final TextPosition targetPosition; |
2604 | final int pivotOffset = |
2605 | forwardSelection |
2606 | ? originTextBoundary.boundaryStart.offset |
2607 | : originTextBoundary.boundaryEnd.offset; |
2608 | final bool shouldSwapEdges = |
2609 | !forwardSelection != (positionRelativeToOriginParagraph.offset < pivotOffset); |
2610 | if (positionRelativeToOriginParagraph.offset < pivotOffset) { |
2611 | targetPosition = boundaryAtPosition.boundaryStart; |
2612 | } else if (positionRelativeToOriginParagraph.offset > pivotOffset) { |
2613 | targetPosition = boundaryAtPosition.boundaryEnd; |
2614 | } else { |
2615 | // Keep the origin text boundary in bounds when position is at the static edge. |
2616 | targetPosition = existingSelectionEnd; |
2617 | } |
2618 | if (shouldSwapEdges) { |
2619 | _setSelectionPosition(existingSelectionEnd, isEnd: false); |
2620 | } |
2621 | _setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd); |
2622 | final bool finalSelectionIsForward = |
2623 | _textSelectionEnd!.offset >= _textSelectionStart!.offset; |
2624 | final TextPosition originParagraphPlaceholderTextPosition = _getPositionInParagraph( |
2625 | originParagraph, |
2626 | ); |
2627 | final TextRange originParagraphPlaceholderRange = TextRange( |
2628 | start: originParagraphPlaceholderTextPosition.offset, |
2629 | end: originParagraphPlaceholderTextPosition.offset + _placeholderLength, |
2630 | ); |
2631 | if (boundaryAtPosition.boundaryStart.offset > originParagraphPlaceholderRange.end && |
2632 | boundaryAtPosition.boundaryEnd.offset > originParagraphPlaceholderRange.end) { |
2633 | return SelectionResult.next; |
2634 | } |
2635 | if (boundaryAtPosition.boundaryStart.offset < originParagraphPlaceholderRange.start && |
2636 | boundaryAtPosition.boundaryEnd.offset < originParagraphPlaceholderRange.start) { |
2637 | return SelectionResult.previous; |
2638 | } |
2639 | if (finalSelectionIsForward) { |
2640 | if (boundaryAtPosition.boundaryEnd.offset <= originTextBoundary.boundaryEnd.offset) { |
2641 | return SelectionResult.end; |
2642 | } |
2643 | if (boundaryAtPosition.boundaryEnd.offset > originTextBoundary.boundaryEnd.offset) { |
2644 | return SelectionResult.next; |
2645 | } |
2646 | } else { |
2647 | if (boundaryAtPosition.boundaryStart.offset >= originTextBoundary.boundaryStart.offset) { |
2648 | return SelectionResult.end; |
2649 | } |
2650 | if (boundaryAtPosition.boundaryStart.offset < originTextBoundary.boundaryStart.offset) { |
2651 | return SelectionResult.previous; |
2652 | } |
2653 | } |
2654 | } else { |
2655 | // When the drag position is not contained within the origin paragraph, |
2656 | // swap the edges when the selection changes direction. |
2657 | // |
2658 | // [SelectionUtils.adjustDragOffset] will adjust the given [Offset] to the |
2659 | // beginning or end of the provided [Rect] based on whether the [Offset] |
2660 | // is located within the given [Rect]. |
2661 | final Offset adjustedOffset = SelectionUtils.adjustDragOffset( |
2662 | originParagraph.paintBounds, |
2663 | originParagraphLocalPosition, |
2664 | direction: paragraph.textDirection, |
2665 | ); |
2666 | final TextPosition adjustedPositionRelativeToOriginParagraph = originParagraph |
2667 | .getPositionForOffset(adjustedOffset); |
2668 | final TextPosition originParagraphPlaceholderTextPosition = _getPositionInParagraph( |
2669 | originParagraph, |
2670 | ); |
2671 | final TextRange originParagraphPlaceholderRange = TextRange( |
2672 | start: originParagraphPlaceholderTextPosition.offset, |
2673 | end: originParagraphPlaceholderTextPosition.offset + _placeholderLength, |
2674 | ); |
2675 | if (forwardSelection && |
2676 | adjustedPositionRelativeToOriginParagraph.offset <= |
2677 | originParagraphPlaceholderRange.start) { |
2678 | _setSelectionPosition(existingSelectionEnd, isEnd: false); |
2679 | _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); |
2680 | return SelectionResult.previous; |
2681 | } |
2682 | if (!forwardSelection && |
2683 | adjustedPositionRelativeToOriginParagraph.offset >= |
2684 | originParagraphPlaceholderRange.end) { |
2685 | _setSelectionPosition(existingSelectionEnd, isEnd: false); |
2686 | _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); |
2687 | return SelectionResult.next; |
2688 | } |
2689 | if (forwardSelection && |
2690 | adjustedPositionRelativeToOriginParagraph.offset >= |
2691 | originParagraphPlaceholderRange.end) { |
2692 | _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); |
2693 | return SelectionResult.next; |
2694 | } |
2695 | if (!forwardSelection && |
2696 | adjustedPositionRelativeToOriginParagraph.offset <= |
2697 | originParagraphPlaceholderRange.start) { |
2698 | _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); |
2699 | return SelectionResult.previous; |
2700 | } |
2701 | } |
2702 | } else { |
2703 | // When the drag position is somewhere on the root text and not a placeholder, |
2704 | // traverse the selectable fragments relative to the [RenderParagraph] that |
2705 | // contains the drag position. |
2706 | if (paragraphContainsPosition) { |
2707 | return _updateSelectionEndEdgeByMultiSelectableTextBoundary( |
2708 | getTextBoundary, |
2709 | paragraphContainsPosition, |
2710 | position, |
2711 | existingSelectionStart, |
2712 | existingSelectionEnd, |
2713 | ); |
2714 | } |
2715 | if (existingSelectionStart != null) { |
2716 | final ({RenderParagraph paragraph, Offset localPosition})? targetDetails = |
2717 | _getParagraphContainingPosition(globalPosition); |
2718 | if (targetDetails == null) { |
2719 | return null; |
2720 | } |
2721 | final RenderParagraph targetParagraph = targetDetails.paragraph; |
2722 | final TextPosition positionRelativeToTargetParagraph = targetParagraph.getPositionForOffset( |
2723 | targetDetails.localPosition, |
2724 | ); |
2725 | final String targetText = targetParagraph.text.toPlainText(includeSemanticsLabels: false); |
2726 | final bool positionOnPlaceholder = |
2727 | targetParagraph |
2728 | .getWordBoundary(positionRelativeToTargetParagraph) |
2729 | .textInside(targetText) == |
2730 | _placeholderCharacter; |
2731 | if (positionOnPlaceholder) { |
2732 | return null; |
2733 | } |
2734 | final bool backwardSelection = |
2735 | existingSelectionEnd == null && existingSelectionStart.offset == range.end || |
2736 | existingSelectionStart == existingSelectionEnd && |
2737 | existingSelectionStart.offset == range.end || |
2738 | existingSelectionEnd != null && |
2739 | existingSelectionStart.offset > existingSelectionEnd.offset; |
2740 | final _TextBoundaryRecord boundaryAtPositionRelativeToTargetParagraph = getTextBoundary( |
2741 | positionRelativeToTargetParagraph, |
2742 | targetText, |
2743 | ); |
2744 | final TextPosition targetParagraphPlaceholderTextPosition = _getPositionInParagraph( |
2745 | targetParagraph, |
2746 | ); |
2747 | final TextRange targetParagraphPlaceholderRange = TextRange( |
2748 | start: targetParagraphPlaceholderTextPosition.offset, |
2749 | end: targetParagraphPlaceholderTextPosition.offset + _placeholderLength, |
2750 | ); |
2751 | if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < |
2752 | targetParagraphPlaceholderRange.start && |
2753 | boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset < |
2754 | targetParagraphPlaceholderRange.start) { |
2755 | _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); |
2756 | return SelectionResult.previous; |
2757 | } |
2758 | if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset > |
2759 | targetParagraphPlaceholderRange.end && |
2760 | boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > |
2761 | targetParagraphPlaceholderRange.end) { |
2762 | _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); |
2763 | return SelectionResult.next; |
2764 | } |
2765 | if (backwardSelection) { |
2766 | if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset >= |
2767 | targetParagraphPlaceholderRange.start) { |
2768 | _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); |
2769 | return SelectionResult.end; |
2770 | } |
2771 | if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < |
2772 | targetParagraphPlaceholderRange.start) { |
2773 | _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); |
2774 | return SelectionResult.previous; |
2775 | } |
2776 | } else { |
2777 | if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset <= |
2778 | targetParagraphPlaceholderRange.end) { |
2779 | _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); |
2780 | return SelectionResult.end; |
2781 | } |
2782 | if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > |
2783 | targetParagraphPlaceholderRange.end) { |
2784 | _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); |
2785 | return SelectionResult.next; |
2786 | } |
2787 | } |
2788 | } |
2789 | } |
2790 | return null; |
2791 | } |
2792 | |
2793 | SelectionResult _updateSelectionEdgeByMultiSelectableTextBoundary( |
2794 | Offset globalPosition, { |
2795 | required bool isEnd, |
2796 | required _TextBoundaryAtPositionInText getTextBoundary, |
2797 | required _TextBoundaryAtPosition getClampedTextBoundary, |
2798 | }) { |
2799 | // When the start/end edges are swapped, i.e. the start is after the end, and |
2800 | // the scrollable synthesizes an event for the opposite edge, this will potentially |
2801 | // move the opposite edge outside of the origin text boundary and we are unable to recover. |
2802 | final TextPosition? existingSelectionStart = _textSelectionStart; |
2803 | final TextPosition? existingSelectionEnd = _textSelectionEnd; |
2804 | |
2805 | _setSelectionPosition(null, isEnd: isEnd); |
2806 | final Matrix4 transform = paragraph.getTransformTo(null); |
2807 | transform.invert(); |
2808 | final Offset localPosition = MatrixUtils.transformPoint(transform, globalPosition); |
2809 | if (_rect.isEmpty) { |
2810 | return SelectionUtils.getResultBasedOnRect(_rect, localPosition); |
2811 | } |
2812 | final Offset adjustedOffset = SelectionUtils.adjustDragOffset( |
2813 | _rect, |
2814 | localPosition, |
2815 | direction: paragraph.textDirection, |
2816 | ); |
2817 | final Offset adjustedOffsetRelativeToParagraph = SelectionUtils.adjustDragOffset( |
2818 | paragraph.paintBounds, |
2819 | localPosition, |
2820 | direction: paragraph.textDirection, |
2821 | ); |
2822 | |
2823 | final TextPosition position = paragraph.getPositionForOffset(adjustedOffset); |
2824 | final TextPosition positionInFullText = paragraph.getPositionForOffset( |
2825 | adjustedOffsetRelativeToParagraph, |
2826 | ); |
2827 | |
2828 | final SelectionResult? result; |
2829 | if (_isPlaceholder()) { |
2830 | result = |
2831 | isEnd |
2832 | ? _updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary( |
2833 | getTextBoundary, |
2834 | globalPosition, |
2835 | paragraph.paintBounds.contains(localPosition), |
2836 | positionInFullText, |
2837 | existingSelectionStart, |
2838 | existingSelectionEnd, |
2839 | ) |
2840 | : _updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary( |
2841 | getTextBoundary, |
2842 | globalPosition, |
2843 | paragraph.paintBounds.contains(localPosition), |
2844 | positionInFullText, |
2845 | existingSelectionStart, |
2846 | existingSelectionEnd, |
2847 | ); |
2848 | } else { |
2849 | result = |
2850 | isEnd |
2851 | ? _updateSelectionEndEdgeByMultiSelectableTextBoundary( |
2852 | getTextBoundary, |
2853 | paragraph.paintBounds.contains(localPosition), |
2854 | positionInFullText, |
2855 | existingSelectionStart, |
2856 | existingSelectionEnd, |
2857 | ) |
2858 | : _updateSelectionStartEdgeByMultiSelectableTextBoundary( |
2859 | getTextBoundary, |
2860 | paragraph.paintBounds.contains(localPosition), |
2861 | positionInFullText, |
2862 | existingSelectionStart, |
2863 | existingSelectionEnd, |
2864 | ); |
2865 | } |
2866 | if (result != null) { |
2867 | return result; |
2868 | } |
2869 | |
2870 | // Check if the original local position is within the rect, if it is not then |
2871 | // we do not need to look up the text boundary for that position. This is to |
2872 | // maintain a selectables selection collapsed at 0 when the local position is |
2873 | // not located inside its rect. |
2874 | _TextBoundaryRecord? textBoundary = |
2875 | _boundingBoxesContains(localPosition) ? getClampedTextBoundary(position) : null; |
2876 | if (textBoundary != null && |
2877 | (textBoundary.boundaryStart.offset < range.start && |
2878 | textBoundary.boundaryEnd.offset <= range.start || |
2879 | textBoundary.boundaryStart.offset >= range.end && |
2880 | textBoundary.boundaryEnd.offset > range.end)) { |
2881 | // When the position is located at a placeholder inside of the text, then we may compute |
2882 | // a text boundary that does not belong to the current selectable fragment. In this case |
2883 | // we should invalidate the text boundary so that it is not taken into account when |
2884 | // computing the target position. |
2885 | textBoundary = null; |
2886 | } |
2887 | final TextPosition targetPosition = _clampTextPosition( |
2888 | isEnd |
2889 | ? _updateSelectionEndEdgeByTextBoundary( |
2890 | textBoundary, |
2891 | getClampedTextBoundary, |
2892 | position, |
2893 | existingSelectionStart, |
2894 | existingSelectionEnd, |
2895 | ) |
2896 | : _updateSelectionStartEdgeByTextBoundary( |
2897 | textBoundary, |
2898 | getClampedTextBoundary, |
2899 | position, |
2900 | existingSelectionStart, |
2901 | existingSelectionEnd, |
2902 | ), |
2903 | ); |
2904 | |
2905 | _setSelectionPosition(targetPosition, isEnd: isEnd); |
2906 | if (targetPosition.offset == range.end) { |
2907 | return SelectionResult.next; |
2908 | } |
2909 | |
2910 | if (targetPosition.offset == range.start) { |
2911 | return SelectionResult.previous; |
2912 | } |
2913 | // TODO(chunhtai): The geometry information should not be used to determine |
2914 | // selection result. This is a workaround to RenderParagraph, where it does |
2915 | // not have a way to get accurate text length if its text is truncated due to |
2916 | // layout constraint. |
2917 | return SelectionUtils.getResultBasedOnRect(_rect, localPosition); |
2918 | } |
2919 | |
2920 | TextPosition _closestTextBoundary(_TextBoundaryRecord textBoundary, TextPosition position) { |
2921 | final int differenceA = (position.offset - textBoundary.boundaryStart.offset).abs(); |
2922 | final int differenceB = (position.offset - textBoundary.boundaryEnd.offset).abs(); |
2923 | return differenceA < differenceB ? textBoundary.boundaryStart : textBoundary.boundaryEnd; |
2924 | } |
2925 | |
2926 | bool _isPlaceholder() { |
2927 | // Determine whether this selectable fragment is a placeholder. |
2928 | RenderObject? current = paragraph.parent; |
2929 | while (current != null) { |
2930 | if (current is RenderParagraph) { |
2931 | return true; |
2932 | } |
2933 | current = current.parent; |
2934 | } |
2935 | return false; |
2936 | } |
2937 | |
2938 | RenderParagraph _getOriginParagraph() { |
2939 | // This method should only be called from a fragment that contains |
2940 | // the origin boundary. By traversing up the RenderTree, determine the |
2941 | // highest RenderParagraph that contains the origin text boundary. |
2942 | assert(_selectableContainsOriginTextBoundary); |
2943 | // Begin at the parent because it is guaranteed the paragraph containing |
2944 | // this selectable fragment contains the origin boundary. |
2945 | RenderObject? current = paragraph.parent; |
2946 | RenderParagraph? originParagraph; |
2947 | while (current != null) { |
2948 | if (current is RenderParagraph) { |
2949 | if (current._lastSelectableFragments != null) { |
2950 | bool paragraphContainsOriginTextBoundary = false; |
2951 | for (final _SelectableFragment fragment in current._lastSelectableFragments!) { |
2952 | if (fragment._selectableContainsOriginTextBoundary) { |
2953 | paragraphContainsOriginTextBoundary = true; |
2954 | originParagraph = current; |
2955 | break; |
2956 | } |
2957 | } |
2958 | if (!paragraphContainsOriginTextBoundary) { |
2959 | return originParagraph ?? paragraph; |
2960 | } |
2961 | } |
2962 | } |
2963 | current = current.parent; |
2964 | } |
2965 | return originParagraph ?? paragraph; |
2966 | } |
2967 | |
2968 | ({RenderParagraph paragraph, Offset localPosition})? _getParagraphContainingPosition( |
2969 | Offset globalPosition, |
2970 | ) { |
2971 | // This method will return the closest [RenderParagraph] whose rect |
2972 | // contains the given `globalPosition` and the given `globalPosition` |
2973 | // relative to that [RenderParagraph]. If no ancestor [RenderParagraph] |
2974 | // contains the given `globalPosition` then this method will return null. |
2975 | RenderObject? current = paragraph; |
2976 | while (current != null) { |
2977 | if (current is RenderParagraph) { |
2978 | final Matrix4 currentTransform = current.getTransformTo(null); |
2979 | currentTransform.invert(); |
2980 | final Offset currentParagraphLocalPosition = MatrixUtils.transformPoint( |
2981 | currentTransform, |
2982 | globalPosition, |
2983 | ); |
2984 | final bool positionWithinCurrentParagraph = current.paintBounds.contains( |
2985 | currentParagraphLocalPosition, |
2986 | ); |
2987 | if (positionWithinCurrentParagraph) { |
2988 | return (paragraph: current, localPosition: currentParagraphLocalPosition); |
2989 | } |
2990 | } |
2991 | current = current.parent; |
2992 | } |
2993 | return null; |
2994 | } |
2995 | |
2996 | bool _boundingBoxesContains(Offset position) { |
2997 | for (final Rect rect in boundingBoxes) { |
2998 | if (rect.contains(position)) { |
2999 | return true; |
3000 | } |
3001 | } |
3002 | return false; |
3003 | } |
3004 | |
3005 | TextPosition _clampTextPosition(TextPosition position) { |
3006 | // Affinity of range.end is upstream. |
3007 | if (position.offset > range.end || |
3008 | (position.offset == range.end && position.affinity == TextAffinity.downstream)) { |
3009 | return TextPosition(offset: range.end, affinity: TextAffinity.upstream); |
3010 | } |
3011 | if (position.offset < range.start) { |
3012 | return TextPosition(offset: range.start); |
3013 | } |
3014 | return position; |
3015 | } |
3016 | |
3017 | void _setSelectionPosition(TextPosition? position, {required bool isEnd}) { |
3018 | if (isEnd) { |
3019 | _textSelectionEnd = position; |
3020 | } else { |
3021 | _textSelectionStart = position; |
3022 | } |
3023 | } |
3024 | |
3025 | SelectionResult _handleClearSelection() { |
3026 | _textSelectionStart = null; |
3027 | _textSelectionEnd = null; |
3028 | _selectableContainsOriginTextBoundary = false; |
3029 | return SelectionResult.none; |
3030 | } |
3031 | |
3032 | SelectionResult _handleSelectAll() { |
3033 | _textSelectionStart = TextPosition(offset: range.start); |
3034 | _textSelectionEnd = TextPosition(offset: range.end, affinity: TextAffinity.upstream); |
3035 | return SelectionResult.none; |
3036 | } |
3037 | |
3038 | SelectionResult _handleSelectTextBoundary(_TextBoundaryRecord textBoundary) { |
3039 | // This fragment may not contain the boundary, decide what direction the target |
3040 | // fragment is located in. Because fragments are separated by placeholder |
3041 | // spans, we also check if the beginning or end of the boundary is touching |
3042 | // either edge of this fragment. |
3043 | if (textBoundary.boundaryStart.offset < range.start && |
3044 | textBoundary.boundaryEnd.offset <= range.start) { |
3045 | return SelectionResult.previous; |
3046 | } else if (textBoundary.boundaryStart.offset >= range.end && |
3047 | textBoundary.boundaryEnd.offset > range.end) { |
3048 | return SelectionResult.next; |
3049 | } |
3050 | // Fragments are separated by placeholder span, the text boundary shouldn't |
3051 | // expand across fragments. |
3052 | assert( |
3053 | textBoundary.boundaryStart.offset >= range.start && |
3054 | textBoundary.boundaryEnd.offset <= range.end, |
3055 | ); |
3056 | _textSelectionStart = textBoundary.boundaryStart; |
3057 | _textSelectionEnd = textBoundary.boundaryEnd; |
3058 | _selectableContainsOriginTextBoundary = true; |
3059 | return SelectionResult.end; |
3060 | } |
3061 | |
3062 | TextRange? _intersect(TextRange a, TextRange b) { |
3063 | assert(a.isNormalized); |
3064 | assert(b.isNormalized); |
3065 | final int startMax = math.max(a.start, b.start); |
3066 | final int endMin = math.min(a.end, b.end); |
3067 | if (startMax <= endMin) { |
3068 | // Intersection. |
3069 | return TextRange(start: startMax, end: endMin); |
3070 | } |
3071 | return null; |
3072 | } |
3073 | |
3074 | SelectionResult _handleSelectMultiFragmentTextBoundary(_TextBoundaryRecord textBoundary) { |
3075 | // This fragment may not contain the boundary, decide what direction the target |
3076 | // fragment is located in. Because fragments are separated by placeholder |
3077 | // spans, we also check if the beginning or end of the boundary is touching |
3078 | // either edge of this fragment. |
3079 | if (textBoundary.boundaryStart.offset < range.start && |
3080 | textBoundary.boundaryEnd.offset <= range.start) { |
3081 | return SelectionResult.previous; |
3082 | } else if (textBoundary.boundaryStart.offset >= range.end && |
3083 | textBoundary.boundaryEnd.offset > range.end) { |
3084 | return SelectionResult.next; |
3085 | } |
3086 | final TextRange boundaryAsRange = TextRange( |
3087 | start: textBoundary.boundaryStart.offset, |
3088 | end: textBoundary.boundaryEnd.offset, |
3089 | ); |
3090 | final TextRange? intersectRange = _intersect(range, boundaryAsRange); |
3091 | if (intersectRange != null) { |
3092 | _textSelectionStart = TextPosition(offset: intersectRange.start); |
3093 | _textSelectionEnd = TextPosition(offset: intersectRange.end); |
3094 | _selectableContainsOriginTextBoundary = true; |
3095 | if (range.end < textBoundary.boundaryEnd.offset) { |
3096 | return SelectionResult.next; |
3097 | } |
3098 | return SelectionResult.end; |
3099 | } |
3100 | return SelectionResult.none; |
3101 | } |
3102 | |
3103 | _TextBoundaryRecord _adjustTextBoundaryAtPosition(TextRange textBoundary, TextPosition position) { |
3104 | late final TextPosition start; |
3105 | late final TextPosition end; |
3106 | if (position.offset > textBoundary.end) { |
3107 | start = end = TextPosition(offset: position.offset); |
3108 | } else { |
3109 | start = TextPosition(offset: textBoundary.start); |
3110 | end = TextPosition(offset: textBoundary.end, affinity: TextAffinity.upstream); |
3111 | } |
3112 | return (boundaryStart: start, boundaryEnd: end); |
3113 | } |
3114 | |
3115 | SelectionResult _handleSelectWord(Offset globalPosition) { |
3116 | final TextPosition position = paragraph.getPositionForOffset( |
3117 | paragraph.globalToLocal(globalPosition), |
3118 | ); |
3119 | if (_positionIsWithinCurrentSelection(position) && _textSelectionStart != _textSelectionEnd) { |
3120 | return SelectionResult.end; |
3121 | } |
3122 | final _TextBoundaryRecord wordBoundary = _getWordBoundaryAtPosition(position); |
3123 | return _handleSelectTextBoundary(wordBoundary); |
3124 | } |
3125 | |
3126 | _TextBoundaryRecord _getWordBoundaryAtPosition(TextPosition position) { |
3127 | final TextRange word = paragraph.getWordBoundary(position); |
3128 | assert(word.isNormalized); |
3129 | return _adjustTextBoundaryAtPosition(word, position); |
3130 | } |
3131 | |
3132 | SelectionResult _handleSelectParagraph(Offset globalPosition) { |
3133 | final Offset localPosition = paragraph.globalToLocal(globalPosition); |
3134 | final TextPosition position = paragraph.getPositionForOffset(localPosition); |
3135 | final _TextBoundaryRecord paragraphBoundary = _getParagraphBoundaryAtPosition( |
3136 | position, |
3137 | fullText, |
3138 | ); |
3139 | return _handleSelectMultiFragmentTextBoundary(paragraphBoundary); |
3140 | } |
3141 | |
3142 | TextPosition _getPositionInParagraph(RenderParagraph targetParagraph) { |
3143 | final Matrix4 transform = paragraph.getTransformTo(targetParagraph); |
3144 | final Offset localCenter = paragraph.paintBounds.centerLeft; |
3145 | final Offset localPos = MatrixUtils.transformPoint(transform, localCenter); |
3146 | final TextPosition position = targetParagraph.getPositionForOffset(localPos); |
3147 | return position; |
3148 | } |
3149 | |
3150 | _TextBoundaryRecord _getParagraphBoundaryAtPosition(TextPosition position, String text) { |
3151 | final ParagraphBoundary paragraphBoundary = ParagraphBoundary(text); |
3152 | // Use position.offset - 1 when `position` is at the end of the selectable to retrieve |
3153 | // the previous text boundary's location. |
3154 | final int paragraphStart = |
3155 | paragraphBoundary.getLeadingTextBoundaryAt( |
3156 | position.offset == text.length || position.affinity == TextAffinity.upstream |
3157 | ? position.offset - 1 |
3158 | : position.offset, |
3159 | ) ?? |
3160 | 0; |
3161 | final int paragraphEnd = |
3162 | paragraphBoundary.getTrailingTextBoundaryAt(position.offset) ?? text.length; |
3163 | final TextRange paragraphRange = TextRange(start: paragraphStart, end: paragraphEnd); |
3164 | assert(paragraphRange.isNormalized); |
3165 | return _adjustTextBoundaryAtPosition(paragraphRange, position); |
3166 | } |
3167 | |
3168 | _TextBoundaryRecord _getClampedParagraphBoundaryAtPosition(TextPosition position) { |
3169 | final ParagraphBoundary paragraphBoundary = ParagraphBoundary(fullText); |
3170 | // Use position.offset - 1 when `position` is at the end of the selectable to retrieve |
3171 | // the previous text boundary's location. |
3172 | int paragraphStart = |
3173 | paragraphBoundary.getLeadingTextBoundaryAt( |
3174 | position.offset == fullText.length || position.affinity == TextAffinity.upstream |
3175 | ? position.offset - 1 |
3176 | : position.offset, |
3177 | ) ?? |
3178 | 0; |
3179 | int paragraphEnd = |
3180 | paragraphBoundary.getTrailingTextBoundaryAt(position.offset) ?? fullText.length; |
3181 | paragraphStart = |
3182 | paragraphStart < range.start |
3183 | ? range.start |
3184 | : paragraphStart > range.end |
3185 | ? range.end |
3186 | : paragraphStart; |
3187 | paragraphEnd = |
3188 | paragraphEnd > range.end |
3189 | ? range.end |
3190 | : paragraphEnd < range.start |
3191 | ? range.start |
3192 | : paragraphEnd; |
3193 | final TextRange paragraphRange = TextRange(start: paragraphStart, end: paragraphEnd); |
3194 | assert(paragraphRange.isNormalized); |
3195 | return _adjustTextBoundaryAtPosition(paragraphRange, position); |
3196 | } |
3197 | |
3198 | SelectionResult _handleDirectionallyExtendSelection( |
3199 | double horizontalBaseline, |
3200 | bool isExtent, |
3201 | SelectionExtendDirection movement, |
3202 | ) { |
3203 | final Matrix4 transform = paragraph.getTransformTo(null); |
3204 | if (transform.invert() == 0.0) { |
3205 | switch (movement) { |
3206 | case SelectionExtendDirection.previousLine: |
3207 | case SelectionExtendDirection.backward: |
3208 | return SelectionResult.previous; |
3209 | case SelectionExtendDirection.nextLine: |
3210 | case SelectionExtendDirection.forward: |
3211 | return SelectionResult.next; |
3212 | } |
3213 | } |
3214 | final double baselineInParagraphCoordinates = |
3215 | MatrixUtils.transformPoint(transform, Offset(horizontalBaseline, 0)).dx; |
3216 | assert(!baselineInParagraphCoordinates.isNaN); |
3217 | final TextPosition newPosition; |
3218 | final SelectionResult result; |
3219 | switch (movement) { |
3220 | case SelectionExtendDirection.previousLine: |
3221 | case SelectionExtendDirection.nextLine: |
3222 | assert(_textSelectionEnd != null && _textSelectionStart != null); |
3223 | final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!; |
3224 | final MapEntry<TextPosition, SelectionResult> moveResult = _handleVerticalMovement( |
3225 | targetedEdge, |
3226 | horizontalBaselineInParagraphCoordinates: baselineInParagraphCoordinates, |
3227 | below: movement == SelectionExtendDirection.nextLine, |
3228 | ); |
3229 | newPosition = moveResult.key; |
3230 | result = moveResult.value; |
3231 | case SelectionExtendDirection.forward: |
3232 | case SelectionExtendDirection.backward: |
3233 | _textSelectionEnd ??= |
3234 | movement == SelectionExtendDirection.forward |
3235 | ? TextPosition(offset: range.start) |
3236 | : TextPosition(offset: range.end, affinity: TextAffinity.upstream); |
3237 | _textSelectionStart ??= _textSelectionEnd; |
3238 | final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!; |
3239 | final Offset edgeOffsetInParagraphCoordinates = paragraph._getOffsetForPosition( |
3240 | targetedEdge, |
3241 | ); |
3242 | final Offset baselineOffsetInParagraphCoordinates = Offset( |
3243 | baselineInParagraphCoordinates, |
3244 | // Use half of line height to point to the middle of the line. |
3245 | edgeOffsetInParagraphCoordinates.dy - paragraph._textPainter.preferredLineHeight / 2, |
3246 | ); |
3247 | newPosition = paragraph.getPositionForOffset(baselineOffsetInParagraphCoordinates); |
3248 | result = SelectionResult.end; |
3249 | } |
3250 | if (isExtent) { |
3251 | _textSelectionEnd = newPosition; |
3252 | } else { |
3253 | _textSelectionStart = newPosition; |
3254 | } |
3255 | return result; |
3256 | } |
3257 | |
3258 | SelectionResult _handleGranularlyExtendSelection( |
3259 | bool forward, |
3260 | bool isExtent, |
3261 | TextGranularity granularity, |
3262 | ) { |
3263 | _textSelectionEnd ??= |
3264 | forward |
3265 | ? TextPosition(offset: range.start) |
3266 | : TextPosition(offset: range.end, affinity: TextAffinity.upstream); |
3267 | _textSelectionStart ??= _textSelectionEnd; |
3268 | final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!; |
3269 | if (forward && (targetedEdge.offset == range.end)) { |
3270 | return SelectionResult.next; |
3271 | } |
3272 | if (!forward && (targetedEdge.offset == range.start)) { |
3273 | return SelectionResult.previous; |
3274 | } |
3275 | final SelectionResult result; |
3276 | final TextPosition newPosition; |
3277 | switch (granularity) { |
3278 | case TextGranularity.character: |
3279 | final String text = range.textInside(fullText); |
3280 | newPosition = _moveBeyondTextBoundaryAtDirection( |
3281 | targetedEdge, |
3282 | forward, |
3283 | CharacterBoundary(text), |
3284 | ); |
3285 | result = SelectionResult.end; |
3286 | case TextGranularity.word: |
3287 | final TextBoundary textBoundary = paragraph._textPainter.wordBoundaries.moveByWordBoundary; |
3288 | newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, textBoundary); |
3289 | result = SelectionResult.end; |
3290 | case TextGranularity.paragraph: |
3291 | final String text = range.textInside(fullText); |
3292 | newPosition = _moveBeyondTextBoundaryAtDirection( |
3293 | targetedEdge, |
3294 | forward, |
3295 | ParagraphBoundary(text), |
3296 | ); |
3297 | result = SelectionResult.end; |
3298 | case TextGranularity.line: |
3299 | newPosition = _moveToTextBoundaryAtDirection(targetedEdge, forward, LineBoundary(this)); |
3300 | result = SelectionResult.end; |
3301 | case TextGranularity.document: |
3302 | final String text = range.textInside(fullText); |
3303 | newPosition = _moveBeyondTextBoundaryAtDirection( |
3304 | targetedEdge, |
3305 | forward, |
3306 | DocumentBoundary(text), |
3307 | ); |
3308 | if (forward && newPosition.offset == range.end) { |
3309 | result = SelectionResult.next; |
3310 | } else if (!forward && newPosition.offset == range.start) { |
3311 | result = SelectionResult.previous; |
3312 | } else { |
3313 | result = SelectionResult.end; |
3314 | } |
3315 | } |
3316 | |
3317 | if (isExtent) { |
3318 | _textSelectionEnd = newPosition; |
3319 | } else { |
3320 | _textSelectionStart = newPosition; |
3321 | } |
3322 | return result; |
3323 | } |
3324 | |
3325 | // Move **beyond** the local boundary of the given type (unless range.start or |
3326 | // range.end is reached). Used for most TextGranularity types except for |
3327 | // TextGranularity.line, to ensure the selection movement doesn't get stuck at |
3328 | // a local fixed point. |
3329 | TextPosition _moveBeyondTextBoundaryAtDirection( |
3330 | TextPosition end, |
3331 | bool forward, |
3332 | TextBoundary textBoundary, |
3333 | ) { |
3334 | final int newOffset = |
3335 | forward |
3336 | ? textBoundary.getTrailingTextBoundaryAt(end.offset) ?? range.end |
3337 | : textBoundary.getLeadingTextBoundaryAt(end.offset - 1) ?? range.start; |
3338 | return TextPosition(offset: newOffset); |
3339 | } |
3340 | |
3341 | // Move **to** the local boundary of the given type. Typically used for line |
3342 | // boundaries, such that performing "move to line start" more than once never |
3343 | // moves the selection to the previous line. |
3344 | TextPosition _moveToTextBoundaryAtDirection( |
3345 | TextPosition end, |
3346 | bool forward, |
3347 | TextBoundary textBoundary, |
3348 | ) { |
3349 | assert(end.offset >= 0); |
3350 | final int caretOffset; |
3351 | switch (end.affinity) { |
3352 | case TextAffinity.upstream: |
3353 | if (end.offset < 1 && !forward) { |
3354 | assert(end.offset == 0); |
3355 | return const TextPosition(offset: 0); |
3356 | } |
3357 | final CharacterBoundary characterBoundary = CharacterBoundary(fullText); |
3358 | caretOffset = |
3359 | math.max( |
3360 | 0, |
3361 | characterBoundary.getLeadingTextBoundaryAt(range.start + end.offset) ?? range.start, |
3362 | ) - |
3363 | 1; |
3364 | case TextAffinity.downstream: |
3365 | caretOffset = end.offset; |
3366 | } |
3367 | final int offset = |
3368 | forward |
3369 | ? textBoundary.getTrailingTextBoundaryAt(caretOffset) ?? range.end |
3370 | : textBoundary.getLeadingTextBoundaryAt(caretOffset) ?? range.start; |
3371 | return TextPosition(offset: offset); |
3372 | } |
3373 | |
3374 | MapEntry<TextPosition, SelectionResult> _handleVerticalMovement( |
3375 | TextPosition position, { |
3376 | required double horizontalBaselineInParagraphCoordinates, |
3377 | required bool below, |
3378 | }) { |
3379 | final List<ui.LineMetrics> lines = paragraph._textPainter.computeLineMetrics(); |
3380 | final Offset offset = paragraph.getOffsetForCaret(position, Rect.zero); |
3381 | int currentLine = lines.length - 1; |
3382 | for (final ui.LineMetrics lineMetrics in lines) { |
3383 | if (lineMetrics.baseline > offset.dy) { |
3384 | currentLine = lineMetrics.lineNumber; |
3385 | break; |
3386 | } |
3387 | } |
3388 | final TextPosition newPosition; |
3389 | if (below && currentLine == lines.length - 1) { |
3390 | newPosition = TextPosition(offset: range.end, affinity: TextAffinity.upstream); |
3391 | } else if (!below && currentLine == 0) { |
3392 | newPosition = TextPosition(offset: range.start); |
3393 | } else { |
3394 | final int newLine = below ? currentLine + 1 : currentLine - 1; |
3395 | newPosition = _clampTextPosition( |
3396 | paragraph.getPositionForOffset( |
3397 | Offset(horizontalBaselineInParagraphCoordinates, lines[newLine].baseline), |
3398 | ), |
3399 | ); |
3400 | } |
3401 | final SelectionResult result; |
3402 | if (newPosition.offset == range.start) { |
3403 | result = SelectionResult.previous; |
3404 | } else if (newPosition.offset == range.end) { |
3405 | result = SelectionResult.next; |
3406 | } else { |
3407 | result = SelectionResult.end; |
3408 | } |
3409 | assert(result != SelectionResult.next || below); |
3410 | assert(result != SelectionResult.previous || !below); |
3411 | return MapEntry<TextPosition, SelectionResult>(newPosition, result); |
3412 | } |
3413 | |
3414 | /// Whether the given text position is contained in current selection |
3415 | /// range. |
3416 | /// |
3417 | /// The parameter `start` must be smaller than `end`. |
3418 | bool _positionIsWithinCurrentSelection(TextPosition position) { |
3419 | if (_textSelectionStart == null || _textSelectionEnd == null) { |
3420 | return false; |
3421 | } |
3422 | // Normalize current selection. |
3423 | late TextPosition currentStart; |
3424 | late TextPosition currentEnd; |
3425 | if (_compareTextPositions(_textSelectionStart!, _textSelectionEnd!) > 0) { |
3426 | currentStart = _textSelectionStart!; |
3427 | currentEnd = _textSelectionEnd!; |
3428 | } else { |
3429 | currentStart = _textSelectionEnd!; |
3430 | currentEnd = _textSelectionStart!; |
3431 | } |
3432 | return _compareTextPositions(currentStart, position) >= 0 && |
3433 | _compareTextPositions(currentEnd, position) <= 0; |
3434 | } |
3435 | |
3436 | /// Compares two text positions. |
3437 | /// |
3438 | /// Returns 1 if `position` < `otherPosition`, -1 if `position` > `otherPosition`, |
3439 | /// or 0 if they are equal. |
3440 | static int _compareTextPositions(TextPosition position, TextPosition otherPosition) { |
3441 | if (position.offset < otherPosition.offset) { |
3442 | return 1; |
3443 | } else if (position.offset > otherPosition.offset) { |
3444 | return -1; |
3445 | } else if (position.affinity == otherPosition.affinity) { |
3446 | return 0; |
3447 | } else { |
3448 | return position.affinity == TextAffinity.upstream ? 1 : -1; |
3449 | } |
3450 | } |
3451 | |
3452 | @override |
3453 | Matrix4 getTransformTo(RenderObject? ancestor) { |
3454 | return paragraph.getTransformTo(ancestor); |
3455 | } |
3456 | |
3457 | @override |
3458 | void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { |
3459 | if (!paragraph.attached) { |
3460 | assert(startHandle == null && endHandle == null, 'Only clean up can be called.'); |
3461 | return; |
3462 | } |
3463 | if (_startHandleLayerLink != startHandle) { |
3464 | _startHandleLayerLink = startHandle; |
3465 | paragraph.markNeedsPaint(); |
3466 | } |
3467 | if (_endHandleLayerLink != endHandle) { |
3468 | _endHandleLayerLink = endHandle; |
3469 | paragraph.markNeedsPaint(); |
3470 | } |
3471 | } |
3472 | |
3473 | List<Rect>? _cachedBoundingBoxes; |
3474 | @override |
3475 | List<Rect> get boundingBoxes { |
3476 | if (_cachedBoundingBoxes == null) { |
3477 | final List<TextBox> boxes = paragraph.getBoxesForSelection( |
3478 | TextSelection(baseOffset: range.start, extentOffset: range.end), |
3479 | boxHeightStyle: ui.BoxHeightStyle.max, |
3480 | ); |
3481 | if (boxes.isNotEmpty) { |
3482 | _cachedBoundingBoxes = <Rect>[]; |
3483 | for (final TextBox textBox in boxes) { |
3484 | _cachedBoundingBoxes!.add(textBox.toRect()); |
3485 | } |
3486 | } else { |
3487 | final Offset offset = paragraph._getOffsetForPosition(TextPosition(offset: range.start)); |
3488 | final Rect rect = Rect.fromPoints( |
3489 | offset, |
3490 | offset.translate(0, -paragraph._textPainter.preferredLineHeight), |
3491 | ); |
3492 | _cachedBoundingBoxes = <Rect>[rect]; |
3493 | } |
3494 | } |
3495 | return _cachedBoundingBoxes!; |
3496 | } |
3497 | |
3498 | Rect? _cachedRect; |
3499 | Rect get _rect { |
3500 | if (_cachedRect == null) { |
3501 | final List<TextBox> boxes = paragraph.getBoxesForSelection( |
3502 | TextSelection(baseOffset: range.start, extentOffset: range.end), |
3503 | ); |
3504 | if (boxes.isNotEmpty) { |
3505 | Rect result = boxes.first.toRect(); |
3506 | for (int index = 1; index < boxes.length; index += 1) { |
3507 | result = result.expandToInclude(boxes[index].toRect()); |
3508 | } |
3509 | _cachedRect = result; |
3510 | } else { |
3511 | final Offset offset = paragraph._getOffsetForPosition(TextPosition(offset: range.start)); |
3512 | _cachedRect = Rect.fromPoints( |
3513 | offset, |
3514 | offset.translate(0, -paragraph._textPainter.preferredLineHeight), |
3515 | ); |
3516 | } |
3517 | } |
3518 | return _cachedRect!; |
3519 | } |
3520 | |
3521 | void didChangeParagraphLayout() { |
3522 | _cachedRect = null; |
3523 | _cachedBoundingBoxes = null; |
3524 | } |
3525 | |
3526 | @override |
3527 | int get contentLength => range.end - range.start; |
3528 | |
3529 | @override |
3530 | Size get size { |
3531 | return _rect.size; |
3532 | } |
3533 | |
3534 | void paint(PaintingContext context, Offset offset) { |
3535 | if (_textSelectionStart == null || _textSelectionEnd == null) { |
3536 | return; |
3537 | } |
3538 | if (paragraph.selectionColor != null) { |
3539 | final TextSelection selection = TextSelection( |
3540 | baseOffset: _textSelectionStart!.offset, |
3541 | extentOffset: _textSelectionEnd!.offset, |
3542 | ); |
3543 | final Paint selectionPaint = |
3544 | Paint() |
3545 | ..style = PaintingStyle.fill |
3546 | ..color = paragraph.selectionColor!; |
3547 | for (final TextBox textBox in paragraph.getBoxesForSelection(selection)) { |
3548 | context.canvas.drawRect(textBox.toRect().shift(offset), selectionPaint); |
3549 | } |
3550 | } |
3551 | if (_startHandleLayerLink != null && value.startSelectionPoint != null) { |
3552 | context.pushLayer( |
3553 | LeaderLayer( |
3554 | link: _startHandleLayerLink!, |
3555 | offset: offset + value.startSelectionPoint!.localPosition, |
3556 | ), |
3557 | (PaintingContext context, Offset offset) {}, |
3558 | Offset.zero, |
3559 | ); |
3560 | } |
3561 | if (_endHandleLayerLink != null && value.endSelectionPoint != null) { |
3562 | context.pushLayer( |
3563 | LeaderLayer( |
3564 | link: _endHandleLayerLink!, |
3565 | offset: offset + value.endSelectionPoint!.localPosition, |
3566 | ), |
3567 | (PaintingContext context, Offset offset) {}, |
3568 | Offset.zero, |
3569 | ); |
3570 | } |
3571 | } |
3572 | |
3573 | @override |
3574 | TextSelection getLineAtOffset(TextPosition position) { |
3575 | final TextRange line = paragraph._getLineAtOffset(position); |
3576 | final int start = line.start.clamp(range.start, range.end); |
3577 | final int end = line.end.clamp(range.start, range.end); |
3578 | return TextSelection(baseOffset: start, extentOffset: end); |
3579 | } |
3580 | |
3581 | @override |
3582 | TextPosition getTextPositionAbove(TextPosition position) { |
3583 | return _clampTextPosition(paragraph._getTextPositionAbove(position)); |
3584 | } |
3585 | |
3586 | @override |
3587 | TextPosition getTextPositionBelow(TextPosition position) { |
3588 | return _clampTextPosition(paragraph._getTextPositionBelow(position)); |
3589 | } |
3590 | |
3591 | @override |
3592 | TextRange getWordBoundary(TextPosition position) => paragraph.getWordBoundary(position); |
3593 | |
3594 | @override |
3595 | void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
3596 | super.debugFillProperties(properties); |
3597 | properties.add(DiagnosticsProperty<String>('textInsideRange', range.textInside(fullText))); |
3598 | properties.add(DiagnosticsProperty<TextRange>('range', range)); |
3599 | properties.add(DiagnosticsProperty<String>('fullText', fullText)); |
3600 | } |
3601 | } |
3602 |
Definitions
- _kEllipsis
- PlaceholderSpanIndexSemanticsTag
- PlaceholderSpanIndexSemanticsTag
- ==
- hashCode
- TextParentData
- offset
- detach
- toString
- RenderInlineChildrenContainerDefaults
- setupParentData
- _layoutChild
- layoutInlineChildren
- positionInlineChildren
- defaultApplyPaintTransform
- paintInlineChildren
- hitTestInlineChildren
- RenderParagraph
- RenderParagraph
- _textIntrinsics
- text
- text
- selections
- registrar
- registrar
- _updateSelectionRegistrarSubscription
- _removeSelectionRegistrarSubscription
- _getSelectableFragments
- selectableBelongsToParagraph
- _disposeSelectableFragments
- alwaysNeedsCompositing
- markNeedsLayout
- dispose
- textAlign
- textAlign
- textDirection
- textDirection
- softWrap
- softWrap
- overflow
- overflow
- textScaleFactor
- textScaleFactor
- textScaler
- textScaler
- maxLines
- maxLines
- locale
- locale
- strutStyle
- strutStyle
- textWidthBasis
- textWidthBasis
- textHeightBehavior
- textHeightBehavior
- selectionColor
- selectionColor
- _getOffsetForPosition
- computeMinIntrinsicWidth
- computeMaxIntrinsicWidth
- preferredLineHeight
- _computeIntrinsicHeight
- computeMinIntrinsicHeight
- computeMaxIntrinsicHeight
- hitTestSelf
- hitTestChildren
- debugHasOverflowShader
- systemFontsDidChange
- _adjustMaxWidth
- _layoutTextWithConstraints
- computeDryLayout
- computeDistanceToActualBaseline
- computeDryBaseline
- performLayout
- applyPaintTransform
- paint
- getOffsetForCaret
- getFullHeightForCaret
- getBoxesForSelection
- getPositionForOffset
- getWordBoundary
- _getLineAtOffset
- _getTextPositionAbove
- _getTextPositionBelow
- _getTextPositionVertical
- textSize
- didExceedMaxLines
- describeSemanticsConfiguration
- _childSemanticsConfigurationsDelegate
- _createSemanticsConfigForTextInfo
- assembleSemanticsNode
- _createShowOnScreenFor
- clearSemantics
- debugDescribeChildren
- debugFillProperties
- _SelectableFragment
- _SelectableFragment
- value
- _updateSelectionGeometry
- _getSelectionGeometry
- dispatchSelectionEvent
- getSelectedContent
- getSelection
- _didChangeSelection
- _updateSelectionStartEdgeByTextBoundary
- _updateSelectionEndEdgeByTextBoundary
- _updateSelectionEdgeByTextBoundary
- _updateSelectionEdge
- _updateSelectionStartEdgeByMultiSelectableTextBoundary
- _updateSelectionEndEdgeByMultiSelectableTextBoundary
- _updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary
- _updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary
- _updateSelectionEdgeByMultiSelectableTextBoundary
- _closestTextBoundary
- _isPlaceholder
- _getOriginParagraph
- _getParagraphContainingPosition
- _boundingBoxesContains
- _clampTextPosition
- _setSelectionPosition
- _handleClearSelection
- _handleSelectAll
- _handleSelectTextBoundary
- _intersect
- _handleSelectMultiFragmentTextBoundary
- _adjustTextBoundaryAtPosition
- _handleSelectWord
- _getWordBoundaryAtPosition
- _handleSelectParagraph
- _getPositionInParagraph
- _getParagraphBoundaryAtPosition
- _getClampedParagraphBoundaryAtPosition
- _handleDirectionallyExtendSelection
- _handleGranularlyExtendSelection
- _moveBeyondTextBoundaryAtDirection
- _moveToTextBoundaryAtDirection
- _handleVerticalMovement
- _positionIsWithinCurrentSelection
- _compareTextPositions
- getTransformTo
- pushHandleLayers
- boundingBoxes
- _rect
- didChangeParagraphLayout
- contentLength
- size
- paint
- getLineAtOffset
- getTextPositionAbove
- getTextPositionBelow
- getWordBoundary
Learn more about Flutter for embedded and desktop on industrialflutter.com