Unverified Commit 67cee630 authored by chunhtai's avatar chunhtai Committed by GitHub

Add string attribute api to text span (#86667)

parent 3ca8d7b7
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:ui' as ui show ParagraphBuilder; import 'dart:ui' as ui show ParagraphBuilder, StringAttribute;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
...@@ -56,6 +56,7 @@ class InlineSpanSemanticsInformation { ...@@ -56,6 +56,7 @@ class InlineSpanSemanticsInformation {
this.text, { this.text, {
this.isPlaceholder = false, this.isPlaceholder = false,
this.semanticsLabel, this.semanticsLabel,
this.stringAttributes = const <ui.StringAttribute>[],
this.recognizer, this.recognizer,
}) : assert(text != null), }) : assert(text != null),
assert(isPlaceholder != null), assert(isPlaceholder != null),
...@@ -84,13 +85,17 @@ class InlineSpanSemanticsInformation { ...@@ -84,13 +85,17 @@ class InlineSpanSemanticsInformation {
/// [isPlaceholder] is true. /// [isPlaceholder] is true.
final bool requiresOwnNode; final bool requiresOwnNode;
/// The string attributes attached to this semantics information
final List<ui.StringAttribute> stringAttributes;
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return other is InlineSpanSemanticsInformation return other is InlineSpanSemanticsInformation
&& other.text == text && other.text == text
&& other.semanticsLabel == semanticsLabel && other.semanticsLabel == semanticsLabel
&& other.recognizer == recognizer && other.recognizer == recognizer
&& other.isPlaceholder == isPlaceholder; && other.isPlaceholder == isPlaceholder
&& listEquals<ui.StringAttribute>(other.stringAttributes, stringAttributes);
} }
@override @override
...@@ -107,31 +112,40 @@ class InlineSpanSemanticsInformation { ...@@ -107,31 +112,40 @@ class InlineSpanSemanticsInformation {
List<InlineSpanSemanticsInformation> combineSemanticsInfo(List<InlineSpanSemanticsInformation> infoList) { List<InlineSpanSemanticsInformation> combineSemanticsInfo(List<InlineSpanSemanticsInformation> infoList) {
final List<InlineSpanSemanticsInformation> combined = <InlineSpanSemanticsInformation>[]; final List<InlineSpanSemanticsInformation> combined = <InlineSpanSemanticsInformation>[];
String workingText = ''; String workingText = '';
// TODO(ianh): this algorithm is internally inconsistent. workingText String workingLabel = '';
// never becomes null, but we check for it being so below. List<ui.StringAttribute> workingAttributes = <ui.StringAttribute>[];
String? workingLabel;
for (final InlineSpanSemanticsInformation info in infoList) { for (final InlineSpanSemanticsInformation info in infoList) {
if (info.requiresOwnNode) { if (info.requiresOwnNode) {
combined.add(InlineSpanSemanticsInformation( combined.add(InlineSpanSemanticsInformation(
workingText, workingText,
semanticsLabel: workingLabel ?? workingText, semanticsLabel: workingLabel,
stringAttributes: workingAttributes,
)); ));
workingText = ''; workingText = '';
workingLabel = null; workingLabel = '';
workingAttributes = <ui.StringAttribute>[];
combined.add(info); combined.add(info);
} else { } else {
workingText += info.text; workingText += info.text;
workingLabel ??= ''; final String effectiveLabel = info.semanticsLabel ?? info.text;
if (info.semanticsLabel != null) { for (final ui.StringAttribute infoAttribute in info.stringAttributes) {
workingLabel += info.semanticsLabel!; workingAttributes.add(
} else { infoAttribute.copy(
workingLabel += info.text; range: TextRange(
start: infoAttribute.range.start + workingLabel.length,
end: infoAttribute.range.end + workingLabel.length,
),
),
);
} }
workingLabel += effectiveLabel;
} }
} }
combined.add(InlineSpanSemanticsInformation( combined.add(InlineSpanSemanticsInformation(
workingText, workingText,
semanticsLabel: workingLabel, semanticsLabel: workingLabel,
stringAttributes: workingAttributes,
)); ));
return combined; return combined;
} }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:ui' as ui show ParagraphBuilder; import 'dart:ui' as ui show ParagraphBuilder, Locale, StringAttribute, LocaleStringAttribute, SpellOutStringAttribute;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
...@@ -74,6 +74,8 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati ...@@ -74,6 +74,8 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati
this.onEnter, this.onEnter,
this.onExit, this.onExit,
this.semanticsLabel, this.semanticsLabel,
this.locale,
this.spellOut,
}) : mouseCursor = mouseCursor ?? }) : mouseCursor = mouseCursor ??
(recognizer == null ? MouseCursor.defer : SystemMouseCursors.click), (recognizer == null ? MouseCursor.defer : SystemMouseCursors.click),
assert(!(text == null && semanticsLabel != null)), assert(!(text == null && semanticsLabel != null)),
...@@ -218,6 +220,32 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati ...@@ -218,6 +220,32 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati
/// ``` /// ```
final String? semanticsLabel; final String? semanticsLabel;
/// The language of the text in this span and its span children.
///
/// Setting the locale of this text span affects the way that assistive
/// technologies, such as VoiceOver or TalkBack, pronounce the text.
///
/// If this span contains other text span children, they also inherit the
/// locale from this span unless explicitly set to different locales.
final ui.Locale? locale;
/// Whether the assistive technologies should spell out this text character
/// by character.
///
/// If the text is 'hello world', setting this to true causes the assistive
/// technologies, such as VoiceOver or TalkBack, to pronounce
/// 'h-e-l-l-o-space-w-o-r-l-d' instead of complete words. This is useful for
/// texts, such as passwords or verification codes.
///
/// If this span contains other text span children, they also inherit the
/// property from this span unless explicitly set.
///
/// If the property is not set, this text span inherits the spell out setting
/// from its parent. If this text span does not have a parent or the parent
/// does not have a spell out setting, this text span does not spell out the
/// text by default.
final bool? spellOut;
@override @override
bool get validForMouseTracker => true; bool get validForMouseTracker => true;
...@@ -333,21 +361,42 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati ...@@ -333,21 +361,42 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati
} }
@override @override
void computeSemanticsInformation(List<InlineSpanSemanticsInformation> collector) { void computeSemanticsInformation(
List<InlineSpanSemanticsInformation> collector, {
ui.Locale? inheritedLocale,
bool inheritedSpellOut = false,
}) {
assert(debugAssertIsValid()); assert(debugAssertIsValid());
final ui.Locale? effectiveLocale = locale ?? inheritedLocale;
final bool effectiveSpellOut = spellOut ?? inheritedSpellOut;
if (text != null) { if (text != null) {
collector.add(InlineSpanSemanticsInformation( collector.add(InlineSpanSemanticsInformation(
text!, text!,
stringAttributes: <ui.StringAttribute>[
if (effectiveSpellOut)
ui.SpellOutStringAttribute(range: TextRange(start: 0, end: semanticsLabel?.length ?? text!.length)),
if (effectiveLocale != null)
ui.LocaleStringAttribute(locale: effectiveLocale, range: TextRange(start: 0, end: semanticsLabel?.length ?? text!.length)),
],
semanticsLabel: semanticsLabel, semanticsLabel: semanticsLabel,
recognizer: recognizer, recognizer: recognizer,
)); ));
} }
if (children != null) { if (children != null) {
for (final InlineSpan child in children!) { for (final InlineSpan child in children!) {
if (child is TextSpan) {
child.computeSemanticsInformation(
collector,
inheritedLocale: effectiveLocale,
inheritedSpellOut: effectiveSpellOut,
);
} else {
child.computeSemanticsInformation(collector); child.computeSemanticsInformation(collector);
} }
} }
} }
}
@override @override
int? codeUnitAtVisitor(int index, Accumulator offset) { int? codeUnitAtVisitor(int index, Accumulator offset) {
......
...@@ -2274,11 +2274,15 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ...@@ -2274,11 +2274,15 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
/// The text to display. /// The text to display.
InlineSpan? get text => _textPainter.text; InlineSpan? get text => _textPainter.text;
final TextPainter _textPainter; final TextPainter _textPainter;
AttributedString? _cachedAttributedValue;
List<InlineSpanSemanticsInformation>? _cachedCombinedSemanticsInfos;
set text(InlineSpan? value) { set text(InlineSpan? value) {
if (_textPainter.text == value) if (_textPainter.text == value)
return; return;
_textPainter.text = value; _textPainter.text = value;
_cachedPlainText = null; _cachedPlainText = null;
_cachedAttributedValue = null;
_cachedCombinedSemanticsInfos = null;
_extractPlaceholderSpans(value); _extractPlaceholderSpans(value);
markNeedsTextLayout(); markNeedsTextLayout();
markNeedsSemanticsUpdate(); markNeedsSemanticsUpdate();
...@@ -2739,10 +2743,31 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ...@@ -2739,10 +2743,31 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
..explicitChildNodes = true; ..explicitChildNodes = true;
return; return;
} }
if (_cachedAttributedValue == null) {
if (obscureText) {
_cachedAttributedValue = AttributedString(obscuringCharacter * _plainText.length);
} else {
final StringBuffer buffer = StringBuffer();
int offset = 0;
final List<StringAttribute> attributes = <StringAttribute>[];
for (final InlineSpanSemanticsInformation info in _semanticsInfo!) {
final String label = info.semanticsLabel ?? info.text;
for (final StringAttribute infoAttribute in info.stringAttributes) {
final TextRange originalRange = infoAttribute.range;
attributes.add(
infoAttribute.copy(
range: TextRange(start: offset + originalRange.start, end: offset + originalRange.end),
),
);
}
buffer.write(label);
offset += label.length;
}
_cachedAttributedValue = AttributedString(buffer.toString(), attributes: attributes);
}
}
config config
..value = obscureText ..attributedValue = _cachedAttributedValue!
? obscuringCharacter * _plainText.length
: _plainText
..isObscured = obscureText ..isObscured = obscureText
..isMultiline = _isMultiline ..isMultiline = _isMultiline
..textDirection = textDirection ..textDirection = textDirection
...@@ -2793,7 +2818,8 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ...@@ -2793,7 +2818,8 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
int childIndex = 0; int childIndex = 0;
RenderBox? child = firstChild; RenderBox? child = firstChild;
final Queue<SemanticsNode> newChildCache = Queue<SemanticsNode>(); final Queue<SemanticsNode> newChildCache = Queue<SemanticsNode>();
for (final InlineSpanSemanticsInformation info in combineSemanticsInfo(_semanticsInfo!)) { _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!);
for (final InlineSpanSemanticsInformation info in _cachedCombinedSemanticsInfos!) {
final TextSelection selection = TextSelection( final TextSelection selection = TextSelection(
baseOffset: start, baseOffset: start,
extentOffset: start + info.text.length, extentOffset: start + info.text.length,
...@@ -2849,7 +2875,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ...@@ -2849,7 +2875,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
final SemanticsConfiguration configuration = SemanticsConfiguration() final SemanticsConfiguration configuration = SemanticsConfiguration()
..sortKey = OrdinalSortKey(ordinal++) ..sortKey = OrdinalSortKey(ordinal++)
..textDirection = initialDirection ..textDirection = initialDirection
..label = info.semanticsLabel ?? info.text; ..attributedLabel = AttributedString(info.semanticsLabel ?? info.text, attributes: info.stringAttributes);
final GestureRecognizer? recognizer = info.recognizer; final GestureRecognizer? recognizer = info.recognizer;
if (recognizer != null) { if (recognizer != null) {
if (recognizer is TapGestureRecognizer) { if (recognizer is TapGestureRecognizer) {
......
...@@ -118,6 +118,8 @@ class RenderParagraph extends RenderBox ...@@ -118,6 +118,8 @@ class RenderParagraph extends RenderBox
} }
final TextPainter _textPainter; final TextPainter _textPainter;
AttributedString? _cachedAttributedLabel;
List<InlineSpanSemanticsInformation>? _cachedCombinedSemanticsInfos;
/// The text to display. /// The text to display.
InlineSpan get text => _textPainter.text!; InlineSpan get text => _textPainter.text!;
...@@ -129,6 +131,8 @@ class RenderParagraph extends RenderBox ...@@ -129,6 +131,8 @@ class RenderParagraph extends RenderBox
return; return;
case RenderComparison.paint: case RenderComparison.paint:
_textPainter.text = value; _textPainter.text = value;
_cachedAttributedLabel = null;
_cachedCombinedSemanticsInfos = null;
_extractPlaceholderSpans(value); _extractPlaceholderSpans(value);
markNeedsPaint(); markNeedsPaint();
markNeedsSemanticsUpdate(); markNeedsSemanticsUpdate();
...@@ -136,6 +140,8 @@ class RenderParagraph extends RenderBox ...@@ -136,6 +140,8 @@ class RenderParagraph extends RenderBox
case RenderComparison.layout: case RenderComparison.layout:
_textPainter.text = value; _textPainter.text = value;
_overflowShader = null; _overflowShader = null;
_cachedAttributedLabel = null;
_cachedCombinedSemanticsInfos = null;
_extractPlaceholderSpans(value); _extractPlaceholderSpans(value);
markNeedsLayout(); markNeedsLayout();
break; break;
...@@ -869,11 +875,27 @@ class RenderParagraph extends RenderBox ...@@ -869,11 +875,27 @@ class RenderParagraph extends RenderBox
config.explicitChildNodes = true; config.explicitChildNodes = true;
config.isSemanticBoundary = true; config.isSemanticBoundary = true;
} else { } else {
if (_cachedAttributedLabel == null) {
final StringBuffer buffer = StringBuffer(); final StringBuffer buffer = StringBuffer();
int offset = 0;
final List<StringAttribute> attributes = <StringAttribute>[];
for (final InlineSpanSemanticsInformation info in _semanticsInfo!) { for (final InlineSpanSemanticsInformation info in _semanticsInfo!) {
buffer.write(info.semanticsLabel ?? info.text); final String label = info.semanticsLabel ?? info.text;
for (final StringAttribute infoAttribute in info.stringAttributes) {
final TextRange originalRange = infoAttribute.range;
attributes.add(
infoAttribute.copy(
range: TextRange(start: offset + originalRange.start,
end: offset + originalRange.end)
),
);
}
buffer.write(label);
offset += label.length;
}
_cachedAttributedLabel = AttributedString(buffer.toString(), attributes: attributes);
} }
config.label = buffer.toString(); config.attributedLabel = _cachedAttributedLabel!;
config.textDirection = textDirection; config.textDirection = textDirection;
} }
} }
...@@ -896,7 +918,8 @@ class RenderParagraph extends RenderBox ...@@ -896,7 +918,8 @@ class RenderParagraph extends RenderBox
int childIndex = 0; int childIndex = 0;
RenderBox? child = firstChild; RenderBox? child = firstChild;
final Queue<SemanticsNode> newChildCache = Queue<SemanticsNode>(); final Queue<SemanticsNode> newChildCache = Queue<SemanticsNode>();
for (final InlineSpanSemanticsInformation info in combineSemanticsInfo(_semanticsInfo!)) { _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!);
for (final InlineSpanSemanticsInformation info in _cachedCombinedSemanticsInfos!) {
final TextSelection selection = TextSelection( final TextSelection selection = TextSelection(
baseOffset: start, baseOffset: start,
extentOffset: start + info.text.length, extentOffset: start + info.text.length,
...@@ -952,7 +975,7 @@ class RenderParagraph extends RenderBox ...@@ -952,7 +975,7 @@ class RenderParagraph extends RenderBox
final SemanticsConfiguration configuration = SemanticsConfiguration() final SemanticsConfiguration configuration = SemanticsConfiguration()
..sortKey = OrdinalSortKey(ordinal++) ..sortKey = OrdinalSortKey(ordinal++)
..textDirection = initialDirection ..textDirection = initialDirection
..label = info.semanticsLabel ?? info.text; ..attributedLabel = AttributedString(info.semanticsLabel ?? info.text, attributes: info.stringAttributes);
final GestureRecognizer? recognizer = info.recognizer; final GestureRecognizer? recognizer = info.recognizer;
if (recognizer != null) { if (recognizer != null) {
if (recognizer is TapGestureRecognizer) { if (recognizer is TapGestureRecognizer) {
......
...@@ -364,4 +364,53 @@ void main() { ...@@ -364,4 +364,53 @@ void main() {
expect(logEvents[1], isA<PointerExitEvent>()); expect(logEvents[1], isA<PointerExitEvent>());
}); });
testWidgets('TextSpan can compute StringAttributes', (WidgetTester tester) async {
const TextSpan span = TextSpan(
text: 'aaaaa',
spellOut: true,
children: <InlineSpan>[
TextSpan(text: 'yyyyy', locale: Locale('es', 'MX')),
TextSpan(
text: 'xxxxx',
spellOut: false,
children: <InlineSpan>[
TextSpan(text: 'zzzzz'),
TextSpan(text: 'bbbbb', spellOut: true),
]
),
],
);
final List<InlineSpanSemanticsInformation> collector = <InlineSpanSemanticsInformation>[];
span.computeSemanticsInformation(collector);
expect(collector.length, 5);
expect(collector[0].stringAttributes.length, 1);
expect(collector[0].stringAttributes[0], isA<SpellOutStringAttribute>());
expect(collector[0].stringAttributes[0].range, const TextRange(start: 0, end: 5));
expect(collector[1].stringAttributes.length, 2);
expect(collector[1].stringAttributes[0], isA<SpellOutStringAttribute>());
expect(collector[1].stringAttributes[0].range, const TextRange(start: 0, end: 5));
expect(collector[1].stringAttributes[1], isA<LocaleStringAttribute>());
expect(collector[1].stringAttributes[1].range, const TextRange(start: 0, end: 5));
final LocaleStringAttribute localeStringAttribute = collector[1].stringAttributes[1] as LocaleStringAttribute;
expect(localeStringAttribute.locale, const Locale('es', 'MX'));
expect(collector[2].stringAttributes.length, 0);
expect(collector[3].stringAttributes.length, 0);
expect(collector[4].stringAttributes.length, 1);
expect(collector[4].stringAttributes[0], isA<SpellOutStringAttribute>());
expect(collector[4].stringAttributes[0].range, const TextRange(start: 0, end: 5));
final List<InlineSpanSemanticsInformation> combined = combineSemanticsInfo(collector);
expect(combined.length, 1);
expect(combined[0].stringAttributes.length, 4);
expect(combined[0].stringAttributes[0], isA<SpellOutStringAttribute>());
expect(combined[0].stringAttributes[0].range, const TextRange(start: 0, end: 5));
expect(combined[0].stringAttributes[1], isA<SpellOutStringAttribute>());
expect(combined[0].stringAttributes[1].range, const TextRange(start: 5, end: 10));
expect(combined[0].stringAttributes[2], isA<LocaleStringAttribute>());
expect(combined[0].stringAttributes[2].range, const TextRange(start: 5, end: 10));
final LocaleStringAttribute combinedLocaleStringAttribute = combined[0].stringAttributes[2] as LocaleStringAttribute;
expect(combinedLocaleStringAttribute.locale, const Locale('es', 'MX'));
expect(combined[0].stringAttributes[3], isA<SpellOutStringAttribute>());
expect(combined[0].stringAttributes[3].range, const TextRange(start: 20, end: 25));
});
} }
...@@ -48,8 +48,105 @@ void main() { ...@@ -48,8 +48,105 @@ void main() {
)); ));
}); });
testWidgets('TextSpan Locale works', (WidgetTester tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: RichText(
text: TextSpan(
text: 'root',
locale: const Locale('es', 'MX'),
children: <InlineSpan>[
TextSpan(text: 'one', recognizer: TapGestureRecognizer()),
const WidgetSpan(
child: SizedBox(),
),
TextSpan(text: 'three', recognizer: DoubleTapGestureRecognizer()),
]
),
),
),
);
expect(tester.getSemantics(find.byType(RichText)), matchesSemantics(
children: <Matcher>[
matchesSemantics(
attributedLabel: AttributedString(
'root',
attributes: <StringAttribute>[
LocaleStringAttribute(range: const TextRange(start: 0, end: 4), locale: const Locale('es', 'MX')),
]
),
),
matchesSemantics(
attributedLabel: AttributedString(
'one',
attributes: <StringAttribute>[
LocaleStringAttribute(range: const TextRange(start: 0, end: 3), locale: const Locale('es', 'MX')),
]
),
),
matchesSemantics(
attributedLabel: AttributedString(
'three',
attributes: <StringAttribute>[
LocaleStringAttribute(range: const TextRange(start: 0, end: 5), locale: const Locale('es', 'MX')),
]
),
),
],
));
});
testWidgets('TextSpan spellOut works', (WidgetTester tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: RichText(
text: TextSpan(
text: 'root',
spellOut: true,
children: <InlineSpan>[
TextSpan(text: 'one', recognizer: TapGestureRecognizer()),
const WidgetSpan(
child: SizedBox(),
),
TextSpan(text: 'three', recognizer: DoubleTapGestureRecognizer()),
]
),
),
),
);
expect(tester.getSemantics(find.byType(RichText)), matchesSemantics(
children: <Matcher>[
matchesSemantics(
attributedLabel: AttributedString(
'root',
attributes: <StringAttribute>[
SpellOutStringAttribute(range: const TextRange(start: 0, end: 4)),
]
),
),
matchesSemantics(
attributedLabel: AttributedString(
'one',
attributes: <StringAttribute>[
SpellOutStringAttribute(range: const TextRange(start: 0, end: 3)),
]
),
),
matchesSemantics(
attributedLabel: AttributedString(
'three',
attributes: <StringAttribute>[
SpellOutStringAttribute(range: const TextRange(start: 0, end: 5)),
]
),
),
],
));
});
testWidgets('WidgetSpan calculate correct intrinsic heights', (WidgetTester tester) async { testWidgets('WidgetSpan calculate correct intrinsic heights', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/48679.
await tester.pumpWidget( await tester.pumpWidget(
Directionality( Directionality(
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
......
...@@ -1488,6 +1488,72 @@ void main() { ...@@ -1488,6 +1488,72 @@ void main() {
semantics.dispose(); semantics.dispose();
}); });
testWidgets('Selectable text rich text with spell out in semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Center(
child: SelectableText.rich(TextSpan(text: 'some text', spellOut: true)),
),
),
),
);
expect(
semantics,
includesNodeWith(
attributedValue: AttributedString(
'some text',
attributes: <StringAttribute>[
SpellOutStringAttribute(range: const TextRange(start: 0, end:9)),
],
),
flags: <SemanticsFlag>[
SemanticsFlag.isTextField,
SemanticsFlag.isReadOnly,
SemanticsFlag.isMultiline,
],
),
);
semantics.dispose();
});
testWidgets('Selectable text rich text with locale in semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Center(
child: SelectableText.rich(TextSpan(text: 'some text', locale: Locale('es', 'MX'))),
),
),
),
);
expect(
semantics,
includesNodeWith(
attributedValue: AttributedString(
'some text',
attributes: <StringAttribute>[
LocaleStringAttribute(range: const TextRange(start: 0, end:9), locale: const Locale('es', 'MX')),
],
),
flags: <SemanticsFlag>[
SemanticsFlag.isTextField,
SemanticsFlag.isReadOnly,
SemanticsFlag.isMultiline,
],
),
);
semantics.dispose();
});
testWidgets('Selectable rich text with gesture recognizer has correct semantics', (WidgetTester tester) async { testWidgets('Selectable rich text with gesture recognizer has correct semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester); final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget( await tester.pumpWidget(
......
...@@ -427,6 +427,25 @@ class SemanticsTester { ...@@ -427,6 +427,25 @@ class SemanticsTester {
@override @override
String toString() => 'SemanticsTester for ${tester.binding.pipelineOwner.semanticsOwner?.rootSemanticsNode}'; String toString() => 'SemanticsTester for ${tester.binding.pipelineOwner.semanticsOwner?.rootSemanticsNode}';
bool _stringAttributesEqual(List<StringAttribute> first, List<StringAttribute> second) {
if (first.length != second.length)
return false;
for (int i = 0; i < first.length; i++) {
if (first[i] is SpellOutStringAttribute &&
(second[i] is! SpellOutStringAttribute ||
second[i].range != first[i].range)) {
return false;
}
if (first[i] is LocaleStringAttribute &&
(second[i] is! LocaleStringAttribute ||
second[i].range != first[i].range ||
(second[i] as LocaleStringAttribute).locale != (second[i] as LocaleStringAttribute).locale)) {
return false;
}
}
return true;
}
/// Returns all semantics nodes in the current semantics tree whose properties /// Returns all semantics nodes in the current semantics tree whose properties
/// match the non-null arguments. /// match the non-null arguments.
/// ///
...@@ -435,6 +454,9 @@ class SemanticsTester { ...@@ -435,6 +454,9 @@ class SemanticsTester {
/// ///
/// If `ancestor` is not null, only the descendants of it are returned. /// If `ancestor` is not null, only the descendants of it are returned.
Iterable<SemanticsNode> nodesWith({ Iterable<SemanticsNode> nodesWith({
AttributedString? attributedLabel,
AttributedString? attributedValue,
AttributedString? attributedHint,
String? label, String? label,
String? value, String? value,
String? hint, String? hint,
...@@ -451,10 +473,25 @@ class SemanticsTester { ...@@ -451,10 +473,25 @@ class SemanticsTester {
bool checkNode(SemanticsNode node) { bool checkNode(SemanticsNode node) {
if (label != null && node.label != label) if (label != null && node.label != label)
return false; return false;
if (attributedLabel != null &&
(attributedLabel.string != node.attributedLabel.string ||
!_stringAttributesEqual(attributedLabel.attributes, node.attributedLabel.attributes))) {
return false;
}
if (value != null && node.value != value) if (value != null && node.value != value)
return false; return false;
if (attributedValue != null &&
(attributedValue.string != node.attributedValue.string ||
!_stringAttributesEqual(attributedValue.attributes, node.attributedValue.attributes))) {
return false;
}
if (hint != null && node.hint != hint) if (hint != null && node.hint != hint)
return false; return false;
if (attributedHint != null &&
(attributedHint.string != node.attributedHint.string ||
!_stringAttributesEqual(attributedHint.attributes, node.attributedHint.attributes))) {
return false;
}
if (textDirection != null && node.textDirection != textDirection) if (textDirection != null && node.textDirection != textDirection)
return false; return false;
if (actions != null) { if (actions != null) {
...@@ -714,6 +751,9 @@ Matcher hasSemantics( ...@@ -714,6 +751,9 @@ Matcher hasSemantics(
class _IncludesNodeWith extends Matcher { class _IncludesNodeWith extends Matcher {
const _IncludesNodeWith({ const _IncludesNodeWith({
this.attributedLabel,
this.attributedValue,
this.attributedHint,
this.label, this.label,
this.value, this.value,
this.hint, this.hint,
...@@ -725,7 +765,7 @@ class _IncludesNodeWith extends Matcher { ...@@ -725,7 +765,7 @@ class _IncludesNodeWith extends Matcher {
this.scrollExtentMin, this.scrollExtentMin,
this.maxValueLength, this.maxValueLength,
this.currentValueLength, this.currentValueLength,
}) : assert( }) : assert(
label != null || label != null ||
value != null || value != null ||
actions != null || actions != null ||
...@@ -736,7 +776,9 @@ class _IncludesNodeWith extends Matcher { ...@@ -736,7 +776,9 @@ class _IncludesNodeWith extends Matcher {
maxValueLength != null || maxValueLength != null ||
currentValueLength != null, currentValueLength != null,
); );
final AttributedString? attributedLabel;
final AttributedString? attributedValue;
final AttributedString? attributedHint;
final String? label; final String? label;
final String? value; final String? value;
final String? hint; final String? hint;
...@@ -752,6 +794,9 @@ class _IncludesNodeWith extends Matcher { ...@@ -752,6 +794,9 @@ class _IncludesNodeWith extends Matcher {
@override @override
bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) { bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
return item.nodesWith( return item.nodesWith(
attributedLabel: attributedLabel,
attributedValue: attributedValue,
attributedHint: attributedHint,
label: label, label: label,
value: value, value: value,
hint: hint, hint: hint,
...@@ -800,8 +845,11 @@ class _IncludesNodeWith extends Matcher { ...@@ -800,8 +845,11 @@ class _IncludesNodeWith extends Matcher {
/// If null is provided for an argument, it will match against any value. /// If null is provided for an argument, it will match against any value.
Matcher includesNodeWith({ Matcher includesNodeWith({
String? label, String? label,
AttributedString? attributedLabel,
String? value, String? value,
AttributedString? attributedValue,
String? hint, String? hint,
AttributedString? attributedHint,
TextDirection? textDirection, TextDirection? textDirection,
List<SemanticsAction>? actions, List<SemanticsAction>? actions,
List<SemanticsFlag>? flags, List<SemanticsFlag>? flags,
...@@ -813,8 +861,11 @@ Matcher includesNodeWith({ ...@@ -813,8 +861,11 @@ Matcher includesNodeWith({
}) { }) {
return _IncludesNodeWith( return _IncludesNodeWith(
label: label, label: label,
attributedLabel: attributedLabel,
value: value, value: value,
attributedValue: attributedValue,
hint: hint, hint: hint,
attributedHint: attributedHint,
textDirection: textDirection, textDirection: textDirection,
actions: actions, actions: actions,
flags: flags, flags: flags,
......
...@@ -431,10 +431,15 @@ AsyncMatcher matchesReferenceImage(ui.Image image) { ...@@ -431,10 +431,15 @@ AsyncMatcher matchesReferenceImage(ui.Image image) {
/// * [WidgetTester.getSemantics], the tester method which retrieves semantics. /// * [WidgetTester.getSemantics], the tester method which retrieves semantics.
Matcher matchesSemantics({ Matcher matchesSemantics({
String? label, String? label,
AttributedString? attributedLabel,
String? hint, String? hint,
AttributedString? attributedHint,
String? value, String? value,
AttributedString? attributedValue,
String? increasedValue, String? increasedValue,
AttributedString? attributedIncreasedValue,
String? decreasedValue, String? decreasedValue,
AttributedString? attributedDecreasedValue,
TextDirection? textDirection, TextDirection? textDirection,
Rect? rect, Rect? rect,
Size? size, Size? size,
...@@ -559,10 +564,15 @@ Matcher matchesSemantics({ ...@@ -559,10 +564,15 @@ Matcher matchesSemantics({
return _MatchesSemanticsData( return _MatchesSemanticsData(
label: label, label: label,
attributedLabel: attributedLabel,
hint: hint, hint: hint,
attributedHint: attributedHint,
value: value, value: value,
attributedValue: attributedValue,
increasedValue: increasedValue, increasedValue: increasedValue,
attributedIncreasedValue: attributedIncreasedValue,
decreasedValue: decreasedValue, decreasedValue: decreasedValue,
attributedDecreasedValue: attributedDecreasedValue,
actions: actions, actions: actions,
flags: flags, flags: flags,
textDirection: textDirection, textDirection: textDirection,
...@@ -1708,10 +1718,15 @@ class _MatchesReferenceImage extends AsyncMatcher { ...@@ -1708,10 +1718,15 @@ class _MatchesReferenceImage extends AsyncMatcher {
class _MatchesSemanticsData extends Matcher { class _MatchesSemanticsData extends Matcher {
_MatchesSemanticsData({ _MatchesSemanticsData({
this.label, this.label,
this.attributedLabel,
this.hint,
this.attributedHint,
this.value, this.value,
this.attributedValue,
this.increasedValue, this.increasedValue,
this.attributedIncreasedValue,
this.decreasedValue, this.decreasedValue,
this.hint, this.attributedDecreasedValue,
this.flags, this.flags,
this.actions, this.actions,
this.textDirection, this.textDirection,
...@@ -1728,10 +1743,15 @@ class _MatchesSemanticsData extends Matcher { ...@@ -1728,10 +1743,15 @@ class _MatchesSemanticsData extends Matcher {
}); });
final String? label; final String? label;
final String? value; final AttributedString? attributedLabel;
final String? hint; final String? hint;
final AttributedString? attributedHint;
final String? value;
final AttributedString? attributedValue;
final String? increasedValue; final String? increasedValue;
final AttributedString? attributedIncreasedValue;
final String? decreasedValue; final String? decreasedValue;
final AttributedString? attributedDecreasedValue;
final SemanticsHintOverrides? hintOverrides; final SemanticsHintOverrides? hintOverrides;
final List<SemanticsAction>? actions; final List<SemanticsAction>? actions;
final List<CustomSemanticsAction>? customActions; final List<CustomSemanticsAction>? customActions;
...@@ -1751,14 +1771,24 @@ class _MatchesSemanticsData extends Matcher { ...@@ -1751,14 +1771,24 @@ class _MatchesSemanticsData extends Matcher {
description.add('has semantics'); description.add('has semantics');
if (label != null) if (label != null)
description.add(' with label: $label'); description.add(' with label: $label');
if (attributedLabel != null)
description.add(' with attributedLabel: $attributedLabel');
if (value != null) if (value != null)
description.add(' with value: $value'); description.add(' with value: $value');
if (attributedValue != null)
description.add(' with attributedValue: $attributedValue');
if (hint != null) if (hint != null)
description.add(' with hint: $hint'); description.add(' with hint: $hint');
if (attributedHint != null)
description.add(' with attributedHint: $attributedHint');
if (increasedValue != null) if (increasedValue != null)
description.add(' with increasedValue: $increasedValue '); description.add(' with increasedValue: $increasedValue ');
if (attributedIncreasedValue != null)
description.add(' with attributedIncreasedValue: $attributedIncreasedValue');
if (decreasedValue != null) if (decreasedValue != null)
description.add(' with decreasedValue: $decreasedValue '); description.add(' with decreasedValue: $decreasedValue ');
if (attributedDecreasedValue != null)
description.add(' with attributedDecreasedValue: $attributedDecreasedValue');
if (actions != null) if (actions != null)
description.add(' with actions: ').addDescriptionOf(actions); description.add(' with actions: ').addDescriptionOf(actions);
if (flags != null) if (flags != null)
...@@ -1791,6 +1821,24 @@ class _MatchesSemanticsData extends Matcher { ...@@ -1791,6 +1821,24 @@ class _MatchesSemanticsData extends Matcher {
return description; return description;
} }
bool _stringAttributesEqual(List<StringAttribute> first, List<StringAttribute> second) {
if (first.length != second.length)
return false;
for (int i = 0; i < first.length; i++) {
if (first[i] is SpellOutStringAttribute &&
(second[i] is! SpellOutStringAttribute ||
second[i].range != first[i].range)) {
return false;
}
if (first[i] is LocaleStringAttribute &&
(second[i] is! LocaleStringAttribute ||
second[i].range != first[i].range ||
(second[i] as LocaleStringAttribute).locale != (second[i] as LocaleStringAttribute).locale)) {
return false;
}
}
return true;
}
@override @override
bool matches(dynamic node, Map<dynamic, dynamic> matchState) { bool matches(dynamic node, Map<dynamic, dynamic> matchState) {
...@@ -1801,14 +1849,44 @@ class _MatchesSemanticsData extends Matcher { ...@@ -1801,14 +1849,44 @@ class _MatchesSemanticsData extends Matcher {
final SemanticsData data = node is SemanticsNode ? node.getSemanticsData() : (node as SemanticsData); final SemanticsData data = node is SemanticsNode ? node.getSemanticsData() : (node as SemanticsData);
if (label != null && label != data.label) if (label != null && label != data.label)
return failWithDescription(matchState, 'label was: ${data.label}'); return failWithDescription(matchState, 'label was: ${data.label}');
if (attributedLabel != null &&
(attributedLabel!.string != data.attributedLabel.string ||
!_stringAttributesEqual(attributedLabel!.attributes, data.attributedLabel.attributes))) {
return failWithDescription(
matchState, 'attributedLabel was: ${data.attributedLabel}');
}
if (hint != null && hint != data.hint) if (hint != null && hint != data.hint)
return failWithDescription(matchState, 'hint was: ${data.hint}'); return failWithDescription(matchState, 'hint was: ${data.hint}');
if (attributedHint != null &&
(attributedHint!.string != data.attributedHint.string ||
!_stringAttributesEqual(attributedHint!.attributes, data.attributedHint.attributes))) {
return failWithDescription(
matchState, 'attributedHint was: ${data.attributedHint}');
}
if (value != null && value != data.value) if (value != null && value != data.value)
return failWithDescription(matchState, 'value was: ${data.value}'); return failWithDescription(matchState, 'value was: ${data.value}');
if (attributedValue != null &&
(attributedValue!.string != data.attributedValue.string ||
!_stringAttributesEqual(attributedValue!.attributes, data.attributedValue.attributes))) {
return failWithDescription(
matchState, 'attributedValue was: ${data.attributedValue}');
}
if (increasedValue != null && increasedValue != data.increasedValue) if (increasedValue != null && increasedValue != data.increasedValue)
return failWithDescription(matchState, 'increasedValue was: ${data.increasedValue}'); return failWithDescription(matchState, 'increasedValue was: ${data.increasedValue}');
if (attributedIncreasedValue != null &&
(attributedIncreasedValue!.string != data.attributedIncreasedValue.string ||
!_stringAttributesEqual(attributedIncreasedValue!.attributes, data.attributedIncreasedValue.attributes))) {
return failWithDescription(
matchState, 'attributedIncreasedValue was: ${data.attributedIncreasedValue}');
}
if (decreasedValue != null && decreasedValue != data.decreasedValue) if (decreasedValue != null && decreasedValue != data.decreasedValue)
return failWithDescription(matchState, 'decreasedValue was: ${data.decreasedValue}'); return failWithDescription(matchState, 'decreasedValue was: ${data.decreasedValue}');
if (attributedDecreasedValue != null &&
(attributedDecreasedValue!.string != data.attributedDecreasedValue.string ||
!_stringAttributesEqual(attributedDecreasedValue!.attributes, data.attributedDecreasedValue.attributes))) {
return failWithDescription(
matchState, 'attributedDecreasedValue was: ${data.attributedDecreasedValue}');
}
if (textDirection != null && textDirection != data.textDirection) if (textDirection != null && textDirection != data.textDirection)
return failWithDescription(matchState, 'textDirection was: $textDirection'); return failWithDescription(matchState, 'textDirection was: $textDirection');
if (rect != null && rect != data.rect) if (rect != null && rect != data.rect)
......
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