diff --git a/packages/flutter/lib/src/painting/basic_types.dart b/packages/flutter/lib/src/painting/basic_types.dart index f07db133cf..fe67c28feb 100644 --- a/packages/flutter/lib/src/painting/basic_types.dart +++ b/packages/flutter/lib/src/painting/basic_types.dart @@ -16,6 +16,7 @@ export 'dart:ui' show FontStyle, FontVariation, FontWeight, + GlyphInfo, ImageShader, Locale, MaskFilter, diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index e9992155bb..c622d57d08 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -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); diff --git a/packages/flutter/lib/src/painting/text_span.dart b/packages/flutter/lib/src/painting/text_span.dart index d50bce8484..2b6f2bbc6f 100644 --- a/packages/flutter/lib/src/painting/text_span.dart +++ b/packages/flutter/lib/src/painting/text_span.dart @@ -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; } diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index f48a4e5f9f..7b4768ad3e 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -1941,8 +1941,16 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, @protected bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { final Offset effectivePosition = position - _paintOffset; - final InlineSpan? textSpan = _textPainter.text; - switch (textSpan?.getSpanForPosition(_textPainter.getPositionForOffset(effectivePosition))) { + final GlyphInfo? glyph = _textPainter.getClosestGlyphForOffset(effectivePosition); + // The hit-test can't fall through the horizontal gaps between visually + // adjacent characters on the same line, even with a large letter-spacing or + // text justification, as graphemeClusterLayoutBounds.width is the advance + // width to the next character, so there's no gap between their + // graphemeClusterLayoutBounds rects. + final InlineSpan? spanHit = glyph != null && glyph.graphemeClusterLayoutBounds.contains(effectivePosition) + ? _textPainter.text!.getSpanForPosition(TextPosition(offset: glyph.graphemeClusterCodeUnitRange.start)) + : null; + switch (spanHit) { case final HitTestTarget span: result.add(HitTestEntry(span)); return true; diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index fb6e9934d5..5f7a4a6643 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -303,6 +303,7 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin? _cachedAttributedLabels; @@ -730,9 +731,18 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin true; @override + @protected bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { - final TextPosition textPosition = _textPainter.getPositionForOffset(position); - switch (_textPainter.text!.getSpanForPosition(textPosition)) { + final GlyphInfo? glyph = _textPainter.getClosestGlyphForOffset(position); + // The hit-test can't fall through the horizontal gaps between visually + // adjacent characters on the same line, even with a large letter-spacing or + // text justification, as graphemeClusterLayoutBounds.width is the advance + // width to the next character, so there's no gap between their + // graphemeClusterLayoutBounds rects. + final InlineSpan? spanHit = glyph != null && glyph.graphemeClusterLayoutBounds.contains(position) + ? _textPainter.text!.getSpanForPosition(TextPosition(offset: glyph.graphemeClusterCodeUnitRange.start)) + : null; + switch (spanHit) { case final HitTestTarget span: result.add(HitTestEntry(span)); return true; diff --git a/packages/flutter/test/painting/text_span_test.dart b/packages/flutter/test/painting/text_span_test.dart index 43056c7986..92bd805803 100644 --- a/packages/flutter/test/painting/text_span_test.dart +++ b/packages/flutter/test/painting/text_span_test.dart @@ -250,6 +250,24 @@ void main() { expect(textSpan2.compareTo(textSpan2), RenderComparison.identical); }); + test('GetSpanForPosition', () { + const TextSpan textSpan = TextSpan( + text: '', + children: [ + TextSpan(text: '', children: [ + 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', diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index eb22f15389..7f1613778f 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -12,6 +12,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'rendering_tester.dart'; +double _caretMarginOf(RenderEditable renderEditable) { + return renderEditable.cursorWidth + 1.0; +} + void _applyParentData(List inlineRenderBoxes, InlineSpan span) { int index = 0; RenderBox? previousBox; @@ -1184,8 +1188,107 @@ void main() { }); group('hit testing', () { + final TextSelectionDelegate delegate = _FakeEditableTextState(); + + test('Basic TextSpan Hit testing', () { + final TextSpan textSpanA = TextSpan(text: 'A' * 10); + const TextSpan textSpanBC = TextSpan(text: 'BC', style: TextStyle(letterSpacing: 26.0)); + + final TextSpan text = TextSpan( + text: '', + style: const TextStyle(fontSize: 10.0), + children: [textSpanA, textSpanBC], + ); + + final RenderEditable renderEditable = RenderEditable( + text: text, + maxLines: null, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + textDirection: TextDirection.ltr, + offset: ViewportOffset.fixed(0.0), + textSelectionDelegate: delegate, + selection: const TextSelection.collapsed(offset: 0), + ); + layout(renderEditable, constraints: BoxConstraints.tightFor(width: 100.0 + _caretMarginOf(renderEditable))); + + BoxHitTestResult result; + + // Hit-testing the first line + // First A + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(5.0, 5.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanA]); + // The last A. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanA]); + // Far away from the line. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(200.0, 5.0)), isFalse); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), []); + + // Hit-testing the second line + // Tapping on B (startX = letter-spacing / 2 = 13.0). + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(18.0, 15.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanBC]); + + // Between B and C, with large letter-spacing. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(31.0, 15.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanBC]); + + // On C. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(54.0, 15.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanBC]); + + // After C. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(100.0, 15.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), []); + + // Not even remotely close. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(9999.0, 9999.0)), isFalse); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), []); + }); + + test('TextSpan Hit testing with text justification', () { + const TextSpan textSpanA = TextSpan(text: 'A '); // The space is a word break. + const TextSpan textSpanB = TextSpan(text: 'B\u200B'); // The zero-width space is used as a line break. + final TextSpan 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 TextSpan text = TextSpan( + text: '', + style: const TextStyle(fontSize: 10.0), + children: [textSpanA, textSpanB, textSpanC], + ); + final RenderEditable renderEditable = RenderEditable( + text: text, + maxLines: null, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + textDirection: TextDirection.ltr, + textAlign: TextAlign.justify, + offset: ViewportOffset.fixed(0.0), + textSelectionDelegate: delegate, + selection: const TextSelection.collapsed(offset: 0), + ); + + layout(renderEditable, constraints: BoxConstraints.tightFor(width: 100.0 + _caretMarginOf(renderEditable))); + BoxHitTestResult result; + + // Tapping on A. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(5.0, 5.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanA]); + + // Between A and B. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(50.0, 5.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanA]); + + // On B. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanB]); + }); + test('hits correct TextSpan when not scrolled', () { - final TextSelectionDelegate delegate = _FakeEditableTextState(); final RenderEditable editable = RenderEditable( text: const TextSpan( style: TextStyle(height: 1.0, fontSize: 10.0), @@ -1692,7 +1795,8 @@ void main() { // Prepare for painting after layout. pumpFrame(phase: EnginePhase.compositingBits); BoxHitTestResult result = BoxHitTestResult(); - editable.hitTest(result, position: Offset.zero); + // The WidgetSpans have a height of 14.0, so "test" has a y offset of 4.0. + editable.hitTest(result, position: const Offset(1.0, 5.0)); // We expect two hit test entries in the path because the RenderEditable // will add itself as well. expect(result.path, hasLength(2)); @@ -1702,7 +1806,7 @@ void main() { // Only testing the RenderEditable entry here once, not anymore below. expect(result.path.last.target, isA()); result = BoxHitTestResult(); - editable.hitTest(result, position: const Offset(15.0, 0.0)); + editable.hitTest(result, position: const Offset(15.0, 5.0)); expect(result.path, hasLength(2)); target = result.path.first.target; expect(target, isA()); @@ -1775,7 +1879,8 @@ void main() { // Prepare for painting after layout. pumpFrame(phase: EnginePhase.compositingBits); BoxHitTestResult result = BoxHitTestResult(); - editable.hitTest(result, position: Offset.zero); + // The WidgetSpans have a height of 14.0, so "test" has a y offset of 4.0. + editable.hitTest(result, position: const Offset(0.0, 4.0)); // We expect two hit test entries in the path because the RenderEditable // will add itself as well. expect(result.path, hasLength(2)); @@ -1785,13 +1890,14 @@ void main() { // Only testing the RenderEditable entry here once, not anymore below. expect(result.path.last.target, isA()); result = BoxHitTestResult(); - editable.hitTest(result, position: const Offset(15.0, 0.0)); + editable.hitTest(result, position: const Offset(15.0, 4.0)); expect(result.path, hasLength(2)); target = result.path.first.target; expect(target, isA()); expect((target as TextSpan).text, text); result = BoxHitTestResult(); + // "test" is 40 pixel wide. editable.hitTest(result, position: const Offset(41.0, 0.0)); expect(result.path, hasLength(3)); target = result.path.first.target; @@ -1814,7 +1920,7 @@ void main() { result = BoxHitTestResult(); editable.hitTest(result, position: const Offset(5.0, 15.0)); - expect(result.path, hasLength(2)); + expect(result.path, hasLength(1)); // Only the RenderEditable. }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61020 }); diff --git a/packages/flutter/test/rendering/paragraph_test.dart b/packages/flutter/test/rendering/paragraph_test.dart index 06aa6a151a..f1acd7a53d 100644 --- a/packages/flutter/test/rendering/paragraph_test.dart +++ b/packages/flutter/test/rendering/paragraph_test.dart @@ -761,6 +761,84 @@ void main() { expect(node.childrenCount, 2); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61020 + test('Basic TextSpan Hit testing', () { + final TextSpan textSpanA = TextSpan(text: 'A' * 10); + const TextSpan textSpanBC = TextSpan(text: 'BC', style: TextStyle(letterSpacing: 26.0)); + + final TextSpan text = TextSpan( + style: const TextStyle(fontSize: 10.0), + children: [textSpanA, textSpanBC], + ); + + final RenderParagraph 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 entry) => entry.target).whereType(), [textSpanA]); + // The last A. + expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanA]); + // Far away from the line. + expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(200.0, 5.0)), isFalse); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), []); + + // 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 entry) => entry.target).whereType(), [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 entry) => entry.target).whereType(), [textSpanBC]); + + // On C. + expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(54.0, 15.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanBC]); + + // After C. + expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(100.0, 15.0)), isFalse); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), []); + + // Not even remotely close. + expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(9999.0, 9999.0)), isFalse); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), []); + }); + + test('TextSpan Hit testing with text justification', () { + const TextSpan textSpanA = TextSpan(text: 'A '); // The space is a word break. + const TextSpan textSpanB = TextSpan(text: 'B\u200B'); // The zero-width space is used as a line break. + final TextSpan 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 TextSpan text = TextSpan( + text: '', + style: const TextStyle(fontSize: 10.0), + children: [textSpanA, textSpanB, textSpanC], + ); + + final RenderParagraph 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 entry) => entry.target).whereType(), [textSpanA]); + + // Between A and B. + expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(50.0, 5.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanA]); + + // On B. + expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanB]); + }); + group('Selection', () { void selectionParagraph(RenderParagraph paragraph, TextPosition start, TextPosition end) { for (final Selectable selectable in (paragraph.registrar! as TestSelectionRegistrar).selectables) { diff --git a/packages/flutter/test/widgets/slivers_padding_test.dart b/packages/flutter/test/widgets/slivers_padding_test.dart index 29cfe10692..391ebe6cbb 100644 --- a/packages/flutter/test/widgets/slivers_padding_test.dart +++ b/packages/flutter/test/widgets/slivers_padding_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -15,7 +16,6 @@ class _MockRenderSliver extends RenderSliver { maxPaintExtent: 10, ); } - } Future test(WidgetTester tester, double offset, EdgeInsetsGeometry padding, AxisDirection axisDirection, TextDirection textDirection) { @@ -180,15 +180,15 @@ void main() { ]); HitTestResult result; result = tester.hitTestOnBinding(const Offset(10.0, 10.0)); - expectIsTextSpan(result.path.first.target, 'before'); + hitsText(result, 'before'); result = tester.hitTestOnBinding(const Offset(10.0, 60.0)); expect(result.path.first.target, isA()); result = tester.hitTestOnBinding(const Offset(100.0, 100.0)); - expectIsTextSpan(result.path.first.target, 'padded'); + hitsText(result, 'padded'); result = tester.hitTestOnBinding(const Offset(100.0, 490.0)); expect(result.path.first.target, isA()); result = tester.hitTestOnBinding(const Offset(10.0, 520.0)); - expectIsTextSpan(result.path.first.target, 'after'); + hitsText(result, 'after'); }); testWidgets('Viewport+SliverPadding hit testing up', (WidgetTester tester) async { @@ -202,15 +202,15 @@ void main() { ]); HitTestResult result; result = tester.hitTestOnBinding(const Offset(10.0, 600.0-10.0)); - expectIsTextSpan(result.path.first.target, 'before'); + hitsText(result, 'before'); result = tester.hitTestOnBinding(const Offset(10.0, 600.0-60.0)); expect(result.path.first.target, isA()); result = tester.hitTestOnBinding(const Offset(100.0, 600.0-100.0)); - expectIsTextSpan(result.path.first.target, 'padded'); + hitsText(result, 'padded'); result = tester.hitTestOnBinding(const Offset(100.0, 600.0-490.0)); expect(result.path.first.target, isA()); result = tester.hitTestOnBinding(const Offset(10.0, 600.0-520.0)); - expectIsTextSpan(result.path.first.target, 'after'); + hitsText(result, 'after'); }); testWidgets('Viewport+SliverPadding hit testing left', (WidgetTester tester) async { @@ -224,15 +224,15 @@ void main() { ]); HitTestResult result; result = tester.hitTestOnBinding(const Offset(800.0-10.0, 10.0)); - expectIsTextSpan(result.path.first.target, 'before'); + hitsText(result, 'before'); result = tester.hitTestOnBinding(const Offset(800.0-60.0, 10.0)); expect(result.path.first.target, isA()); result = tester.hitTestOnBinding(const Offset(800.0-100.0, 100.0)); - expectIsTextSpan(result.path.first.target, 'padded'); + hitsText(result, 'padded'); result = tester.hitTestOnBinding(const Offset(800.0-490.0, 100.0)); expect(result.path.first.target, isA()); result = tester.hitTestOnBinding(const Offset(800.0-520.0, 10.0)); - expectIsTextSpan(result.path.first.target, 'after'); + hitsText(result, 'after'); }); testWidgets('Viewport+SliverPadding hit testing right', (WidgetTester tester) async { @@ -246,15 +246,15 @@ void main() { ]); HitTestResult result; result = tester.hitTestOnBinding(const Offset(10.0, 10.0)); - expectIsTextSpan(result.path.first.target, 'before'); + hitsText(result, 'before'); result = tester.hitTestOnBinding(const Offset(60.0, 10.0)); expect(result.path.first.target, isA()); result = tester.hitTestOnBinding(const Offset(100.0, 100.0)); - expectIsTextSpan(result.path.first.target, 'padded'); + hitsText(result, 'padded'); result = tester.hitTestOnBinding(const Offset(490.0, 100.0)); expect(result.path.first.target, isA()); result = tester.hitTestOnBinding(const Offset(520.0, 10.0)); - expectIsTextSpan(result.path.first.target, 'after'); + hitsText(result, 'after'); }); testWidgets('Viewport+SliverPadding no child', (WidgetTester tester) async { @@ -617,7 +617,15 @@ void main() { }); } -void expectIsTextSpan(Object target, String text) { - expect(target, isA()); - expect((target as TextSpan).text, text); +void hitsText(HitTestResult hitTestResult, String text) { + switch (hitTestResult.path.first.target) { + case final TextSpan span: + expect(span.text, text); + case final RenderParagraph paragraph: + final InlineSpan span = paragraph.text; + expect(span, isA()); + expect((span as TextSpan).text, text); + case final HitTestTarget target: + fail('$target is not a TextSpan or a RenderParagraph.'); + } } diff --git a/packages/flutter_test/lib/src/controller.dart b/packages/flutter_test/lib/src/controller.dart index a54dc1ee51..602a1b94b6 100644 --- a/packages/flutter_test/lib/src/controller.dart +++ b/packages/flutter_test/lib/src/controller.dart @@ -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 tapOnText(finders.FinderBase textRangeFinder, {int? pointer, int buttons = kPrimaryButton }) { + final Iterable 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([ + 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 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 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 finder, Offset Function(Size size) sizeToPoint, { required bool warnIfMissed, required String callee }) { TestAsyncUtils.guardSync(); final Iterable 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([ ErrorSummary('Finder specifies a widget that would not receive pointer events.'), diff --git a/packages/flutter_test/lib/src/finders.dart b/packages/flutter_test/lib/src/finders.dart index ae5ea20351..308555d539 100644 --- a/packages/flutter_test/lib/src/finders.dart +++ b/packages/flutter_test/lib/src/finders.dart @@ -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 ofSubstring(String substring, { bool skipOffstage = true, FinderBase? 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 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 { } } +/// A base class for creating finders that search for static text rendered by a +/// [RenderParagraph]. +class _StaticTextRangeFinder extends FinderBase { + /// Creates a new [_StaticTextRangeFinder] that searches for the given + /// `pattern` in the [Element]s found by `_parent`. + _StaticTextRangeFinder(this._parent, this.pattern); + + final FinderBase _parent; + final Pattern pattern; + + Iterable _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.empty(); + } + + final View view = from.findAncestorWidgetOfExactType()!; + final List paragraphs = []; + + 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 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 findInCandidates(Iterable candidates) => candidates; + + @override + Iterable 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 on FinderBase { diff --git a/packages/flutter_test/test/controller_test.dart b/packages/flutter_test/test/controller_test.dart index 023121eb74..00e1fc8736 100644 --- a/packages/flutter_test/test/controller_test.dart +++ b/packages/flutter_test/test/controller_test.dart @@ -1482,6 +1482,172 @@ void main() { }); }); }); + + group('WidgetTester.tapOnText', () { + final List tapLogs = []; + 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, ['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: [ + 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: [ + Positioned( + width: 100.0, + top: 23.0, + left: 0.0, + child: Text.rich( + TextSpan( + style: const TextStyle(fontSize: 10), + children: [ + 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, ['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: [TextSpan(text: 'another match', recognizer: tapB)], + ), + ), + ), + ); + + await expectLater( + () => tester.tapOnText(find.textRange.ofSubstring('match')), + throwsA(isFlutterError.having( + (FlutterError error) => error.message, + 'message', + stringContainsInOrder([ + '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: [ + 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([ + '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 { diff --git a/packages/flutter_test/test/finders_test.dart b/packages/flutter_test/test/finders_test.dart index 3562cb1d4f..67335d68b6 100644 --- a/packages/flutter_test/test/finders_test.dart +++ b/packages/flutter_test/test/finders_test.dart @@ -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: [ + Text.rich(TextSpan( + text: 'sub', + children: [ + 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: [ + Text.rich(TextSpan( + text: 'sub', + children: [ + 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: [ + 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(