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
5import 'dart:math' as math;
6import 'dart:ui';
7
8import 'package:flutter/foundation.dart';
9import 'package:flutter/gestures.dart';
10import 'package:flutter/material.dart';
11import 'package:flutter/rendering.dart';
12import 'package:flutter/src/services/text_input.dart';
13import 'package:flutter_test/flutter_test.dart';
14
15import 'rendering_tester.dart';
16
17double _caretMarginOf(RenderEditable renderEditable) {
18 return renderEditable.cursorWidth + 1.0;
19}
20
21void _applyParentData(List<RenderBox> inlineRenderBoxes, InlineSpan span) {
22 int index = 0;
23 RenderBox? previousBox;
24 span.visitChildren((InlineSpan span) {
25 if (span is! WidgetSpan) {
26 return true;
27 }
28
29 final RenderBox box = inlineRenderBoxes[index];
30 box.parentData =
31 TextParentData()
32 ..span = span
33 ..previousSibling = previousBox;
34 (previousBox?.parentData as TextParentData?)?.nextSibling = box;
35 index += 1;
36 previousBox = box;
37 return true;
38 });
39}
40
41class _FakeEditableTextState with TextSelectionDelegate {
42 @override
43 TextEditingValue textEditingValue = TextEditingValue.empty;
44
45 TextSelection? selection;
46
47 @override
48 void hideToolbar([bool hideHandles = true]) {}
49
50 @override
51 void userUpdateTextEditingValue(TextEditingValue value, SelectionChangedCause cause) {
52 selection = value.selection;
53 }
54
55 @override
56 void bringIntoView(TextPosition position) {}
57
58 @override
59 void cutSelection(SelectionChangedCause cause) {}
60
61 @override
62 Future<void> pasteText(SelectionChangedCause cause) {
63 return Future<void>.value();
64 }
65
66 @override
67 void selectAll(SelectionChangedCause cause) {}
68
69 @override
70 void copySelection(SelectionChangedCause cause) {}
71}
72
73void main() {
74 TestRenderingFlutterBinding.ensureInitialized();
75
76 test('RenderEditable respects clipBehavior', () {
77 const BoxConstraints viewport = BoxConstraints(maxHeight: 100.0, maxWidth: 1.0);
78 final String longString = 'a' * 10000;
79
80 for (final Clip? clip in <Clip?>[null, ...Clip.values]) {
81 final TestClipPaintingContext context = TestClipPaintingContext();
82 final RenderEditable editable;
83 switch (clip) {
84 case Clip.none:
85 case Clip.hardEdge:
86 case Clip.antiAlias:
87 case Clip.antiAliasWithSaveLayer:
88 editable = RenderEditable(
89 text: TextSpan(text: longString),
90 textDirection: TextDirection.ltr,
91 startHandleLayerLink: LayerLink(),
92 endHandleLayerLink: LayerLink(),
93 offset: ViewportOffset.zero(),
94 textSelectionDelegate: _FakeEditableTextState(),
95 selection: const TextSelection(baseOffset: 0, extentOffset: 0),
96 clipBehavior: clip!,
97 );
98 case null:
99 editable = RenderEditable(
100 text: TextSpan(text: longString),
101 textDirection: TextDirection.ltr,
102 startHandleLayerLink: LayerLink(),
103 endHandleLayerLink: LayerLink(),
104 offset: ViewportOffset.zero(),
105 textSelectionDelegate: _FakeEditableTextState(),
106 selection: const TextSelection(baseOffset: 0, extentOffset: 0),
107 );
108 }
109 layout(
110 editable,
111 constraints: viewport,
112 phase: EnginePhase.composite,
113 onErrors: expectNoFlutterErrors,
114 );
115 context.paintChild(editable, Offset.zero);
116 // By default, clipBehavior is Clip.hardEdge.
117 expect(context.clipBehavior, equals(clip ?? Clip.hardEdge), reason: 'for $clip');
118 }
119 });
120
121 test('Reports the real height when maxLines is 1', () {
122 const InlineSpan tallSpan = TextSpan(
123 style: TextStyle(fontSize: 10),
124 children: <InlineSpan>[TextSpan(text: 'TALL', style: TextStyle(fontSize: 100))],
125 );
126 final BoxConstraints constraints = BoxConstraints.loose(const Size(600, 600));
127 final RenderEditable editable = RenderEditable(
128 textDirection: TextDirection.ltr,
129 startHandleLayerLink: LayerLink(),
130 endHandleLayerLink: LayerLink(),
131 offset: ViewportOffset.zero(),
132 textSelectionDelegate: _FakeEditableTextState(),
133 text: tallSpan,
134 );
135
136 layout(editable, constraints: constraints);
137 expect(editable.size.height, 100);
138 });
139
140 test('has default semantics input type', () {
141 const InlineSpan text = TextSpan(text: 'text');
142 final RenderEditable editable = RenderEditable(
143 textDirection: TextDirection.ltr,
144 startHandleLayerLink: LayerLink(),
145 endHandleLayerLink: LayerLink(),
146 offset: ViewportOffset.zero(),
147 textSelectionDelegate: _FakeEditableTextState(),
148 text: text,
149 );
150 final SemanticsConfiguration config = SemanticsConfiguration();
151 editable.describeSemanticsConfiguration(config);
152 expect(config.inputType, SemanticsInputType.text);
153 });
154
155 test('Reports the height of the first line when maxLines is 1', () {
156 final InlineSpan multilineSpan = TextSpan(
157 text: 'liiiiines\n' * 10,
158 style: const TextStyle(fontSize: 10),
159 );
160 final BoxConstraints constraints = BoxConstraints.loose(const Size(600, 600));
161 final RenderEditable editable = RenderEditable(
162 textDirection: TextDirection.ltr,
163 startHandleLayerLink: LayerLink(),
164 endHandleLayerLink: LayerLink(),
165 offset: ViewportOffset.zero(),
166 textSelectionDelegate: _FakeEditableTextState(),
167 text: multilineSpan,
168 );
169
170 layout(editable, constraints: constraints);
171 expect(editable.size.height, 10);
172 });
173
174 test('Editable respect clipBehavior in describeApproximatePaintClip', () {
175 final String longString = 'a' * 10000;
176 final RenderEditable editable = RenderEditable(
177 text: TextSpan(text: longString),
178 textDirection: TextDirection.ltr,
179 startHandleLayerLink: LayerLink(),
180 endHandleLayerLink: LayerLink(),
181 offset: ViewportOffset.zero(),
182 textSelectionDelegate: _FakeEditableTextState(),
183 selection: const TextSelection(baseOffset: 0, extentOffset: 0),
184 clipBehavior: Clip.none,
185 );
186 layout(editable);
187
188 bool visited = false;
189 editable.visitChildren((RenderObject child) {
190 visited = true;
191 expect(editable.describeApproximatePaintClip(child), null);
192 });
193 expect(visited, true);
194 });
195
196 test('RenderEditable.paint respects offset argument', () {
197 const BoxConstraints viewport = BoxConstraints(maxHeight: 1000.0, maxWidth: 1000.0);
198 final TestPushLayerPaintingContext context = TestPushLayerPaintingContext();
199
200 const Offset paintOffset = Offset(100, 200);
201 const double fontSize = 20.0;
202 const Offset endpoint = Offset(0.0, fontSize);
203
204 final RenderEditable editable = RenderEditable(
205 text: const TextSpan(text: 'text', style: TextStyle(fontSize: fontSize, height: 1.0)),
206 textDirection: TextDirection.ltr,
207 startHandleLayerLink: LayerLink(),
208 endHandleLayerLink: LayerLink(),
209 offset: ViewportOffset.zero(),
210 textSelectionDelegate: _FakeEditableTextState(),
211 selection: const TextSelection(baseOffset: 0, extentOffset: 0),
212 );
213 layout(editable, constraints: viewport, phase: EnginePhase.composite);
214 editable.paint(context, paintOffset);
215
216 final List<LeaderLayer> leaderLayers = context.pushedLayers.whereType<LeaderLayer>().toList();
217 expect(leaderLayers, hasLength(2), reason: '_paintHandleLayers will paint LeaderLayers');
218 expect(
219 leaderLayers.first.offset,
220 endpoint + paintOffset,
221 reason: 'offset should respect paintOffset',
222 );
223 expect(
224 leaderLayers.last.offset,
225 endpoint + paintOffset,
226 reason: 'offset should respect paintOffset',
227 );
228 });
229
230 // Test that clipping will be used even when the text fits within the visible
231 // region if the start position of the text is offset (e.g. during scrolling
232 // animation).
233 test('correct clipping', () {
234 final TextSelectionDelegate delegate = _FakeEditableTextState();
235 final RenderEditable editable = RenderEditable(
236 text: const TextSpan(style: TextStyle(height: 1.0, fontSize: 10.0), text: 'A'),
237 startHandleLayerLink: LayerLink(),
238 endHandleLayerLink: LayerLink(),
239 textDirection: TextDirection.ltr,
240 locale: const Locale('en', 'US'),
241 offset: ViewportOffset.fixed(10.0),
242 textSelectionDelegate: delegate,
243 selection: const TextSelection.collapsed(offset: 0),
244 );
245 layout(editable, constraints: BoxConstraints.loose(const Size(500.0, 500.0)));
246 // Prepare for painting after layout.
247 pumpFrame(phase: EnginePhase.compositingBits);
248 expect(
249 (Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
250 paints..clipRect(rect: const Rect.fromLTRB(0.0, 0.0, 500.0, 10.0)),
251 );
252 });
253
254 test('Can change cursor color, radius, visibility', () {
255 final TextSelectionDelegate delegate = _FakeEditableTextState();
256 final ValueNotifier<bool> showCursor = ValueNotifier<bool>(true);
257 EditableText.debugDeterministicCursor = true;
258
259 final RenderEditable editable = RenderEditable(
260 backgroundCursorColor: Colors.grey,
261 textDirection: TextDirection.ltr,
262 cursorColor: const Color.fromARGB(0xFF, 0xFF, 0x00, 0x00),
263 offset: ViewportOffset.zero(),
264 textSelectionDelegate: delegate,
265 text: const TextSpan(text: 'test', style: TextStyle(height: 1.0, fontSize: 10.0)),
266 startHandleLayerLink: LayerLink(),
267 endHandleLayerLink: LayerLink(),
268 selection: const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
269 );
270
271 layout(editable);
272
273 editable.layout(BoxConstraints.loose(const Size(100, 100)));
274 // Prepare for painting after layout.
275 pumpFrame(phase: EnginePhase.compositingBits);
276
277 expect(
278 editable,
279 // Draw no cursor by default.
280 paintsExactlyCountTimes(#drawRect, 0),
281 );
282
283 editable.showCursor = showCursor;
284 pumpFrame(phase: EnginePhase.compositingBits);
285
286 expect(
287 editable,
288 paints..rect(
289 color: const Color.fromARGB(0xFF, 0xFF, 0x00, 0x00),
290 rect: const Rect.fromLTWH(40, 0, 1, 10),
291 ),
292 );
293
294 // Now change to a rounded caret.
295 editable.cursorColor = const Color.fromARGB(0xFF, 0x00, 0x00, 0xFF);
296 editable.cursorWidth = 4;
297 editable.cursorRadius = const Radius.circular(3);
298 pumpFrame(phase: EnginePhase.compositingBits);
299
300 expect(
301 editable,
302 paints..rrect(
303 color: const Color.fromARGB(0xFF, 0x00, 0x00, 0xFF),
304 rrect: RRect.fromRectAndRadius(const Rect.fromLTWH(40, 0, 4, 10), const Radius.circular(3)),
305 ),
306 );
307
308 editable.textScaler = const TextScaler.linear(2.0);
309 pumpFrame(phase: EnginePhase.compositingBits);
310
311 // Now the caret height is much bigger due to the bigger font scale.
312 expect(
313 editable,
314 paints..rrect(
315 color: const Color.fromARGB(0xFF, 0x00, 0x00, 0xFF),
316 rrect: RRect.fromRectAndRadius(const Rect.fromLTWH(80, 0, 4, 20), const Radius.circular(3)),
317 ),
318 );
319
320 // Can turn off caret.
321 showCursor.value = false;
322 pumpFrame(phase: EnginePhase.compositingBits);
323
324 expect(editable, paintsExactlyCountTimes(#drawRRect, 0));
325 });
326
327 test('Can change textAlign', () {
328 final TextSelectionDelegate delegate = _FakeEditableTextState();
329
330 final RenderEditable editable = RenderEditable(
331 textDirection: TextDirection.ltr,
332 offset: ViewportOffset.zero(),
333 textSelectionDelegate: delegate,
334 text: const TextSpan(text: 'test'),
335 startHandleLayerLink: LayerLink(),
336 endHandleLayerLink: LayerLink(),
337 );
338
339 layout(editable);
340
341 editable.layout(BoxConstraints.loose(const Size(100, 100)));
342 expect(editable.textAlign, TextAlign.start);
343 expect(editable.debugNeedsLayout, isFalse);
344
345 editable.textAlign = TextAlign.center;
346 expect(editable.textAlign, TextAlign.center);
347 expect(editable.debugNeedsLayout, isTrue);
348 });
349
350 test('Can read plain text', () {
351 final TextSelectionDelegate delegate = _FakeEditableTextState();
352 final RenderEditable editable = RenderEditable(
353 maxLines: null,
354 textDirection: TextDirection.ltr,
355 offset: ViewportOffset.zero(),
356 textSelectionDelegate: delegate,
357 startHandleLayerLink: LayerLink(),
358 endHandleLayerLink: LayerLink(),
359 );
360
361 expect(editable.plainText, '');
362
363 editable.text = const TextSpan(text: '123');
364 expect(editable.plainText, '123');
365
366 editable.text = const TextSpan(
367 children: <TextSpan>[
368 TextSpan(text: 'abc', style: TextStyle(fontSize: 12)),
369 TextSpan(text: 'def', style: TextStyle(fontSize: 10)),
370 ],
371 );
372 expect(editable.plainText, 'abcdef');
373
374 editable.layout(const BoxConstraints.tightFor(width: 200));
375 expect(editable.plainText, 'abcdef');
376 });
377
378 test('Cursor with ideographic script', () {
379 final TextSelectionDelegate delegate = _FakeEditableTextState();
380 final ValueNotifier<bool> showCursor = ValueNotifier<bool>(true);
381 EditableText.debugDeterministicCursor = true;
382
383 final RenderEditable editable = RenderEditable(
384 backgroundCursorColor: Colors.grey,
385 textDirection: TextDirection.ltr,
386 cursorColor: const Color.fromARGB(0xFF, 0xFF, 0x00, 0x00),
387 offset: ViewportOffset.zero(),
388 textSelectionDelegate: delegate,
389 text: const TextSpan(
390 text: '中文测试文本是否正确',
391 style: TextStyle(fontSize: 10.0, fontFamily: 'FlutterTest'),
392 ),
393 startHandleLayerLink: LayerLink(),
394 endHandleLayerLink: LayerLink(),
395 selection: const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
396 );
397
398 layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
399 pumpFrame(phase: EnginePhase.compositingBits);
400 expect(
401 editable,
402 // Draw no cursor by default.
403 paintsExactlyCountTimes(#drawRect, 0),
404 );
405
406 editable.showCursor = showCursor;
407 pumpFrame(phase: EnginePhase.compositingBits);
408
409 expect(
410 editable,
411 paints..rect(
412 color: const Color.fromARGB(0xFF, 0xFF, 0x00, 0x00),
413 rect: const Rect.fromLTWH(40, 0, 1, 10),
414 ),
415 );
416
417 // Now change to a rounded caret.
418 editable.cursorColor = const Color.fromARGB(0xFF, 0x00, 0x00, 0xFF);
419 editable.cursorWidth = 4;
420 editable.cursorRadius = const Radius.circular(3);
421 pumpFrame(phase: EnginePhase.compositingBits);
422
423 expect(
424 editable,
425 paints..rrect(
426 color: const Color.fromARGB(0xFF, 0x00, 0x00, 0xFF),
427 rrect: RRect.fromRectAndRadius(const Rect.fromLTWH(40, 0, 4, 10), const Radius.circular(3)),
428 ),
429 );
430
431 editable.textScaler = const TextScaler.linear(2.0);
432 pumpFrame(phase: EnginePhase.compositingBits);
433
434 // Now the caret height is much bigger due to the bigger font scale.
435 expect(
436 editable,
437 paints..rrect(
438 color: const Color.fromARGB(0xFF, 0x00, 0x00, 0xFF),
439 rrect: RRect.fromRectAndRadius(const Rect.fromLTWH(80, 0, 4, 20), const Radius.circular(3)),
440 ),
441 );
442
443 // Can turn off caret.
444 showCursor.value = false;
445 pumpFrame(phase: EnginePhase.compositingBits);
446
447 expect(editable, paintsExactlyCountTimes(#drawRRect, 0));
448 });
449
450 test('text is painted above selection', () {
451 final TextSelectionDelegate delegate = _FakeEditableTextState();
452 final RenderEditable editable = RenderEditable(
453 backgroundCursorColor: Colors.grey,
454 selectionColor: Colors.black,
455 textDirection: TextDirection.ltr,
456 cursorColor: Colors.red,
457 offset: ViewportOffset.zero(),
458 textSelectionDelegate: delegate,
459 text: const TextSpan(text: 'test', style: TextStyle(height: 1.0, fontSize: 10.0)),
460 startHandleLayerLink: LayerLink(),
461 endHandleLayerLink: LayerLink(),
462 selection: const TextSelection(
463 baseOffset: 0,
464 extentOffset: 3,
465 affinity: TextAffinity.upstream,
466 ),
467 );
468
469 layout(editable);
470
471 expect(
472 editable,
473 paints
474 // Check that it's the black selection box, not the red cursor.
475 ..rect(color: Colors.black)
476 ..paragraph(),
477 );
478
479 // There is exactly one rect paint (1 selection, 0 cursor).
480 expect(editable, paintsExactlyCountTimes(#drawRect, 1));
481 });
482
483 test('cursor can paint above or below the text', () {
484 final TextSelectionDelegate delegate = _FakeEditableTextState();
485 final ValueNotifier<bool> showCursor = ValueNotifier<bool>(true);
486 final RenderEditable editable = RenderEditable(
487 backgroundCursorColor: Colors.grey,
488 selectionColor: Colors.black,
489 paintCursorAboveText: true,
490 textDirection: TextDirection.ltr,
491 cursorColor: Colors.red,
492 showCursor: showCursor,
493 offset: ViewportOffset.zero(),
494 textSelectionDelegate: delegate,
495 text: const TextSpan(text: 'test', style: TextStyle(height: 1.0, fontSize: 10.0)),
496 startHandleLayerLink: LayerLink(),
497 endHandleLayerLink: LayerLink(),
498 selection: const TextSelection.collapsed(offset: 2, affinity: TextAffinity.upstream),
499 );
500
501 layout(editable);
502
503 expect(
504 editable,
505 paints
506 ..paragraph()
507 // Red collapsed cursor is painted, not a selection box.
508 ..rect(color: Colors.red[500]),
509 );
510
511 // There is exactly one rect paint (0 selection, 1 cursor).
512 expect(editable, paintsExactlyCountTimes(#drawRect, 1));
513
514 editable.paintCursorAboveText = false;
515 pumpFrame(phase: EnginePhase.compositingBits);
516
517 expect(
518 editable,
519 // The paint order is now flipped.
520 paints
521 ..rect(color: Colors.red[500])
522 ..paragraph(),
523 );
524 expect(editable, paintsExactlyCountTimes(#drawRect, 1));
525 });
526
527 test('does not paint the caret when selection is null or invalid', () async {
528 final TextSelectionDelegate delegate = _FakeEditableTextState();
529 final ValueNotifier<bool> showCursor = ValueNotifier<bool>(true);
530 final RenderEditable editable = RenderEditable(
531 backgroundCursorColor: Colors.grey,
532 selectionColor: Colors.black,
533 paintCursorAboveText: true,
534 textDirection: TextDirection.ltr,
535 cursorColor: Colors.red,
536 showCursor: showCursor,
537 offset: ViewportOffset.zero(),
538 textSelectionDelegate: delegate,
539 text: const TextSpan(text: 'test', style: TextStyle(height: 1.0, fontSize: 10.0)),
540 startHandleLayerLink: LayerLink(),
541 endHandleLayerLink: LayerLink(),
542 selection: const TextSelection.collapsed(offset: 2, affinity: TextAffinity.upstream),
543 );
544
545 layout(editable);
546
547 expect(
548 editable,
549 paints
550 ..paragraph()
551 // Red collapsed cursor is painted, not a selection box.
552 ..rect(color: Colors.red[500]),
553 );
554
555 // Let the RenderEditable paint again. Setting the selection to null should
556 // prevent the caret from being painted.
557 editable.selection = null;
558 // Still paints the paragraph.
559 expect(editable, paints..paragraph());
560 // No longer paints the caret.
561 expect(editable, isNot(paints..rect(color: Colors.red[500])));
562
563 // Reset.
564 editable.selection = const TextSelection.collapsed(offset: 0);
565 expect(editable, paints..paragraph());
566 expect(editable, paints..rect(color: Colors.red[500]));
567
568 // Invalid cursor position.
569 editable.selection = const TextSelection.collapsed(offset: -1);
570 // Still paints the paragraph.
571 expect(editable, paints..paragraph());
572 // No longer paints the caret.
573 expect(editable, isNot(paints..rect(color: Colors.red[500])));
574 });
575
576 test('selects correct place with offsets', () {
577 const String text = 'test\ntest';
578 final _FakeEditableTextState delegate =
579 _FakeEditableTextState()..textEditingValue = const TextEditingValue(text: text);
580 final ViewportOffset viewportOffset = ViewportOffset.zero();
581 final RenderEditable editable = RenderEditable(
582 backgroundCursorColor: Colors.grey,
583 selectionColor: Colors.black,
584 textDirection: TextDirection.ltr,
585 cursorColor: Colors.red,
586 offset: viewportOffset,
587 // This makes the scroll axis vertical.
588 maxLines: 2,
589 textSelectionDelegate: delegate,
590 startHandleLayerLink: LayerLink(),
591 endHandleLayerLink: LayerLink(),
592 text: const TextSpan(text: text, style: TextStyle(height: 1.0, fontSize: 10.0)),
593 selection: const TextSelection.collapsed(offset: 4),
594 );
595
596 layout(editable);
597
598 expect(editable, paints..paragraph(offset: Offset.zero));
599
600 editable.selectPositionAt(from: const Offset(0, 2), cause: SelectionChangedCause.tap);
601 pumpFrame();
602 expect(delegate.selection!.isCollapsed, true);
603 expect(delegate.selection!.baseOffset, 0);
604
605 viewportOffset.correctBy(10);
606
607 pumpFrame(phase: EnginePhase.compositingBits);
608
609 expect(editable, paints..paragraph(offset: const Offset(0, -10)));
610
611 // Tap the same place. But because the offset is scrolled up, the second line
612 // gets tapped instead.
613 editable.selectPositionAt(from: const Offset(0, 2), cause: SelectionChangedCause.tap);
614 pumpFrame();
615
616 expect(delegate.selection!.isCollapsed, true);
617 expect(delegate.selection!.baseOffset, 5);
618
619 // Test the other selection methods.
620 // Move over by one character.
621 editable.handleTapDown(TapDownDetails(globalPosition: const Offset(10, 2)));
622 pumpFrame();
623 editable.selectPosition(cause: SelectionChangedCause.tap);
624 pumpFrame();
625 expect(delegate.selection!.isCollapsed, true);
626 expect(delegate.selection!.baseOffset, 6);
627
628 editable.handleTapDown(TapDownDetails(globalPosition: const Offset(20, 2)));
629 pumpFrame();
630 editable.selectWord(cause: SelectionChangedCause.longPress);
631 pumpFrame();
632 expect(delegate.selection!.isCollapsed, false);
633 expect(delegate.selection!.baseOffset, 5);
634 expect(delegate.selection!.extentOffset, 9);
635
636 // Select one more character down but since it's still part of the same
637 // word, the same word is selected.
638 editable.selectWordsInRange(from: const Offset(30, 2), cause: SelectionChangedCause.longPress);
639 pumpFrame();
640 expect(delegate.selection!.isCollapsed, false);
641 expect(delegate.selection!.baseOffset, 5);
642 expect(delegate.selection!.extentOffset, 9);
643 });
644
645 test('selects readonly renderEditable matches native behavior for android', () {
646 // Regression test for https://github.com/flutter/flutter/issues/79166.
647 final TargetPlatform? previousPlatform = debugDefaultTargetPlatformOverride;
648 debugDefaultTargetPlatformOverride = TargetPlatform.android;
649 const String text = ' test';
650 final _FakeEditableTextState delegate =
651 _FakeEditableTextState()..textEditingValue = const TextEditingValue(text: text);
652 final ViewportOffset viewportOffset = ViewportOffset.zero();
653 final RenderEditable editable = RenderEditable(
654 backgroundCursorColor: Colors.grey,
655 selectionColor: Colors.black,
656 textDirection: TextDirection.ltr,
657 cursorColor: Colors.red,
658 readOnly: true,
659 offset: viewportOffset,
660 textSelectionDelegate: delegate,
661 startHandleLayerLink: LayerLink(),
662 endHandleLayerLink: LayerLink(),
663 text: const TextSpan(text: text, style: TextStyle(height: 1.0, fontSize: 10.0)),
664 selection: const TextSelection.collapsed(offset: 4),
665 );
666
667 layout(editable);
668
669 // Select the second white space, where the text position = 1.
670 editable.selectWordsInRange(from: const Offset(10, 2), cause: SelectionChangedCause.longPress);
671 pumpFrame();
672 expect(delegate.selection!.isCollapsed, false);
673 expect(delegate.selection!.baseOffset, 1);
674 expect(delegate.selection!.extentOffset, 2);
675 debugDefaultTargetPlatformOverride = previousPlatform;
676 });
677
678 test('selects renderEditable matches native behavior for iOS case 1', () {
679 // Regression test for https://github.com/flutter/flutter/issues/79166.
680 final TargetPlatform? previousPlatform = debugDefaultTargetPlatformOverride;
681 debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
682 const String text = ' test';
683 final _FakeEditableTextState delegate =
684 _FakeEditableTextState()..textEditingValue = const TextEditingValue(text: text);
685 final ViewportOffset viewportOffset = ViewportOffset.zero();
686 final RenderEditable editable = RenderEditable(
687 backgroundCursorColor: Colors.grey,
688 selectionColor: Colors.black,
689 textDirection: TextDirection.ltr,
690 cursorColor: Colors.red,
691 offset: viewportOffset,
692 textSelectionDelegate: delegate,
693 startHandleLayerLink: LayerLink(),
694 endHandleLayerLink: LayerLink(),
695 text: const TextSpan(text: text, style: TextStyle(height: 1.0, fontSize: 10.0)),
696 selection: const TextSelection.collapsed(offset: 4),
697 );
698
699 layout(editable);
700
701 // Select the second white space, where the text position = 1.
702 editable.selectWordsInRange(from: const Offset(10, 2), cause: SelectionChangedCause.longPress);
703 pumpFrame();
704 expect(delegate.selection!.isCollapsed, false);
705 expect(delegate.selection!.baseOffset, 1);
706 expect(delegate.selection!.extentOffset, 6);
707 debugDefaultTargetPlatformOverride = previousPlatform;
708 });
709
710 test('selects renderEditable matches native behavior for iOS case 2', () {
711 // Regression test for https://github.com/flutter/flutter/issues/79166.
712 final TargetPlatform? previousPlatform = debugDefaultTargetPlatformOverride;
713 debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
714 const String text = ' ';
715 final _FakeEditableTextState delegate =
716 _FakeEditableTextState()..textEditingValue = const TextEditingValue(text: text);
717 final ViewportOffset viewportOffset = ViewportOffset.zero();
718 final RenderEditable editable = RenderEditable(
719 backgroundCursorColor: Colors.grey,
720 selectionColor: Colors.black,
721 textDirection: TextDirection.ltr,
722 cursorColor: Colors.red,
723 offset: viewportOffset,
724 textSelectionDelegate: delegate,
725 startHandleLayerLink: LayerLink(),
726 endHandleLayerLink: LayerLink(),
727 text: const TextSpan(text: text, style: TextStyle(height: 1.0, fontSize: 10.0)),
728 selection: const TextSelection.collapsed(offset: 4),
729 );
730
731 layout(editable);
732
733 // Select the second white space, where the text position = 1.
734 editable.selectWordsInRange(from: const Offset(10, 2), cause: SelectionChangedCause.longPress);
735 pumpFrame();
736 expect(delegate.selection!.isCollapsed, true);
737 expect(delegate.selection!.baseOffset, 1);
738 expect(delegate.selection!.extentOffset, 1);
739 debugDefaultTargetPlatformOverride = previousPlatform;
740 });
741
742 test('selects correct place when offsets are flipped', () {
743 const String text = 'abc def ghi';
744 final _FakeEditableTextState delegate =
745 _FakeEditableTextState()..textEditingValue = const TextEditingValue(text: text);
746 final ViewportOffset viewportOffset = ViewportOffset.zero();
747 final RenderEditable editable = RenderEditable(
748 backgroundCursorColor: Colors.grey,
749 selectionColor: Colors.black,
750 textDirection: TextDirection.ltr,
751 cursorColor: Colors.red,
752 offset: viewportOffset,
753 textSelectionDelegate: delegate,
754 text: const TextSpan(text: text, style: TextStyle(height: 1.0, fontSize: 10.0)),
755 startHandleLayerLink: LayerLink(),
756 endHandleLayerLink: LayerLink(),
757 );
758
759 layout(editable);
760
761 editable.selectPositionAt(
762 from: const Offset(30, 2),
763 to: const Offset(10, 2),
764 cause: SelectionChangedCause.drag,
765 );
766 pumpFrame();
767 expect(delegate.selection!.isCollapsed, isFalse);
768 expect(delegate.selection!.baseOffset, 3);
769 expect(delegate.selection!.extentOffset, 1);
770 });
771
772 test('promptRect disappears when promptRectColor is set to null', () {
773 const Color promptRectColor = Color(0x12345678);
774 final TextSelectionDelegate delegate = _FakeEditableTextState();
775 final RenderEditable editable = RenderEditable(
776 text: const TextSpan(style: TextStyle(height: 1.0, fontSize: 10.0), text: 'ABCDEFG'),
777 startHandleLayerLink: LayerLink(),
778 endHandleLayerLink: LayerLink(),
779 textDirection: TextDirection.ltr,
780 locale: const Locale('en', 'US'),
781 offset: ViewportOffset.fixed(10.0),
782 textSelectionDelegate: delegate,
783 selection: const TextSelection.collapsed(offset: 0),
784 promptRectColor: promptRectColor,
785 promptRectRange: const TextRange(start: 0, end: 1),
786 );
787
788 layout(editable, constraints: BoxConstraints.loose(const Size(1000.0, 1000.0)));
789 pumpFrame(phase: EnginePhase.compositingBits);
790
791 expect(
792 (Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
793 paints..rect(color: promptRectColor),
794 );
795
796 editable.promptRectColor = null;
797
798 editable.layout(BoxConstraints.loose(const Size(1000.0, 1000.0)));
799 pumpFrame(phase: EnginePhase.compositingBits);
800
801 expect(editable.promptRectColor, null);
802 expect(
803 (Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
804 isNot(paints..rect(color: promptRectColor)),
805 );
806 });
807
808 test('editable hasFocus correctly initialized', () {
809 // Regression test for https://github.com/flutter/flutter/issues/21640
810 final TextSelectionDelegate delegate = _FakeEditableTextState();
811 final RenderEditable editable = RenderEditable(
812 text: const TextSpan(style: TextStyle(height: 1.0, fontSize: 10.0), text: '12345'),
813 textDirection: TextDirection.ltr,
814 locale: const Locale('en', 'US'),
815 offset: ViewportOffset.zero(),
816 textSelectionDelegate: delegate,
817 hasFocus: true,
818 startHandleLayerLink: LayerLink(),
819 endHandleLayerLink: LayerLink(),
820 );
821
822 expect(editable.hasFocus, true);
823 editable.hasFocus = false;
824 expect(editable.hasFocus, false);
825 });
826
827 test('has correct maxScrollExtent', () {
828 final TextSelectionDelegate delegate = _FakeEditableTextState();
829 EditableText.debugDeterministicCursor = true;
830
831 final RenderEditable editable = RenderEditable(
832 maxLines: 2,
833 backgroundCursorColor: Colors.grey,
834 textDirection: TextDirection.ltr,
835 cursorColor: const Color.fromARGB(0xFF, 0xFF, 0x00, 0x00),
836 offset: ViewportOffset.zero(),
837 textSelectionDelegate: delegate,
838 text: const TextSpan(
839 text:
840 '撒地方加咖啡哈金凤凰卡号方式剪坏算法发挥福建垃\nasfjafjajfjaslfjaskjflasjfksajf撒分开建安路口附近拉设\n计费可使肌肤撒附近埃里克圾房卡设计费"',
841 style: TextStyle(height: 1.0, fontSize: 10.0, fontFamily: 'Roboto'),
842 ),
843 startHandleLayerLink: LayerLink(),
844 endHandleLayerLink: LayerLink(),
845 selection: const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
846 );
847
848 editable.layout(BoxConstraints.loose(const Size(100.0, 1000.0)));
849 expect(editable.size, equals(const Size(100, 20)));
850 expect(editable.maxLines, equals(2));
851 expect(editable.maxScrollExtent, equals(90));
852
853 editable.layout(BoxConstraints.loose(const Size(150.0, 1000.0)));
854 expect(editable.maxScrollExtent, equals(50));
855
856 editable.layout(BoxConstraints.loose(const Size(200.0, 1000.0)));
857 expect(editable.maxScrollExtent, equals(40));
858
859 editable.layout(BoxConstraints.loose(const Size(500.0, 1000.0)));
860 expect(editable.maxScrollExtent, equals(10));
861
862 editable.layout(BoxConstraints.loose(const Size(1000.0, 1000.0)));
863 expect(editable.maxScrollExtent, equals(10));
864 });
865
866 test('getEndpointsForSelection handles empty characters', () {
867 final TextSelectionDelegate delegate = _FakeEditableTextState();
868 final RenderEditable editable = RenderEditable(
869 // This is a Unicode left-to-right mark character that will not render
870 // any glyphs.
871 text: const TextSpan(text: '\u200e'),
872 textDirection: TextDirection.ltr,
873 offset: ViewportOffset.zero(),
874 textSelectionDelegate: delegate,
875 startHandleLayerLink: LayerLink(),
876 endHandleLayerLink: LayerLink(),
877 );
878 editable.layout(BoxConstraints.loose(const Size(100, 100)));
879 final List<TextSelectionPoint> endpoints = editable.getEndpointsForSelection(
880 const TextSelection(baseOffset: 0, extentOffset: 1),
881 );
882 expect(endpoints[0].point.dx, 0);
883 });
884
885 test('TextSelectionPoint can compare', () {
886 // ignore: prefer_const_constructors
887 final TextSelectionPoint first = TextSelectionPoint(Offset(1, 2), TextDirection.ltr);
888 // ignore: prefer_const_constructors
889 final TextSelectionPoint second = TextSelectionPoint(Offset(1, 2), TextDirection.ltr);
890 expect(first == second, isTrue);
891 expect(first.hashCode == second.hashCode, isTrue);
892
893 // ignore: prefer_const_constructors
894 final TextSelectionPoint different = TextSelectionPoint(Offset(2, 2), TextDirection.ltr);
895 expect(first == different, isFalse);
896 expect(first.hashCode == different.hashCode, isFalse);
897 });
898
899 group('getRectForComposingRange', () {
900 const TextSpan emptyTextSpan = TextSpan(text: '\u200e');
901 final TextSelectionDelegate delegate = _FakeEditableTextState();
902 final RenderEditable editable = RenderEditable(
903 maxLines: null,
904 textDirection: TextDirection.ltr,
905 offset: ViewportOffset.zero(),
906 textSelectionDelegate: delegate,
907 startHandleLayerLink: LayerLink(),
908 endHandleLayerLink: LayerLink(),
909 );
910
911 test('returns null when no composing range', () {
912 editable.text = const TextSpan(text: '123');
913 editable.layout(const BoxConstraints.tightFor(width: 200));
914
915 // Invalid range.
916 expect(editable.getRectForComposingRange(const TextRange(start: -1, end: 2)), isNull);
917 // Collapsed range.
918 expect(editable.getRectForComposingRange(const TextRange.collapsed(2)), isNull);
919
920 // Empty Editable.
921 editable.text = emptyTextSpan;
922 editable.layout(const BoxConstraints.tightFor(width: 200));
923
924 expect(
925 editable.getRectForComposingRange(const TextRange(start: 0, end: 1)),
926 // On web this evaluates to a zero-width Rect.
927 anyOf(isNull, (Rect rect) => rect.width == 0),
928 );
929 });
930
931 test('more than 1 run on the same line', () {
932 const TextStyle tinyText = TextStyle(fontSize: 1);
933 const TextStyle normalText = TextStyle(fontSize: 10);
934 editable.text = TextSpan(
935 children: <TextSpan>[
936 const TextSpan(text: 'A', style: tinyText),
937 TextSpan(text: 'A' * 20, style: normalText),
938 const TextSpan(text: 'A', style: tinyText),
939 ],
940 );
941 // Give it a width that forces the editable to wrap.
942 editable.layout(const BoxConstraints.tightFor(width: 200));
943
944 final Rect composingRect =
945 editable.getRectForComposingRange(const TextRange(start: 0, end: 20 + 2))!;
946
947 // Since the range covers an entire line, the Rect should also be almost
948 // as wide as the entire paragraph (give or take 1 character).
949 expect(composingRect.width, greaterThan(200 - 10));
950 });
951 });
952
953 group('custom painters', () {
954 final TextSelectionDelegate delegate = _FakeEditableTextState();
955
956 final _TestRenderEditable editable = _TestRenderEditable(
957 textDirection: TextDirection.ltr,
958 offset: ViewportOffset.zero(),
959 textSelectionDelegate: delegate,
960 text: const TextSpan(text: 'test', style: TextStyle(height: 1.0, fontSize: 10.0)),
961 startHandleLayerLink: LayerLink(),
962 endHandleLayerLink: LayerLink(),
963 selection: const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
964 );
965
966 setUp(() {
967 EditableText.debugDeterministicCursor = true;
968 });
969 tearDown(() {
970 EditableText.debugDeterministicCursor = false;
971 editable.foregroundPainter = null;
972 editable.painter = null;
973 editable.paintCount = 0;
974
975 final RenderObject? parent = editable.parent;
976 if (parent is RenderConstrainedBox) {
977 parent.child = null;
978 }
979 });
980
981 test('paints in the correct order', () {
982 layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
983 // Prepare for painting after layout.
984
985 // Foreground painter.
986 editable.foregroundPainter = _TestRenderEditablePainter();
987 pumpFrame(phase: EnginePhase.compositingBits);
988
989 expect(
990 (Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
991 paints
992 ..paragraph()
993 ..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: const Color(0x12345678)),
994 );
995
996 // Background painter.
997 editable.foregroundPainter = null;
998 editable.painter = _TestRenderEditablePainter();
999
1000 expect(
1001 (Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
1002 paints
1003 ..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: const Color(0x12345678))
1004 ..paragraph(),
1005 );
1006
1007 editable.foregroundPainter = _TestRenderEditablePainter();
1008 editable.painter = _TestRenderEditablePainter();
1009
1010 expect(
1011 (Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
1012 paints
1013 ..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: const Color(0x12345678))
1014 ..paragraph()
1015 ..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: const Color(0x12345678)),
1016 );
1017 });
1018
1019 test('changing foreground painter', () {
1020 layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
1021 // Prepare for painting after layout.
1022
1023 _TestRenderEditablePainter currentPainter = _TestRenderEditablePainter();
1024 // Foreground painter.
1025 editable.foregroundPainter = currentPainter;
1026 pumpFrame(phase: EnginePhase.paint);
1027 expect(currentPainter.paintCount, 1);
1028
1029 editable.foregroundPainter = currentPainter = _TestRenderEditablePainter()..repaint = false;
1030 pumpFrame(phase: EnginePhase.paint);
1031 expect(currentPainter.paintCount, 0);
1032
1033 editable.foregroundPainter = currentPainter = _TestRenderEditablePainter()..repaint = true;
1034 pumpFrame(phase: EnginePhase.paint);
1035 expect(currentPainter.paintCount, 1);
1036 });
1037
1038 test('changing background painter', () {
1039 layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
1040 // Prepare for painting after layout.
1041
1042 _TestRenderEditablePainter currentPainter = _TestRenderEditablePainter();
1043 // Foreground painter.
1044 editable.painter = currentPainter;
1045 pumpFrame(phase: EnginePhase.paint);
1046 expect(currentPainter.paintCount, 1);
1047
1048 editable.painter = currentPainter = _TestRenderEditablePainter()..repaint = false;
1049 pumpFrame(phase: EnginePhase.paint);
1050 expect(currentPainter.paintCount, 0);
1051
1052 editable.painter = currentPainter = _TestRenderEditablePainter()..repaint = true;
1053 pumpFrame(phase: EnginePhase.paint);
1054 expect(currentPainter.paintCount, 1);
1055 });
1056
1057 test('swapping painters', () {
1058 layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
1059
1060 final _TestRenderEditablePainter painter1 = _TestRenderEditablePainter(
1061 color: const Color(0x01234567),
1062 );
1063 final _TestRenderEditablePainter painter2 = _TestRenderEditablePainter(
1064 color: const Color(0x76543210),
1065 );
1066
1067 editable.painter = painter1;
1068 editable.foregroundPainter = painter2;
1069 expect(
1070 (Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
1071 paints
1072 ..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: painter1.color)
1073 ..paragraph()
1074 ..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: painter2.color),
1075 );
1076
1077 editable.painter = painter2;
1078 editable.foregroundPainter = painter1;
1079 expect(
1080 (Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
1081 paints
1082 ..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: painter2.color)
1083 ..paragraph()
1084 ..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: painter1.color),
1085 );
1086 });
1087
1088 test('reusing the same painter', () {
1089 layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
1090
1091 final _TestRenderEditablePainter painter = _TestRenderEditablePainter();
1092 FlutterErrorDetails? errorDetails;
1093 editable.painter = painter;
1094 editable.foregroundPainter = painter;
1095 pumpFrame(
1096 phase: EnginePhase.paint,
1097 onErrors: () {
1098 errorDetails = TestRenderingFlutterBinding.instance.takeFlutterErrorDetails();
1099 },
1100 );
1101 expect(errorDetails, isNull);
1102 expect(painter.paintCount, 2);
1103
1104 expect(
1105 (Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
1106 paints
1107 ..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: const Color(0x12345678))
1108 ..paragraph()
1109 ..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: const Color(0x12345678)),
1110 );
1111 });
1112
1113 test('does not repaint the render editable when custom painters need repaint', () {
1114 layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
1115
1116 final _TestRenderEditablePainter painter = _TestRenderEditablePainter();
1117 editable.painter = painter;
1118 pumpFrame(phase: EnginePhase.paint);
1119 editable.paintCount = 0;
1120 painter.paintCount = 0;
1121
1122 painter.markNeedsPaint();
1123
1124 pumpFrame(phase: EnginePhase.paint);
1125 expect(editable.paintCount, 0);
1126 expect(painter.paintCount, 1);
1127 });
1128
1129 test('repaints when its RenderEditable repaints', () {
1130 layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
1131
1132 final _TestRenderEditablePainter painter = _TestRenderEditablePainter();
1133 editable.painter = painter;
1134 pumpFrame(phase: EnginePhase.paint);
1135 editable.paintCount = 0;
1136 painter.paintCount = 0;
1137
1138 editable.markNeedsPaint();
1139
1140 pumpFrame(phase: EnginePhase.paint);
1141 expect(editable.paintCount, 1);
1142 expect(painter.paintCount, 1);
1143 });
1144
1145 test('correct coordinate space', () {
1146 layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
1147
1148 final _TestRenderEditablePainter painter = _TestRenderEditablePainter();
1149 editable.painter = painter;
1150 editable.offset = ViewportOffset.fixed(1000);
1151
1152 pumpFrame(phase: EnginePhase.compositingBits);
1153 expect(
1154 (Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
1155 paints
1156 ..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: const Color(0x12345678))
1157 ..paragraph(),
1158 );
1159 });
1160
1161 group('hit testing', () {
1162 final TextSelectionDelegate delegate = _FakeEditableTextState();
1163
1164 test('Basic TextSpan Hit testing', () {
1165 final TextSpan textSpanA = TextSpan(text: 'A' * 10);
1166 const TextSpan textSpanBC = TextSpan(text: 'BC', style: TextStyle(letterSpacing: 26.0));
1167
1168 final TextSpan text = TextSpan(
1169 text: '',
1170 style: const TextStyle(fontSize: 10.0),
1171 children: <InlineSpan>[textSpanA, textSpanBC],
1172 );
1173
1174 final RenderEditable renderEditable = RenderEditable(
1175 text: text,
1176 maxLines: null,
1177 startHandleLayerLink: LayerLink(),
1178 endHandleLayerLink: LayerLink(),
1179 textDirection: TextDirection.ltr,
1180 offset: ViewportOffset.fixed(0.0),
1181 textSelectionDelegate: delegate,
1182 selection: const TextSelection.collapsed(offset: 0),
1183 );
1184 layout(
1185 renderEditable,
1186 constraints: BoxConstraints.tightFor(width: 100.0 + _caretMarginOf(renderEditable)),
1187 );
1188
1189 BoxHitTestResult result;
1190
1191 // Hit-testing the first line
1192 // First A
1193 expect(
1194 renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(5.0, 5.0)),
1195 isTrue,
1196 );
1197 expect(
1198 result.path
1199 .map((HitTestEntry<HitTestTarget> entry) => entry.target)
1200 .whereType<TextSpan>(),
1201 <TextSpan>[textSpanA],
1202 );
1203 // The last A.
1204 expect(
1205 renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)),
1206 isTrue,
1207 );
1208 expect(
1209 result.path
1210 .map((HitTestEntry<HitTestTarget> entry) => entry.target)
1211 .whereType<TextSpan>(),
1212 <TextSpan>[textSpanA],
1213 );
1214 // Far away from the line.
1215 expect(
1216 renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(200.0, 5.0)),
1217 isFalse,
1218 );
1219 expect(
1220 result.path
1221 .map((HitTestEntry<HitTestTarget> entry) => entry.target)
1222 .whereType<TextSpan>(),
1223 <TextSpan>[],
1224 );
1225
1226 // Hit-testing the second line
1227 // Tapping on B (startX = letter-spacing / 2 = 13.0).
1228 expect(
1229 renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(18.0, 15.0)),
1230 isTrue,
1231 );
1232 expect(
1233 result.path
1234 .map((HitTestEntry<HitTestTarget> entry) => entry.target)
1235 .whereType<TextSpan>(),
1236 <TextSpan>[textSpanBC],
1237 );
1238
1239 // Between B and C, with large letter-spacing.
1240 expect(
1241 renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(31.0, 15.0)),
1242 isTrue,
1243 );
1244 expect(
1245 result.path
1246 .map((HitTestEntry<HitTestTarget> entry) => entry.target)
1247 .whereType<TextSpan>(),
1248 <TextSpan>[textSpanBC],
1249 );
1250
1251 // On C.
1252 expect(
1253 renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(54.0, 15.0)),
1254 isTrue,
1255 );
1256 expect(
1257 result.path
1258 .map((HitTestEntry<HitTestTarget> entry) => entry.target)
1259 .whereType<TextSpan>(),
1260 <TextSpan>[textSpanBC],
1261 );
1262
1263 // After C.
1264 expect(
1265 renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(100.0, 15.0)),
1266 isTrue,
1267 );
1268 expect(
1269 result.path
1270 .map((HitTestEntry<HitTestTarget> entry) => entry.target)
1271 .whereType<TextSpan>(),
1272 <TextSpan>[],
1273 );
1274
1275 // Not even remotely close.
1276 expect(
1277 renderEditable.hitTest(
1278 result = BoxHitTestResult(),
1279 position: const Offset(9999.0, 9999.0),
1280 ),
1281 isFalse,
1282 );
1283 expect(
1284 result.path
1285 .map((HitTestEntry<HitTestTarget> entry) => entry.target)
1286 .whereType<TextSpan>(),
1287 <TextSpan>[],
1288 );
1289 });
1290
1291 test('TextSpan Hit testing with text justification', () {
1292 const TextSpan textSpanA = TextSpan(text: 'A '); // The space is a word break.
1293 const TextSpan textSpanB = TextSpan(
1294 text: 'B\u200B',
1295 ); // The zero-width space is used as a line break.
1296 final TextSpan textSpanC = TextSpan(
1297 text: 'C' * 10,
1298 ); // The third span starts a new line since it's too long for the first line.
1299
1300 // The text should look like:
1301 // A B
1302 // CCCCCCCCCC
1303 final TextSpan text = TextSpan(
1304 text: '',
1305 style: const TextStyle(fontSize: 10.0),
1306 children: <InlineSpan>[textSpanA, textSpanB, textSpanC],
1307 );
1308 final RenderEditable renderEditable = RenderEditable(
1309 text: text,
1310 maxLines: null,
1311 startHandleLayerLink: LayerLink(),
1312 endHandleLayerLink: LayerLink(),
1313 textDirection: TextDirection.ltr,
1314 textAlign: TextAlign.justify,
1315 offset: ViewportOffset.fixed(0.0),
1316 textSelectionDelegate: delegate,
1317 selection: const TextSelection.collapsed(offset: 0),
1318 );
1319
1320 layout(
1321 renderEditable,
1322 constraints: BoxConstraints.tightFor(width: 100.0 + _caretMarginOf(renderEditable)),
1323 );
1324 BoxHitTestResult result;
1325
1326 // Tapping on A.
1327 expect(
1328 renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(5.0, 5.0)),
1329 isTrue,
1330 );
1331 expect(
1332 result.path
1333 .map((HitTestEntry<HitTestTarget> entry) => entry.target)
1334 .whereType<TextSpan>(),
1335 <TextSpan>[textSpanA],
1336 );
1337
1338 // Between A and B.
1339 expect(
1340 renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(50.0, 5.0)),
1341 isTrue,
1342 );
1343 expect(
1344 result.path
1345 .map((HitTestEntry<HitTestTarget> entry) => entry.target)
1346 .whereType<TextSpan>(),
1347 <TextSpan>[textSpanA],
1348 );
1349
1350 // On B.
1351 expect(
1352 renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)),
1353 isTrue,
1354 );
1355 expect(
1356 result.path
1357 .map((HitTestEntry<HitTestTarget> entry) => entry.target)
1358 .whereType<TextSpan>(),
1359 <TextSpan>[textSpanB],
1360 );
1361 });
1362
1363 test('hits correct TextSpan when not scrolled', () {
1364 final RenderEditable editable = RenderEditable(
1365 text: const TextSpan(
1366 style: TextStyle(height: 1.0, fontSize: 10.0),
1367 children: <InlineSpan>[TextSpan(text: 'A'), TextSpan(text: 'B')],
1368 ),
1369 startHandleLayerLink: LayerLink(),
1370 endHandleLayerLink: LayerLink(),
1371 textDirection: TextDirection.ltr,
1372 offset: ViewportOffset.fixed(0.0),
1373 textSelectionDelegate: delegate,
1374 selection: const TextSelection.collapsed(offset: 0),
1375 );
1376 layout(editable, constraints: BoxConstraints.loose(const Size(500.0, 500.0)));
1377 // Prepare for painting after layout.
1378 pumpFrame(phase: EnginePhase.compositingBits);
1379
1380 BoxHitTestResult result = BoxHitTestResult();
1381 editable.hitTest(result, position: Offset.zero);
1382 // We expect two hit test entries in the path because the RenderEditable
1383 // will add itself as well.
1384 expect(result.path, hasLength(2));
1385 HitTestTarget target = result.path.first.target;
1386 expect(target, isA<TextSpan>());
1387 expect((target as TextSpan).text, 'A');
1388 // Only testing the RenderEditable entry here once, not anymore below.
1389 expect(result.path.last.target, isA<RenderEditable>());
1390
1391 result = BoxHitTestResult();
1392 editable.hitTest(result, position: const Offset(15.0, 0.0));
1393 expect(result.path, hasLength(2));
1394 target = result.path.first.target;
1395 expect(target, isA<TextSpan>());
1396 expect((target as TextSpan).text, 'B');
1397 });
1398
1399 test('hits correct TextSpan when scrolled vertically', () {
1400 final TextSelectionDelegate delegate = _FakeEditableTextState();
1401 final RenderEditable editable = RenderEditable(
1402 text: const TextSpan(
1403 style: TextStyle(height: 1.0, fontSize: 10.0),
1404 children: <InlineSpan>[TextSpan(text: 'A'), TextSpan(text: 'B\n'), TextSpan(text: 'C')],
1405 ),
1406 startHandleLayerLink: LayerLink(),
1407 endHandleLayerLink: LayerLink(),
1408 textDirection: TextDirection.ltr,
1409 // Given maxLines of null and an offset of 5, the editable will be
1410 // scrolled vertically by 5 pixels.
1411 maxLines: null,
1412 offset: ViewportOffset.fixed(5.0),
1413 textSelectionDelegate: delegate,
1414 selection: const TextSelection.collapsed(offset: 0),
1415 );
1416 layout(editable, constraints: BoxConstraints.loose(const Size(500.0, 500.0)));
1417 // Prepare for painting after layout.
1418 pumpFrame(phase: EnginePhase.compositingBits);
1419
1420 BoxHitTestResult result = BoxHitTestResult();
1421 editable.hitTest(result, position: Offset.zero);
1422 expect(result.path, hasLength(2));
1423 HitTestTarget target = result.path.first.target;
1424 expect(target, isA<TextSpan>());
1425 expect((target as TextSpan).text, 'A');
1426
1427 result = BoxHitTestResult();
1428 editable.hitTest(result, position: const Offset(15.0, 0.0));
1429 expect(result.path, hasLength(2));
1430 target = result.path.first.target;
1431 expect(target, isA<TextSpan>());
1432 expect((target as TextSpan).text, 'B\n');
1433
1434 result = BoxHitTestResult();
1435 // When we hit at y=6 and are scrolled by -5 vertically, we expect "C"
1436 // to be hit because the font size is 10.
1437 editable.hitTest(result, position: const Offset(0.0, 6.0));
1438 expect(result.path, hasLength(2));
1439 target = result.path.first.target;
1440 expect(target, isA<TextSpan>());
1441 expect((target as TextSpan).text, 'C');
1442 });
1443
1444 test('hits correct TextSpan when scrolled horizontally', () {
1445 final TextSelectionDelegate delegate = _FakeEditableTextState();
1446 final RenderEditable editable = RenderEditable(
1447 text: const TextSpan(
1448 style: TextStyle(height: 1.0, fontSize: 10.0),
1449 children: <InlineSpan>[TextSpan(text: 'A'), TextSpan(text: 'B')],
1450 ),
1451 startHandleLayerLink: LayerLink(),
1452 endHandleLayerLink: LayerLink(),
1453 textDirection: TextDirection.ltr,
1454 // Given maxLines of 1 and an offset of 5, the editable will be
1455 // scrolled by 5 pixels to the left.
1456 offset: ViewportOffset.fixed(5.0),
1457 textSelectionDelegate: delegate,
1458 selection: const TextSelection.collapsed(offset: 0),
1459 );
1460 layout(editable, constraints: BoxConstraints.loose(const Size(500.0, 500.0)));
1461 // Prepare for painting after layout.
1462 pumpFrame(phase: EnginePhase.compositingBits);
1463
1464 final BoxHitTestResult result = BoxHitTestResult();
1465 // At x=6, we should hit "B" as we are scrolled to the left by 6
1466 // pixels.
1467 editable.hitTest(result, position: const Offset(6.0, 0));
1468 expect(result.path, hasLength(2));
1469 final HitTestTarget target = result.path.first.target;
1470 expect(target, isA<TextSpan>());
1471 expect((target as TextSpan).text, 'B');
1472 });
1473 });
1474 });
1475
1476 group('WidgetSpan support', () {
1477 test('able to render basic WidgetSpan', () async {
1478 final TextSelectionDelegate delegate =
1479 _FakeEditableTextState()
1480 ..textEditingValue = const TextEditingValue(
1481 text: 'test',
1482 selection: TextSelection.collapsed(offset: 3),
1483 );
1484 final List<RenderBox> renderBoxes = <RenderBox>[
1485 RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
1486 ];
1487 final ViewportOffset viewportOffset = ViewportOffset.zero();
1488 final RenderEditable editable = RenderEditable(
1489 backgroundCursorColor: Colors.grey,
1490 selectionColor: Colors.black,
1491 textDirection: TextDirection.ltr,
1492 cursorColor: Colors.red,
1493 offset: viewportOffset,
1494 textSelectionDelegate: delegate,
1495 startHandleLayerLink: LayerLink(),
1496 endHandleLayerLink: LayerLink(),
1497 text: TextSpan(
1498 style: const TextStyle(height: 1.0, fontSize: 10.0),
1499 children: <InlineSpan>[
1500 const TextSpan(text: 'test'),
1501 WidgetSpan(child: Container(width: 10, height: 10, color: Colors.blue)),
1502 ],
1503 ),
1504 selection: const TextSelection.collapsed(offset: 3),
1505 children: renderBoxes,
1506 );
1507 _applyParentData(renderBoxes, editable.text!);
1508 layout(editable);
1509 editable.hasFocus = true;
1510 pumpFrame();
1511
1512 final Rect composingRect =
1513 editable.getRectForComposingRange(const TextRange(start: 4, end: 5))!;
1514 expect(composingRect, const Rect.fromLTRB(40.0, 0.0, 54.0, 14.0));
1515 });
1516
1517 test('able to render multiple WidgetSpans', () async {
1518 final TextSelectionDelegate delegate =
1519 _FakeEditableTextState()
1520 ..textEditingValue = const TextEditingValue(
1521 text: 'test',
1522 selection: TextSelection.collapsed(offset: 3),
1523 );
1524 final List<RenderBox> renderBoxes = <RenderBox>[
1525 RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
1526 RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr),
1527 RenderParagraph(const TextSpan(text: 'd'), textDirection: TextDirection.ltr),
1528 ];
1529 final ViewportOffset viewportOffset = ViewportOffset.zero();
1530 final RenderEditable editable = RenderEditable(
1531 backgroundCursorColor: Colors.grey,
1532 selectionColor: Colors.black,
1533 textDirection: TextDirection.ltr,
1534 cursorColor: Colors.red,
1535 offset: viewportOffset,
1536 textSelectionDelegate: delegate,
1537 startHandleLayerLink: LayerLink(),
1538 endHandleLayerLink: LayerLink(),
1539 text: TextSpan(
1540 style: const TextStyle(height: 1.0, fontSize: 10.0),
1541 children: <InlineSpan>[
1542 const TextSpan(text: 'test'),
1543 WidgetSpan(child: Container(width: 10, height: 10, color: Colors.blue)),
1544 WidgetSpan(child: Container(width: 10, height: 10, color: Colors.blue)),
1545 WidgetSpan(child: Container(width: 10, height: 10, color: Colors.blue)),
1546 ],
1547 ),
1548 selection: const TextSelection.collapsed(offset: 3),
1549 children: renderBoxes,
1550 );
1551
1552 _applyParentData(renderBoxes, editable.text!);
1553 layout(editable);
1554 editable.hasFocus = true;
1555 pumpFrame();
1556
1557 final Rect composingRect =
1558 editable.getRectForComposingRange(const TextRange(start: 4, end: 7))!;
1559 expect(composingRect, const Rect.fromLTRB(40.0, 0.0, 82.0, 14.0));
1560 });
1561
1562 test('able to render WidgetSpans with line wrap', () async {
1563 final TextSelectionDelegate delegate =
1564 _FakeEditableTextState()
1565 ..textEditingValue = const TextEditingValue(
1566 text: 'test',
1567 selection: TextSelection.collapsed(offset: 3),
1568 );
1569 final List<RenderBox> renderBoxes = <RenderBox>[
1570 RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
1571 RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr),
1572 RenderParagraph(const TextSpan(text: 'd'), textDirection: TextDirection.ltr),
1573 ];
1574 final ViewportOffset viewportOffset = ViewportOffset.zero();
1575 final RenderEditable editable = RenderEditable(
1576 backgroundCursorColor: Colors.grey,
1577 selectionColor: Colors.black,
1578 textDirection: TextDirection.ltr,
1579 cursorColor: Colors.red,
1580 offset: viewportOffset,
1581 textSelectionDelegate: delegate,
1582 startHandleLayerLink: LayerLink(),
1583 endHandleLayerLink: LayerLink(),
1584 text: const TextSpan(
1585 style: TextStyle(height: 1.0, fontSize: 10.0),
1586 children: <InlineSpan>[
1587 TextSpan(text: 'test'),
1588 WidgetSpan(child: Text('b')),
1589 WidgetSpan(child: Text('c')),
1590 WidgetSpan(child: Text('d')),
1591 ],
1592 ),
1593 selection: const TextSelection.collapsed(offset: 3),
1594 maxLines: 2,
1595 minLines: 2,
1596 children: renderBoxes,
1597 );
1598
1599 // Force a line wrap
1600 _applyParentData(renderBoxes, editable.text!);
1601 layout(editable, constraints: const BoxConstraints(maxWidth: 75));
1602 editable.hasFocus = true;
1603 pumpFrame();
1604
1605 Rect composingRect = editable.getRectForComposingRange(const TextRange(start: 4, end: 6))!;
1606 expect(composingRect, const Rect.fromLTRB(40.0, 0.0, 68.0, 14.0));
1607 composingRect = editable.getRectForComposingRange(const TextRange(start: 6, end: 7))!;
1608 expect(composingRect, const Rect.fromLTRB(0.0, 14.0, 14.0, 28.0));
1609 });
1610
1611 test('able to render WidgetSpans with line wrap alternating spans', () async {
1612 final TextSelectionDelegate delegate =
1613 _FakeEditableTextState()
1614 ..textEditingValue = const TextEditingValue(
1615 text: 'test',
1616 selection: TextSelection.collapsed(offset: 3),
1617 );
1618 final List<RenderBox> renderBoxes = <RenderBox>[
1619 RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
1620 RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr),
1621 RenderParagraph(const TextSpan(text: 'd'), textDirection: TextDirection.ltr),
1622 RenderParagraph(const TextSpan(text: 'e'), textDirection: TextDirection.ltr),
1623 ];
1624 final ViewportOffset viewportOffset = ViewportOffset.zero();
1625 final RenderEditable editable = RenderEditable(
1626 backgroundCursorColor: Colors.grey,
1627 selectionColor: Colors.black,
1628 textDirection: TextDirection.ltr,
1629 cursorColor: Colors.red,
1630 offset: viewportOffset,
1631 textSelectionDelegate: delegate,
1632 startHandleLayerLink: LayerLink(),
1633 endHandleLayerLink: LayerLink(),
1634 text: const TextSpan(
1635 style: TextStyle(height: 1.0, fontSize: 10.0),
1636 children: <InlineSpan>[
1637 TextSpan(text: 'test'),
1638 WidgetSpan(child: Text('b')),
1639 WidgetSpan(child: Text('c')),
1640 WidgetSpan(child: Text('d')),
1641 TextSpan(text: 'HI'),
1642 WidgetSpan(child: Text('e')),
1643 ],
1644 ),
1645 selection: const TextSelection.collapsed(offset: 3),
1646 maxLines: 2,
1647 minLines: 2,
1648 children: renderBoxes,
1649 );
1650
1651 // Force a line wrap
1652 _applyParentData(renderBoxes, editable.text!);
1653 layout(editable, constraints: const BoxConstraints(maxWidth: 75));
1654 editable.hasFocus = true;
1655 pumpFrame();
1656
1657 Rect composingRect = editable.getRectForComposingRange(const TextRange(start: 4, end: 6))!;
1658 expect(composingRect, const Rect.fromLTRB(40.0, 0.0, 68.0, 14.0));
1659 composingRect = editable.getRectForComposingRange(const TextRange(start: 6, end: 7))!;
1660 expect(composingRect, const Rect.fromLTRB(0.0, 14.0, 14.0, 28.0));
1661 composingRect = editable.getRectForComposingRange(const TextRange(start: 7, end: 8))!; // H
1662 expect(composingRect, const Rect.fromLTRB(14.0, 18.0, 24.0, 28.0));
1663 composingRect = editable.getRectForComposingRange(const TextRange(start: 8, end: 9))!; // I
1664 expect(composingRect, const Rect.fromLTRB(24.0, 18.0, 34.0, 28.0));
1665 composingRect = editable.getRectForComposingRange(const TextRange(start: 9, end: 10))!;
1666 expect(composingRect, const Rect.fromLTRB(34.0, 14.0, 48.0, 28.0));
1667 });
1668
1669 test('able to render WidgetSpans nested spans', () async {
1670 final TextSelectionDelegate delegate =
1671 _FakeEditableTextState()
1672 ..textEditingValue = const TextEditingValue(
1673 text: 'test',
1674 selection: TextSelection.collapsed(offset: 3),
1675 );
1676 final List<RenderBox> renderBoxes = <RenderBox>[
1677 RenderParagraph(const TextSpan(text: 'a'), textDirection: TextDirection.ltr),
1678 RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
1679 RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr),
1680 ];
1681 final ViewportOffset viewportOffset = ViewportOffset.zero();
1682 final RenderEditable editable = RenderEditable(
1683 backgroundCursorColor: Colors.grey,
1684 selectionColor: Colors.black,
1685 textDirection: TextDirection.ltr,
1686 cursorColor: Colors.red,
1687 offset: viewportOffset,
1688 textSelectionDelegate: delegate,
1689 startHandleLayerLink: LayerLink(),
1690 endHandleLayerLink: LayerLink(),
1691 text: const TextSpan(
1692 style: TextStyle(height: 1.0, fontSize: 10.0),
1693 children: <InlineSpan>[
1694 TextSpan(text: 'test'),
1695 WidgetSpan(child: Text('a')),
1696 TextSpan(
1697 children: <InlineSpan>[WidgetSpan(child: Text('b')), WidgetSpan(child: Text('c'))],
1698 ),
1699 ],
1700 ),
1701 selection: const TextSelection.collapsed(offset: 3),
1702 maxLines: 2,
1703 minLines: 2,
1704 children: renderBoxes,
1705 );
1706
1707 _applyParentData(renderBoxes, editable.text!);
1708 // Force a line wrap
1709 layout(editable, constraints: const BoxConstraints(maxWidth: 75));
1710 editable.hasFocus = true;
1711 pumpFrame();
1712
1713 Rect? composingRect = editable.getRectForComposingRange(const TextRange(start: 4, end: 5));
1714 expect(composingRect, const Rect.fromLTRB(40.0, 0.0, 54.0, 14.0));
1715 composingRect = editable.getRectForComposingRange(const TextRange(start: 5, end: 6));
1716 expect(composingRect, const Rect.fromLTRB(54.0, 0.0, 68.0, 14.0));
1717 composingRect = editable.getRectForComposingRange(const TextRange(start: 6, end: 7));
1718 expect(composingRect, const Rect.fromLTRB(0.0, 14.0, 14.0, 28.0));
1719 composingRect = editable.getRectForComposingRange(const TextRange(start: 7, end: 8));
1720 expect(composingRect, null);
1721 });
1722
1723 test('WidgetSpan render box is painted at correct offset when scrolled', () async {
1724 final TextSelectionDelegate delegate =
1725 _FakeEditableTextState()
1726 ..textEditingValue = const TextEditingValue(
1727 text: 'test',
1728 selection: TextSelection.collapsed(offset: 3),
1729 );
1730 final List<RenderBox> renderBoxes = <RenderBox>[
1731 RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
1732 ];
1733 final ViewportOffset viewportOffset = ViewportOffset.fixed(100.0);
1734 final RenderEditable editable = RenderEditable(
1735 backgroundCursorColor: Colors.grey,
1736 selectionColor: Colors.black,
1737 textDirection: TextDirection.ltr,
1738 cursorColor: Colors.red,
1739 offset: viewportOffset,
1740 textSelectionDelegate: delegate,
1741 startHandleLayerLink: LayerLink(),
1742 endHandleLayerLink: LayerLink(),
1743 maxLines: null,
1744 text: TextSpan(
1745 style: const TextStyle(height: 1.0, fontSize: 10.0),
1746 children: <InlineSpan>[
1747 const TextSpan(text: 'test'),
1748 WidgetSpan(child: Container(width: 10, height: 10, color: Colors.blue)),
1749 ],
1750 ),
1751 selection: const TextSelection.collapsed(offset: 3),
1752 children: renderBoxes,
1753 );
1754 _applyParentData(renderBoxes, editable.text!);
1755 layout(editable);
1756 editable.hasFocus = true;
1757 pumpFrame();
1758
1759 final Rect composingRect =
1760 editable.getRectForComposingRange(const TextRange(start: 4, end: 5))!;
1761 expect(composingRect, const Rect.fromLTRB(40.0, -100.0, 54.0, -86.0));
1762 });
1763
1764 test('can compute IntrinsicWidth for WidgetSpans', () {
1765 // Regression test for https://github.com/flutter/flutter/issues/59316
1766 const double screenWidth = 1000.0;
1767 const double fixedHeight = 1000.0;
1768 const String sentence = 'one two';
1769 final TextSelectionDelegate delegate =
1770 _FakeEditableTextState()
1771 ..textEditingValue = const TextEditingValue(
1772 text: 'test',
1773 selection: TextSelection.collapsed(offset: 3),
1774 );
1775 final List<RenderBox> renderBoxes = <RenderBox>[
1776 RenderParagraph(const TextSpan(text: sentence), textDirection: TextDirection.ltr),
1777 ];
1778 final ViewportOffset viewportOffset = ViewportOffset.zero();
1779 final RenderEditable editable = RenderEditable(
1780 backgroundCursorColor: Colors.grey,
1781 selectionColor: Colors.black,
1782 textDirection: TextDirection.ltr,
1783 cursorColor: Colors.red,
1784 cursorWidth: 0.0,
1785 offset: viewportOffset,
1786 textSelectionDelegate: delegate,
1787 startHandleLayerLink: LayerLink(),
1788 endHandleLayerLink: LayerLink(),
1789 text: const TextSpan(
1790 style: TextStyle(height: 1.0, fontSize: 10.0),
1791 children: <InlineSpan>[TextSpan(text: 'test'), WidgetSpan(child: Text('a'))],
1792 ),
1793 selection: const TextSelection.collapsed(offset: 3),
1794 maxLines: 2,
1795 minLines: 2,
1796 textScaler: const TextScaler.linear(2.0),
1797 children: renderBoxes,
1798 );
1799 _applyParentData(renderBoxes, editable.text!);
1800 // Intrinsics can be computed without doing layout.
1801 expect(
1802 editable.computeMaxIntrinsicWidth(fixedHeight),
1803 2.0 * 10.0 * 4 + 14.0 * 7 + 1.0,
1804 reason:
1805 "intrinsic width = scale factor * width of 'test' + width of 'one two' + _caretMargin",
1806 );
1807 expect(
1808 editable.computeMinIntrinsicWidth(fixedHeight),
1809 math.max(math.max(2.0 * 10.0 * 4, 14.0 * 3), 14.0 * 3),
1810 reason:
1811 "intrinsic width = max(scale factor * width of 'test', width of 'one', width of 'two')",
1812 );
1813 expect(editable.computeMaxIntrinsicHeight(fixedHeight), 40.0);
1814 expect(editable.computeMinIntrinsicHeight(fixedHeight), 40.0);
1815
1816 layout(editable, constraints: const BoxConstraints(maxWidth: screenWidth));
1817 // Intrinsics can be computed after layout.
1818 expect(
1819 editable.computeMaxIntrinsicWidth(fixedHeight),
1820 2.0 * 10.0 * 4 + 14.0 * 7 + 1.0,
1821 reason:
1822 "intrinsic width = scale factor * width of 'test' + width of 'one two' + _caretMargin",
1823 );
1824 });
1825
1826 test('hits correct WidgetSpan when not scrolled', () {
1827 final TextSelectionDelegate delegate =
1828 _FakeEditableTextState()
1829 ..textEditingValue = const TextEditingValue(
1830 text: 'test',
1831 selection: TextSelection.collapsed(offset: 3),
1832 );
1833 final List<RenderBox> renderBoxes = <RenderBox>[
1834 RenderParagraph(const TextSpan(text: 'a'), textDirection: TextDirection.ltr),
1835 RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
1836 RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr),
1837 ];
1838 final RenderEditable editable = RenderEditable(
1839 text: const TextSpan(
1840 style: TextStyle(height: 1.0, fontSize: 10.0),
1841 children: <InlineSpan>[
1842 TextSpan(text: 'test'),
1843 WidgetSpan(child: Text('a')),
1844 TextSpan(
1845 children: <InlineSpan>[WidgetSpan(child: Text('b')), WidgetSpan(child: Text('c'))],
1846 ),
1847 ],
1848 ),
1849 startHandleLayerLink: LayerLink(),
1850 endHandleLayerLink: LayerLink(),
1851 textDirection: TextDirection.ltr,
1852 offset: ViewportOffset.fixed(0.0),
1853 textSelectionDelegate: delegate,
1854 selection: const TextSelection.collapsed(offset: 0),
1855 children: renderBoxes,
1856 );
1857 _applyParentData(renderBoxes, editable.text!);
1858 layout(editable, constraints: BoxConstraints.loose(const Size(500.0, 500.0)));
1859 // Prepare for painting after layout.
1860 pumpFrame(phase: EnginePhase.compositingBits);
1861 BoxHitTestResult result = BoxHitTestResult();
1862 // The WidgetSpans have a height of 14.0, so "test" has a y offset of 4.0.
1863 editable.hitTest(result, position: const Offset(1.0, 5.0));
1864 // We expect two hit test entries in the path because the RenderEditable
1865 // will add itself as well.
1866 expect(result.path, hasLength(2));
1867 HitTestTarget target = result.path.first.target;
1868 expect(target, isA<TextSpan>());
1869 expect((target as TextSpan).text, 'test');
1870 // Only testing the RenderEditable entry here once, not anymore below.
1871 expect(result.path.last.target, isA<RenderEditable>());
1872 result = BoxHitTestResult();
1873 editable.hitTest(result, position: const Offset(15.0, 5.0));
1874 expect(result.path, hasLength(2));
1875 target = result.path.first.target;
1876 expect(target, isA<TextSpan>());
1877 expect((target as TextSpan).text, 'test');
1878
1879 result = BoxHitTestResult();
1880 editable.hitTest(result, position: const Offset(41.0, 0.0));
1881 expect(result.path, hasLength(3));
1882 target = result.path.first.target;
1883 expect(target, isA<TextSpan>());
1884 expect((target as TextSpan).text, 'a');
1885
1886 result = BoxHitTestResult();
1887 editable.hitTest(result, position: const Offset(55.0, 0.0));
1888 expect(result.path, hasLength(3));
1889 target = result.path.first.target;
1890 expect(target, isA<TextSpan>());
1891 expect((target as TextSpan).text, 'b');
1892
1893 result = BoxHitTestResult();
1894 editable.hitTest(result, position: const Offset(69.0, 5.0));
1895 expect(result.path, hasLength(3));
1896 target = result.path.first.target;
1897 expect(target, isA<TextSpan>());
1898 expect((target as TextSpan).text, 'c');
1899
1900 result = BoxHitTestResult();
1901 editable.hitTest(result, position: const Offset(5.0, 15.0));
1902 expect(result.path, hasLength(0));
1903 });
1904
1905 test('hits correct WidgetSpan when scrolled', () {
1906 final String text = '${"\n" * 10}test';
1907 final TextSelectionDelegate delegate =
1908 _FakeEditableTextState()
1909 ..textEditingValue = TextEditingValue(
1910 text: text,
1911 selection: const TextSelection.collapsed(offset: 13),
1912 );
1913 final List<RenderBox> renderBoxes = <RenderBox>[
1914 RenderParagraph(const TextSpan(text: 'a'), textDirection: TextDirection.ltr),
1915 RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
1916 RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr),
1917 ];
1918 final RenderEditable editable = RenderEditable(
1919 maxLines: null,
1920 text: TextSpan(
1921 style: const TextStyle(height: 1.0, fontSize: 10.0),
1922 children: <InlineSpan>[
1923 TextSpan(text: text),
1924 const WidgetSpan(child: Text('a')),
1925 const TextSpan(
1926 children: <InlineSpan>[WidgetSpan(child: Text('b')), WidgetSpan(child: Text('c'))],
1927 ),
1928 ],
1929 ),
1930 startHandleLayerLink: LayerLink(),
1931 endHandleLayerLink: LayerLink(),
1932 textDirection: TextDirection.ltr,
1933 offset: ViewportOffset.fixed(100.0), // equal to the height of the 10 empty lines
1934 textSelectionDelegate: delegate,
1935 selection: const TextSelection.collapsed(offset: 0),
1936 children: renderBoxes,
1937 );
1938 _applyParentData(renderBoxes, editable.text!);
1939 layout(editable, constraints: BoxConstraints.loose(const Size(500.0, 500.0)));
1940 // Prepare for painting after layout.
1941 pumpFrame(phase: EnginePhase.compositingBits);
1942 BoxHitTestResult result = BoxHitTestResult();
1943 // The WidgetSpans have a height of 14.0, so "test" has a y offset of 4.0.
1944 editable.hitTest(result, position: const Offset(0.0, 4.0));
1945 // We expect two hit test entries in the path because the RenderEditable
1946 // will add itself as well.
1947 expect(result.path, hasLength(2));
1948 HitTestTarget target = result.path.first.target;
1949 expect(target, isA<TextSpan>());
1950 expect((target as TextSpan).text, text);
1951 // Only testing the RenderEditable entry here once, not anymore below.
1952 expect(result.path.last.target, isA<RenderEditable>());
1953 result = BoxHitTestResult();
1954 editable.hitTest(result, position: const Offset(15.0, 4.0));
1955 expect(result.path, hasLength(2));
1956 target = result.path.first.target;
1957 expect(target, isA<TextSpan>());
1958 expect((target as TextSpan).text, text);
1959
1960 result = BoxHitTestResult();
1961 // "test" is 40 pixel wide.
1962 editable.hitTest(result, position: const Offset(41.0, 0.0));
1963 expect(result.path, hasLength(3));
1964 target = result.path.first.target;
1965 expect(target, isA<TextSpan>());
1966 expect((target as TextSpan).text, 'a');
1967
1968 result = BoxHitTestResult();
1969 editable.hitTest(result, position: const Offset(55.0, 0.0));
1970 expect(result.path, hasLength(3));
1971 target = result.path.first.target;
1972 expect(target, isA<TextSpan>());
1973 expect((target as TextSpan).text, 'b');
1974
1975 result = BoxHitTestResult();
1976 editable.hitTest(result, position: const Offset(69.0, 5.0));
1977 expect(result.path, hasLength(3));
1978 target = result.path.first.target;
1979 expect(target, isA<TextSpan>());
1980 expect((target as TextSpan).text, 'c');
1981
1982 result = BoxHitTestResult();
1983 editable.hitTest(result, position: const Offset(5.0, 15.0));
1984 expect(result.path, hasLength(1)); // Only the RenderEditable.
1985 });
1986 });
1987
1988 test('does not skip TextPainter.layout because of invalid cache', () {
1989 // Regression test for https://github.com/flutter/flutter/issues/84896.
1990 final TextSelectionDelegate delegate = _FakeEditableTextState();
1991 const BoxConstraints constraints = BoxConstraints(minWidth: 100, maxWidth: 500);
1992 final RenderEditable editable = RenderEditable(
1993 text: const TextSpan(style: TextStyle(height: 1.0, fontSize: 10.0), text: 'A'),
1994 startHandleLayerLink: LayerLink(),
1995 endHandleLayerLink: LayerLink(),
1996 textDirection: TextDirection.ltr,
1997 locale: const Locale('en', 'US'),
1998 offset: ViewportOffset.fixed(10.0),
1999 textSelectionDelegate: delegate,
2000 selection: const TextSelection.collapsed(offset: 0),
2001 cursorColor: const Color(0xFFFFFFFF),
2002 showCursor: ValueNotifier<bool>(true),
2003 );
2004 layout(editable, constraints: constraints);
2005
2006 // ignore: invalid_use_of_protected_member
2007 final double initialWidth = editable.computeDryLayout(constraints).width;
2008 expect(initialWidth, 500);
2009
2010 // Turn off forceLine. Now the width should be significantly smaller.
2011 editable.forceLine = false;
2012 // ignore: invalid_use_of_protected_member
2013 expect(editable.computeDryLayout(constraints).width, lessThan(initialWidth));
2014 });
2015
2016 test('Floating cursor position is independent of viewport offset', () {
2017 final TextSelectionDelegate delegate = _FakeEditableTextState();
2018 final ValueNotifier<bool> showCursor = ValueNotifier<bool>(true);
2019 EditableText.debugDeterministicCursor = true;
2020
2021 const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00);
2022
2023 final RenderEditable editable = RenderEditable(
2024 backgroundCursorColor: Colors.grey,
2025 textDirection: TextDirection.ltr,
2026 cursorColor: cursorColor,
2027 offset: ViewportOffset.zero(),
2028 textSelectionDelegate: delegate,
2029 text: const TextSpan(text: 'test', style: TextStyle(height: 1.0, fontSize: 10.0)),
2030 maxLines: 3,
2031 startHandleLayerLink: LayerLink(),
2032 endHandleLayerLink: LayerLink(),
2033 selection: const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
2034 );
2035
2036 layout(editable);
2037
2038 editable.layout(BoxConstraints.loose(const Size(100, 100)));
2039 // Prepare for painting after layout.
2040 pumpFrame(phase: EnginePhase.compositingBits);
2041
2042 expect(
2043 editable,
2044 // Draw no cursor by default.
2045 paintsExactlyCountTimes(#drawRect, 0),
2046 );
2047
2048 editable.showCursor = showCursor;
2049 editable.setFloatingCursor(
2050 FloatingCursorDragState.Start,
2051 const Offset(50, 50),
2052 const TextPosition(offset: 4, affinity: TextAffinity.upstream),
2053 );
2054 pumpFrame(phase: EnginePhase.compositingBits);
2055
2056 final RRect expectedRRect = RRect.fromRectAndRadius(
2057 const Rect.fromLTWH(49.5, 51, 2, 8),
2058 const Radius.circular(1),
2059 );
2060
2061 expect(editable, paints..rrect(color: cursorColor.withOpacity(0.75), rrect: expectedRRect));
2062
2063 // Change the text viewport offset.
2064 editable.offset = ViewportOffset.fixed(200);
2065
2066 // Floating cursor should be drawn in the same position.
2067 editable.setFloatingCursor(
2068 FloatingCursorDragState.Start,
2069 const Offset(50, 50),
2070 const TextPosition(offset: 4, affinity: TextAffinity.upstream),
2071 );
2072 pumpFrame(phase: EnginePhase.compositingBits);
2073
2074 expect(editable, paints..rrect(color: cursorColor.withOpacity(0.75), rrect: expectedRRect));
2075 });
2076
2077 test('getWordAtOffset with a negative position', () {
2078 const String text = 'abc';
2079 final _FakeEditableTextState delegate =
2080 _FakeEditableTextState()..textEditingValue = const TextEditingValue(text: text);
2081 final ViewportOffset viewportOffset = ViewportOffset.zero();
2082 final RenderEditable editable = RenderEditable(
2083 backgroundCursorColor: Colors.grey,
2084 selectionColor: Colors.black,
2085 textDirection: TextDirection.ltr,
2086 cursorColor: Colors.red,
2087 offset: viewportOffset,
2088 textSelectionDelegate: delegate,
2089 startHandleLayerLink: LayerLink(),
2090 endHandleLayerLink: LayerLink(),
2091 text: const TextSpan(text: text, style: TextStyle(height: 1.0, fontSize: 10.0)),
2092 );
2093
2094 layout(editable, onErrors: expectNoFlutterErrors);
2095
2096 // Cause text metrics to be computed.
2097 editable.computeDistanceToActualBaseline(TextBaseline.alphabetic);
2098
2099 final TextSelection selection;
2100 try {
2101 selection = editable.getWordAtOffset(
2102 const TextPosition(offset: -1, affinity: TextAffinity.upstream),
2103 );
2104 } catch (error) {
2105 // In debug mode, negative offsets are caught by an assertion.
2106 expect(error, isA<AssertionError>());
2107 return;
2108 }
2109
2110 // Web's Paragraph.getWordBoundary behaves differently for a negative
2111 // position.
2112 if (kIsWeb) {
2113 expect(selection, const TextSelection.collapsed(offset: 0));
2114 } else {
2115 expect(selection, const TextSelection.collapsed(offset: text.length));
2116 }
2117 });
2118}
2119
2120class _TestRenderEditable extends RenderEditable {
2121 _TestRenderEditable({
2122 required super.textDirection,
2123 required super.offset,
2124 required super.textSelectionDelegate,
2125 TextSpan? super.text,
2126 required super.startHandleLayerLink,
2127 required super.endHandleLayerLink,
2128 super.selection,
2129 });
2130
2131 int paintCount = 0;
2132
2133 @override
2134 void paint(PaintingContext context, Offset offset) {
2135 super.paint(context, offset);
2136 paintCount += 1;
2137 }
2138}
2139
2140class _TestRenderEditablePainter extends RenderEditablePainter {
2141 _TestRenderEditablePainter({this.color = const Color(0x12345678)});
2142
2143 final Color color;
2144
2145 bool repaint = true;
2146 int paintCount = 0;
2147
2148 @override
2149 void paint(Canvas canvas, Size size, RenderEditable renderEditable) {
2150 paintCount += 1;
2151 canvas.drawRect(const Rect.fromLTRB(1, 1, 1, 1), Paint()..color = color);
2152 }
2153
2154 @override
2155 bool shouldRepaint(RenderEditablePainter? oldDelegate) => repaint;
2156
2157 void markNeedsPaint() {
2158 notifyListeners();
2159 }
2160
2161 @override
2162 String toString() => '_TestRenderEditablePainter#${shortHash(this)}';
2163}
2164

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com