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