Kate Lovett 9d96df2364
Modernize framework lints (#179089)
WIP

Commits separated as follows:
- Update lints in analysis_options files
- Run `dart fix --apply`
- Clean up leftover analysis issues 
- Run `dart format .` in the right places.

Local analysis and testing passes. Checking CI now.

Part of https://github.com/flutter/flutter/issues/178827
- Adoption of flutter_lints in examples/api coming in a separate change
(cc @loic-sharma)

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
2025-11-26 01:10:39 +00:00

1554 lines
56 KiB
Dart

// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, Paragraph, TextBox;
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'rendering_tester.dart';
const String _kText =
"I polished up that handle so carefullee\nThat now I am the Ruler of the Queen's Navee!";
void _applyParentData(List<RenderBox> inlineRenderBoxes, InlineSpan span) {
var index = 0;
RenderBox? previousBox;
span.visitChildren((InlineSpan span) {
if (span is! WidgetSpan) {
return true;
}
final RenderBox box = inlineRenderBoxes[index];
box.parentData = TextParentData()
..span = span
..previousSibling = previousBox;
(previousBox?.parentData as TextParentData?)?.nextSibling = box;
index += 1;
previousBox = box;
return true;
});
}
// A subclass of RenderParagraph that returns an empty list in getBoxesForSelection
// for a given TextSelection.
// This is intended to simulate SkParagraph's implementation of Paragraph.getBoxesForRange,
// which may return an empty list in some situations where Libtxt would return a list
// containing an empty box.
class RenderParagraphWithEmptySelectionBoxList extends RenderParagraph {
RenderParagraphWithEmptySelectionBoxList(
super.text, {
required super.textDirection,
required this.emptyListSelection,
});
TextSelection emptyListSelection;
@override
List<ui.TextBox> getBoxesForSelection(
TextSelection selection, {
ui.BoxHeightStyle boxHeightStyle = ui.BoxHeightStyle.tight,
ui.BoxWidthStyle boxWidthStyle = ui.BoxWidthStyle.tight,
}) {
if (selection == emptyListSelection) {
return <ui.TextBox>[];
}
return super.getBoxesForSelection(
selection,
boxHeightStyle: boxHeightStyle,
boxWidthStyle: boxWidthStyle,
);
}
}
// A subclass of RenderParagraph that returns an empty list in getBoxesForSelection
// for a selection representing a WidgetSpan.
// This is intended to simulate how SkParagraph's implementation of Paragraph.getBoxesForRange
// can return an empty list for a WidgetSpan with empty dimensions.
class RenderParagraphWithEmptyBoxListForWidgetSpan extends RenderParagraph {
RenderParagraphWithEmptyBoxListForWidgetSpan(
super.text, {
required List<RenderBox> super.children,
required super.textDirection,
});
@override
List<ui.TextBox> getBoxesForSelection(
TextSelection selection, {
ui.BoxHeightStyle boxHeightStyle = ui.BoxHeightStyle.tight,
ui.BoxWidthStyle boxWidthStyle = ui.BoxWidthStyle.tight,
}) {
if (text.getSpanForPosition(selection.base) is WidgetSpan) {
return <ui.TextBox>[];
}
return super.getBoxesForSelection(
selection,
boxHeightStyle: boxHeightStyle,
boxWidthStyle: boxWidthStyle,
);
}
}
void main() {
TestRenderingFlutterBinding.ensureInitialized();
test('getOffsetForCaret control test', () {
final paragraph = RenderParagraph(
const TextSpan(text: _kText),
textDirection: TextDirection.ltr,
);
layout(paragraph);
const caret = Rect.fromLTWH(0.0, 0.0, 2.0, 20.0);
final Offset offset5 = paragraph.getOffsetForCaret(const TextPosition(offset: 5), caret);
expect(offset5.dx, greaterThan(0.0));
final Offset offset25 = paragraph.getOffsetForCaret(const TextPosition(offset: 25), caret);
expect(offset25.dx, greaterThan(offset5.dx));
final Offset offset50 = paragraph.getOffsetForCaret(const TextPosition(offset: 50), caret);
expect(offset50.dy, greaterThan(offset5.dy));
});
test('getFullHeightForCaret control test', () {
final paragraph = RenderParagraph(
const TextSpan(text: _kText, style: TextStyle(fontSize: 10.0)),
textDirection: TextDirection.ltr,
);
layout(paragraph);
final double height5 = paragraph.getFullHeightForCaret(const TextPosition(offset: 5));
expect(height5, equals(10.0));
});
test('getPositionForOffset control test', () {
final paragraph = RenderParagraph(
const TextSpan(text: _kText),
textDirection: TextDirection.ltr,
);
layout(paragraph);
final TextPosition position20 = paragraph.getPositionForOffset(const Offset(20.0, 5.0));
expect(position20.offset, greaterThan(0.0));
final TextPosition position40 = paragraph.getPositionForOffset(const Offset(40.0, 5.0));
expect(position40.offset, greaterThan(position20.offset));
final TextPosition positionBelow = paragraph.getPositionForOffset(const Offset(5.0, 20.0));
expect(positionBelow.offset, greaterThan(position40.offset));
});
test('getBoxesForSelection control test', () {
final paragraph = RenderParagraph(
const TextSpan(text: _kText, style: TextStyle(fontSize: 10.0)),
textDirection: TextDirection.ltr,
);
layout(paragraph);
List<ui.TextBox> boxes = paragraph.getBoxesForSelection(
const TextSelection(baseOffset: 5, extentOffset: 25),
);
expect(boxes.length, equals(1));
boxes = paragraph.getBoxesForSelection(const TextSelection(baseOffset: 25, extentOffset: 50));
expect(boxes.any((ui.TextBox box) => box.left == 250 && box.top == 0), isTrue);
expect(boxes.any((ui.TextBox box) => box.right == 100 && box.top == 10), isTrue);
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61016
test('getBoxesForSelection test with multiple TextSpans and lines', () {
final paragraph = RenderParagraph(
const TextSpan(
text: 'First ',
style: TextStyle(fontSize: 10.0),
children: <InlineSpan>[
TextSpan(text: 'smallsecond ', style: TextStyle(fontSize: 5.0)),
TextSpan(text: 'third fourth fifth'),
],
),
textDirection: TextDirection.ltr,
);
// Do layout with width chosen so that this splits as
// First smallsecond |
// third fourth |
// fifth|
// The corresponding line widths come out to be:
// 1st line: 120px wide: 6 chars * 10px plus 12 chars * 5px.
// 2nd line: 130px wide: 13 chars * 10px.
// 3rd line: 50px wide.
layout(paragraph, constraints: const BoxConstraints(maxWidth: 140.0));
final List<ui.TextBox> boxes = paragraph.getBoxesForSelection(
const TextSelection(baseOffset: 0, extentOffset: 36),
);
expect(boxes.length, equals(4));
// The widths of the boxes should match the calculations above.
// The heights should all be 10, except for the box for 'smallsecond ',
// which should have height 5, and be alphabetic baseline-aligned with
// 'First '. The test font specifies alphabetic baselines at 0.25em above
// the bottom extent, and 0.75em below the top, so the difference in top
// alignment becomes (10px * 0.75 - 5px * 0.75) = 3.75px.
// 'First ':
expect(boxes[0], const TextBox.fromLTRBD(0.0, 0.0, 60.0, 10.0, TextDirection.ltr));
// 'smallsecond ' in size 5:
expect(boxes[1], const TextBox.fromLTRBD(60.0, 3.75, 120.0, 8.75, TextDirection.ltr));
// 'third fourth ':
expect(boxes[2], const TextBox.fromLTRBD(0.0, 10.0, 130.0, 20.0, TextDirection.ltr));
// 'fifth':
expect(boxes[3], const TextBox.fromLTRBD(0.0, 20.0, 50.0, 30.0, TextDirection.ltr));
});
test('getBoxesForSelection test with boxHeightStyle and boxWidthStyle set to max', () {
final paragraph = RenderParagraph(
const TextSpan(
text: 'First ',
style: TextStyle(fontFamily: 'FlutterTest', fontSize: 10.0),
children: <InlineSpan>[
TextSpan(text: 'smallsecond ', style: TextStyle(fontSize: 8.0)),
TextSpan(text: 'third fourth fifth'),
],
),
textDirection: TextDirection.ltr,
);
// Do layout with width chosen so that this splits as
// First smallsecond |
// third fourth |
// fifth|
// The corresponding line widths come out to be:
// 1st line: 156px wide: 6 chars * 10px plus 12 chars * 8px.
// 2nd line: 130px wide: 13 chars * 10px.
// 3rd line: 50px wide.
layout(paragraph, constraints: const BoxConstraints(maxWidth: 160.0));
final List<ui.TextBox> boxes = paragraph.getBoxesForSelection(
const TextSelection(baseOffset: 0, extentOffset: 36),
boxHeightStyle: ui.BoxHeightStyle.max,
boxWidthStyle: ui.BoxWidthStyle.max,
);
expect(boxes.length, equals(5));
// 'First ':
expect(boxes[0], const TextBox.fromLTRBD(0.0, 0.0, 60.0, 10.0, TextDirection.ltr));
// 'smallsecond ' in size 8, but on same line as previous box, so height remains 10:
expect(boxes[1], const TextBox.fromLTRBD(60.0, 0.0, 156.0, 10.0, TextDirection.ltr));
// 'third fourth ':
expect(boxes[2], const TextBox.fromLTRBD(0.0, 10.0, 130.0, 20.0, TextDirection.ltr));
// extra box added to extend width, as per definition of ui.BoxWidthStyle.max:
expect(boxes[3], const TextBox.fromLTRBD(130.0, 10.0, 156.0, 20.0, TextDirection.ltr));
// 'fifth':
expect(boxes[4], const TextBox.fromLTRBD(0.0, 20.0, 50.0, 30.0, TextDirection.ltr));
});
test('getWordBoundary control test', () {
final paragraph = RenderParagraph(
const TextSpan(text: _kText),
textDirection: TextDirection.ltr,
);
layout(paragraph);
final TextRange range5 = paragraph.getWordBoundary(const TextPosition(offset: 5));
expect(range5.textInside(_kText), equals('polished'));
final TextRange range50 = paragraph.getWordBoundary(const TextPosition(offset: 50));
expect(range50.textInside(_kText), equals(' '));
final TextRange range85 = paragraph.getWordBoundary(const TextPosition(offset: 75));
expect(range85.textInside(_kText), equals("Queen's"));
});
test('overflow test', () {
final paragraph = RenderParagraph(
const TextSpan(
text:
'This\n' // 4 characters * 10px font size = 40px width on the first line
'is a wrapping test. It should wrap at manual newlines, and if softWrap is true, also at spaces.',
style: TextStyle(fontSize: 10.0),
),
textDirection: TextDirection.ltr,
maxLines: 1,
);
void relayoutWith({int? maxLines, required bool softWrap, required TextOverflow overflow}) {
paragraph
..maxLines = maxLines
..softWrap = softWrap
..overflow = overflow;
pumpFrame();
}
// Lay out in a narrow box to force wrapping.
layout(
paragraph,
constraints: const BoxConstraints(maxWidth: 50.0),
); // enough to fit "This" but not "This is"
final double lineHeight = paragraph.size.height;
relayoutWith(maxLines: 3, softWrap: true, overflow: TextOverflow.clip);
expect(paragraph.size.height, equals(3 * lineHeight));
relayoutWith(softWrap: true, overflow: TextOverflow.clip);
expect(paragraph.size.height, greaterThan(5 * lineHeight));
// Try again with ellipsis overflow. We can't test that the ellipsis are
// drawn, but we can test the sizing.
relayoutWith(maxLines: 1, softWrap: true, overflow: TextOverflow.ellipsis);
expect(paragraph.size.height, equals(lineHeight));
relayoutWith(maxLines: 3, softWrap: true, overflow: TextOverflow.ellipsis);
expect(paragraph.size.height, equals(3 * lineHeight));
// This is the one weird case. If maxLines is null, we would expect to allow
// infinite wrapping. However, if we did, we'd never know when to append an
// ellipsis, so this really means "append ellipsis as soon as we exceed the
// width".
relayoutWith(softWrap: true, overflow: TextOverflow.ellipsis);
expect(paragraph.size.height, equals(2 * lineHeight));
// Now with no soft wrapping.
relayoutWith(maxLines: 1, softWrap: false, overflow: TextOverflow.clip);
expect(paragraph.size.height, equals(lineHeight));
relayoutWith(maxLines: 3, softWrap: false, overflow: TextOverflow.clip);
expect(paragraph.size.height, equals(2 * lineHeight));
relayoutWith(softWrap: false, overflow: TextOverflow.clip);
expect(paragraph.size.height, equals(2 * lineHeight));
relayoutWith(maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis);
expect(paragraph.size.height, equals(lineHeight));
relayoutWith(maxLines: 3, softWrap: false, overflow: TextOverflow.ellipsis);
expect(paragraph.size.height, equals(3 * lineHeight));
relayoutWith(softWrap: false, overflow: TextOverflow.ellipsis);
expect(paragraph.size.height, equals(2 * lineHeight));
// Test presence of the fade effect.
relayoutWith(maxLines: 3, softWrap: true, overflow: TextOverflow.fade);
expect(paragraph.debugHasOverflowShader, isTrue);
// Change back to ellipsis and check that the fade shader is cleared.
relayoutWith(maxLines: 3, softWrap: true, overflow: TextOverflow.ellipsis);
expect(paragraph.debugHasOverflowShader, isFalse);
relayoutWith(maxLines: 100, softWrap: true, overflow: TextOverflow.fade);
expect(paragraph.debugHasOverflowShader, isFalse);
});
test('maxLines', () {
final paragraph = RenderParagraph(
const TextSpan(
text:
"How do you write like you're running out of time? Write day and night like you're running out of time?",
// 0123456789 0123456789 012 345 0123456 012345 01234 012345678 012345678 0123 012 345 0123456 012345 01234
// 0 1 2 3 4 5 6 7 8 9 10 11 12
style: TextStyle(fontSize: 10.0),
),
textDirection: TextDirection.ltr,
);
layout(paragraph, constraints: const BoxConstraints(maxWidth: 100.0));
void layoutAt(int? maxLines) {
paragraph.maxLines = maxLines;
pumpFrame();
}
layoutAt(null);
expect(paragraph.size.height, 130.0);
layoutAt(1);
expect(paragraph.size.height, 10.0);
layoutAt(2);
expect(paragraph.size.height, 20.0);
layoutAt(3);
expect(paragraph.size.height, 30.0);
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61018
test('textAlign triggers TextPainter relayout in the paint method', () {
final paragraph = RenderParagraph(
const TextSpan(text: 'A', style: TextStyle(fontSize: 10.0)),
textDirection: TextDirection.ltr,
textAlign: TextAlign.left,
);
Rect getRectForA() => paragraph
.getBoxesForSelection(const TextSelection(baseOffset: 0, extentOffset: 1))
.single
.toRect();
layout(paragraph, constraints: const BoxConstraints.tightFor(width: 100.0));
expect(getRectForA(), const Rect.fromLTWH(0, 0, 10, 10));
paragraph.textAlign = TextAlign.right;
expect(paragraph.debugNeedsLayout, isFalse);
expect(paragraph.debugNeedsPaint, isTrue);
paragraph.paint(MockPaintingContext(), Offset.zero);
expect(getRectForA(), const Rect.fromLTWH(90, 0, 10, 10));
});
group('didExceedMaxLines', () {
RenderParagraph createRenderParagraph({
int? maxLines,
TextOverflow overflow = TextOverflow.clip,
}) {
return RenderParagraph(
const TextSpan(
text: 'Here is a long text, maybe exceed maxlines',
style: TextStyle(fontSize: 10.0),
),
textDirection: TextDirection.ltr,
overflow: overflow,
maxLines: maxLines,
);
}
test('none limited', () {
final RenderParagraph paragraph = createRenderParagraph();
layout(paragraph, constraints: const BoxConstraints(maxWidth: 100.0));
expect(paragraph.didExceedMaxLines, false);
});
test('limited by maxLines', () {
final RenderParagraph paragraph = createRenderParagraph(maxLines: 1);
layout(paragraph, constraints: const BoxConstraints(maxWidth: 100.0));
expect(paragraph.didExceedMaxLines, true);
});
test('limited by ellipsis', () {
final RenderParagraph paragraph = createRenderParagraph(overflow: TextOverflow.ellipsis);
layout(paragraph, constraints: const BoxConstraints(maxWidth: 100.0));
expect(paragraph.didExceedMaxLines, true);
});
});
test('changing color does not do layout', () {
final paragraph = RenderParagraph(
const TextSpan(
text: 'Hello',
style: TextStyle(color: Color(0xFF000000)),
),
textDirection: TextDirection.ltr,
);
layout(paragraph, constraints: const BoxConstraints(maxWidth: 100.0), phase: EnginePhase.paint);
expect(paragraph.debugNeedsLayout, isFalse);
expect(paragraph.debugNeedsPaint, isFalse);
paragraph.text = const TextSpan(
text: 'Hello World',
style: TextStyle(color: Color(0xFF000000)),
);
expect(paragraph.debugNeedsLayout, isTrue);
expect(paragraph.debugNeedsPaint, isFalse);
pumpFrame(phase: EnginePhase.paint);
expect(paragraph.debugNeedsLayout, isFalse);
expect(paragraph.debugNeedsPaint, isFalse);
paragraph.text = const TextSpan(
text: 'Hello World',
style: TextStyle(color: Color(0xFFFFFFFF)),
);
expect(paragraph.debugNeedsLayout, isFalse);
expect(paragraph.debugNeedsPaint, isTrue);
pumpFrame(phase: EnginePhase.paint);
expect(paragraph.debugNeedsLayout, isFalse);
expect(paragraph.debugNeedsPaint, isFalse);
});
test('nested TextSpans in paragraph handle linear textScaler correctly.', () {
const testSpan = TextSpan(
text: 'a',
style: TextStyle(fontSize: 10.0),
children: <TextSpan>[
TextSpan(
text: 'b',
children: <TextSpan>[TextSpan(text: 'c')],
style: TextStyle(fontSize: 20.0),
),
TextSpan(text: 'd'),
],
);
final paragraph = RenderParagraph(
testSpan,
textDirection: TextDirection.ltr,
textScaler: const TextScaler.linear(1.3),
);
paragraph.layout(const BoxConstraints());
expect(paragraph.size.width, 78.0);
expect(paragraph.size.height, 26.0);
final int length = testSpan.toPlainText().length;
// Test the sizes of nested spans.
final boxes = <ui.TextBox>[
for (int i = 0; i < length; ++i)
...paragraph.getBoxesForSelection(TextSelection(baseOffset: i, extentOffset: i + 1)),
];
expect(boxes, hasLength(4));
expect(boxes[0].toRect().width, 13.0);
expect(boxes[0].toRect().height, 13.0);
expect(boxes[1].toRect().width, 26.0);
expect(boxes[1].toRect().height, 26.0);
expect(boxes[2].toRect().width, 26.0);
expect(boxes[2].toRect().height, 26.0);
expect(boxes[3].toRect().width, 13.0);
expect(boxes[3].toRect().height, 13.0);
});
test('toStringDeep', () {
final paragraph = RenderParagraph(
const TextSpan(text: _kText),
textDirection: TextDirection.ltr,
locale: const Locale('ja', 'JP'),
);
expect(paragraph, hasAGoodToStringDeep);
expect(
paragraph.toStringDeep(minLevel: DiagnosticLevel.info),
equalsIgnoringHashCodes(
'RenderParagraph#00000 NEEDS-LAYOUT NEEDS-PAINT DETACHED\n'
' │ parentData: MISSING\n'
' │ constraints: MISSING\n'
' │ size: MISSING\n'
' │ textAlign: start\n'
' │ textDirection: ltr\n'
' │ softWrap: wrapping at box width\n'
' │ overflow: clip\n'
' │ locale: ja_JP\n'
' │ maxLines: unlimited\n'
' ╘═╦══ text ═══\n'
' ║ TextSpan:\n'
' ║ "I polished up that handle so carefullee\n'
' ║ That now I am the Ruler of the Queen\'s Navee!"\n'
' ╚═══════════\n',
),
);
});
test('locale setter', () {
// Regression test for https://github.com/flutter/flutter/issues/18175
final paragraph = RenderParagraph(
const TextSpan(text: _kText),
locale: const Locale('zh', 'HK'),
textDirection: TextDirection.ltr,
);
expect(paragraph.locale, const Locale('zh', 'HK'));
paragraph.locale = const Locale('ja', 'JP');
expect(paragraph.locale, const Locale('ja', 'JP'));
});
test('inline widgets test', () {
const text = TextSpan(
text: 'a',
style: TextStyle(fontSize: 10.0),
children: <InlineSpan>[
WidgetSpan(child: SizedBox(width: 21, height: 21)),
WidgetSpan(child: SizedBox(width: 21, height: 21)),
TextSpan(text: 'a'),
WidgetSpan(child: SizedBox(width: 21, height: 21)),
],
);
// Fake the render boxes that correspond to the WidgetSpans. We use
// RenderParagraph to reduce dependencies this test has.
final renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
];
final paragraph = RenderParagraph(
text,
textDirection: TextDirection.ltr,
children: renderBoxes,
);
_applyParentData(renderBoxes, text);
layout(paragraph, constraints: const BoxConstraints(maxWidth: 100.0));
final List<ui.TextBox> boxes = paragraph.getBoxesForSelection(
const TextSelection(baseOffset: 0, extentOffset: 8),
);
expect(boxes.length, equals(5));
expect(boxes[0], const TextBox.fromLTRBD(0.0, 4.0, 10.0, 14.0, TextDirection.ltr));
expect(boxes[1], const TextBox.fromLTRBD(10.0, 0.0, 24.0, 14.0, TextDirection.ltr));
expect(boxes[2], const TextBox.fromLTRBD(24.0, 0.0, 38.0, 14.0, TextDirection.ltr));
expect(boxes[3], const TextBox.fromLTRBD(38.0, 4.0, 48.0, 14.0, TextDirection.ltr));
expect(boxes[4], const TextBox.fromLTRBD(48.0, 0.0, 62.0, 14.0, TextDirection.ltr));
});
test('getBoxesForSelection with boxHeightStyle for inline widgets', () {
const text = TextSpan(
text: 'a',
style: TextStyle(fontSize: 10.0),
children: <InlineSpan>[
WidgetSpan(child: SizedBox(width: 21, height: 21)),
WidgetSpan(child: SizedBox(width: 21, height: 21)),
TextSpan(text: 'a'),
WidgetSpan(child: SizedBox(width: 21, height: 21)),
],
);
// Fake the render boxes that correspond to the WidgetSpans. We use
// RenderParagraph to reduce the dependencies this test has. The dimensions
// of these get used in place of the widths and heights specified in the
// SizedBoxes above: each comes out as (w,h) = (14,14).
final renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
];
final paragraph = RenderParagraph(
text,
textDirection: TextDirection.ltr,
children: renderBoxes,
);
_applyParentData(renderBoxes, text);
layout(paragraph, constraints: const BoxConstraints(maxWidth: 100.0));
final List<ui.TextBox> boxes = paragraph.getBoxesForSelection(
const TextSelection(baseOffset: 0, extentOffset: 8),
boxHeightStyle: ui.BoxHeightStyle.max,
);
expect(boxes.length, equals(5));
expect(boxes[0], const TextBox.fromLTRBD(0.0, 0.0, 10.0, 14.0, TextDirection.ltr));
expect(boxes[1], const TextBox.fromLTRBD(10.0, 0.0, 24.0, 14.0, TextDirection.ltr));
expect(boxes[2], const TextBox.fromLTRBD(24.0, 0.0, 38.0, 14.0, TextDirection.ltr));
expect(boxes[3], const TextBox.fromLTRBD(38.0, 0.0, 48.0, 14.0, TextDirection.ltr));
expect(boxes[4], const TextBox.fromLTRBD(48.0, 0.0, 62.0, 14.0, TextDirection.ltr));
});
test('inline widgets multiline test', () {
const text = TextSpan(
text: 'a',
style: TextStyle(fontSize: 10.0),
children: <InlineSpan>[
WidgetSpan(child: SizedBox(width: 21, height: 21)),
WidgetSpan(child: SizedBox(width: 21, height: 21)),
TextSpan(text: 'a'),
WidgetSpan(child: SizedBox(width: 21, height: 21)),
WidgetSpan(child: SizedBox(width: 21, height: 21)),
WidgetSpan(child: SizedBox(width: 21, height: 21)),
WidgetSpan(child: SizedBox(width: 21, height: 21)),
WidgetSpan(child: SizedBox(width: 21, height: 21)),
],
);
// Fake the render boxes that correspond to the WidgetSpans. We use
// RenderParagraph to reduce dependencies this test has.
final renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
];
final paragraph = RenderParagraph(
text,
textDirection: TextDirection.ltr,
children: renderBoxes,
);
_applyParentData(renderBoxes, text);
layout(paragraph, constraints: const BoxConstraints(maxWidth: 50.0));
final List<ui.TextBox> boxes = paragraph.getBoxesForSelection(
const TextSelection(baseOffset: 0, extentOffset: 12),
);
expect(boxes.length, equals(9));
expect(boxes[0], const TextBox.fromLTRBD(0.0, 4.0, 10.0, 14.0, TextDirection.ltr));
expect(boxes[1], const TextBox.fromLTRBD(10.0, 0.0, 24.0, 14.0, TextDirection.ltr));
expect(boxes[2], const TextBox.fromLTRBD(24.0, 0.0, 38.0, 14.0, TextDirection.ltr));
expect(boxes[3], const TextBox.fromLTRBD(38.0, 4.0, 48.0, 14.0, TextDirection.ltr));
// Wraps
expect(boxes[4], const TextBox.fromLTRBD(0.0, 14.0, 14.0, 28.0, TextDirection.ltr));
expect(boxes[5], const TextBox.fromLTRBD(14.0, 14.0, 28.0, 28.0, TextDirection.ltr));
expect(boxes[6], const TextBox.fromLTRBD(28.0, 14.0, 42.0, 28.0, TextDirection.ltr));
// Wraps
expect(boxes[7], const TextBox.fromLTRBD(0.0, 28.0, 14.0, 42.0, TextDirection.ltr));
expect(boxes[8], const TextBox.fromLTRBD(14.0, 28.0, 28.0, 42.0, TextDirection.ltr));
});
test('Does not include the semantics node of truncated rendering children', () {
// Regression test for https://github.com/flutter/flutter/issues/88180
const double screenWidth = 100;
const sentence = 'truncated';
final renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: sentence), textDirection: TextDirection.ltr),
];
final paragraph = RenderParagraph(
const TextSpan(
text: 'a long line to be truncated.',
children: <InlineSpan>[WidgetSpan(child: Text(sentence))],
),
overflow: TextOverflow.ellipsis,
children: renderBoxes,
textDirection: TextDirection.ltr,
);
_applyParentData(renderBoxes, paragraph.text);
layout(paragraph, constraints: const BoxConstraints(maxWidth: screenWidth));
final result = SemanticsNode();
final truncatedChild = SemanticsNode();
truncatedChild.tags = <SemanticsTag>{const PlaceholderSpanIndexSemanticsTag(0)};
paragraph.assembleSemanticsNode(result, SemanticsConfiguration(), <SemanticsNode>[
truncatedChild,
]);
// It should only contain the semantics node of the TextSpan.
expect(result.childrenCount, 1);
result.visitChildren((SemanticsNode node) {
expect(node != truncatedChild, isTrue);
return true;
});
});
test('Supports gesture recognizer semantics', () {
final paragraph = RenderParagraph(
TextSpan(
text: _kText,
children: <InlineSpan>[
TextSpan(text: 'one', recognizer: TapGestureRecognizer()..onTap = () {}),
TextSpan(text: 'two', recognizer: LongPressGestureRecognizer()..onLongPress = () {}),
TextSpan(text: 'three', recognizer: DoubleTapGestureRecognizer()..onDoubleTap = () {}),
],
),
textDirection: TextDirection.rtl,
);
layout(paragraph);
final node = SemanticsNode();
paragraph.assembleSemanticsNode(node, SemanticsConfiguration(), <SemanticsNode>[]);
final children = <SemanticsNode>[];
node.visitChildren((SemanticsNode child) {
children.add(child);
return true;
});
expect(children.length, 4);
expect(children[0].getSemanticsData().actions, 0);
expect(children[1].getSemanticsData().hasAction(SemanticsAction.tap), true);
expect(children[2].getSemanticsData().hasAction(SemanticsAction.longPress), true);
expect(children[3].getSemanticsData().hasAction(SemanticsAction.tap), true);
});
test('Supports empty text span with spell out', () {
final paragraph = RenderParagraph(
const TextSpan(text: '', spellOut: true),
textDirection: TextDirection.rtl,
);
layout(paragraph);
final node = SemanticsNode();
paragraph.assembleSemanticsNode(node, SemanticsConfiguration(), <SemanticsNode>[]);
expect(node.attributedLabel.string, '');
expect(node.attributedLabel.attributes.length, 0);
});
test('Asserts on unsupported gesture recognizer', () {
final paragraph = RenderParagraph(
TextSpan(
text: _kText,
children: <InlineSpan>[
TextSpan(text: 'three', recognizer: MultiTapGestureRecognizer()..onTap = (int id) {}),
],
),
textDirection: TextDirection.rtl,
);
layout(paragraph);
var failed = false;
try {
paragraph.assembleSemanticsNode(SemanticsNode(), SemanticsConfiguration(), <SemanticsNode>[]);
} on AssertionError catch (e) {
failed = true;
expect(e.message, 'MultiTapGestureRecognizer is not supported.');
}
expect(failed, true);
});
test('assembleSemanticsNode handles text spans that do not yield selection boxes', () {
final RenderParagraph paragraph = RenderParagraphWithEmptySelectionBoxList(
TextSpan(
text: '',
children: <InlineSpan>[
TextSpan(text: 'A', recognizer: TapGestureRecognizer()..onTap = () {}),
TextSpan(text: 'B', recognizer: TapGestureRecognizer()..onTap = () {}),
TextSpan(text: 'C', recognizer: TapGestureRecognizer()..onTap = () {}),
],
),
textDirection: TextDirection.rtl,
emptyListSelection: const TextSelection(baseOffset: 0, extentOffset: 1),
);
layout(paragraph);
final node = SemanticsNode();
paragraph.assembleSemanticsNode(node, SemanticsConfiguration(), <SemanticsNode>[]);
expect(node.childrenCount, 2);
});
test('assembleSemanticsNode handles empty WidgetSpans that do not yield selection boxes', () {
final text = TextSpan(
text: '',
children: <InlineSpan>[
TextSpan(text: 'A', recognizer: TapGestureRecognizer()..onTap = () {}),
const WidgetSpan(child: SizedBox.shrink()),
TextSpan(text: 'C', recognizer: TapGestureRecognizer()..onTap = () {}),
],
);
final renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
];
final RenderParagraph paragraph = RenderParagraphWithEmptyBoxListForWidgetSpan(
text,
children: renderBoxes,
textDirection: TextDirection.ltr,
);
_applyParentData(renderBoxes, paragraph.text);
layout(paragraph);
final node = SemanticsNode();
paragraph.assembleSemanticsNode(node, SemanticsConfiguration(), <SemanticsNode>[]);
expect(node.childrenCount, 2);
});
test('Basic TextSpan Hit testing', () {
final textSpanA = TextSpan(text: 'A' * 10);
const textSpanBC = TextSpan(text: 'BC', style: TextStyle(letterSpacing: 26.0));
final text = TextSpan(
style: const TextStyle(fontSize: 10.0),
children: <InlineSpan>[textSpanA, textSpanBC],
);
final paragraph = RenderParagraph(text, textDirection: TextDirection.ltr);
layout(paragraph, constraints: const BoxConstraints.tightFor(width: 100.0));
BoxHitTestResult result;
// Hit-testing the first line
// First A
expect(
paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(5.0, 5.0)),
isTrue,
);
expect(
result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(),
<TextSpan>[textSpanA],
);
// The last A.
expect(
paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)),
isTrue,
);
expect(
result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(),
<TextSpan>[textSpanA],
);
// Far away from the line.
expect(
paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(200.0, 5.0)),
isFalse,
);
expect(
result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(),
<TextSpan>[],
);
// Hit-testing the second line
// Tapping on B (startX = letter-spacing / 2 = 13.0).
expect(
paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(18.0, 15.0)),
isTrue,
);
expect(
result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(),
<TextSpan>[textSpanBC],
);
// Between B and C, with large letter-spacing.
expect(
paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(31.0, 15.0)),
isTrue,
);
expect(
result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(),
<TextSpan>[textSpanBC],
);
// On C.
expect(
paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(54.0, 15.0)),
isTrue,
);
expect(
result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(),
<TextSpan>[textSpanBC],
);
// After C.
expect(
paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(100.0, 15.0)),
isFalse,
);
expect(
result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(),
<TextSpan>[],
);
// Not even remotely close.
expect(
paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(9999.0, 9999.0)),
isFalse,
);
expect(
result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(),
<TextSpan>[],
);
});
test('TextSpan Hit testing with text justification', () {
const textSpanA = TextSpan(text: 'A '); // The space is a word break.
const textSpanB = TextSpan(text: 'B\u200B'); // The zero-width space is used as a line break.
final textSpanC = TextSpan(
text: 'C' * 10,
); // The third span starts a new line since it's too long for the first line.
// The text should look like:
// A B
// CCCCCCCCCC
final text = TextSpan(
text: '',
style: const TextStyle(fontSize: 10.0),
children: <InlineSpan>[textSpanA, textSpanB, textSpanC],
);
final paragraph = RenderParagraph(
text,
textDirection: TextDirection.ltr,
textAlign: TextAlign.justify,
);
layout(paragraph, constraints: const BoxConstraints.tightFor(width: 100.0));
BoxHitTestResult result;
// Tapping on A.
expect(
paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(5.0, 5.0)),
isTrue,
);
expect(
result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(),
<TextSpan>[textSpanA],
);
// Between A and B.
expect(
paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(50.0, 5.0)),
isTrue,
);
expect(
result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(),
<TextSpan>[textSpanA],
);
// On B.
expect(
paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)),
isTrue,
);
expect(
result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(),
<TextSpan>[textSpanB],
);
});
group('Selection', () {
void selectionParagraph(RenderParagraph paragraph, TextPosition start, TextPosition end) {
for (final Selectable selectable
in (paragraph.registrar! as TestSelectionRegistrar).selectables) {
selectable.dispatchSelectionEvent(
SelectionEdgeUpdateEvent.forStart(
globalPosition: paragraph.getOffsetForCaret(start, Rect.zero) + const Offset(0, 5),
),
);
selectable.dispatchSelectionEvent(
SelectionEdgeUpdateEvent.forEnd(
globalPosition: paragraph.getOffsetForCaret(end, Rect.zero) + const Offset(0, 5),
),
);
}
}
test('subscribe to SelectionRegistrar', () {
final registrar = TestSelectionRegistrar();
final paragraph = RenderParagraph(
const TextSpan(text: '1234567'),
textDirection: TextDirection.ltr,
registrar: registrar,
);
expect(registrar.selectables.length, 1);
paragraph.text = const TextSpan(text: '');
expect(registrar.selectables.length, 0);
});
test('paints selection highlight', () async {
final registrar = TestSelectionRegistrar();
const selectionColor = Color(0xAF6694e8);
final paragraph = RenderParagraph(
const TextSpan(text: '1234567'),
textDirection: TextDirection.ltr,
registrar: registrar,
selectionColor: selectionColor,
);
layout(paragraph);
final paintingContext = MockPaintingContext();
paragraph.paint(paintingContext, Offset.zero);
expect(paintingContext.canvas.drawnRect, isNull);
expect(paintingContext.canvas.drawnRectPaint, isNull);
selectionParagraph(paragraph, const TextPosition(offset: 1), const TextPosition(offset: 5));
paintingContext.canvas.clear();
paragraph.paint(paintingContext, Offset.zero);
expect(paintingContext.canvas.drawnRect, const Rect.fromLTWH(14.0, 0.0, 56.0, 14.0));
expect(paintingContext.canvas.drawnRectPaint!.style, PaintingStyle.fill);
expect(paintingContext.canvas.drawnRectPaint!.color, isSameColorAs(selectionColor));
// Selection highlight is painted before text.
expect(paintingContext.canvas.drawnItemTypes, <Type>[Rect, ui.Paragraph]);
selectionParagraph(paragraph, const TextPosition(offset: 2), const TextPosition(offset: 4));
paragraph.paint(paintingContext, Offset.zero);
expect(paintingContext.canvas.drawnRect, const Rect.fromLTWH(28.0, 0.0, 28.0, 14.0));
expect(paintingContext.canvas.drawnRectPaint!.style, PaintingStyle.fill);
expect(paintingContext.canvas.drawnRectPaint!.color, isSameColorAs(selectionColor));
});
// Regression test for https://github.com/flutter/flutter/issues/126652.
test('paints selection when tap at chinese character', () async {
final registrar = TestSelectionRegistrar();
const selectionColor = Color(0xAF6694e8);
final paragraph = RenderParagraph(
const TextSpan(text: '你好'),
textDirection: TextDirection.ltr,
registrar: registrar,
selectionColor: selectionColor,
);
layout(paragraph);
final paintingContext = MockPaintingContext();
paragraph.paint(paintingContext, Offset.zero);
expect(paintingContext.canvas.drawnRect, isNull);
expect(paintingContext.canvas.drawnRectPaint, isNull);
for (final Selectable selectable
in (paragraph.registrar! as TestSelectionRegistrar).selectables) {
selectable.dispatchSelectionEvent(
const SelectWordSelectionEvent(globalPosition: Offset(7, 0)),
);
}
paintingContext.canvas.clear();
paragraph.paint(paintingContext, Offset.zero);
expect(paintingContext.canvas.drawnRect!.isEmpty, false);
expect(paintingContext.canvas.drawnRectPaint!.style, PaintingStyle.fill);
expect(paintingContext.canvas.drawnRectPaint!.color, isSameColorAs(selectionColor));
});
test('getPositionForOffset works', () async {
final paragraph = RenderParagraph(
const TextSpan(text: '1234567'),
textDirection: TextDirection.ltr,
);
layout(paragraph);
expect(
paragraph.getPositionForOffset(const Offset(42.0, 14.0)),
const TextPosition(offset: 3),
);
});
test('can handle select all when contains widget span', () async {
final registrar = TestSelectionRegistrar();
final renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: 'widget'), textDirection: TextDirection.ltr),
];
final paragraph = RenderParagraph(
const TextSpan(
children: <InlineSpan>[
TextSpan(text: 'before the span'),
WidgetSpan(child: Text('widget')),
TextSpan(text: 'after the span'),
],
),
textDirection: TextDirection.ltr,
registrar: registrar,
children: renderBoxes,
);
_applyParentData(renderBoxes, paragraph.text);
layout(paragraph);
// The widget span will register to the selection container without going
// through the render paragraph.
expect(registrar.selectables.length, 2);
final Selectable segment1 = registrar.selectables[0];
segment1.dispatchSelectionEvent(const SelectAllSelectionEvent());
final SelectionGeometry geometry1 = segment1.value;
expect(geometry1.hasContent, true);
expect(geometry1.status, SelectionStatus.uncollapsed);
final Selectable segment2 = registrar.selectables[1];
segment2.dispatchSelectionEvent(const SelectAllSelectionEvent());
final SelectionGeometry geometry2 = segment2.value;
expect(geometry2.hasContent, true);
expect(geometry2.status, SelectionStatus.uncollapsed);
});
test('can granularly extend selection - character', () async {
final registrar = TestSelectionRegistrar();
final renderBoxes = <RenderBox>[];
final paragraph = RenderParagraph(
const TextSpan(children: <InlineSpan>[TextSpan(text: 'how are you\nI am fine\nThank you')]),
textDirection: TextDirection.ltr,
registrar: registrar,
children: renderBoxes,
);
layout(paragraph);
expect(registrar.selectables.length, 1);
selectionParagraph(paragraph, const TextPosition(offset: 4), const TextPosition(offset: 5));
expect(paragraph.selections.length, 1);
TextSelection selection = paragraph.selections[0];
expect(selection.start, 4); // how [a]re you
expect(selection.end, 5);
// Equivalent to sending shift + arrow-right
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: true,
isEnd: true,
granularity: TextGranularity.character,
),
);
selection = paragraph.selections[0];
expect(selection.start, 4); // how [ar]e you
expect(selection.end, 6);
// Equivalent to sending shift + arrow-left
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: false,
isEnd: true,
granularity: TextGranularity.character,
),
);
selection = paragraph.selections[0];
expect(selection.start, 4); // how [a]re you
expect(selection.end, 5);
});
test('can granularly extend selection - word', () async {
final registrar = TestSelectionRegistrar();
final renderBoxes = <RenderBox>[];
final paragraph = RenderParagraph(
const TextSpan(children: <InlineSpan>[TextSpan(text: 'how are you\nI am fine\nThank you')]),
textDirection: TextDirection.ltr,
registrar: registrar,
children: renderBoxes,
);
layout(paragraph);
expect(registrar.selectables.length, 1);
selectionParagraph(paragraph, const TextPosition(offset: 4), const TextPosition(offset: 5));
expect(paragraph.selections.length, 1);
TextSelection selection = paragraph.selections[0];
expect(selection.start, 4); // how [a]re you
expect(selection.end, 5);
// Equivalent to sending shift + alt + arrow-right.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: true,
isEnd: true,
granularity: TextGranularity.word,
),
);
selection = paragraph.selections[0];
expect(selection.start, 4); // how [are] you
expect(selection.end, 7);
// Equivalent to sending shift + alt + arrow-left.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: false,
isEnd: true,
granularity: TextGranularity.word,
),
);
expect(paragraph.selections.length, 1); // how []are you
expect(paragraph.selections[0], const TextSelection.collapsed(offset: 4));
// Equivalent to sending shift + alt + arrow-left.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: false,
isEnd: true,
granularity: TextGranularity.word,
),
);
selection = paragraph.selections[0];
expect(selection.start, 0); // [how ]are you
expect(selection.end, 4);
});
test('can granularly extend selection - line', () async {
final registrar = TestSelectionRegistrar();
final renderBoxes = <RenderBox>[];
final paragraph = RenderParagraph(
const TextSpan(children: <InlineSpan>[TextSpan(text: 'how are you\nI am fine\nThank you')]),
textDirection: TextDirection.ltr,
registrar: registrar,
children: renderBoxes,
);
layout(paragraph);
expect(registrar.selectables.length, 1);
selectionParagraph(paragraph, const TextPosition(offset: 4), const TextPosition(offset: 5));
expect(paragraph.selections.length, 1);
TextSelection selection = paragraph.selections[0];
expect(selection.start, 4); // how [a]re you
expect(selection.end, 5);
// Equivalent to sending shift + meta + arrow-right.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: true,
isEnd: true,
granularity: TextGranularity.line,
),
);
selection = paragraph.selections[0];
// how [are you]
expect(selection, const TextRange(start: 4, end: 11));
// Equivalent to sending shift + meta + arrow-left.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: false,
isEnd: true,
granularity: TextGranularity.line,
),
);
selection = paragraph.selections[0];
// [how ]are you
expect(selection, const TextRange(start: 0, end: 4));
});
test('can granularly extend selection - document', () async {
final registrar = TestSelectionRegistrar();
final renderBoxes = <RenderBox>[];
final paragraph = RenderParagraph(
const TextSpan(children: <InlineSpan>[TextSpan(text: 'how are you\nI am fine\nThank you')]),
textDirection: TextDirection.ltr,
registrar: registrar,
children: renderBoxes,
);
layout(paragraph);
expect(registrar.selectables.length, 1);
selectionParagraph(paragraph, const TextPosition(offset: 14), const TextPosition(offset: 15));
expect(paragraph.selections.length, 1);
TextSelection selection = paragraph.selections[0];
// how are you
// I [a]m fine
expect(selection.start, 14);
expect(selection.end, 15);
// Equivalent to sending shift + meta + arrow-down.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: true,
isEnd: true,
granularity: TextGranularity.document,
),
);
selection = paragraph.selections[0];
// how are you
// I [am fine
// Thank you]
expect(selection.start, 14);
expect(selection.end, 31);
// Equivalent to sending shift + meta + arrow-up.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: false,
isEnd: true,
granularity: TextGranularity.document,
),
);
selection = paragraph.selections[0];
// [how are you
// I ]am fine
// Thank you
expect(selection.start, 0);
expect(selection.end, 14);
});
test('can granularly extend selection when no active selection', () async {
final registrar = TestSelectionRegistrar();
final renderBoxes = <RenderBox>[];
final paragraph = RenderParagraph(
const TextSpan(children: <InlineSpan>[TextSpan(text: 'how are you\nI am fine\nThank you')]),
textDirection: TextDirection.ltr,
registrar: registrar,
children: renderBoxes,
);
layout(paragraph);
expect(registrar.selectables.length, 1);
expect(paragraph.selections.length, 0);
// Equivalent to sending shift + alt + right.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: true,
isEnd: true,
granularity: TextGranularity.word,
),
);
TextSelection selection = paragraph.selections[0];
// [how] are you
// I am fine
// Thank you
expect(selection.start, 0);
expect(selection.end, 3);
// Remove selection
registrar.selectables[0].dispatchSelectionEvent(const ClearSelectionEvent());
expect(paragraph.selections.length, 0);
// Equivalent to sending shift + alt + left.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: false,
isEnd: true,
granularity: TextGranularity.word,
),
);
selection = paragraph.selections[0];
// how are you
// I am fine
// Thank [you]
expect(selection.start, 28);
expect(selection.end, 31);
});
test('can directionally extend selection', () async {
final registrar = TestSelectionRegistrar();
final renderBoxes = <RenderBox>[];
final paragraph = RenderParagraph(
const TextSpan(children: <InlineSpan>[TextSpan(text: 'how are you\nI am fine\nThank you')]),
textDirection: TextDirection.ltr,
registrar: registrar,
children: renderBoxes,
);
layout(paragraph);
expect(registrar.selectables.length, 1);
selectionParagraph(paragraph, const TextPosition(offset: 14), const TextPosition(offset: 15));
expect(paragraph.selections.length, 1);
TextSelection selection = paragraph.selections[0];
// how are you
// I [a]m fine
expect(selection.start, 14);
expect(selection.end, 15);
final Matrix4 transform = registrar.selectables[0].getTransformTo(null);
final double baseline = MatrixUtils.transformPoint(
transform,
registrar.selectables[0].value.endSelectionPoint!.localPosition,
).dx;
// Equivalent to sending shift + arrow-down.
registrar.selectables[0].dispatchSelectionEvent(
DirectionallyExtendSelectionEvent(
isEnd: true,
dx: baseline,
direction: SelectionExtendDirection.nextLine,
),
);
selection = paragraph.selections[0];
// how are you
// I [am fine
// Tha]nk you
expect(selection.start, 14);
expect(selection.end, 25);
// Equivalent to sending shift + arrow-up.
registrar.selectables[0].dispatchSelectionEvent(
DirectionallyExtendSelectionEvent(
isEnd: true,
dx: baseline,
direction: SelectionExtendDirection.previousLine,
),
);
selection = paragraph.selections[0];
// how are you
// I [a]m fine
// Thank you
expect(selection.start, 14);
expect(selection.end, 15);
});
test('can directionally extend selection when no selection', () async {
final registrar = TestSelectionRegistrar();
final renderBoxes = <RenderBox>[];
final paragraph = RenderParagraph(
const TextSpan(children: <InlineSpan>[TextSpan(text: 'how are you\nI am fine\nThank you')]),
textDirection: TextDirection.ltr,
registrar: registrar,
children: renderBoxes,
);
layout(paragraph);
expect(registrar.selectables.length, 1);
expect(paragraph.selections.length, 0);
final Matrix4 transform = registrar.selectables[0].getTransformTo(null);
final double baseline = MatrixUtils.transformPoint(
transform,
Offset(registrar.selectables[0].size.width / 2, 0),
).dx;
// Equivalent to sending shift + arrow-down.
registrar.selectables[0].dispatchSelectionEvent(
DirectionallyExtendSelectionEvent(
isEnd: true,
dx: baseline,
direction: SelectionExtendDirection.forward,
),
);
TextSelection selection = paragraph.selections[0];
// [how ar]e you
// I am fine
// Thank you
expect(selection.start, 0);
expect(selection.end, 6);
registrar.selectables[0].dispatchSelectionEvent(const ClearSelectionEvent());
expect(paragraph.selections.length, 0);
// Equivalent to sending shift + arrow-up.
registrar.selectables[0].dispatchSelectionEvent(
DirectionallyExtendSelectionEvent(
isEnd: true,
dx: baseline,
direction: SelectionExtendDirection.backward,
),
);
selection = paragraph.selections[0];
// how are you
// I am fine
// Thank [you]
expect(selection.start, 28);
expect(selection.end, 31);
});
});
test('can just update the gesture recognizer', () async {
final recognizerBefore = TapGestureRecognizer()..onTap = () {};
final paragraph = RenderParagraph(
TextSpan(text: 'How are you \n', recognizer: recognizerBefore),
textDirection: TextDirection.ltr,
);
var semanticsUpdateCount = 0;
final SemanticsHandle semanticsHandle = TestRenderingFlutterBinding.instance.ensureSemantics();
TestRenderingFlutterBinding.instance.pipelineOwner.semanticsOwner!.addListener(() {
++semanticsUpdateCount;
});
layout(paragraph);
expect((paragraph.text as TextSpan).recognizer, same(recognizerBefore));
final nodeBefore = SemanticsNode();
paragraph.assembleSemanticsNode(nodeBefore, SemanticsConfiguration(), <SemanticsNode>[]);
expect(semanticsUpdateCount, 0);
var children = <SemanticsNode>[];
nodeBefore.visitChildren((SemanticsNode child) {
children.add(child);
return true;
});
SemanticsData data = children.single.getSemanticsData();
expect(data.hasAction(SemanticsAction.longPress), false);
expect(data.hasAction(SemanticsAction.tap), true);
final recognizerAfter = LongPressGestureRecognizer()..onLongPress = () {};
paragraph.text = TextSpan(text: 'How are you \n', recognizer: recognizerAfter);
pumpFrame(phase: EnginePhase.flushSemantics);
expect((paragraph.text as TextSpan).recognizer, same(recognizerAfter));
final nodeAfter = SemanticsNode();
paragraph.assembleSemanticsNode(nodeAfter, SemanticsConfiguration(), <SemanticsNode>[]);
expect(semanticsUpdateCount, 1);
children = <SemanticsNode>[];
nodeAfter.visitChildren((SemanticsNode child) {
children.add(child);
return true;
});
data = children.single.getSemanticsData();
expect(data.hasAction(SemanticsAction.longPress), true);
expect(data.hasAction(SemanticsAction.tap), false);
semanticsHandle.dispose();
});
}
class MockCanvas extends Fake implements Canvas {
Rect? drawnRect;
Paint? drawnRectPaint;
List<Type> drawnItemTypes = <Type>[];
@override
void drawRect(Rect rect, Paint paint) {
drawnRect = rect;
drawnRectPaint = paint;
drawnItemTypes.add(Rect);
}
@override
void drawParagraph(ui.Paragraph paragraph, Offset offset) {
drawnItemTypes.add(ui.Paragraph);
}
void clear() {
drawnRect = null;
drawnRectPaint = null;
drawnItemTypes.clear();
}
}
class MockPaintingContext extends Fake implements PaintingContext {
@override
final MockCanvas canvas = MockCanvas();
}
class TestSelectionRegistrar extends SelectionRegistrar {
final List<Selectable> selectables = <Selectable>[];
@override
void add(Selectable selectable) {
selectables.add(selectable);
}
@override
void remove(Selectable selectable) {
expect(selectables.remove(selectable), isTrue);
}
}