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 | import 'dart:collection'; |
6 | import 'dart:math' as math; |
7 | import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, Gradient, LineMetrics, PlaceholderAlignment, Shader, TextBox, TextHeightBehavior; |
8 | |
9 | import 'package:flutter/foundation.dart'; |
10 | import 'package:flutter/gestures.dart'; |
11 | import 'package:flutter/semantics.dart'; |
12 | import 'package:flutter/services.dart'; |
13 | |
14 | import 'box.dart'; |
15 | import 'debug.dart'; |
16 | import 'layer.dart'; |
17 | import 'layout_helper.dart'; |
18 | import 'object.dart'; |
19 | import 'selection.dart'; |
20 | |
21 | /// The start and end positions for a word. |
22 | typedef _WordBoundaryRecord = ({TextPosition wordStart, TextPosition wordEnd}); |
23 | |
24 | const String _kEllipsis = '\u2026' ; |
25 | |
26 | /// Used by the [RenderParagraph] to map its rendering children to their |
27 | /// corresponding semantics nodes. |
28 | /// |
29 | /// The [RichText] uses this to tag the relation between its placeholder spans |
30 | /// and their semantics nodes. |
31 | @immutable |
32 | class PlaceholderSpanIndexSemanticsTag extends SemanticsTag { |
33 | /// Creates a semantics tag with the input `index`. |
34 | /// |
35 | /// Different [PlaceholderSpanIndexSemanticsTag]s with the same `index` are |
36 | /// consider the same. |
37 | const PlaceholderSpanIndexSemanticsTag(this.index) : super('PlaceholderSpanIndexSemanticsTag( $index)' ); |
38 | |
39 | /// The index of this tag. |
40 | final int index; |
41 | |
42 | @override |
43 | bool operator ==(Object other) { |
44 | return other is PlaceholderSpanIndexSemanticsTag |
45 | && other.index == index; |
46 | } |
47 | |
48 | @override |
49 | int get hashCode => Object.hash(PlaceholderSpanIndexSemanticsTag, index); |
50 | } |
51 | |
52 | /// Parent data used by [RenderParagraph] and [RenderEditable] to annotate |
53 | /// inline contents (such as [WidgetSpan]s) with. |
54 | class TextParentData extends ParentData with ContainerParentDataMixin<RenderBox> { |
55 | /// The offset at which to paint the child in the parent's coordinate system. |
56 | /// |
57 | /// A `null` value indicates this inline widget is not laid out. For instance, |
58 | /// when the inline widget has never been laid out, or the inline widget is |
59 | /// ellipsized away. |
60 | Offset? get offset => _offset; |
61 | Offset? _offset; |
62 | |
63 | /// The [PlaceholderSpan] associated with this render child. |
64 | /// |
65 | /// This field is usually set by a [ParentDataWidget], and is typically not |
66 | /// null when `performLayout` is called. |
67 | PlaceholderSpan? span; |
68 | |
69 | @override |
70 | void detach() { |
71 | span = null; |
72 | _offset = null; |
73 | super.detach(); |
74 | } |
75 | |
76 | @override |
77 | String toString() => 'widget: $span, ${offset == null ? "not laid out" : "offset: $offset" }' ; |
78 | } |
79 | |
80 | /// A mixin that provides useful default behaviors for text [RenderBox]es |
81 | /// ([RenderParagraph] and [RenderEditable] for example) with inline content |
82 | /// children managed by the [ContainerRenderObjectMixin] mixin. |
83 | /// |
84 | /// This mixin assumes every child managed by the [ContainerRenderObjectMixin] |
85 | /// mixin corresponds to a [PlaceholderSpan], and they are organized in logical |
86 | /// order of the text (the order each [PlaceholderSpan] is encountered when the |
87 | /// user reads the text). |
88 | /// |
89 | /// To use this mixin in a [RenderBox] class: |
90 | /// |
91 | /// * Call [layoutInlineChildren] in the `performLayout` and `computeDryLayout` |
92 | /// implementation, and during intrinsic size calculations, to get the size |
93 | /// information of the inline widgets as a `List` of `PlaceholderDimensions`. |
94 | /// Determine the positioning of the inline widgets (which is usually done by |
95 | /// a [TextPainter] using its line break algorithm). |
96 | /// |
97 | /// * Call [positionInlineChildren] with the positioning information of the |
98 | /// inline widgets. |
99 | /// |
100 | /// * Implement [RenderBox.applyPaintTransform], optionally with |
101 | /// [defaultApplyPaintTransform]. |
102 | /// |
103 | /// * Call [paintInlineChildren] in [RenderBox.paint] to paint the inline widgets. |
104 | /// |
105 | /// * Call [hitTestInlineChildren] in [RenderBox.hitTestChildren] to hit test the |
106 | /// inline widgets. |
107 | /// |
108 | /// See also: |
109 | /// |
110 | /// * [WidgetSpan.extractFromInlineSpan], a helper function for extracting |
111 | /// [WidgetSpan]s from an [InlineSpan] tree. |
112 | mixin RenderInlineChildrenContainerDefaults on RenderBox, ContainerRenderObjectMixin<RenderBox, TextParentData> { |
113 | @override |
114 | void setupParentData(RenderBox child) { |
115 | if (child.parentData is! TextParentData) { |
116 | child.parentData = TextParentData(); |
117 | } |
118 | } |
119 | |
120 | static PlaceholderDimensions _layoutChild(RenderBox child, double maxWidth, ChildLayouter layoutChild) { |
121 | final TextParentData parentData = child.parentData! as TextParentData; |
122 | final PlaceholderSpan? span = parentData.span; |
123 | assert(span != null); |
124 | return span == null |
125 | ? PlaceholderDimensions.empty |
126 | : PlaceholderDimensions( |
127 | size: layoutChild(child, BoxConstraints(maxWidth: maxWidth)), |
128 | alignment: span.alignment, |
129 | baseline: span.baseline, |
130 | baselineOffset: switch (span.alignment) { |
131 | ui.PlaceholderAlignment.aboveBaseline || |
132 | ui.PlaceholderAlignment.belowBaseline || |
133 | ui.PlaceholderAlignment.bottom || |
134 | ui.PlaceholderAlignment.middle || |
135 | ui.PlaceholderAlignment.top => null, |
136 | ui.PlaceholderAlignment.baseline => child.getDistanceToBaseline(span.baseline!), |
137 | }, |
138 | ); |
139 | } |
140 | |
141 | /// Computes the layout for every inline child using the given `layoutChild` |
142 | /// function and the `maxWidth` constraint. |
143 | /// |
144 | /// Returns a list of [PlaceholderDimensions], representing the layout results |
145 | /// for each child managed by the [ContainerRenderObjectMixin] mixin. |
146 | /// |
147 | /// Since this method does not impose a maximum height constraint on the |
148 | /// inline children, some children may become taller than this [RenderBox]. |
149 | /// |
150 | /// See also: |
151 | /// |
152 | /// * [TextPainter.setPlaceholderDimensions], the method that usually takes |
153 | /// the layout results from this method as the input. |
154 | @protected |
155 | List<PlaceholderDimensions> layoutInlineChildren(double maxWidth, ChildLayouter layoutChild) { |
156 | return <PlaceholderDimensions>[ |
157 | for (RenderBox? child = firstChild; child != null; child = childAfter(child)) |
158 | _layoutChild(child, maxWidth, layoutChild), |
159 | ]; |
160 | } |
161 | |
162 | /// Positions each inline child according to the coordinates provided in the |
163 | /// `boxes` list. |
164 | /// |
165 | /// The `boxes` list must be in logical order, which is the order each child |
166 | /// is encountered when the user reads the text. Usually the length of the |
167 | /// list equals [childCount], but it can be less than that, when some children |
168 | /// are ommitted due to ellipsing. It never exceeds [childCount]. |
169 | /// |
170 | /// See also: |
171 | /// |
172 | /// * [TextPainter.inlinePlaceholderBoxes], the method that can be used to |
173 | /// get the input `boxes`. |
174 | @protected |
175 | void positionInlineChildren(List<ui.TextBox> boxes) { |
176 | RenderBox? child = firstChild; |
177 | for (final ui.TextBox box in boxes) { |
178 | if (child == null) { |
179 | assert(false, 'The length of boxes ( ${boxes.length}) should be greater than childCount ( $childCount)' ); |
180 | return; |
181 | } |
182 | final TextParentData textParentData = child.parentData! as TextParentData; |
183 | textParentData._offset = Offset(box.left, box.top); |
184 | child = childAfter(child); |
185 | } |
186 | while (child != null) { |
187 | final TextParentData textParentData = child.parentData! as TextParentData; |
188 | textParentData._offset = null; |
189 | child = childAfter(child); |
190 | } |
191 | } |
192 | |
193 | /// Applies the transform that would be applied when painting the given child |
194 | /// to the given matrix. |
195 | /// |
196 | /// Render children whose [TextParentData.offset] is null zeros out the |
197 | /// `transform` to indicate they're invisible thus should not be painted. |
198 | @protected |
199 | void defaultApplyPaintTransform(RenderBox child, Matrix4 transform) { |
200 | final TextParentData childParentData = child.parentData! as TextParentData; |
201 | final Offset? offset = childParentData.offset; |
202 | if (offset == null) { |
203 | transform.setZero(); |
204 | } else { |
205 | transform.translate(offset.dx, offset.dy); |
206 | } |
207 | } |
208 | |
209 | /// Paints each inline child. |
210 | /// |
211 | /// Render children whose [TextParentData.offset] is null will be skipped by |
212 | /// this method. |
213 | @protected |
214 | void paintInlineChildren(PaintingContext context, Offset offset) { |
215 | RenderBox? child = firstChild; |
216 | while (child != null) { |
217 | final TextParentData childParentData = child.parentData! as TextParentData; |
218 | final Offset? childOffset = childParentData.offset; |
219 | if (childOffset == null) { |
220 | return; |
221 | } |
222 | context.paintChild(child, childOffset + offset); |
223 | child = childAfter(child); |
224 | } |
225 | } |
226 | |
227 | /// Performs a hit test on each inline child. |
228 | /// |
229 | /// Render children whose [TextParentData.offset] is null will be skipped by |
230 | /// this method. |
231 | @protected |
232 | bool hitTestInlineChildren(BoxHitTestResult result, Offset position) { |
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 false; |
239 | } |
240 | final bool isHit = result.addWithPaintOffset( |
241 | offset: childOffset, |
242 | position: position, |
243 | hitTest: (BoxHitTestResult result, Offset transformed) => child!.hitTest(result, position: transformed), |
244 | ); |
245 | if (isHit) { |
246 | return true; |
247 | } |
248 | child = childAfter(child); |
249 | } |
250 | return false; |
251 | } |
252 | } |
253 | |
254 | /// A render object that displays a paragraph of text. |
255 | class RenderParagraph extends RenderBox with ContainerRenderObjectMixin<RenderBox, TextParentData>, RenderInlineChildrenContainerDefaults, RelayoutWhenSystemFontsChangeMixin { |
256 | /// Creates a paragraph render object. |
257 | /// |
258 | /// The [maxLines] property may be null (and indeed defaults to null), but if |
259 | /// it is not null, it must be greater than zero. |
260 | RenderParagraph(InlineSpan text, { |
261 | TextAlign textAlign = TextAlign.start, |
262 | required TextDirection textDirection, |
263 | bool softWrap = true, |
264 | TextOverflow overflow = TextOverflow.clip, |
265 | @Deprecated( |
266 | 'Use textScaler instead. ' |
267 | 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' |
268 | 'This feature was deprecated after v3.12.0-2.0.pre.' , |
269 | ) |
270 | double textScaleFactor = 1.0, |
271 | TextScaler textScaler = TextScaler.noScaling, |
272 | int? maxLines, |
273 | Locale? locale, |
274 | StrutStyle? strutStyle, |
275 | TextWidthBasis textWidthBasis = TextWidthBasis.parent, |
276 | ui.TextHeightBehavior? textHeightBehavior, |
277 | List<RenderBox>? children, |
278 | Color? selectionColor, |
279 | SelectionRegistrar? registrar, |
280 | }) : assert(text.debugAssertIsValid()), |
281 | assert(maxLines == null || maxLines > 0), |
282 | assert( |
283 | identical(textScaler, TextScaler.noScaling) || textScaleFactor == 1.0, |
284 | 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.' , |
285 | ), |
286 | _softWrap = softWrap, |
287 | _overflow = overflow, |
288 | _selectionColor = selectionColor, |
289 | _textPainter = TextPainter( |
290 | text: text, |
291 | textAlign: textAlign, |
292 | textDirection: textDirection, |
293 | textScaler: textScaler == TextScaler.noScaling ? TextScaler.linear(textScaleFactor) : textScaler, |
294 | maxLines: maxLines, |
295 | ellipsis: overflow == TextOverflow.ellipsis ? _kEllipsis : null, |
296 | locale: locale, |
297 | strutStyle: strutStyle, |
298 | textWidthBasis: textWidthBasis, |
299 | textHeightBehavior: textHeightBehavior, |
300 | ) { |
301 | addAll(children); |
302 | this.registrar = registrar; |
303 | } |
304 | |
305 | static final String _placeholderCharacter = String.fromCharCode(PlaceholderSpan.placeholderCodeUnit); |
306 | |
307 | final TextPainter _textPainter; |
308 | |
309 | List<AttributedString>? _cachedAttributedLabels; |
310 | |
311 | List<InlineSpanSemanticsInformation>? _cachedCombinedSemanticsInfos; |
312 | |
313 | /// The text to display. |
314 | InlineSpan get text => _textPainter.text!; |
315 | set text(InlineSpan value) { |
316 | switch (_textPainter.text!.compareTo(value)) { |
317 | case RenderComparison.identical: |
318 | return; |
319 | case RenderComparison.metadata: |
320 | _textPainter.text = value; |
321 | _cachedCombinedSemanticsInfos = null; |
322 | markNeedsSemanticsUpdate(); |
323 | case RenderComparison.paint: |
324 | _textPainter.text = value; |
325 | _cachedAttributedLabels = null; |
326 | _canComputeIntrinsicsCached = null; |
327 | _cachedCombinedSemanticsInfos = null; |
328 | markNeedsPaint(); |
329 | markNeedsSemanticsUpdate(); |
330 | case RenderComparison.layout: |
331 | _textPainter.text = value; |
332 | _overflowShader = null; |
333 | _cachedAttributedLabels = null; |
334 | _cachedCombinedSemanticsInfos = null; |
335 | _canComputeIntrinsicsCached = null; |
336 | markNeedsLayout(); |
337 | _removeSelectionRegistrarSubscription(); |
338 | _disposeSelectableFragments(); |
339 | _updateSelectionRegistrarSubscription(); |
340 | } |
341 | } |
342 | |
343 | /// The ongoing selections in this paragraph. |
344 | /// |
345 | /// The selection does not include selections in [PlaceholderSpan] if there |
346 | /// are any. |
347 | @visibleForTesting |
348 | List<TextSelection> get selections { |
349 | if (_lastSelectableFragments == null) { |
350 | return const <TextSelection>[]; |
351 | } |
352 | final List<TextSelection> results = <TextSelection>[]; |
353 | for (final _SelectableFragment fragment in _lastSelectableFragments!) { |
354 | if (fragment._textSelectionStart != null && |
355 | fragment._textSelectionEnd != null) { |
356 | results.add( |
357 | TextSelection( |
358 | baseOffset: fragment._textSelectionStart!.offset, |
359 | extentOffset: fragment._textSelectionEnd!.offset |
360 | ) |
361 | ); |
362 | } |
363 | } |
364 | return results; |
365 | } |
366 | |
367 | // Should be null if selection is not enabled, i.e. _registrar = null. The |
368 | // paragraph splits on [PlaceholderSpan.placeholderCodeUnit], and stores each |
369 | // fragment in this list. |
370 | List<_SelectableFragment>? _lastSelectableFragments; |
371 | |
372 | /// The [SelectionRegistrar] this paragraph will be, or is, registered to. |
373 | SelectionRegistrar? get registrar => _registrar; |
374 | SelectionRegistrar? _registrar; |
375 | set registrar(SelectionRegistrar? value) { |
376 | if (value == _registrar) { |
377 | return; |
378 | } |
379 | _removeSelectionRegistrarSubscription(); |
380 | _disposeSelectableFragments(); |
381 | _registrar = value; |
382 | _updateSelectionRegistrarSubscription(); |
383 | } |
384 | |
385 | void _updateSelectionRegistrarSubscription() { |
386 | if (_registrar == null) { |
387 | return; |
388 | } |
389 | _lastSelectableFragments ??= _getSelectableFragments(); |
390 | _lastSelectableFragments!.forEach(_registrar!.add); |
391 | if (_lastSelectableFragments!.isNotEmpty) { |
392 | markNeedsCompositingBitsUpdate(); |
393 | } |
394 | } |
395 | |
396 | void _removeSelectionRegistrarSubscription() { |
397 | if (_registrar == null || _lastSelectableFragments == null) { |
398 | return; |
399 | } |
400 | _lastSelectableFragments!.forEach(_registrar!.remove); |
401 | } |
402 | |
403 | List<_SelectableFragment> _getSelectableFragments() { |
404 | final String plainText = text.toPlainText(includeSemanticsLabels: false); |
405 | final List<_SelectableFragment> result = <_SelectableFragment>[]; |
406 | int start = 0; |
407 | while (start < plainText.length) { |
408 | int end = plainText.indexOf(_placeholderCharacter, start); |
409 | if (start != end) { |
410 | if (end == -1) { |
411 | end = plainText.length; |
412 | } |
413 | result.add( |
414 | _SelectableFragment( |
415 | paragraph: this, |
416 | range: TextRange(start: start, end: end), |
417 | fullText: plainText, |
418 | ), |
419 | ); |
420 | start = end; |
421 | } |
422 | start += 1; |
423 | } |
424 | return result; |
425 | } |
426 | |
427 | void _disposeSelectableFragments() { |
428 | if (_lastSelectableFragments == null) { |
429 | return; |
430 | } |
431 | for (final _SelectableFragment fragment in _lastSelectableFragments!) { |
432 | fragment.dispose(); |
433 | } |
434 | _lastSelectableFragments = null; |
435 | } |
436 | |
437 | @override |
438 | bool get alwaysNeedsCompositing => _lastSelectableFragments?.isNotEmpty ?? false; |
439 | |
440 | @override |
441 | void markNeedsLayout() { |
442 | _lastSelectableFragments?.forEach((_SelectableFragment element) => element.didChangeParagraphLayout()); |
443 | super.markNeedsLayout(); |
444 | } |
445 | |
446 | @override |
447 | void dispose() { |
448 | _removeSelectionRegistrarSubscription(); |
449 | _disposeSelectableFragments(); |
450 | _textPainter.dispose(); |
451 | super.dispose(); |
452 | } |
453 | |
454 | /// How the text should be aligned horizontally. |
455 | TextAlign get textAlign => _textPainter.textAlign; |
456 | set textAlign(TextAlign value) { |
457 | if (_textPainter.textAlign == value) { |
458 | return; |
459 | } |
460 | _textPainter.textAlign = value; |
461 | markNeedsPaint(); |
462 | } |
463 | |
464 | /// The directionality of the text. |
465 | /// |
466 | /// This decides how the [TextAlign.start], [TextAlign.end], and |
467 | /// [TextAlign.justify] values of [textAlign] are interpreted. |
468 | /// |
469 | /// This is also used to disambiguate how to render bidirectional text. For |
470 | /// example, if the [text] is an English phrase followed by a Hebrew phrase, |
471 | /// in a [TextDirection.ltr] context the English phrase will be on the left |
472 | /// and the Hebrew phrase to its right, while in a [TextDirection.rtl] |
473 | /// context, the English phrase will be on the right and the Hebrew phrase on |
474 | /// its left. |
475 | TextDirection get textDirection => _textPainter.textDirection!; |
476 | set textDirection(TextDirection value) { |
477 | if (_textPainter.textDirection == value) { |
478 | return; |
479 | } |
480 | _textPainter.textDirection = value; |
481 | markNeedsLayout(); |
482 | } |
483 | |
484 | /// Whether the text should break at soft line breaks. |
485 | /// |
486 | /// If false, the glyphs in the text will be positioned as if there was |
487 | /// unlimited horizontal space. |
488 | /// |
489 | /// If [softWrap] is false, [overflow] and [textAlign] may have unexpected |
490 | /// effects. |
491 | bool get softWrap => _softWrap; |
492 | bool _softWrap; |
493 | set softWrap(bool value) { |
494 | if (_softWrap == value) { |
495 | return; |
496 | } |
497 | _softWrap = value; |
498 | markNeedsLayout(); |
499 | } |
500 | |
501 | /// How visual overflow should be handled. |
502 | TextOverflow get overflow => _overflow; |
503 | TextOverflow _overflow; |
504 | set overflow(TextOverflow value) { |
505 | if (_overflow == value) { |
506 | return; |
507 | } |
508 | _overflow = value; |
509 | _textPainter.ellipsis = value == TextOverflow.ellipsis ? _kEllipsis : null; |
510 | markNeedsLayout(); |
511 | } |
512 | |
513 | /// Deprecated. Will be removed in a future version of Flutter. Use |
514 | /// [textScaler] instead. |
515 | /// |
516 | /// The number of font pixels for each logical pixel. |
517 | /// |
518 | /// For example, if the text scale factor is 1.5, text will be 50% larger than |
519 | /// the specified font size. |
520 | @Deprecated( |
521 | 'Use textScaler instead. ' |
522 | 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' |
523 | 'This feature was deprecated after v3.12.0-2.0.pre.' , |
524 | ) |
525 | double get textScaleFactor => _textPainter.textScaleFactor; |
526 | @Deprecated( |
527 | 'Use textScaler instead. ' |
528 | 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' |
529 | 'This feature was deprecated after v3.12.0-2.0.pre.' , |
530 | ) |
531 | set textScaleFactor(double value) { |
532 | textScaler = TextScaler.linear(value); |
533 | } |
534 | |
535 | /// {@macro flutter.painting.textPainter.textScaler} |
536 | TextScaler get textScaler => _textPainter.textScaler; |
537 | set textScaler(TextScaler value) { |
538 | if (_textPainter.textScaler == value) { |
539 | return; |
540 | } |
541 | _textPainter.textScaler = value; |
542 | _overflowShader = null; |
543 | markNeedsLayout(); |
544 | } |
545 | |
546 | /// An optional maximum number of lines for the text to span, wrapping if |
547 | /// necessary. If the text exceeds the given number of lines, it will be |
548 | /// truncated according to [overflow] and [softWrap]. |
549 | int? get maxLines => _textPainter.maxLines; |
550 | /// The value may be null. If it is not null, then it must be greater than |
551 | /// zero. |
552 | set maxLines(int? value) { |
553 | assert(value == null || value > 0); |
554 | if (_textPainter.maxLines == value) { |
555 | return; |
556 | } |
557 | _textPainter.maxLines = value; |
558 | _overflowShader = null; |
559 | markNeedsLayout(); |
560 | } |
561 | |
562 | /// Used by this paragraph's internal [TextPainter] to select a |
563 | /// locale-specific font. |
564 | /// |
565 | /// In some cases, the same Unicode character may be rendered differently |
566 | /// depending on the locale. For example, the '骨' character is rendered |
567 | /// differently in the Chinese and Japanese locales. In these cases, the |
568 | /// [locale] may be used to select a locale-specific font. |
569 | Locale? get locale => _textPainter.locale; |
570 | /// The value may be null. |
571 | set locale(Locale? value) { |
572 | if (_textPainter.locale == value) { |
573 | return; |
574 | } |
575 | _textPainter.locale = value; |
576 | _overflowShader = null; |
577 | markNeedsLayout(); |
578 | } |
579 | |
580 | /// {@macro flutter.painting.textPainter.strutStyle} |
581 | StrutStyle? get strutStyle => _textPainter.strutStyle; |
582 | /// The value may be null. |
583 | set strutStyle(StrutStyle? value) { |
584 | if (_textPainter.strutStyle == value) { |
585 | return; |
586 | } |
587 | _textPainter.strutStyle = value; |
588 | _overflowShader = null; |
589 | markNeedsLayout(); |
590 | } |
591 | |
592 | /// {@macro flutter.painting.textPainter.textWidthBasis} |
593 | TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis; |
594 | set textWidthBasis(TextWidthBasis value) { |
595 | if (_textPainter.textWidthBasis == value) { |
596 | return; |
597 | } |
598 | _textPainter.textWidthBasis = value; |
599 | _overflowShader = null; |
600 | markNeedsLayout(); |
601 | } |
602 | |
603 | /// {@macro dart.ui.textHeightBehavior} |
604 | ui.TextHeightBehavior? get textHeightBehavior => _textPainter.textHeightBehavior; |
605 | set textHeightBehavior(ui.TextHeightBehavior? value) { |
606 | if (_textPainter.textHeightBehavior == value) { |
607 | return; |
608 | } |
609 | _textPainter.textHeightBehavior = value; |
610 | _overflowShader = null; |
611 | markNeedsLayout(); |
612 | } |
613 | |
614 | /// The color to use when painting the selection. |
615 | /// |
616 | /// Ignored if the text is not selectable (e.g. if [registrar] is null). |
617 | Color? get selectionColor => _selectionColor; |
618 | Color? _selectionColor; |
619 | set selectionColor(Color? value) { |
620 | if (_selectionColor == value) { |
621 | return; |
622 | } |
623 | _selectionColor = value; |
624 | if (_lastSelectableFragments?.any((_SelectableFragment fragment) => fragment.value.hasSelection) ?? false) { |
625 | markNeedsPaint(); |
626 | } |
627 | } |
628 | |
629 | Offset _getOffsetForPosition(TextPosition position) { |
630 | return getOffsetForCaret(position, Rect.zero) + Offset(0, getFullHeightForCaret(position) ?? 0.0); |
631 | } |
632 | |
633 | List<ui.LineMetrics> _computeLineMetrics() { |
634 | return _textPainter.computeLineMetrics(); |
635 | } |
636 | |
637 | @override |
638 | double computeMinIntrinsicWidth(double height) { |
639 | if (!_canComputeIntrinsics()) { |
640 | return 0.0; |
641 | } |
642 | _textPainter.setPlaceholderDimensions(layoutInlineChildren( |
643 | double.infinity, |
644 | (RenderBox child, BoxConstraints constraints) => Size(child.getMinIntrinsicWidth(double.infinity), 0.0), |
645 | )); |
646 | _layoutText(); // layout with infinite width. |
647 | return _textPainter.minIntrinsicWidth; |
648 | } |
649 | |
650 | @override |
651 | double computeMaxIntrinsicWidth(double height) { |
652 | if (!_canComputeIntrinsics()) { |
653 | return 0.0; |
654 | } |
655 | _textPainter.setPlaceholderDimensions(layoutInlineChildren( |
656 | double.infinity, |
657 | // Height and baseline is irrelevant as all text will be laid |
658 | // out in a single line. Therefore, using 0.0 as a dummy for the height. |
659 | (RenderBox child, BoxConstraints constraints) => Size(child.getMaxIntrinsicWidth(double.infinity), 0.0), |
660 | )); |
661 | _layoutText(); // layout with infinite width. |
662 | return _textPainter.maxIntrinsicWidth; |
663 | } |
664 | |
665 | double _computeIntrinsicHeight(double width) { |
666 | if (!_canComputeIntrinsics()) { |
667 | return 0.0; |
668 | } |
669 | _textPainter.setPlaceholderDimensions(layoutInlineChildren(width, ChildLayoutHelper.dryLayoutChild)); |
670 | _layoutText(minWidth: width, maxWidth: width); |
671 | return _textPainter.height; |
672 | } |
673 | |
674 | @override |
675 | double computeMinIntrinsicHeight(double width) { |
676 | return _computeIntrinsicHeight(width); |
677 | } |
678 | |
679 | @override |
680 | double computeMaxIntrinsicHeight(double width) { |
681 | return _computeIntrinsicHeight(width); |
682 | } |
683 | |
684 | @override |
685 | double computeDistanceToActualBaseline(TextBaseline baseline) { |
686 | assert(!debugNeedsLayout); |
687 | assert(constraints.debugAssertIsValid()); |
688 | _layoutTextWithConstraints(constraints); |
689 | // TODO(garyq): Since our metric for ideographic baseline is currently |
690 | // inaccurate and the non-alphabetic baselines are based off of the |
691 | // alphabetic baseline, we use the alphabetic for now to produce correct |
692 | // layouts. We should eventually change this back to pass the `baseline` |
693 | // property when the ideographic baseline is properly implemented |
694 | // (https://github.com/flutter/flutter/issues/22625). |
695 | return _textPainter.computeDistanceToActualBaseline(TextBaseline.alphabetic); |
696 | } |
697 | |
698 | /// Whether all inline widget children of this [RenderBox] support dry layout |
699 | /// calculation. |
700 | bool _canComputeDryLayoutForInlineWidgets() { |
701 | // Dry layout cannot be calculated without a full layout for |
702 | // alignments that require the baseline (baseline, aboveBaseline, |
703 | // belowBaseline). |
704 | return text.visitChildren((InlineSpan span) { |
705 | return (span is! PlaceholderSpan) || switch (span.alignment) { |
706 | ui.PlaceholderAlignment.baseline || |
707 | ui.PlaceholderAlignment.aboveBaseline || |
708 | ui.PlaceholderAlignment.belowBaseline => false, |
709 | ui.PlaceholderAlignment.top || |
710 | ui.PlaceholderAlignment.middle || |
711 | ui.PlaceholderAlignment.bottom => true, |
712 | }; |
713 | }); |
714 | } |
715 | |
716 | bool? _canComputeIntrinsicsCached; |
717 | // Intrinsics cannot be calculated without a full layout for |
718 | // alignments that require the baseline (baseline, aboveBaseline, |
719 | // belowBaseline). |
720 | bool _canComputeIntrinsics() { |
721 | final bool returnValue = _canComputeIntrinsicsCached ??= _canComputeDryLayoutForInlineWidgets(); |
722 | assert( |
723 | returnValue || RenderObject.debugCheckingIntrinsics, |
724 | 'Intrinsics are not available for PlaceholderAlignment.baseline, ' |
725 | 'PlaceholderAlignment.aboveBaseline, or PlaceholderAlignment.belowBaseline.' , |
726 | ); |
727 | return returnValue; |
728 | } |
729 | |
730 | @override |
731 | bool hitTestSelf(Offset position) => true; |
732 | |
733 | @override |
734 | @protected |
735 | bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { |
736 | final GlyphInfo? glyph = _textPainter.getClosestGlyphForOffset(position); |
737 | // The hit-test can't fall through the horizontal gaps between visually |
738 | // adjacent characters on the same line, even with a large letter-spacing or |
739 | // text justification, as graphemeClusterLayoutBounds.width is the advance |
740 | // width to the next character, so there's no gap between their |
741 | // graphemeClusterLayoutBounds rects. |
742 | final InlineSpan? spanHit = glyph != null && glyph.graphemeClusterLayoutBounds.contains(position) |
743 | ? _textPainter.text!.getSpanForPosition(TextPosition(offset: glyph.graphemeClusterCodeUnitRange.start)) |
744 | : null; |
745 | switch (spanHit) { |
746 | case final HitTestTarget span: |
747 | result.add(HitTestEntry(span)); |
748 | return true; |
749 | case _: |
750 | return hitTestInlineChildren(result, position); |
751 | } |
752 | } |
753 | |
754 | bool _needsClipping = false; |
755 | ui.Shader? _overflowShader; |
756 | |
757 | /// Whether this paragraph currently has a [dart:ui.Shader] for its overflow |
758 | /// effect. |
759 | /// |
760 | /// Used to test this object. Not for use in production. |
761 | @visibleForTesting |
762 | bool get debugHasOverflowShader => _overflowShader != null; |
763 | |
764 | void _layoutText({ double minWidth = 0.0, double maxWidth = double.infinity }) { |
765 | final bool widthMatters = softWrap || overflow == TextOverflow.ellipsis; |
766 | _textPainter.layout( |
767 | minWidth: minWidth, |
768 | maxWidth: widthMatters ? maxWidth : double.infinity, |
769 | ); |
770 | } |
771 | |
772 | @override |
773 | void systemFontsDidChange() { |
774 | super.systemFontsDidChange(); |
775 | _textPainter.markNeedsLayout(); |
776 | } |
777 | |
778 | // Placeholder dimensions representing the sizes of child inline widgets. |
779 | // |
780 | // These need to be cached because the text painter's placeholder dimensions |
781 | // will be overwritten during intrinsic width/height calculations and must be |
782 | // restored to the original values before final layout and painting. |
783 | List<PlaceholderDimensions>? _placeholderDimensions; |
784 | |
785 | void _layoutTextWithConstraints(BoxConstraints constraints) { |
786 | _textPainter.setPlaceholderDimensions(_placeholderDimensions); |
787 | _layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); |
788 | } |
789 | |
790 | @override |
791 | @protected |
792 | Size computeDryLayout(covariant BoxConstraints constraints) { |
793 | if (!_canComputeIntrinsics()) { |
794 | assert(debugCannotComputeDryLayout( |
795 | reason: 'Dry layout not available for alignments that require baseline.' , |
796 | )); |
797 | return Size.zero; |
798 | } |
799 | _textPainter.setPlaceholderDimensions(layoutInlineChildren(constraints.maxWidth, ChildLayoutHelper.dryLayoutChild)); |
800 | _layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); |
801 | return constraints.constrain(_textPainter.size); |
802 | } |
803 | |
804 | @override |
805 | void performLayout() { |
806 | final BoxConstraints constraints = this.constraints; |
807 | _placeholderDimensions = layoutInlineChildren(constraints.maxWidth, ChildLayoutHelper.layoutChild); |
808 | _layoutTextWithConstraints(constraints); |
809 | positionInlineChildren(_textPainter.inlinePlaceholderBoxes!); |
810 | |
811 | // We grab _textPainter.size and _textPainter.didExceedMaxLines here because |
812 | // assigning to `size` will trigger us to validate our intrinsic sizes, |
813 | // which will change _textPainter's layout because the intrinsic size |
814 | // calculations are destructive. Other _textPainter state will also be |
815 | // affected. See also RenderEditable which has a similar issue. |
816 | final Size textSize = _textPainter.size; |
817 | final bool textDidExceedMaxLines = _textPainter.didExceedMaxLines; |
818 | size = constraints.constrain(textSize); |
819 | |
820 | final bool didOverflowHeight = size.height < textSize.height || textDidExceedMaxLines; |
821 | final bool didOverflowWidth = size.width < textSize.width; |
822 | // TODO(abarth): We're only measuring the sizes of the line boxes here. If |
823 | // the glyphs draw outside the line boxes, we might think that there isn't |
824 | // visual overflow when there actually is visual overflow. This can become |
825 | // a problem if we start having horizontal overflow and introduce a clip |
826 | // that affects the actual (but undetected) vertical overflow. |
827 | final bool hasVisualOverflow = didOverflowWidth || didOverflowHeight; |
828 | if (hasVisualOverflow) { |
829 | switch (_overflow) { |
830 | case TextOverflow.visible: |
831 | _needsClipping = false; |
832 | _overflowShader = null; |
833 | case TextOverflow.clip: |
834 | case TextOverflow.ellipsis: |
835 | _needsClipping = true; |
836 | _overflowShader = null; |
837 | case TextOverflow.fade: |
838 | _needsClipping = true; |
839 | final TextPainter fadeSizePainter = TextPainter( |
840 | text: TextSpan(style: _textPainter.text!.style, text: '\u2026' ), |
841 | textDirection: textDirection, |
842 | textScaler: textScaler, |
843 | locale: locale, |
844 | )..layout(); |
845 | if (didOverflowWidth) { |
846 | double fadeEnd, fadeStart; |
847 | switch (textDirection) { |
848 | case TextDirection.rtl: |
849 | fadeEnd = 0.0; |
850 | fadeStart = fadeSizePainter.width; |
851 | case TextDirection.ltr: |
852 | fadeEnd = size.width; |
853 | fadeStart = fadeEnd - fadeSizePainter.width; |
854 | } |
855 | _overflowShader = ui.Gradient.linear( |
856 | Offset(fadeStart, 0.0), |
857 | Offset(fadeEnd, 0.0), |
858 | <Color>[const Color(0xFFFFFFFF), const Color(0x00FFFFFF)], |
859 | ); |
860 | } else { |
861 | final double fadeEnd = size.height; |
862 | final double fadeStart = fadeEnd - fadeSizePainter.height / 2.0; |
863 | _overflowShader = ui.Gradient.linear( |
864 | Offset(0.0, fadeStart), |
865 | Offset(0.0, fadeEnd), |
866 | <Color>[const Color(0xFFFFFFFF), const Color(0x00FFFFFF)], |
867 | ); |
868 | } |
869 | fadeSizePainter.dispose(); |
870 | } |
871 | } else { |
872 | _needsClipping = false; |
873 | _overflowShader = null; |
874 | } |
875 | } |
876 | |
877 | @override |
878 | void applyPaintTransform(RenderBox child, Matrix4 transform) { |
879 | defaultApplyPaintTransform(child, transform); |
880 | } |
881 | |
882 | @override |
883 | void paint(PaintingContext context, Offset offset) { |
884 | // Ideally we could compute the min/max intrinsic width/height with a |
885 | // non-destructive operation. However, currently, computing these values |
886 | // will destroy state inside the painter. If that happens, we need to get |
887 | // back the correct state by calling _layout again. |
888 | // |
889 | // TODO(abarth): Make computing the min/max intrinsic width/height a |
890 | // non-destructive operation. |
891 | // |
892 | // If you remove this call, make sure that changing the textAlign still |
893 | // works properly. |
894 | _layoutTextWithConstraints(constraints); |
895 | |
896 | assert(() { |
897 | if (debugRepaintTextRainbowEnabled) { |
898 | final Paint paint = Paint() |
899 | ..color = debugCurrentRepaintColor.toColor(); |
900 | context.canvas.drawRect(offset & size, paint); |
901 | } |
902 | return true; |
903 | }()); |
904 | |
905 | if (_needsClipping) { |
906 | final Rect bounds = offset & size; |
907 | if (_overflowShader != null) { |
908 | // This layer limits what the shader below blends with to be just the |
909 | // text (as opposed to the text and its background). |
910 | context.canvas.saveLayer(bounds, Paint()); |
911 | } else { |
912 | context.canvas.save(); |
913 | } |
914 | context.canvas.clipRect(bounds); |
915 | } |
916 | |
917 | if (_lastSelectableFragments != null) { |
918 | for (final _SelectableFragment fragment in _lastSelectableFragments!) { |
919 | fragment.paint(context, offset); |
920 | } |
921 | } |
922 | |
923 | _textPainter.paint(context.canvas, offset); |
924 | |
925 | paintInlineChildren(context, offset); |
926 | |
927 | if (_needsClipping) { |
928 | if (_overflowShader != null) { |
929 | context.canvas.translate(offset.dx, offset.dy); |
930 | final Paint paint = Paint() |
931 | ..blendMode = BlendMode.modulate |
932 | ..shader = _overflowShader; |
933 | context.canvas.drawRect(Offset.zero & size, paint); |
934 | } |
935 | context.canvas.restore(); |
936 | } |
937 | } |
938 | |
939 | /// Returns the offset at which to paint the caret. |
940 | /// |
941 | /// Valid only after [layout]. |
942 | Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) { |
943 | assert(!debugNeedsLayout); |
944 | _layoutTextWithConstraints(constraints); |
945 | return _textPainter.getOffsetForCaret(position, caretPrototype); |
946 | } |
947 | |
948 | /// {@macro flutter.painting.textPainter.getFullHeightForCaret} |
949 | /// |
950 | /// Valid only after [layout]. |
951 | double? getFullHeightForCaret(TextPosition position) { |
952 | assert(!debugNeedsLayout); |
953 | _layoutTextWithConstraints(constraints); |
954 | return _textPainter.getFullHeightForCaret(position, Rect.zero); |
955 | } |
956 | |
957 | /// Returns a list of rects that bound the given selection. |
958 | /// |
959 | /// The [boxHeightStyle] and [boxWidthStyle] arguments may be used to select |
960 | /// the shape of the [TextBox]es. These properties default to |
961 | /// [ui.BoxHeightStyle.tight] and [ui.BoxWidthStyle.tight] respectively. |
962 | /// |
963 | /// A given selection might have more than one rect if the [RenderParagraph] |
964 | /// contains multiple [InlineSpan]s or bidirectional text, because logically |
965 | /// contiguous text might not be visually contiguous. |
966 | /// |
967 | /// Valid only after [layout]. |
968 | /// |
969 | /// See also: |
970 | /// |
971 | /// * [TextPainter.getBoxesForSelection], the method in TextPainter to get |
972 | /// the equivalent boxes. |
973 | List<ui.TextBox> getBoxesForSelection( |
974 | TextSelection selection, { |
975 | ui.BoxHeightStyle boxHeightStyle = ui.BoxHeightStyle.tight, |
976 | ui.BoxWidthStyle boxWidthStyle = ui.BoxWidthStyle.tight, |
977 | }) { |
978 | assert(!debugNeedsLayout); |
979 | _layoutTextWithConstraints(constraints); |
980 | return _textPainter.getBoxesForSelection( |
981 | selection, |
982 | boxHeightStyle: boxHeightStyle, |
983 | boxWidthStyle: boxWidthStyle, |
984 | ); |
985 | } |
986 | |
987 | /// Returns the position within the text for the given pixel offset. |
988 | /// |
989 | /// Valid only after [layout]. |
990 | TextPosition getPositionForOffset(Offset offset) { |
991 | assert(!debugNeedsLayout); |
992 | _layoutTextWithConstraints(constraints); |
993 | return _textPainter.getPositionForOffset(offset); |
994 | } |
995 | |
996 | /// Returns the text range of the word at the given offset. Characters not |
997 | /// part of a word, such as spaces, symbols, and punctuation, have word breaks |
998 | /// on both sides. In such cases, this method will return a text range that |
999 | /// contains the given text position. |
1000 | /// |
1001 | /// Word boundaries are defined more precisely in Unicode Standard Annex #29 |
1002 | /// <http://www.unicode.org/reports/tr29/#Word_Boundaries>. |
1003 | /// |
1004 | /// Valid only after [layout]. |
1005 | TextRange getWordBoundary(TextPosition position) { |
1006 | assert(!debugNeedsLayout); |
1007 | _layoutTextWithConstraints(constraints); |
1008 | return _textPainter.getWordBoundary(position); |
1009 | } |
1010 | |
1011 | TextRange _getLineAtOffset(TextPosition position) => _textPainter.getLineBoundary(position); |
1012 | |
1013 | TextPosition _getTextPositionAbove(TextPosition position) { |
1014 | // -0.5 of preferredLineHeight points to the middle of the line above. |
1015 | final double preferredLineHeight = _textPainter.preferredLineHeight; |
1016 | final double verticalOffset = -0.5 * preferredLineHeight; |
1017 | return _getTextPositionVertical(position, verticalOffset); |
1018 | } |
1019 | |
1020 | TextPosition _getTextPositionBelow(TextPosition position) { |
1021 | // 1.5 of preferredLineHeight points to the middle of the line below. |
1022 | final double preferredLineHeight = _textPainter.preferredLineHeight; |
1023 | final double verticalOffset = 1.5 * preferredLineHeight; |
1024 | return _getTextPositionVertical(position, verticalOffset); |
1025 | } |
1026 | |
1027 | TextPosition _getTextPositionVertical(TextPosition position, double verticalOffset) { |
1028 | final Offset caretOffset = _textPainter.getOffsetForCaret(position, Rect.zero); |
1029 | final Offset caretOffsetTranslated = caretOffset.translate(0.0, verticalOffset); |
1030 | return _textPainter.getPositionForOffset(caretOffsetTranslated); |
1031 | } |
1032 | |
1033 | /// Returns the size of the text as laid out. |
1034 | /// |
1035 | /// This can differ from [size] if the text overflowed or if the [constraints] |
1036 | /// provided by the parent [RenderObject] forced the layout to be bigger than |
1037 | /// necessary for the given [text]. |
1038 | /// |
1039 | /// This returns the [TextPainter.size] of the underlying [TextPainter]. |
1040 | /// |
1041 | /// Valid only after [layout]. |
1042 | Size get textSize { |
1043 | assert(!debugNeedsLayout); |
1044 | return _textPainter.size; |
1045 | } |
1046 | |
1047 | /// Whether the text was truncated or ellipsized as laid out. |
1048 | /// |
1049 | /// This returns the [TextPainter.didExceedMaxLines] of the underlying [TextPainter]. |
1050 | /// |
1051 | /// Valid only after [layout]. |
1052 | bool get didExceedMaxLines { |
1053 | assert(!debugNeedsLayout); |
1054 | return _textPainter.didExceedMaxLines; |
1055 | } |
1056 | |
1057 | /// Collected during [describeSemanticsConfiguration], used by |
1058 | /// [assembleSemanticsNode] and [_combineSemanticsInfo]. |
1059 | List<InlineSpanSemanticsInformation>? _semanticsInfo; |
1060 | |
1061 | @override |
1062 | void describeSemanticsConfiguration(SemanticsConfiguration config) { |
1063 | super.describeSemanticsConfiguration(config); |
1064 | _semanticsInfo = text.getSemanticsInformation(); |
1065 | bool needsAssembleSemanticsNode = false; |
1066 | bool needsChildConfigrationsDelegate = false; |
1067 | for (final InlineSpanSemanticsInformation info in _semanticsInfo!) { |
1068 | if (info.recognizer != null) { |
1069 | needsAssembleSemanticsNode = true; |
1070 | break; |
1071 | } |
1072 | needsChildConfigrationsDelegate = needsChildConfigrationsDelegate || info.isPlaceholder; |
1073 | } |
1074 | |
1075 | if (needsAssembleSemanticsNode) { |
1076 | config.explicitChildNodes = true; |
1077 | config.isSemanticBoundary = true; |
1078 | } else if (needsChildConfigrationsDelegate) { |
1079 | config.childConfigurationsDelegate = _childSemanticsConfigurationsDelegate; |
1080 | } else { |
1081 | if (_cachedAttributedLabels == null) { |
1082 | final StringBuffer buffer = StringBuffer(); |
1083 | int offset = 0; |
1084 | final List<StringAttribute> attributes = <StringAttribute>[]; |
1085 | for (final InlineSpanSemanticsInformation info in _semanticsInfo!) { |
1086 | final String label = info.semanticsLabel ?? info.text; |
1087 | for (final StringAttribute infoAttribute in info.stringAttributes) { |
1088 | final TextRange originalRange = infoAttribute.range; |
1089 | attributes.add( |
1090 | infoAttribute.copy( |
1091 | range: TextRange( |
1092 | start: offset + originalRange.start, |
1093 | end: offset + originalRange.end, |
1094 | ), |
1095 | ), |
1096 | ); |
1097 | } |
1098 | buffer.write(label); |
1099 | offset += label.length; |
1100 | } |
1101 | _cachedAttributedLabels = <AttributedString>[AttributedString(buffer.toString(), attributes: attributes)]; |
1102 | } |
1103 | config.attributedLabel = _cachedAttributedLabels![0]; |
1104 | config.textDirection = textDirection; |
1105 | } |
1106 | } |
1107 | |
1108 | ChildSemanticsConfigurationsResult _childSemanticsConfigurationsDelegate(List<SemanticsConfiguration> childConfigs) { |
1109 | final ChildSemanticsConfigurationsResultBuilder builder = ChildSemanticsConfigurationsResultBuilder(); |
1110 | int placeholderIndex = 0; |
1111 | int childConfigsIndex = 0; |
1112 | int attributedLabelCacheIndex = 0; |
1113 | InlineSpanSemanticsInformation? seenTextInfo; |
1114 | _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!); |
1115 | for (final InlineSpanSemanticsInformation info in _cachedCombinedSemanticsInfos!) { |
1116 | if (info.isPlaceholder) { |
1117 | if (seenTextInfo != null) { |
1118 | builder.markAsMergeUp(_createSemanticsConfigForTextInfo(seenTextInfo, attributedLabelCacheIndex)); |
1119 | attributedLabelCacheIndex += 1; |
1120 | } |
1121 | // Mark every childConfig belongs to this placeholder to merge up group. |
1122 | while (childConfigsIndex < childConfigs.length && |
1123 | childConfigs[childConfigsIndex].tagsChildrenWith(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) { |
1124 | builder.markAsMergeUp(childConfigs[childConfigsIndex]); |
1125 | childConfigsIndex += 1; |
1126 | } |
1127 | placeholderIndex += 1; |
1128 | } else { |
1129 | seenTextInfo = info; |
1130 | } |
1131 | } |
1132 | |
1133 | // Handle plain text info at the end. |
1134 | if (seenTextInfo != null) { |
1135 | builder.markAsMergeUp(_createSemanticsConfigForTextInfo(seenTextInfo, attributedLabelCacheIndex)); |
1136 | } |
1137 | return builder.build(); |
1138 | } |
1139 | |
1140 | SemanticsConfiguration _createSemanticsConfigForTextInfo(InlineSpanSemanticsInformation textInfo, int cacheIndex) { |
1141 | assert(!textInfo.requiresOwnNode); |
1142 | final List<AttributedString> cachedStrings = _cachedAttributedLabels ??= <AttributedString>[]; |
1143 | assert(cacheIndex <= cachedStrings.length); |
1144 | final bool hasCache = cacheIndex < cachedStrings.length; |
1145 | |
1146 | late AttributedString attributedLabel; |
1147 | if (hasCache) { |
1148 | attributedLabel = cachedStrings[cacheIndex]; |
1149 | } else { |
1150 | assert(cachedStrings.length == cacheIndex); |
1151 | attributedLabel = AttributedString( |
1152 | textInfo.semanticsLabel ?? textInfo.text, |
1153 | attributes: textInfo.stringAttributes, |
1154 | ); |
1155 | cachedStrings.add(attributedLabel); |
1156 | } |
1157 | return SemanticsConfiguration() |
1158 | ..textDirection = textDirection |
1159 | ..attributedLabel = attributedLabel; |
1160 | } |
1161 | |
1162 | // Caches [SemanticsNode]s created during [assembleSemanticsNode] so they |
1163 | // can be re-used when [assembleSemanticsNode] is called again. This ensures |
1164 | // stable ids for the [SemanticsNode]s of [TextSpan]s across |
1165 | // [assembleSemanticsNode] invocations. |
1166 | LinkedHashMap<Key, SemanticsNode>? _cachedChildNodes; |
1167 | |
1168 | @override |
1169 | void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) { |
1170 | assert(_semanticsInfo != null && _semanticsInfo!.isNotEmpty); |
1171 | final List<SemanticsNode> newChildren = <SemanticsNode>[]; |
1172 | TextDirection currentDirection = textDirection; |
1173 | Rect currentRect; |
1174 | double ordinal = 0.0; |
1175 | int start = 0; |
1176 | int placeholderIndex = 0; |
1177 | int childIndex = 0; |
1178 | RenderBox? child = firstChild; |
1179 | final LinkedHashMap<Key, SemanticsNode> newChildCache = LinkedHashMap<Key, SemanticsNode>(); |
1180 | _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!); |
1181 | for (final InlineSpanSemanticsInformation info in _cachedCombinedSemanticsInfos!) { |
1182 | final TextSelection selection = TextSelection( |
1183 | baseOffset: start, |
1184 | extentOffset: start + info.text.length, |
1185 | ); |
1186 | start += info.text.length; |
1187 | |
1188 | if (info.isPlaceholder) { |
1189 | // A placeholder span may have 0 to multiple semantics nodes, we need |
1190 | // to annotate all of the semantics nodes belong to this span. |
1191 | while (children.length > childIndex && |
1192 | children.elementAt(childIndex).isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) { |
1193 | final SemanticsNode childNode = children.elementAt(childIndex); |
1194 | final TextParentData parentData = child!.parentData! as TextParentData; |
1195 | // parentData.scale may be null if the render object is truncated. |
1196 | if (parentData.offset != null) { |
1197 | newChildren.add(childNode); |
1198 | } |
1199 | childIndex += 1; |
1200 | } |
1201 | child = childAfter(child!); |
1202 | placeholderIndex += 1; |
1203 | } else { |
1204 | final TextDirection initialDirection = currentDirection; |
1205 | final List<ui.TextBox> rects = getBoxesForSelection(selection); |
1206 | if (rects.isEmpty) { |
1207 | continue; |
1208 | } |
1209 | Rect rect = rects.first.toRect(); |
1210 | currentDirection = rects.first.direction; |
1211 | for (final ui.TextBox textBox in rects.skip(1)) { |
1212 | rect = rect.expandToInclude(textBox.toRect()); |
1213 | currentDirection = textBox.direction; |
1214 | } |
1215 | // Any of the text boxes may have had infinite dimensions. |
1216 | // We shouldn't pass infinite dimensions up to the bridges. |
1217 | rect = Rect.fromLTWH( |
1218 | math.max(0.0, rect.left), |
1219 | math.max(0.0, rect.top), |
1220 | math.min(rect.width, constraints.maxWidth), |
1221 | math.min(rect.height, constraints.maxHeight), |
1222 | ); |
1223 | // round the current rectangle to make this API testable and add some |
1224 | // padding so that the accessibility rects do not overlap with the text. |
1225 | currentRect = Rect.fromLTRB( |
1226 | rect.left.floorToDouble() - 4.0, |
1227 | rect.top.floorToDouble() - 4.0, |
1228 | rect.right.ceilToDouble() + 4.0, |
1229 | rect.bottom.ceilToDouble() + 4.0, |
1230 | ); |
1231 | final SemanticsConfiguration configuration = SemanticsConfiguration() |
1232 | ..sortKey = OrdinalSortKey(ordinal++) |
1233 | ..textDirection = initialDirection |
1234 | ..attributedLabel = AttributedString(info.semanticsLabel ?? info.text, attributes: info.stringAttributes); |
1235 | final GestureRecognizer? recognizer = info.recognizer; |
1236 | if (recognizer != null) { |
1237 | if (recognizer is TapGestureRecognizer) { |
1238 | if (recognizer.onTap != null) { |
1239 | configuration.onTap = recognizer.onTap; |
1240 | configuration.isLink = true; |
1241 | } |
1242 | } else if (recognizer is DoubleTapGestureRecognizer) { |
1243 | if (recognizer.onDoubleTap != null) { |
1244 | configuration.onTap = recognizer.onDoubleTap; |
1245 | configuration.isLink = true; |
1246 | } |
1247 | } else if (recognizer is LongPressGestureRecognizer) { |
1248 | if (recognizer.onLongPress != null) { |
1249 | configuration.onLongPress = recognizer.onLongPress; |
1250 | } |
1251 | } else { |
1252 | assert(false, ' ${recognizer.runtimeType} is not supported.' ); |
1253 | } |
1254 | } |
1255 | if (node.parentPaintClipRect != null) { |
1256 | final Rect paintRect = node.parentPaintClipRect!.intersect(currentRect); |
1257 | configuration.isHidden = paintRect.isEmpty && !currentRect.isEmpty; |
1258 | } |
1259 | final SemanticsNode newChild; |
1260 | if (_cachedChildNodes?.isNotEmpty ?? false) { |
1261 | newChild = _cachedChildNodes!.remove(_cachedChildNodes!.keys.first)!; |
1262 | } else { |
1263 | final UniqueKey key = UniqueKey(); |
1264 | newChild = SemanticsNode( |
1265 | key: key, |
1266 | showOnScreen: _createShowOnScreenFor(key), |
1267 | ); |
1268 | } |
1269 | newChild |
1270 | ..updateWith(config: configuration) |
1271 | ..rect = currentRect; |
1272 | newChildCache[newChild.key!] = newChild; |
1273 | newChildren.add(newChild); |
1274 | } |
1275 | } |
1276 | // Makes sure we annotated all of the semantics children. |
1277 | assert(childIndex == children.length); |
1278 | assert(child == null); |
1279 | |
1280 | _cachedChildNodes = newChildCache; |
1281 | node.updateWith(config: config, childrenInInversePaintOrder: newChildren); |
1282 | } |
1283 | |
1284 | VoidCallback? _createShowOnScreenFor(Key key) { |
1285 | return () { |
1286 | final SemanticsNode node = _cachedChildNodes![key]!; |
1287 | showOnScreen(descendant: this, rect: node.rect); |
1288 | }; |
1289 | } |
1290 | |
1291 | @override |
1292 | void clearSemantics() { |
1293 | super.clearSemantics(); |
1294 | _cachedChildNodes = null; |
1295 | } |
1296 | |
1297 | @override |
1298 | List<DiagnosticsNode> debugDescribeChildren() { |
1299 | return <DiagnosticsNode>[ |
1300 | text.toDiagnosticsNode( |
1301 | name: 'text' , |
1302 | style: DiagnosticsTreeStyle.transition, |
1303 | ), |
1304 | ]; |
1305 | } |
1306 | |
1307 | @override |
1308 | void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
1309 | super.debugFillProperties(properties); |
1310 | properties.add(EnumProperty<TextAlign>('textAlign' , textAlign)); |
1311 | properties.add(EnumProperty<TextDirection>('textDirection' , textDirection)); |
1312 | properties.add( |
1313 | FlagProperty( |
1314 | 'softWrap' , |
1315 | value: softWrap, |
1316 | ifTrue: 'wrapping at box width' , |
1317 | ifFalse: 'no wrapping except at line break characters' , |
1318 | showName: true, |
1319 | ), |
1320 | ); |
1321 | properties.add(EnumProperty<TextOverflow>('overflow' , overflow)); |
1322 | properties.add( |
1323 | DiagnosticsProperty<TextScaler>('textScaler' , textScaler, defaultValue: TextScaler.noScaling), |
1324 | ); |
1325 | properties.add( |
1326 | DiagnosticsProperty<Locale>( |
1327 | 'locale' , |
1328 | locale, |
1329 | defaultValue: null, |
1330 | ), |
1331 | ); |
1332 | properties.add(IntProperty('maxLines' , maxLines, ifNull: 'unlimited' )); |
1333 | } |
1334 | } |
1335 | |
1336 | /// A continuous, selectable piece of paragraph. |
1337 | /// |
1338 | /// Since the selections in [PlaceholderSpan] are handled independently in its |
1339 | /// subtree, a selection in [RenderParagraph] can't continue across a |
1340 | /// [PlaceholderSpan]. The [RenderParagraph] splits itself on [PlaceholderSpan] |
1341 | /// to create multiple `_SelectableFragment`s so that they can be selected |
1342 | /// separately. |
1343 | class _SelectableFragment with Selectable, Diagnosticable, ChangeNotifier implements TextLayoutMetrics { |
1344 | _SelectableFragment({ |
1345 | required this.paragraph, |
1346 | required this.fullText, |
1347 | required this.range, |
1348 | }) : assert(range.isValid && !range.isCollapsed && range.isNormalized) { |
1349 | if (kFlutterMemoryAllocationsEnabled) { |
1350 | ChangeNotifier.maybeDispatchObjectCreation(this); |
1351 | } |
1352 | _selectionGeometry = _getSelectionGeometry(); |
1353 | } |
1354 | |
1355 | final TextRange range; |
1356 | final RenderParagraph paragraph; |
1357 | final String fullText; |
1358 | |
1359 | TextPosition? _textSelectionStart; |
1360 | TextPosition? _textSelectionEnd; |
1361 | |
1362 | bool _selectableContainsOriginWord = false; |
1363 | |
1364 | LayerLink? _startHandleLayerLink; |
1365 | LayerLink? _endHandleLayerLink; |
1366 | |
1367 | @override |
1368 | SelectionGeometry get value => _selectionGeometry; |
1369 | late SelectionGeometry _selectionGeometry; |
1370 | void _updateSelectionGeometry() { |
1371 | final SelectionGeometry newValue = _getSelectionGeometry(); |
1372 | if (_selectionGeometry == newValue) { |
1373 | return; |
1374 | } |
1375 | _selectionGeometry = newValue; |
1376 | notifyListeners(); |
1377 | } |
1378 | |
1379 | SelectionGeometry _getSelectionGeometry() { |
1380 | if (_textSelectionStart == null || _textSelectionEnd == null) { |
1381 | return const SelectionGeometry( |
1382 | status: SelectionStatus.none, |
1383 | hasContent: true, |
1384 | ); |
1385 | } |
1386 | |
1387 | final int selectionStart = _textSelectionStart!.offset; |
1388 | final int selectionEnd = _textSelectionEnd!.offset; |
1389 | final bool isReversed = selectionStart > selectionEnd; |
1390 | final Offset startOffsetInParagraphCoordinates = paragraph._getOffsetForPosition(TextPosition(offset: selectionStart)); |
1391 | final Offset endOffsetInParagraphCoordinates = selectionStart == selectionEnd |
1392 | ? startOffsetInParagraphCoordinates |
1393 | : paragraph._getOffsetForPosition(TextPosition(offset: selectionEnd)); |
1394 | final bool flipHandles = isReversed != (TextDirection.rtl == paragraph.textDirection); |
1395 | final TextSelection selection = TextSelection( |
1396 | baseOffset: selectionStart, |
1397 | extentOffset: selectionEnd, |
1398 | ); |
1399 | final List<Rect> selectionRects = <Rect>[]; |
1400 | for (final TextBox textBox in paragraph.getBoxesForSelection(selection)) { |
1401 | selectionRects.add(textBox.toRect()); |
1402 | } |
1403 | return SelectionGeometry( |
1404 | startSelectionPoint: SelectionPoint( |
1405 | localPosition: startOffsetInParagraphCoordinates, |
1406 | lineHeight: paragraph._textPainter.preferredLineHeight, |
1407 | handleType: flipHandles ? TextSelectionHandleType.right : TextSelectionHandleType.left |
1408 | ), |
1409 | endSelectionPoint: SelectionPoint( |
1410 | localPosition: endOffsetInParagraphCoordinates, |
1411 | lineHeight: paragraph._textPainter.preferredLineHeight, |
1412 | handleType: flipHandles ? TextSelectionHandleType.left : TextSelectionHandleType.right, |
1413 | ), |
1414 | selectionRects: selectionRects, |
1415 | status: _textSelectionStart!.offset == _textSelectionEnd!.offset |
1416 | ? SelectionStatus.collapsed |
1417 | : SelectionStatus.uncollapsed, |
1418 | hasContent: true, |
1419 | ); |
1420 | } |
1421 | |
1422 | @override |
1423 | SelectionResult dispatchSelectionEvent(SelectionEvent event) { |
1424 | late final SelectionResult result; |
1425 | final TextPosition? existingSelectionStart = _textSelectionStart; |
1426 | final TextPosition? existingSelectionEnd = _textSelectionEnd; |
1427 | switch (event.type) { |
1428 | case SelectionEventType.startEdgeUpdate: |
1429 | case SelectionEventType.endEdgeUpdate: |
1430 | final SelectionEdgeUpdateEvent edgeUpdate = event as SelectionEdgeUpdateEvent; |
1431 | final TextGranularity granularity = event.granularity; |
1432 | |
1433 | switch (granularity) { |
1434 | case TextGranularity.character: |
1435 | result = _updateSelectionEdge(edgeUpdate.globalPosition, isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate); |
1436 | case TextGranularity.word: |
1437 | result = _updateSelectionEdgeByWord(edgeUpdate.globalPosition, isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate); |
1438 | case TextGranularity.document: |
1439 | case TextGranularity.line: |
1440 | assert(false, 'Moving the selection edge by line or document is not supported.' ); |
1441 | } |
1442 | case SelectionEventType.clear: |
1443 | result = _handleClearSelection(); |
1444 | case SelectionEventType.selectAll: |
1445 | result = _handleSelectAll(); |
1446 | case SelectionEventType.selectWord: |
1447 | final SelectWordSelectionEvent selectWord = event as SelectWordSelectionEvent; |
1448 | result = _handleSelectWord(selectWord.globalPosition); |
1449 | case SelectionEventType.granularlyExtendSelection: |
1450 | final GranularlyExtendSelectionEvent granularlyExtendSelection = event as GranularlyExtendSelectionEvent; |
1451 | result = _handleGranularlyExtendSelection( |
1452 | granularlyExtendSelection.forward, |
1453 | granularlyExtendSelection.isEnd, |
1454 | granularlyExtendSelection.granularity, |
1455 | ); |
1456 | case SelectionEventType.directionallyExtendSelection: |
1457 | final DirectionallyExtendSelectionEvent directionallyExtendSelection = event as DirectionallyExtendSelectionEvent; |
1458 | result = _handleDirectionallyExtendSelection( |
1459 | directionallyExtendSelection.dx, |
1460 | directionallyExtendSelection.isEnd, |
1461 | directionallyExtendSelection.direction, |
1462 | ); |
1463 | } |
1464 | |
1465 | if (existingSelectionStart != _textSelectionStart || |
1466 | existingSelectionEnd != _textSelectionEnd) { |
1467 | _didChangeSelection(); |
1468 | } |
1469 | return result; |
1470 | } |
1471 | |
1472 | @override |
1473 | SelectedContent? getSelectedContent() { |
1474 | if (_textSelectionStart == null || _textSelectionEnd == null) { |
1475 | return null; |
1476 | } |
1477 | final int start = math.min(_textSelectionStart!.offset, _textSelectionEnd!.offset); |
1478 | final int end = math.max(_textSelectionStart!.offset, _textSelectionEnd!.offset); |
1479 | return SelectedContent( |
1480 | plainText: fullText.substring(start, end), |
1481 | ); |
1482 | } |
1483 | |
1484 | void _didChangeSelection() { |
1485 | paragraph.markNeedsPaint(); |
1486 | _updateSelectionGeometry(); |
1487 | } |
1488 | |
1489 | SelectionResult _updateSelectionEdge(Offset globalPosition, {required bool isEnd}) { |
1490 | _setSelectionPosition(null, isEnd: isEnd); |
1491 | final Matrix4 transform = paragraph.getTransformTo(null); |
1492 | transform.invert(); |
1493 | final Offset localPosition = MatrixUtils.transformPoint(transform, globalPosition); |
1494 | if (_rect.isEmpty) { |
1495 | return SelectionUtils.getResultBasedOnRect(_rect, localPosition); |
1496 | } |
1497 | final Offset adjustedOffset = SelectionUtils.adjustDragOffset( |
1498 | _rect, |
1499 | localPosition, |
1500 | direction: paragraph.textDirection, |
1501 | ); |
1502 | |
1503 | final TextPosition position = _clampTextPosition(paragraph.getPositionForOffset(adjustedOffset)); |
1504 | _setSelectionPosition(position, isEnd: isEnd); |
1505 | if (position.offset == range.end) { |
1506 | return SelectionResult.next; |
1507 | } |
1508 | if (position.offset == range.start) { |
1509 | return SelectionResult.previous; |
1510 | } |
1511 | // TODO(chunhtai): The geometry information should not be used to determine |
1512 | // selection result. This is a workaround to RenderParagraph, where it does |
1513 | // not have a way to get accurate text length if its text is truncated due to |
1514 | // layout constraint. |
1515 | return SelectionUtils.getResultBasedOnRect(_rect, localPosition); |
1516 | } |
1517 | |
1518 | TextPosition _closestWordBoundary( |
1519 | _WordBoundaryRecord wordBoundary, |
1520 | TextPosition position, |
1521 | ) { |
1522 | final int differenceA = (position.offset - wordBoundary.wordStart.offset).abs(); |
1523 | final int differenceB = (position.offset - wordBoundary.wordEnd.offset).abs(); |
1524 | return differenceA < differenceB ? wordBoundary.wordStart : wordBoundary.wordEnd; |
1525 | } |
1526 | |
1527 | TextPosition _updateSelectionStartEdgeByWord( |
1528 | _WordBoundaryRecord? wordBoundary, |
1529 | TextPosition position, |
1530 | TextPosition? existingSelectionStart, |
1531 | TextPosition? existingSelectionEnd, |
1532 | ) { |
1533 | TextPosition? targetPosition; |
1534 | if (wordBoundary != null) { |
1535 | assert(wordBoundary.wordStart.offset >= range.start && wordBoundary.wordEnd.offset <= range.end); |
1536 | if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) { |
1537 | final bool isSamePosition = position.offset == existingSelectionEnd.offset; |
1538 | final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset; |
1539 | final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset > existingSelectionEnd.offset)); |
1540 | if (shouldSwapEdges) { |
1541 | if (position.offset < existingSelectionEnd.offset) { |
1542 | targetPosition = wordBoundary.wordStart; |
1543 | } else { |
1544 | targetPosition = wordBoundary.wordEnd; |
1545 | } |
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 word within the selection. |
1549 | final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionEnd); |
1550 | assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end); |
1551 | _setSelectionPosition(existingSelectionEnd.offset == localWordBoundary.wordStart.offset ? localWordBoundary.wordEnd : localWordBoundary.wordStart, isEnd: true); |
1552 | } else { |
1553 | if (position.offset < existingSelectionEnd.offset) { |
1554 | targetPosition = wordBoundary.wordStart; |
1555 | } else if (position.offset > existingSelectionEnd.offset) { |
1556 | targetPosition = wordBoundary.wordEnd; |
1557 | } else { |
1558 | // Keep the origin word in bounds when position is at the static edge. |
1559 | targetPosition = existingSelectionStart; |
1560 | } |
1561 | } |
1562 | } else { |
1563 | if (existingSelectionEnd != null) { |
1564 | // If the end edge exists and the start edge is being moved, then the |
1565 | // start edge is moved to encompass the entire word at the new position. |
1566 | if (position.offset < existingSelectionEnd.offset) { |
1567 | targetPosition = wordBoundary.wordStart; |
1568 | } else { |
1569 | targetPosition = wordBoundary.wordEnd; |
1570 | } |
1571 | } else { |
1572 | // Move the start edge to the closest word boundary. |
1573 | targetPosition = _closestWordBoundary(wordBoundary, position); |
1574 | } |
1575 | } |
1576 | } else { |
1577 | // The position is not contained within the current rect. The targetPosition |
1578 | // will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset] |
1579 | // for a more in depth explanation on this adjustment. |
1580 | if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) { |
1581 | // When the selection is inverted by the new position it is necessary to |
1582 | // swap the start edge (moving edge) with the end edge (static edge) to |
1583 | // maintain the origin word within the selection. |
1584 | final bool isSamePosition = position.offset == existingSelectionEnd.offset; |
1585 | final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset; |
1586 | final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset > existingSelectionEnd.offset)); |
1587 | |
1588 | if (shouldSwapEdges) { |
1589 | final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionEnd); |
1590 | assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end); |
1591 | _setSelectionPosition(isSelectionInverted ? localWordBoundary.wordEnd : localWordBoundary.wordStart, isEnd: true); |
1592 | } |
1593 | } |
1594 | } |
1595 | return targetPosition ?? position; |
1596 | } |
1597 | |
1598 | TextPosition _updateSelectionEndEdgeByWord( |
1599 | _WordBoundaryRecord? wordBoundary, |
1600 | TextPosition position, |
1601 | TextPosition? existingSelectionStart, |
1602 | TextPosition? existingSelectionEnd, |
1603 | ) { |
1604 | TextPosition? targetPosition; |
1605 | if (wordBoundary != null) { |
1606 | assert(wordBoundary.wordStart.offset >= range.start && wordBoundary.wordEnd.offset <= range.end); |
1607 | if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) { |
1608 | final bool isSamePosition = position.offset == existingSelectionStart.offset; |
1609 | final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset; |
1610 | final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset < existingSelectionStart.offset)); |
1611 | if (shouldSwapEdges) { |
1612 | if (position.offset < existingSelectionStart.offset) { |
1613 | targetPosition = wordBoundary.wordStart; |
1614 | } else { |
1615 | targetPosition = wordBoundary.wordEnd; |
1616 | } |
1617 | // When the selection is inverted by the new position it is necessary to |
1618 | // swap the end edge (moving edge) with the start edge (static edge) to |
1619 | // maintain the origin word within the selection. |
1620 | final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionStart); |
1621 | assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end); |
1622 | _setSelectionPosition(existingSelectionStart.offset == localWordBoundary.wordStart.offset ? localWordBoundary.wordEnd : localWordBoundary.wordStart, isEnd: false); |
1623 | } else { |
1624 | if (position.offset < existingSelectionStart.offset) { |
1625 | targetPosition = wordBoundary.wordStart; |
1626 | } else if (position.offset > existingSelectionStart.offset) { |
1627 | targetPosition = wordBoundary.wordEnd; |
1628 | } else { |
1629 | // Keep the origin word in bounds when position is at the static edge. |
1630 | targetPosition = existingSelectionEnd; |
1631 | } |
1632 | } |
1633 | } else { |
1634 | if (existingSelectionStart != null) { |
1635 | // If the start edge exists and the end edge is being moved, then the |
1636 | // end edge is moved to encompass the entire word at the new position. |
1637 | if (position.offset < existingSelectionStart.offset) { |
1638 | targetPosition = wordBoundary.wordStart; |
1639 | } else { |
1640 | targetPosition = wordBoundary.wordEnd; |
1641 | } |
1642 | } else { |
1643 | // Move the end edge to the closest word boundary. |
1644 | targetPosition = _closestWordBoundary(wordBoundary, position); |
1645 | } |
1646 | } |
1647 | } else { |
1648 | // The position is not contained within the current rect. The targetPosition |
1649 | // will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset] |
1650 | // for a more in depth explanation on this adjustment. |
1651 | if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) { |
1652 | // When the selection is inverted by the new position it is necessary to |
1653 | // swap the end edge (moving edge) with the start edge (static edge) to |
1654 | // maintain the origin word within the selection. |
1655 | final bool isSamePosition = position.offset == existingSelectionStart.offset; |
1656 | final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset; |
1657 | final bool shouldSwapEdges = isSelectionInverted != (position.offset < existingSelectionStart.offset) || isSamePosition; |
1658 | if (shouldSwapEdges) { |
1659 | final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionStart); |
1660 | assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end); |
1661 | _setSelectionPosition(isSelectionInverted ? localWordBoundary.wordStart : localWordBoundary.wordEnd, isEnd: false); |
1662 | } |
1663 | } |
1664 | } |
1665 | return targetPosition ?? position; |
1666 | } |
1667 | |
1668 | SelectionResult _updateSelectionEdgeByWord(Offset globalPosition, {required bool isEnd}) { |
1669 | // When the start/end edges are swapped, i.e. the start is after the end, and |
1670 | // the scrollable synthesizes an event for the opposite edge, this will potentially |
1671 | // move the opposite edge outside of the origin word boundary and we are unable to recover. |
1672 | final TextPosition? existingSelectionStart = _textSelectionStart; |
1673 | final TextPosition? existingSelectionEnd = _textSelectionEnd; |
1674 | |
1675 | _setSelectionPosition(null, isEnd: isEnd); |
1676 | final Matrix4 transform = paragraph.getTransformTo(null); |
1677 | transform.invert(); |
1678 | final Offset localPosition = MatrixUtils.transformPoint(transform, globalPosition); |
1679 | if (_rect.isEmpty) { |
1680 | return SelectionUtils.getResultBasedOnRect(_rect, localPosition); |
1681 | } |
1682 | final Offset adjustedOffset = SelectionUtils.adjustDragOffset( |
1683 | _rect, |
1684 | localPosition, |
1685 | direction: paragraph.textDirection, |
1686 | ); |
1687 | |
1688 | final TextPosition position = paragraph.getPositionForOffset(adjustedOffset); |
1689 | // Check if the original local position is within the rect, if it is not then |
1690 | // we do not need to look up the word boundary for that position. This is to |
1691 | // maintain a selectables selection collapsed at 0 when the local position is |
1692 | // not located inside its rect. |
1693 | _WordBoundaryRecord? wordBoundary = _rect.contains(localPosition) ? _getWordBoundaryAtPosition(position) : null; |
1694 | if (wordBoundary != null |
1695 | && (wordBoundary.wordStart.offset < range.start && wordBoundary.wordEnd.offset <= range.start |
1696 | || wordBoundary.wordStart.offset >= range.end && wordBoundary.wordEnd.offset > range.end)) { |
1697 | // When the position is located at a placeholder inside of the text, then we may compute |
1698 | // a word boundary that does not belong to the current selectable fragment. In this case |
1699 | // we should invalidate the word boundary so that it is not taken into account when |
1700 | // computing the target position. |
1701 | wordBoundary = null; |
1702 | } |
1703 | final TextPosition targetPosition = _clampTextPosition(isEnd ? _updateSelectionEndEdgeByWord(wordBoundary, position, existingSelectionStart, existingSelectionEnd) : _updateSelectionStartEdgeByWord(wordBoundary, position, existingSelectionStart, existingSelectionEnd)); |
1704 | |
1705 | _setSelectionPosition(targetPosition, isEnd: isEnd); |
1706 | if (targetPosition.offset == range.end) { |
1707 | return SelectionResult.next; |
1708 | } |
1709 | |
1710 | if (targetPosition.offset == range.start) { |
1711 | return SelectionResult.previous; |
1712 | } |
1713 | // TODO(chunhtai): The geometry information should not be used to determine |
1714 | // selection result. This is a workaround to RenderParagraph, where it does |
1715 | // not have a way to get accurate text length if its text is truncated due to |
1716 | // layout constraint. |
1717 | return SelectionUtils.getResultBasedOnRect(_rect, localPosition); |
1718 | } |
1719 | |
1720 | TextPosition _clampTextPosition(TextPosition position) { |
1721 | // Affinity of range.end is upstream. |
1722 | if (position.offset > range.end || |
1723 | (position.offset == range.end && position.affinity == TextAffinity.downstream)) { |
1724 | return TextPosition(offset: range.end, affinity: TextAffinity.upstream); |
1725 | } |
1726 | if (position.offset < range.start) { |
1727 | return TextPosition(offset: range.start); |
1728 | } |
1729 | return position; |
1730 | } |
1731 | |
1732 | void _setSelectionPosition(TextPosition? position, {required bool isEnd}) { |
1733 | if (isEnd) { |
1734 | _textSelectionEnd = position; |
1735 | } else { |
1736 | _textSelectionStart = position; |
1737 | } |
1738 | } |
1739 | |
1740 | SelectionResult _handleClearSelection() { |
1741 | _textSelectionStart = null; |
1742 | _textSelectionEnd = null; |
1743 | _selectableContainsOriginWord = false; |
1744 | return SelectionResult.none; |
1745 | } |
1746 | |
1747 | SelectionResult _handleSelectAll() { |
1748 | _textSelectionStart = TextPosition(offset: range.start); |
1749 | _textSelectionEnd = TextPosition(offset: range.end, affinity: TextAffinity.upstream); |
1750 | return SelectionResult.none; |
1751 | } |
1752 | |
1753 | SelectionResult _handleSelectWord(Offset globalPosition) { |
1754 | final TextPosition position = paragraph.getPositionForOffset(paragraph.globalToLocal(globalPosition)); |
1755 | if (_positionIsWithinCurrentSelection(position) && _textSelectionStart != _textSelectionEnd) { |
1756 | return SelectionResult.end; |
1757 | } |
1758 | final _WordBoundaryRecord wordBoundary = _getWordBoundaryAtPosition(position); |
1759 | // This fragment may not contain the word, decide what direction the target |
1760 | // fragment is located in. Because fragments are separated by placeholder |
1761 | // spans, we also check if the beginning or end of the word is touching |
1762 | // either edge of this fragment. |
1763 | if (wordBoundary.wordStart.offset < range.start && wordBoundary.wordEnd.offset <= range.start) { |
1764 | return SelectionResult.previous; |
1765 | } else if (wordBoundary.wordStart.offset >= range.end && wordBoundary.wordEnd.offset > range.end) { |
1766 | return SelectionResult.next; |
1767 | } |
1768 | // Fragments are separated by placeholder span, the word boundary shouldn't |
1769 | // expand across fragments. |
1770 | assert(wordBoundary.wordStart.offset >= range.start && wordBoundary.wordEnd.offset <= range.end); |
1771 | _textSelectionStart = wordBoundary.wordStart; |
1772 | _textSelectionEnd = wordBoundary.wordEnd; |
1773 | _selectableContainsOriginWord = true; |
1774 | return SelectionResult.end; |
1775 | } |
1776 | |
1777 | _WordBoundaryRecord _getWordBoundaryAtPosition(TextPosition position) { |
1778 | final TextRange word = paragraph.getWordBoundary(position); |
1779 | assert(word.isNormalized); |
1780 | late TextPosition start; |
1781 | late TextPosition end; |
1782 | if (position.offset > word.end) { |
1783 | start = end = TextPosition(offset: position.offset); |
1784 | } else { |
1785 | start = TextPosition(offset: word.start); |
1786 | end = TextPosition(offset: word.end, affinity: TextAffinity.upstream); |
1787 | } |
1788 | return (wordStart: start, wordEnd: end); |
1789 | } |
1790 | |
1791 | SelectionResult _handleDirectionallyExtendSelection(double horizontalBaseline, bool isExtent, SelectionExtendDirection movement) { |
1792 | final Matrix4 transform = paragraph.getTransformTo(null); |
1793 | if (transform.invert() == 0.0) { |
1794 | switch (movement) { |
1795 | case SelectionExtendDirection.previousLine: |
1796 | case SelectionExtendDirection.backward: |
1797 | return SelectionResult.previous; |
1798 | case SelectionExtendDirection.nextLine: |
1799 | case SelectionExtendDirection.forward: |
1800 | return SelectionResult.next; |
1801 | } |
1802 | } |
1803 | final double baselineInParagraphCoordinates = MatrixUtils.transformPoint(transform, Offset(horizontalBaseline, 0)).dx; |
1804 | assert(!baselineInParagraphCoordinates.isNaN); |
1805 | final TextPosition newPosition; |
1806 | final SelectionResult result; |
1807 | switch (movement) { |
1808 | case SelectionExtendDirection.previousLine: |
1809 | case SelectionExtendDirection.nextLine: |
1810 | assert(_textSelectionEnd != null && _textSelectionStart != null); |
1811 | final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!; |
1812 | final MapEntry<TextPosition, SelectionResult> moveResult = _handleVerticalMovement( |
1813 | targetedEdge, |
1814 | horizontalBaselineInParagraphCoordinates: baselineInParagraphCoordinates, |
1815 | below: movement == SelectionExtendDirection.nextLine, |
1816 | ); |
1817 | newPosition = moveResult.key; |
1818 | result = moveResult.value; |
1819 | case SelectionExtendDirection.forward: |
1820 | case SelectionExtendDirection.backward: |
1821 | _textSelectionEnd ??= movement == SelectionExtendDirection.forward |
1822 | ? TextPosition(offset: range.start) |
1823 | : TextPosition(offset: range.end, affinity: TextAffinity.upstream); |
1824 | _textSelectionStart ??= _textSelectionEnd; |
1825 | final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!; |
1826 | final Offset edgeOffsetInParagraphCoordinates = paragraph._getOffsetForPosition(targetedEdge); |
1827 | final Offset baselineOffsetInParagraphCoordinates = Offset( |
1828 | baselineInParagraphCoordinates, |
1829 | // Use half of line height to point to the middle of the line. |
1830 | edgeOffsetInParagraphCoordinates.dy - paragraph._textPainter.preferredLineHeight / 2, |
1831 | ); |
1832 | newPosition = paragraph.getPositionForOffset(baselineOffsetInParagraphCoordinates); |
1833 | result = SelectionResult.end; |
1834 | } |
1835 | if (isExtent) { |
1836 | _textSelectionEnd = newPosition; |
1837 | } else { |
1838 | _textSelectionStart = newPosition; |
1839 | } |
1840 | return result; |
1841 | } |
1842 | |
1843 | SelectionResult _handleGranularlyExtendSelection(bool forward, bool isExtent, TextGranularity granularity) { |
1844 | _textSelectionEnd ??= forward |
1845 | ? TextPosition(offset: range.start) |
1846 | : TextPosition(offset: range.end, affinity: TextAffinity.upstream); |
1847 | _textSelectionStart ??= _textSelectionEnd; |
1848 | final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!; |
1849 | if (forward && (targetedEdge.offset == range.end)) { |
1850 | return SelectionResult.next; |
1851 | } |
1852 | if (!forward && (targetedEdge.offset == range.start)) { |
1853 | return SelectionResult.previous; |
1854 | } |
1855 | final SelectionResult result; |
1856 | final TextPosition newPosition; |
1857 | switch (granularity) { |
1858 | case TextGranularity.character: |
1859 | final String text = range.textInside(fullText); |
1860 | newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, CharacterBoundary(text)); |
1861 | result = SelectionResult.end; |
1862 | case TextGranularity.word: |
1863 | final TextBoundary textBoundary = paragraph._textPainter.wordBoundaries.moveByWordBoundary; |
1864 | newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, textBoundary); |
1865 | result = SelectionResult.end; |
1866 | case TextGranularity.line: |
1867 | newPosition = _moveToTextBoundaryAtDirection(targetedEdge, forward, LineBoundary(this)); |
1868 | result = SelectionResult.end; |
1869 | case TextGranularity.document: |
1870 | final String text = range.textInside(fullText); |
1871 | newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, DocumentBoundary(text)); |
1872 | if (forward && newPosition.offset == range.end) { |
1873 | result = SelectionResult.next; |
1874 | } else if (!forward && newPosition.offset == range.start) { |
1875 | result = SelectionResult.previous; |
1876 | } else { |
1877 | result = SelectionResult.end; |
1878 | } |
1879 | } |
1880 | |
1881 | if (isExtent) { |
1882 | _textSelectionEnd = newPosition; |
1883 | } else { |
1884 | _textSelectionStart = newPosition; |
1885 | } |
1886 | return result; |
1887 | } |
1888 | |
1889 | // Move **beyond** the local boundary of the given type (unless range.start or |
1890 | // range.end is reached). Used for most TextGranularity types except for |
1891 | // TextGranularity.line, to ensure the selection movement doesn't get stuck at |
1892 | // a local fixed point. |
1893 | TextPosition _moveBeyondTextBoundaryAtDirection(TextPosition end, bool forward, TextBoundary textBoundary) { |
1894 | final int newOffset = forward |
1895 | ? textBoundary.getTrailingTextBoundaryAt(end.offset) ?? range.end |
1896 | : textBoundary.getLeadingTextBoundaryAt(end.offset - 1) ?? range.start; |
1897 | return TextPosition(offset: newOffset); |
1898 | } |
1899 | |
1900 | // Move **to** the local boundary of the given type. Typically used for line |
1901 | // boundaries, such that performing "move to line start" more than once never |
1902 | // moves the selection to the previous line. |
1903 | TextPosition _moveToTextBoundaryAtDirection(TextPosition end, bool forward, TextBoundary textBoundary) { |
1904 | assert(end.offset >= 0); |
1905 | final int caretOffset; |
1906 | switch (end.affinity) { |
1907 | case TextAffinity.upstream: |
1908 | if (end.offset < 1 && !forward) { |
1909 | assert (end.offset == 0); |
1910 | return const TextPosition(offset: 0); |
1911 | } |
1912 | final CharacterBoundary characterBoundary = CharacterBoundary(fullText); |
1913 | caretOffset = math.max( |
1914 | 0, |
1915 | characterBoundary.getLeadingTextBoundaryAt(range.start + end.offset) ?? range.start, |
1916 | ) - 1; |
1917 | case TextAffinity.downstream: |
1918 | caretOffset = end.offset; |
1919 | } |
1920 | final int offset = forward |
1921 | ? textBoundary.getTrailingTextBoundaryAt(caretOffset) ?? range.end |
1922 | : textBoundary.getLeadingTextBoundaryAt(caretOffset) ?? range.start; |
1923 | return TextPosition(offset: offset); |
1924 | } |
1925 | |
1926 | MapEntry<TextPosition, SelectionResult> _handleVerticalMovement(TextPosition position, {required double horizontalBaselineInParagraphCoordinates, required bool below}) { |
1927 | final List<ui.LineMetrics> lines = paragraph._computeLineMetrics(); |
1928 | final Offset offset = paragraph.getOffsetForCaret(position, Rect.zero); |
1929 | int currentLine = lines.length - 1; |
1930 | for (final ui.LineMetrics lineMetrics in lines) { |
1931 | if (lineMetrics.baseline > offset.dy) { |
1932 | currentLine = lineMetrics.lineNumber; |
1933 | break; |
1934 | } |
1935 | } |
1936 | final TextPosition newPosition; |
1937 | if (below && currentLine == lines.length - 1) { |
1938 | newPosition = TextPosition(offset: range.end, affinity: TextAffinity.upstream); |
1939 | } else if (!below && currentLine == 0) { |
1940 | newPosition = TextPosition(offset: range.start); |
1941 | } else { |
1942 | final int newLine = below ? currentLine + 1 : currentLine - 1; |
1943 | newPosition = _clampTextPosition( |
1944 | paragraph.getPositionForOffset(Offset(horizontalBaselineInParagraphCoordinates, lines[newLine].baseline)) |
1945 | ); |
1946 | } |
1947 | final SelectionResult result; |
1948 | if (newPosition.offset == range.start) { |
1949 | result = SelectionResult.previous; |
1950 | } else if (newPosition.offset == range.end) { |
1951 | result = SelectionResult.next; |
1952 | } else { |
1953 | result = SelectionResult.end; |
1954 | } |
1955 | assert(result != SelectionResult.next || below); |
1956 | assert(result != SelectionResult.previous || !below); |
1957 | return MapEntry<TextPosition, SelectionResult>(newPosition, result); |
1958 | } |
1959 | |
1960 | /// Whether the given text position is contained in current selection |
1961 | /// range. |
1962 | /// |
1963 | /// The parameter `start` must be smaller than `end`. |
1964 | bool _positionIsWithinCurrentSelection(TextPosition position) { |
1965 | if (_textSelectionStart == null || _textSelectionEnd == null) { |
1966 | return false; |
1967 | } |
1968 | // Normalize current selection. |
1969 | late TextPosition currentStart; |
1970 | late TextPosition currentEnd; |
1971 | if (_compareTextPositions(_textSelectionStart!, _textSelectionEnd!) > 0) { |
1972 | currentStart = _textSelectionStart!; |
1973 | currentEnd = _textSelectionEnd!; |
1974 | } else { |
1975 | currentStart = _textSelectionEnd!; |
1976 | currentEnd = _textSelectionStart!; |
1977 | } |
1978 | return _compareTextPositions(currentStart, position) >= 0 && _compareTextPositions(currentEnd, position) <= 0; |
1979 | } |
1980 | |
1981 | /// Compares two text positions. |
1982 | /// |
1983 | /// Returns 1 if `position` < `otherPosition`, -1 if `position` > `otherPosition`, |
1984 | /// or 0 if they are equal. |
1985 | static int _compareTextPositions(TextPosition position, TextPosition otherPosition) { |
1986 | if (position.offset < otherPosition.offset) { |
1987 | return 1; |
1988 | } else if (position.offset > otherPosition.offset) { |
1989 | return -1; |
1990 | } else if (position.affinity == otherPosition.affinity){ |
1991 | return 0; |
1992 | } else { |
1993 | return position.affinity == TextAffinity.upstream ? 1 : -1; |
1994 | } |
1995 | } |
1996 | |
1997 | @override |
1998 | Matrix4 getTransformTo(RenderObject? ancestor) { |
1999 | return paragraph.getTransformTo(ancestor); |
2000 | } |
2001 | |
2002 | @override |
2003 | void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { |
2004 | if (!paragraph.attached) { |
2005 | assert(startHandle == null && endHandle == null, 'Only clean up can be called.' ); |
2006 | return; |
2007 | } |
2008 | if (_startHandleLayerLink != startHandle) { |
2009 | _startHandleLayerLink = startHandle; |
2010 | paragraph.markNeedsPaint(); |
2011 | } |
2012 | if (_endHandleLayerLink != endHandle) { |
2013 | _endHandleLayerLink = endHandle; |
2014 | paragraph.markNeedsPaint(); |
2015 | } |
2016 | } |
2017 | |
2018 | List<Rect>? _cachedBoundingBoxes; |
2019 | @override |
2020 | List<Rect> get boundingBoxes { |
2021 | if (_cachedBoundingBoxes == null) { |
2022 | final List<TextBox> boxes = paragraph.getBoxesForSelection( |
2023 | TextSelection(baseOffset: range.start, extentOffset: range.end), |
2024 | ); |
2025 | if (boxes.isNotEmpty) { |
2026 | _cachedBoundingBoxes = <Rect>[]; |
2027 | for (final TextBox textBox in boxes) { |
2028 | _cachedBoundingBoxes!.add(textBox.toRect()); |
2029 | } |
2030 | } else { |
2031 | final Offset offset = paragraph._getOffsetForPosition(TextPosition(offset: range.start)); |
2032 | final Rect rect = Rect.fromPoints(offset, offset.translate(0, - paragraph._textPainter.preferredLineHeight)); |
2033 | _cachedBoundingBoxes = <Rect>[rect]; |
2034 | } |
2035 | } |
2036 | return _cachedBoundingBoxes!; |
2037 | } |
2038 | |
2039 | Rect? _cachedRect; |
2040 | Rect get _rect { |
2041 | if (_cachedRect == null) { |
2042 | final List<TextBox> boxes = paragraph.getBoxesForSelection( |
2043 | TextSelection(baseOffset: range.start, extentOffset: range.end), |
2044 | ); |
2045 | if (boxes.isNotEmpty) { |
2046 | Rect result = boxes.first.toRect(); |
2047 | for (int index = 1; index < boxes.length; index += 1) { |
2048 | result = result.expandToInclude(boxes[index].toRect()); |
2049 | } |
2050 | _cachedRect = result; |
2051 | } else { |
2052 | final Offset offset = paragraph._getOffsetForPosition(TextPosition(offset: range.start)); |
2053 | _cachedRect = Rect.fromPoints(offset, offset.translate(0, - paragraph._textPainter.preferredLineHeight)); |
2054 | } |
2055 | } |
2056 | return _cachedRect!; |
2057 | } |
2058 | |
2059 | void didChangeParagraphLayout() { |
2060 | _cachedRect = null; |
2061 | } |
2062 | |
2063 | @override |
2064 | Size get size { |
2065 | return _rect.size; |
2066 | } |
2067 | |
2068 | void paint(PaintingContext context, Offset offset) { |
2069 | if (_textSelectionStart == null || _textSelectionEnd == null) { |
2070 | return; |
2071 | } |
2072 | if (paragraph.selectionColor != null) { |
2073 | final TextSelection selection = TextSelection( |
2074 | baseOffset: _textSelectionStart!.offset, |
2075 | extentOffset: _textSelectionEnd!.offset, |
2076 | ); |
2077 | final Paint selectionPaint = Paint() |
2078 | ..style = PaintingStyle.fill |
2079 | ..color = paragraph.selectionColor!; |
2080 | for (final TextBox textBox in paragraph.getBoxesForSelection(selection)) { |
2081 | context.canvas.drawRect( |
2082 | textBox.toRect().shift(offset), selectionPaint); |
2083 | } |
2084 | } |
2085 | if (_startHandleLayerLink != null && value.startSelectionPoint != null) { |
2086 | context.pushLayer( |
2087 | LeaderLayer( |
2088 | link: _startHandleLayerLink!, |
2089 | offset: offset + value.startSelectionPoint!.localPosition, |
2090 | ), |
2091 | (PaintingContext context, Offset offset) { }, |
2092 | Offset.zero, |
2093 | ); |
2094 | } |
2095 | if (_endHandleLayerLink != null && value.endSelectionPoint != null) { |
2096 | context.pushLayer( |
2097 | LeaderLayer( |
2098 | link: _endHandleLayerLink!, |
2099 | offset: offset + value.endSelectionPoint!.localPosition, |
2100 | ), |
2101 | (PaintingContext context, Offset offset) { }, |
2102 | Offset.zero, |
2103 | ); |
2104 | } |
2105 | } |
2106 | |
2107 | @override |
2108 | TextSelection getLineAtOffset(TextPosition position) { |
2109 | final TextRange line = paragraph._getLineAtOffset(position); |
2110 | final int start = line.start.clamp(range.start, range.end); |
2111 | final int end = line.end.clamp(range.start, range.end); |
2112 | return TextSelection(baseOffset: start, extentOffset: end); |
2113 | } |
2114 | |
2115 | @override |
2116 | TextPosition getTextPositionAbove(TextPosition position) { |
2117 | return _clampTextPosition(paragraph._getTextPositionAbove(position)); |
2118 | } |
2119 | |
2120 | @override |
2121 | TextPosition getTextPositionBelow(TextPosition position) { |
2122 | return _clampTextPosition(paragraph._getTextPositionBelow(position)); |
2123 | } |
2124 | |
2125 | @override |
2126 | TextRange getWordBoundary(TextPosition position) => paragraph.getWordBoundary(position); |
2127 | |
2128 | @override |
2129 | void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
2130 | super.debugFillProperties(properties); |
2131 | properties.add(DiagnosticsProperty<String>('textInsideRange' , range.textInside(fullText))); |
2132 | properties.add(DiagnosticsProperty<TextRange>('range' , range)); |
2133 | properties.add(DiagnosticsProperty<String>('fullText' , fullText)); |
2134 | } |
2135 | } |
2136 | |