diff --git a/packages/flutter/lib/src/painting/inline_span.dart b/packages/flutter/lib/src/painting/inline_span.dart index 0856bf25e6..e3ec86e0e7 100644 --- a/packages/flutter/lib/src/painting/inline_span.dart +++ b/packages/flutter/lib/src/painting/inline_span.dart @@ -100,6 +100,42 @@ class InlineSpanSemanticsInformation { String toString() => '${objectRuntimeType(this, 'InlineSpanSemanticsInformation')}{text: $text, semanticsLabel: $semanticsLabel, recognizer: $recognizer}'; } +/// Combines _semanticsInfo entries where permissible. +/// +/// Consecutive inline spans can be combined if their +/// [InlineSpanSemanticsInformation.requiresOwnNode] return false. +List combineSemanticsInfo(List infoList) { + final List combined = []; + String workingText = ''; + // TODO(ianh): this algorithm is internally inconsistent. workingText + // never becomes null, but we check for it being so below. + String? workingLabel; + for (final InlineSpanSemanticsInformation info in infoList) { + if (info.requiresOwnNode) { + combined.add(InlineSpanSemanticsInformation( + workingText, + semanticsLabel: workingLabel ?? workingText, + )); + workingText = ''; + workingLabel = null; + combined.add(info); + } else { + workingText += info.text; + workingLabel ??= ''; + if (info.semanticsLabel != null) { + workingLabel += info.semanticsLabel!; + } else { + workingLabel += info.text; + } + } + } + combined.add(InlineSpanSemanticsInformation( + workingText, + semanticsLabel: workingLabel, + )); + return combined; +} + /// An immutable span of inline content which forms part of a paragraph. /// /// * The subclass [TextSpan] specifies text and may contain child [InlineSpan]s. diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index b04cfa9066..6709d82c5b 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.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 'dart:collection'; import 'dart:math' as math; import 'dart:ui' as ui show TextBox, BoxHeightStyle, BoxWidthStyle; @@ -1474,10 +1475,34 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { } } + /// Collected during [describeSemanticsConfiguration], used by + /// [assembleSemanticsNode] and [_combineSemanticsInfo]. + List? _semanticsInfo; + + // Caches [SemanticsNode]s created during [assembleSemanticsNode] so they + // can be re-used when [assembleSemanticsNode] is called again. This ensures + // stable ids for the [SemanticsNode]s of [TextSpan]s across + // [assembleSemanticsNode] invocations. + Queue? _cachedChildNodes; + @override void describeSemanticsConfiguration(SemanticsConfiguration config) { super.describeSemanticsConfiguration(config); - + _semanticsInfo = _textPainter.text!.getSemanticsInformation(); + // TODO(chunhtai): the macOS does not provide a public API to support text + // selections across multiple semantics nodes. Remove this platform check + // once we can support it. + // https://github.com/flutter/flutter/issues/77957 + if (_semanticsInfo!.any((InlineSpanSemanticsInformation info) => info.recognizer != null) && + defaultTargetPlatform != TargetPlatform.macOS) { + assert(readOnly && !obscureText); + // For Selectable rich text with recognizer, we need to create a semantics + // node for each text fragment. + config + ..isSemanticBoundary = true + ..explicitChildNodes = true; + return; + } config ..value = obscureText ? obscuringCharacter * _plainText.length @@ -1520,6 +1545,87 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { ); } + @override + void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable children) { + assert(_semanticsInfo != null && _semanticsInfo!.isNotEmpty); + final List newChildren = []; + TextDirection currentDirection = textDirection; + Rect currentRect; + double ordinal = 0.0; + int start = 0; + final Queue newChildCache = Queue(); + for (final InlineSpanSemanticsInformation info in combineSemanticsInfo(_semanticsInfo!)) { + assert(!info.isPlaceholder); + final TextSelection selection = TextSelection( + baseOffset: start, + extentOffset: start + info.text.length, + ); + start += info.text.length; + + final TextDirection initialDirection = currentDirection; + final List rects = _textPainter.getBoxesForSelection(selection); + if (rects.isEmpty) { + continue; + } + Rect rect = rects.first.toRect(); + currentDirection = rects.first.direction; + for (final ui.TextBox textBox in rects.skip(1)) { + rect = rect.expandToInclude(textBox.toRect()); + currentDirection = textBox.direction; + } + // Any of the text boxes may have had infinite dimensions. + // We shouldn't pass infinite dimensions up to the bridges. + rect = Rect.fromLTWH( + math.max(0.0, rect.left), + math.max(0.0, rect.top), + math.min(rect.width, constraints.maxWidth), + math.min(rect.height, constraints.maxHeight), + ); + // Round the current rectangle to make this API testable and add some + // padding so that the accessibility rects do not overlap with the text. + currentRect = Rect.fromLTRB( + rect.left.floorToDouble() - 4.0, + rect.top.floorToDouble() - 4.0, + rect.right.ceilToDouble() + 4.0, + rect.bottom.ceilToDouble() + 4.0, + ); + final SemanticsConfiguration configuration = SemanticsConfiguration() + ..sortKey = OrdinalSortKey(ordinal++) + ..textDirection = initialDirection + ..label = info.semanticsLabel ?? info.text; + final GestureRecognizer? recognizer = info.recognizer; + if (recognizer != null) { + if (recognizer is TapGestureRecognizer) { + if (recognizer.onTap != null) { + configuration.onTap = recognizer.onTap; + configuration.isLink = true; + } + } else if (recognizer is DoubleTapGestureRecognizer) { + if (recognizer.onDoubleTap != null) { + configuration.onTap = recognizer.onDoubleTap; + configuration.isLink = true; + } + } else if (recognizer is LongPressGestureRecognizer) { + if (recognizer.onLongPress != null) { + configuration.onLongPress = recognizer.onLongPress; + } + } else { + assert(false, '${recognizer.runtimeType} is not supported.'); + } + } + final SemanticsNode newChild = (_cachedChildNodes?.isNotEmpty == true) + ? _cachedChildNodes!.removeFirst() + : SemanticsNode(); + newChild + ..updateWith(config: configuration) + ..rect = currentRect; + newChildCache.addLast(newChild); + newChildren.add(newChild); + } + _cachedChildNodes = newChildCache; + node.updateWith(config: config, childrenInInversePaintOrder: newChildren); + } + // TODO(ianh): in theory, [selection] could become null between when // we last called describeSemanticsConfiguration and when the // callbacks are invoked, in which case the callbacks will crash... diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index 2ca04a53d9..390136811d 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -867,41 +867,6 @@ class RenderParagraph extends RenderBox /// [assembleSemanticsNode] and [_combineSemanticsInfo]. List? _semanticsInfo; - /// Combines _semanticsInfo entries where permissible, determined by - /// [InlineSpanSemanticsInformation.requiresOwnNode]. - List _combineSemanticsInfo() { - assert(_semanticsInfo != null); - final List combined = []; - String workingText = ''; - // TODO(ianh): this algorithm is internally inconsistent. workingText - // never becomes null, but we check for it being so below. - String? workingLabel; - for (final InlineSpanSemanticsInformation info in _semanticsInfo!) { - if (info.requiresOwnNode) { - combined.add(InlineSpanSemanticsInformation( - workingText, - semanticsLabel: workingLabel ?? workingText, - )); - workingText = ''; - workingLabel = null; - combined.add(info); - } else { - workingText += info.text; - workingLabel ??= ''; - if (info.semanticsLabel != null) { - workingLabel += info.semanticsLabel!; - } else { - workingLabel += info.text; - } - } - } - combined.add(InlineSpanSemanticsInformation( - workingText, - semanticsLabel: workingLabel, - )); - return combined; - } - @override void describeSemanticsConfiguration(SemanticsConfiguration config) { super.describeSemanticsConfiguration(config); @@ -938,7 +903,7 @@ class RenderParagraph extends RenderBox int childIndex = 0; RenderBox? child = firstChild; final Queue newChildCache = Queue(); - for (final InlineSpanSemanticsInformation info in _combineSemanticsInfo()) { + for (final InlineSpanSemanticsInformation info in combineSemanticsInfo(_semanticsInfo!)) { final TextSelection selection = TextSelection( baseOffset: start, extentOffset: start + info.text.length, @@ -946,7 +911,7 @@ class RenderParagraph extends RenderBox start += info.text.length; if (info.isPlaceholder) { - // A placeholder span may have 0 to multple semantics nodes, we need + // A placeholder span may have 0 to multiple semantics nodes, we need // to annotate all of the semantics nodes belong to this span. while (children.length > childIndex && children.elementAt(childIndex).isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) { diff --git a/packages/flutter/test/widgets/selectable_text_test.dart b/packages/flutter/test/widgets/selectable_text_test.dart index 6ef1da32dd..0561cac392 100644 --- a/packages/flutter/test/widgets/selectable_text_test.dart +++ b/packages/flutter/test/widgets/selectable_text_test.dart @@ -1444,6 +1444,57 @@ void main() { semantics.dispose(); }); + testWidgets('Selectable rich text with gesture recognizer has correct semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget( + overlay( + child: SelectableText.rich( + TextSpan( + children: [ + const TextSpan(text: 'text'), + TextSpan( + text: 'link', + recognizer: TapGestureRecognizer() + ..onTap = () { }, + ), + ], + ), + ), + ), + ); + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + actions: [SemanticsAction.longPress], + textDirection: TextDirection.ltr, + children: [ + TestSemantics( + id: 2, + children: [ + TestSemantics( + id: 3, + label: 'text', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 4, + flags: [SemanticsFlag.isLink], + actions: [SemanticsAction.tap], + label: 'link', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + + semantics.dispose(); + }); + group('Keyboard Tests', () { late TextEditingController controller;