Unverified Commit e2e8bcb1 authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Reland `find.textRange.ofSubstring` changes (#140469)

Extracted from https://github.com/flutter/flutter/pull/139717 as-is. Landing this change first so we can avoid doing a g3fix.
parent d6e435a7
......@@ -6,6 +6,7 @@ import 'dart:math' show max, min;
import 'dart:ui' as ui show
BoxHeightStyle,
BoxWidthStyle,
GlyphInfo,
LineMetrics,
Paragraph,
ParagraphBuilder,
......@@ -24,6 +25,7 @@ import 'strut_style.dart';
import 'text_scaler.dart';
import 'text_span.dart';
export 'dart:ui' show LineMetrics;
export 'package:flutter/services.dart' show TextRange, TextSelection;
/// The default font size if none is specified.
......@@ -1493,7 +1495,24 @@ class TextPainter {
: boxes.map((TextBox box) => _shiftTextBox(box, offset)).toList(growable: false);
}
/// Returns the position within the text for the given pixel offset.
/// Returns the [GlyphInfo] of the glyph closest to the given `offset` in the
/// paragraph coordinate system, or null if the text is empty, or is entirely
/// clipped or ellipsized away.
///
/// This method first finds the line closest to `offset.dy`, and then returns
/// the [GlyphInfo] of the closest glyph(s) within that line.
ui.GlyphInfo? getClosestGlyphForOffset(Offset offset) {
assert(_debugAssertTextLayoutIsValid);
assert(!_debugNeedsRelayout);
final _TextPainterLayoutCacheWithOffset cachedLayout = _layoutCache!;
final ui.GlyphInfo? rawGlyphInfo = cachedLayout.paragraph.getClosestGlyphInfoForOffset(offset - cachedLayout.paintOffset);
if (rawGlyphInfo == null || cachedLayout.paintOffset == Offset.zero) {
return rawGlyphInfo;
}
return ui.GlyphInfo(rawGlyphInfo.graphemeClusterLayoutBounds.shift(cachedLayout.paintOffset), rawGlyphInfo.graphemeClusterCodeUnitRange, rawGlyphInfo.writingDirection);
}
/// Returns the closest position within the text for the given pixel offset.
TextPosition getPositionForOffset(Offset offset) {
assert(_debugAssertTextLayoutIsValid);
assert(!_debugNeedsRelayout);
......
......@@ -343,18 +343,20 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati
/// Returns the text span that contains the given position in the text.
@override
InlineSpan? getSpanForPositionVisitor(TextPosition position, Accumulator offset) {
if (text == null) {
final String? text = this.text;
if (text == null || text.isEmpty) {
return null;
}
final TextAffinity affinity = position.affinity;
final int targetOffset = position.offset;
final int endOffset = offset.value + text!.length;
final int endOffset = offset.value + text.length;
if (offset.value == targetOffset && affinity == TextAffinity.downstream ||
offset.value < targetOffset && targetOffset < endOffset ||
endOffset == targetOffset && affinity == TextAffinity.upstream) {
return this;
}
offset.increment(text!.length);
offset.increment(text.length);
return null;
}
......
......@@ -250,6 +250,24 @@ void main() {
expect(textSpan2.compareTo(textSpan2), RenderComparison.identical);
});
test('GetSpanForPosition', () {
const TextSpan textSpan = TextSpan(
text: '',
children: <InlineSpan>[
TextSpan(text: '', children: <InlineSpan>[
TextSpan(text: 'a'),
]),
TextSpan(text: 'b'),
TextSpan(text: 'c'),
],
);
expect((textSpan.getSpanForPosition(const TextPosition(offset: 0)) as TextSpan?)?.text, 'a');
expect((textSpan.getSpanForPosition(const TextPosition(offset: 1)) as TextSpan?)?.text, 'b');
expect((textSpan.getSpanForPosition(const TextPosition(offset: 2)) as TextSpan?)?.text, 'c');
expect((textSpan.getSpanForPosition(const TextPosition(offset: 3)) as TextSpan?)?.text, isNull);
});
test('GetSpanForPosition with WidgetSpan', () {
const TextSpan textSpan = TextSpan(
text: 'a',
......
......@@ -24,6 +24,23 @@ const double kDragSlopDefault = 20.0;
const String _defaultPlatform = kIsWeb ? 'web' : 'android';
// Finds the end index (exclusive) of the span at `startIndex`, or `endIndex` if
// there are no other spans between `startIndex` and `endIndex`.
// The InlineSpan protocol doesn't expose the length of the span so we'll
// have to iterate through the whole range.
(InlineSpan, int)? _findEndOfSpan(InlineSpan rootSpan, int startIndex, int endIndex) {
assert(endIndex > startIndex);
final InlineSpan? subspan = rootSpan.getSpanForPosition(TextPosition(offset: startIndex));
if (subspan == null) {
return null;
}
int i = startIndex + 1;
while (i < endIndex && rootSpan.getSpanForPosition(TextPosition(offset: i)) == subspan) {
i += 1;
}
return (subspan, i);
}
// Examples can assume:
// typedef MyWidget = Placeholder;
......@@ -997,6 +1014,47 @@ abstract class WidgetController {
return tapAt(getCenter(finder, warnIfMissed: warnIfMissed, callee: 'tap'), pointer: pointer, buttons: buttons, kind: kind);
}
/// Dispatch a pointer down / pointer up sequence at a hit-testable
/// [InlineSpan] (typically a [TextSpan]) within the given text range.
///
/// This method performs a more spatially precise tap action on a piece of
/// static text, than the widget-based [tap] method.
///
/// The given [Finder] must find one and only one matching substring, and the
/// substring must be hit-testable (meaning, it must not be off-screen, or be
/// obscured by other widgets, or in a disabled widget). Otherwise this method
/// throws a [FlutterError].
///
/// If the target substring contains more than one hit-testable [InlineSpan]s,
/// [tapOnText] taps on one of them, but does not guarantee which.
///
/// The `pointer` and `button` arguments specify [PointerEvent.pointer] and
/// [PointerEvent.buttons] of the tap event.
Future<void> tapOnText(finders.FinderBase<finders.TextRangeContext> textRangeFinder, {int? pointer, int buttons = kPrimaryButton }) {
final Iterable<finders.TextRangeContext> ranges = textRangeFinder.evaluate();
if (ranges.isEmpty) {
throw FlutterError(textRangeFinder.toString());
}
if (ranges.length > 1) {
throw FlutterError(
'$textRangeFinder. The "tapOnText" method needs a single non-empty TextRange.',
);
}
final Offset? tapLocation = _findHitTestableOffsetIn(ranges.single);
if (tapLocation == null) {
final finders.TextRangeContext found = textRangeFinder.evaluate().single;
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Finder specifies a TextRange that can not receive pointer events.'),
ErrorDescription('The finder used was: ${textRangeFinder.toString(describeSelf: true)}'),
ErrorDescription('Found a matching substring in a static text widget, within ${found.textRange}.'),
ErrorDescription('But the "tapOnText" method could not find a hit-testable Offset with in that text range.'),
found.renderObject.toDiagnosticsNode(name: 'The RenderBox of that static text widget was', style: DiagnosticsTreeStyle.shallow),
]
);
}
return tapAt(tapLocation, pointer: pointer, buttons: buttons);
}
/// Dispatch a pointer down / pointer up sequence at the given location.
Future<void> tapAt(
Offset location, {
......@@ -1762,6 +1820,45 @@ abstract class WidgetController {
/// in the documentation for the [flutter_test] library.
static bool hitTestWarningShouldBeFatal = false;
/// Finds one hit-testable Offset in the given `textRangeContext`'s render
/// object.
Offset? _findHitTestableOffsetIn(finders.TextRangeContext textRangeContext) {
TestAsyncUtils.guardSync();
final TextRange range = textRangeContext.textRange;
assert(range.isNormalized);
assert(range.isValid);
final Offset renderParagraphPaintOffset = textRangeContext.renderObject.localToGlobal(Offset.zero);
assert(renderParagraphPaintOffset.isFinite);
int spanStart = range.start;
while (spanStart < range.end) {
switch (_findEndOfSpan(textRangeContext.renderObject.text, spanStart, range.end)) {
case (final HitTestTarget target, final int endIndex):
// Uses BoxHeightStyle.tight in getBoxesForSelection to make sure the
// returned boxes don't extend outside of the hit-testable region.
final Iterable<Offset> testOffsets = textRangeContext.renderObject
.getBoxesForSelection(TextSelection(baseOffset: spanStart, extentOffset: endIndex))
// Try hit-testing the center of each TextBox.
.map((TextBox textBox) => textBox.toRect().center);
for (final Offset localOffset in testOffsets) {
final HitTestResult result = HitTestResult();
final Offset globalOffset = localOffset + renderParagraphPaintOffset;
binding.hitTestInView(result, globalOffset, textRangeContext.view.view.viewId);
if (result.path.any((HitTestEntry entry) => entry.target == target)) {
return globalOffset;
}
}
spanStart = endIndex;
case (_, final int endIndex):
spanStart = endIndex;
case null:
break;
}
}
return null;
}
Offset _getElementPoint(finders.FinderBase<Element> finder, Offset Function(Size size) sizeToPoint, { required bool warnIfMissed, required String callee }) {
TestAsyncUtils.guardSync();
final Iterable<Element> elements = finder.evaluate();
......@@ -1791,17 +1888,10 @@ abstract class WidgetController {
final FlutterView view = _viewOf(finder);
final HitTestResult result = HitTestResult();
binding.hitTestInView(result, location, view.viewId);
bool found = false;
for (final HitTestEntry entry in result.path) {
if (entry.target == box) {
found = true;
break;
}
}
final bool found = result.path.any((HitTestEntry entry) => entry.target == box);
if (!found) {
final RenderView renderView = binding.renderViews.firstWhere((RenderView r) => r.flutterView == view);
bool outOfBounds = false;
outOfBounds = !(Offset.zero & renderView.size).contains(location);
final bool outOfBounds = !(Offset.zero & renderView.size).contains(location);
if (hitTestWarningShouldBeFatal) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Finder specifies a widget that would not receive pointer events.'),
......
......@@ -23,6 +23,26 @@ typedef SemanticsNodePredicate = bool Function(SemanticsNode node);
/// Signature for [FinderBase.describeMatch].
typedef DescribeMatchCallback = String Function(Plurality plurality);
/// The `CandidateType` of finders that search for and filter subtrings,
/// within static text rendered by [RenderParagraph]s.
final class TextRangeContext {
const TextRangeContext._(this.view, this.renderObject, this.textRange);
/// The [View] containing the static text.
///
/// This is used for hit-testing.
final View view;
/// The RenderObject that contains the static text.
final RenderParagraph renderObject;
/// The [TextRange] of the subtring within [renderObject]'s text.
final TextRange textRange;
@override
String toString() => 'TextRangeContext($view, $renderObject, $textRange)';
}
/// Some frequently used [Finder]s and [SemanticsFinder]s.
const CommonFinders find = CommonFinders._();
......@@ -42,6 +62,9 @@ class CommonFinders {
/// Some frequently used semantics finders.
CommonSemanticsFinders get semantics => const CommonSemanticsFinders._();
/// Some frequently used text range finders.
CommonTextRangeFinders get textRange => const CommonTextRangeFinders._();
/// Finds [Text], [EditableText], and optionally [RichText] widgets
/// containing string equal to the `text` argument.
///
......@@ -677,6 +700,35 @@ class CommonSemanticsFinders {
}
}
/// Provides lightweight syntax for getting frequently used text range finders.
///
/// This class is instantiated once, as [CommonFinders.textRange], under [find].
final class CommonTextRangeFinders {
const CommonTextRangeFinders._();
/// Finds all non-overlapping occurrences of the given `substring` in the
/// static text widgets and returns the [TextRange]s.
///
/// If the `skipOffstage` argument is true (the default), then this skips
/// static text inside widgets that are [Offstage], or that are from inactive
/// [Route]s.
///
/// If the `descendentOf` argument is non-null, this method only searches in
/// the descendants of that parameter for the given substring.
///
/// This finder uses the [Pattern.allMatches] method to match the substring in
/// the text. After finding a matching substring in the text, the method
/// continues the search from the end of the match, thus skipping overlapping
/// occurrences of the substring.
FinderBase<TextRangeContext> ofSubstring(String substring, { bool skipOffstage = true, FinderBase<Element>? descendentOf }) {
final _TextContainingWidgetFinder textWidgetFinder = _TextContainingWidgetFinder(substring, skipOffstage: skipOffstage, findRichText: true);
final Finder elementFinder = descendentOf == null
? textWidgetFinder
: _DescendantWidgetFinder(descendentOf, textWidgetFinder, matchRoot: true, skipOffstage: skipOffstage);
return _StaticTextRangeFinder(elementFinder, substring);
}
}
/// Describes how a string of text should be pluralized.
enum Plurality {
/// Text should be pluralized to describe zero items.
......@@ -998,7 +1050,7 @@ abstract class Finder extends FinderBase<Element> with _LegacyFinderMixin {
@override
String describeMatch(Plurality plurality) {
return switch (plurality) {
Plurality.zero ||Plurality.many => 'widgets with $description',
Plurality.zero || Plurality.many => 'widgets with $description',
Plurality.one => 'widget with $description',
};
}
......@@ -1026,6 +1078,61 @@ abstract class SemanticsFinder extends FinderBase<SemanticsNode> {
}
}
/// A base class for creating finders that search for static text rendered by a
/// [RenderParagraph].
class _StaticTextRangeFinder extends FinderBase<TextRangeContext> {
/// Creates a new [_StaticTextRangeFinder] that searches for the given
/// `pattern` in the [Element]s found by `_parent`.
_StaticTextRangeFinder(this._parent, this.pattern);
final FinderBase<Element> _parent;
final Pattern pattern;
Iterable<TextRangeContext> _flatMap(Element from) {
final RenderObject? renderObject = from.renderObject;
// This is currently only exposed on text matchers. Only consider RenderBoxes.
if (renderObject is! RenderBox) {
return const Iterable<TextRangeContext>.empty();
}
final View view = from.findAncestorWidgetOfExactType<View>()!;
final List<RenderParagraph> paragraphs = <RenderParagraph>[];
void visitor(RenderObject child) {
switch (child) {
case RenderParagraph():
paragraphs.add(child);
// No need to continue, we are piggybacking off of a text matcher, so
// inline text widgets will be reported separately.
case RenderBox():
child.visitChildren(visitor);
case _:
}
}
visitor(renderObject);
Iterable<TextRangeContext> searchInParagraph(RenderParagraph paragraph) {
final String text = paragraph.text.toPlainText(includeSemanticsLabels: false);
return pattern.allMatches(text)
.map((Match match) => TextRangeContext._(view, paragraph, TextRange(start: match.start, end: match.end)));
}
return paragraphs.expand(searchInParagraph);
}
@override
Iterable<TextRangeContext> findInCandidates(Iterable<TextRangeContext> candidates) => candidates;
@override
Iterable<TextRangeContext> get allCandidates => _parent.evaluate().expand(_flatMap);
@override
String describeMatch(Plurality plurality) {
return switch (plurality) {
Plurality.zero || Plurality.many => 'non-overlapping TextRanges that match the Pattern "$pattern"',
Plurality.one => 'non-overlapping TextRange that matches the Pattern "$pattern"',
};
}
}
/// A mixin that applies additional filtering to the results of a parent [Finder].
mixin ChainedFinderMixin<CandidateType> on FinderBase<CandidateType> {
......
......@@ -1482,6 +1482,172 @@ void main() {
});
});
});
group('WidgetTester.tapOnText', () {
final List<String > tapLogs = <String>[];
final TapGestureRecognizer tapA = TapGestureRecognizer()..onTap = () { tapLogs.add('A'); };
final TapGestureRecognizer tapB = TapGestureRecognizer()..onTap = () { tapLogs.add('B'); };
final TapGestureRecognizer tapC = TapGestureRecognizer()..onTap = () { tapLogs.add('C'); };
tearDown(tapLogs.clear);
tearDownAll(() {
tapA.dispose();
tapB.dispose();
tapC.dispose();
});
testWidgets('basic test', (WidgetTester tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Text.rich(TextSpan(text: 'match', recognizer: tapA)),
),
);
await tester.tapOnText(find.textRange.ofSubstring('match'));
expect(tapLogs, <String>['A']);
});
testWidgets('partially obstructed: find a hit-testable Offset', (WidgetTester tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Stack(
fit: StackFit.expand,
children: <Widget>[
Positioned(
left: 100.0 - 9 * 10.0, // Only the last character is visible.
child: Text.rich(TextSpan(text: 'text match', style: const TextStyle(fontSize: 10), recognizer: tapA)),
),
const Positioned(
left: 0.0,
right: 100.0,
child: MetaData(behavior: HitTestBehavior.opaque),
),
],
),
),
);
await expectLater(
() => tester.tapOnText(find.textRange.ofSubstring('text match')),
returnsNormally,
);
});
testWidgets('multiline text partially obstructed: find a hit-testable Offset', (WidgetTester tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Stack(
fit: StackFit.expand,
children: <Widget>[
Positioned(
width: 100.0,
top: 23.0,
left: 0.0,
child: Text.rich(
TextSpan(
style: const TextStyle(fontSize: 10),
children: <InlineSpan>[
TextSpan(text: 'AAAAAAAAA ', recognizer: tapA),
TextSpan(text: 'BBBBBBBBB ', recognizer: tapB), // The only visible line
TextSpan(text: 'CCCCCCCCC ', recognizer: tapC),
]
)
),
),
const Positioned(
top: 23.0, // Some random offset to test the global to local Offset conversion
left: 0.0,
right: 0.0,
height: 10.0,
child: MetaData(behavior: HitTestBehavior.opaque, child: SizedBox.expand()),
),
const Positioned(
top: 43.0,
left: 0.0,
right: 0.0,
height: 10.0,
child: MetaData(behavior: HitTestBehavior.opaque, child: SizedBox.expand()),
),
],
),
),
);
await tester.tapOnText(find.textRange.ofSubstring('AAAAAAAAA BBBBBBBBB CCCCCCCCC '));
expect(tapLogs, <String>['B']);
});
testWidgets('error message: no matching text', (WidgetTester tester) async {
await tester.pumpWidget(const SizedBox());
await expectLater(
() => tester.tapOnText(find.textRange.ofSubstring('nonexistent')),
throwsA(isFlutterError.having(
(FlutterError error) => error.message,
'message',
contains('Found 0 non-overlapping TextRanges that match the Pattern "nonexistent": []'),
)),
);
});
testWidgets('error message: too many matches', (WidgetTester tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Text.rich(
TextSpan(
text: 'match',
recognizer: tapA,
children: <InlineSpan>[TextSpan(text: 'another match', recognizer: tapB)],
),
),
),
);
await expectLater(
() => tester.tapOnText(find.textRange.ofSubstring('match')),
throwsA(isFlutterError.having(
(FlutterError error) => error.message,
'message',
stringContainsInOrder(<String>[
'Found 2 non-overlapping TextRanges that match the Pattern "match"',
'TextRange(start: 0, end: 5)',
'TextRange(start: 13, end: 18)',
'The "tapOnText" method needs a single non-empty TextRange.',
])
)),
);
});
testWidgets('error message: not hit-testable', (WidgetTester tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Stack(
fit: StackFit.expand,
children: <Widget>[
Text.rich(TextSpan(text: 'match', recognizer: tapA)),
const MetaData(behavior: HitTestBehavior.opaque),
],
),
),
);
await expectLater(
() => tester.tapOnText(find.textRange.ofSubstring('match')),
throwsA(isFlutterError.having(
(FlutterError error) => error.message,
'message',
stringContainsInOrder(<String>[
'The finder used was: A finder that searches for non-overlapping TextRanges that match the Pattern "match".',
'Found a matching substring in a static text widget, within TextRange(start: 0, end: 5).',
'But the "tapOnText" method could not find a hit-testable Offset with in that text range.',
])
)),
);
});
});
}
class _SemanticsTestWidget extends StatelessWidget {
......
......@@ -331,6 +331,100 @@ void main() {
});
});
group('text range finders', () {
testWidgets('basic text span test', (WidgetTester tester) async {
await tester.pumpWidget(
_boilerplate(const IndexedStack(
sizing: StackFit.expand,
children: <Widget>[
Text.rich(TextSpan(
text: 'sub',
children: <InlineSpan>[
TextSpan(text: 'stringsub'),
TextSpan(text: 'stringsub'),
TextSpan(text: 'stringsub'),
],
)),
Text('substringsub'),
],
)),
);
expect(find.textRange.ofSubstring('substringsub'), findsExactly(2)); // Pattern skips overlapping matches.
expect(find.textRange.ofSubstring('substringsub').first.evaluate().single.textRange, const TextRange(start: 0, end: 12));
expect(find.textRange.ofSubstring('substringsub').last.evaluate().single.textRange, const TextRange(start: 18, end: 30));
expect(
find.textRange.ofSubstring('substringsub').first.evaluate().single.renderObject,
find.textRange.ofSubstring('substringsub').last.evaluate().single.renderObject,
);
expect(find.textRange.ofSubstring('substringsub', skipOffstage: false), findsExactly(3));
});
testWidgets('basic text span test', (WidgetTester tester) async {
await tester.pumpWidget(
_boilerplate(const IndexedStack(
sizing: StackFit.expand,
children: <Widget>[
Text.rich(TextSpan(
text: 'sub',
children: <InlineSpan>[
TextSpan(text: 'stringsub'),
TextSpan(text: 'stringsub'),
TextSpan(text: 'stringsub'),
],
)),
Text('substringsub'),
],
)),
);
expect(find.textRange.ofSubstring('substringsub'), findsExactly(2)); // Pattern skips overlapping matches.
expect(find.textRange.ofSubstring('substringsub').first.evaluate().single.textRange, const TextRange(start: 0, end: 12));
expect(find.textRange.ofSubstring('substringsub').last.evaluate().single.textRange, const TextRange(start: 18, end: 30));
expect(
find.textRange.ofSubstring('substringsub').first.evaluate().single.renderObject,
find.textRange.ofSubstring('substringsub').last.evaluate().single.renderObject,
);
expect(find.textRange.ofSubstring('substringsub', skipOffstage: false), findsExactly(3));
});
testWidgets('descendentOf', (WidgetTester tester) async {
await tester.pumpWidget(
_boilerplate(
const Column(
children: <Widget>[
Text.rich(TextSpan(text: 'text')),
Text.rich(TextSpan(text: 'text')),
],
),
),
);
expect(find.textRange.ofSubstring('text'), findsExactly(2));
expect(find.textRange.ofSubstring('text', descendentOf: find.text('text').first), findsOne);
});
testWidgets('finds only static text for now', (WidgetTester tester) async {
await tester.pumpWidget(
_boilerplate(
EditableText(
controller: TextEditingController(text: 'text'),
focusNode: FocusNode(),
style: const TextStyle(),
cursorColor: const Color(0x00000000),
backgroundCursorColor: const Color(0x00000000),
)
),
);
expect(find.textRange.ofSubstring('text'), findsNothing);
});
});
testWidgets('ChainedFinders chain properly', (WidgetTester tester) async {
final GlobalKey key1 = GlobalKey();
await tester.pumpWidget(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment