Unverified Commit c96806ec authored by Dan Field's avatar Dan Field Committed by GitHub

Allow semantics labels to be shorter or longer than raw text (#36243)

parent 9b08effa
...@@ -35,6 +35,68 @@ class Accumulator { ...@@ -35,6 +35,68 @@ class Accumulator {
/// [InlineSpan]s. /// [InlineSpan]s.
typedef InlineSpanVisitor = bool Function(InlineSpan span); typedef InlineSpanVisitor = bool Function(InlineSpan span);
/// The textual and semantic label information for an [InlineSpan].
///
/// For [PlaceholderSpan]s, [InlineSpanSemanticsInformation.placeholder] is used by default.
///
/// See also:
/// * [InlineSpan.getSemanticsInformation]
@immutable
class InlineSpanSemanticsInformation {
/// Constructs an object that holds the text and sematnics label values of an
/// [InlineSpan].
///
/// The text parameter must not be null.
///
/// Use [InlineSpanSemanticsInformation.placeholder] instead of directly setting
/// [isPlaceholder].
const InlineSpanSemanticsInformation(
this.text, {
this.isPlaceholder = false,
this.semanticsLabel,
this.recognizer
}) : assert(text != null),
assert(isPlaceholder != null),
assert(isPlaceholder == false || (text == '\uFFFC' && semanticsLabel == null && recognizer == null)),
requiresOwnNode = isPlaceholder || recognizer != null;
/// The text info for a [PlaceholderSpan].
static const InlineSpanSemanticsInformation placeholder = InlineSpanSemanticsInformation('\uFFFC', isPlaceholder: true);
/// The text value, if any. For [PlaceholderSpan]s, this will be the unicode
/// placeholder value.
final String text;
/// The semanticsLabel, if any.
final String semanticsLabel;
/// The gesture recognizer, if any, for this span.
final GestureRecognizer recognizer;
/// Whether this is for a placeholder span.
final bool isPlaceholder;
/// True if this configuration should get its own semantics node.
///
/// This will be the case of the [recognizer] is not null, of if
/// [isPlaceholder] is true.
final bool requiresOwnNode;
@override
bool operator ==(dynamic other) {
if (other is! InlineSpanSemanticsInformation) {
return false;
}
return other.text == text && other.semanticsLabel == semanticsLabel && other.recognizer == recognizer && other.isPlaceholder == isPlaceholder;
}
@override
int get hashCode => hashValues(text, semanticsLabel, recognizer, isPlaceholder);
@override
String toString() => '$runtimeType{text: $text, semanticsLabel: $semanticsLabel, recognizer: $recognizer}';
}
/// An immutable span of inline content which forms part of a paragraph. /// An immutable span of inline content which forms part of a paragraph.
/// ///
/// * The subclass [TextSpan] specifies text and may contain child [InlineSpan]s. /// * The subclass [TextSpan] specifies text and may contain child [InlineSpan]s.
...@@ -175,6 +237,28 @@ abstract class InlineSpan extends DiagnosticableTree { ...@@ -175,6 +237,28 @@ abstract class InlineSpan extends DiagnosticableTree {
return buffer.toString(); return buffer.toString();
} }
/// Flattens the [InlineSpan] tree to a list of
/// [InlineSpanSemanticsInformation] objects.
///
/// [PlaceholderSpan]s in the tree will be represented with a
/// [InlineSpanSemanticsInformation.placeholder] value.
List<InlineSpanSemanticsInformation> getSemanticsInformation() {
final List<InlineSpanSemanticsInformation> collector = <InlineSpanSemanticsInformation>[];
computeSemanticsInformation(collector);
return collector;
}
/// Walks the [InlineSpan] tree and accumulates a list of
/// [InlineSpanSemanticsInformation] objects.
///
/// This method should not be directly called. Use
/// [getSemanticsInformation] instead.
///
/// [PlaceholderSpan]s in the tree will be represented with a
/// [InlineSpanSemanticsInformation.placeholder] value.
@protected
void computeSemanticsInformation(List<InlineSpanSemanticsInformation> collector);
/// Walks the [InlineSpan] tree and writes the plain text representation to `buffer`. /// Walks the [InlineSpan] tree and writes the plain text representation to `buffer`.
/// ///
/// This method should not be directly called. Use [toPlainText] instead. /// This method should not be directly called. Use [toPlainText] instead.
...@@ -229,6 +313,7 @@ abstract class InlineSpan extends DiagnosticableTree { ...@@ -229,6 +313,7 @@ abstract class InlineSpan extends DiagnosticableTree {
/// ///
/// Any [GestureRecognizer]s are added to `semanticsElements`. Null is added to /// Any [GestureRecognizer]s are added to `semanticsElements`. Null is added to
/// `semanticsElements` for [PlaceholderSpan]s. /// `semanticsElements` for [PlaceholderSpan]s.
@Deprecated('Implement computeSemanticsInformation instead.')
void describeSemantics(Accumulator offset, List<int> semanticsOffsets, List<dynamic> semanticsElements); void describeSemantics(Accumulator offset, List<int> semanticsOffsets, List<dynamic> semanticsElements);
/// In checked mode, throws an exception if the object is not in a /// In checked mode, throws an exception if the object is not in a
......
...@@ -60,6 +60,11 @@ abstract class PlaceholderSpan extends InlineSpan { ...@@ -60,6 +60,11 @@ abstract class PlaceholderSpan extends InlineSpan {
} }
} }
@override
void computeSemanticsInformation(List<InlineSpanSemanticsInformation> collector) {
collector.add(InlineSpanSemanticsInformation.placeholder);
}
// TODO(garyq): Remove this after next stable release. // TODO(garyq): Remove this after next stable release.
/// The [visitTextSpan] method is invalid on [PlaceholderSpan]s /// The [visitTextSpan] method is invalid on [PlaceholderSpan]s
@override @override
......
...@@ -291,6 +291,23 @@ class TextSpan extends InlineSpan { ...@@ -291,6 +291,23 @@ class TextSpan extends InlineSpan {
} }
} }
@override
void computeSemanticsInformation(List<InlineSpanSemanticsInformation> collector) {
assert(debugAssertIsValid());
if (text != null || semanticsLabel != null) {
collector.add(InlineSpanSemanticsInformation(
text,
semanticsLabel: semanticsLabel,
recognizer: recognizer,
));
}
if (children != null) {
for (InlineSpan child in children) {
child.computeSemanticsInformation(collector);
}
}
}
@override @override
int codeUnitAtVisitor(int index, Accumulator offset) { int codeUnitAtVisitor(int index, Accumulator offset) {
if (text == null) { if (text == null) {
......
...@@ -731,49 +731,83 @@ class RenderParagraph extends RenderBox ...@@ -731,49 +731,83 @@ class RenderParagraph extends RenderBox
return _textPainter.size; return _textPainter.size;
} }
// The offsets for each span that requires custom semantics. /// Collected during [describeSemanticsConfiguration], used by
final List<int> _inlineSemanticsOffsets = <int>[]; /// [assembleSemanticsNode] and [_combineSemanticsInfo].
// Holds either [GestureRecognizer] or null (for placeholders) to generate List<InlineSpanSemanticsInformation> _semanticsInfo;
// proper semnatics configurations.
final List<dynamic> _inlineSemanticsElements = <dynamic>[]; /// Combines _semanticsInfo entries where permissible, determined by
/// [InlineSpanSemanticsInformation.requiresOwnNode].
List<InlineSpanSemanticsInformation> _combineSemanticsInfo() {
assert(_semanticsInfo != null);
final List<InlineSpanSemanticsInformation> combined = <InlineSpanSemanticsInformation>[];
String workingText = '';
String workingLabel;
for (InlineSpanSemanticsInformation info in _semanticsInfo) {
if (info.requiresOwnNode) {
if (workingText != null) {
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;
}
}
}
if (workingText != null) {
combined.add(InlineSpanSemanticsInformation(
workingText,
semanticsLabel: workingLabel,
));
} else {
assert(workingLabel != null);
}
return combined;
}
@override @override
void describeSemanticsConfiguration(SemanticsConfiguration config) { void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config); super.describeSemanticsConfiguration(config);
_inlineSemanticsOffsets.clear(); _semanticsInfo = text.getSemanticsInformation();
_inlineSemanticsElements.clear();
final Accumulator offset = Accumulator(); if (_semanticsInfo.any((InlineSpanSemanticsInformation info) => info.recognizer != null)) {
text.visitChildren((InlineSpan span) {
span.describeSemantics(offset, _inlineSemanticsOffsets, _inlineSemanticsElements);
return true;
});
if (_inlineSemanticsOffsets.isNotEmpty) {
config.explicitChildNodes = true; config.explicitChildNodes = true;
config.isSemanticBoundary = true; config.isSemanticBoundary = true;
} else { } else {
config.label = text.toPlainText(); final StringBuffer buffer = StringBuffer();
for (InlineSpanSemanticsInformation info in _semanticsInfo) {
buffer.write(info.semanticsLabel ?? info.text);
}
config.label = buffer.toString();
config.textDirection = textDirection; config.textDirection = textDirection;
} }
} }
@override @override
void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) { void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) {
assert(_inlineSemanticsOffsets.isNotEmpty); assert(_semanticsInfo != null && _semanticsInfo.isNotEmpty);
assert(_inlineSemanticsOffsets.length.isEven);
assert(_inlineSemanticsElements.isNotEmpty);
final List<SemanticsNode> newChildren = <SemanticsNode>[]; final List<SemanticsNode> newChildren = <SemanticsNode>[];
final String rawLabel = text.toPlainText();
int current = 0;
double order = -1.0;
TextDirection currentDirection = textDirection; TextDirection currentDirection = textDirection;
Rect currentRect; Rect currentRect;
double ordinal = 0.0;
SemanticsConfiguration buildSemanticsConfig(int start, int end) { int start = 0;
int placeholderIndex = 0;
RenderBox child = firstChild;
for (InlineSpanSemanticsInformation info in _combineSemanticsInfo()) {
final TextDirection initialDirection = currentDirection; final TextDirection initialDirection = currentDirection;
final TextSelection selection = TextSelection(baseOffset: start, extentOffset: end); final TextSelection selection = TextSelection(baseOffset: start, extentOffset: start + info.text.length);
final List<ui.TextBox> rects = getBoxesForSelection(selection); final List<ui.TextBox> rects = getBoxesForSelection(selection);
if (rects.isEmpty) { if (rects.isEmpty) {
return null; continue;
} }
Rect rect = rects.first.toRect(); Rect rect = rects.first.toRect();
currentDirection = rects.first.direction; currentDirection = rects.first.direction;
...@@ -791,64 +825,15 @@ class RenderParagraph extends RenderBox ...@@ -791,64 +825,15 @@ class RenderParagraph extends RenderBox
); );
// round the current rectangle to make this API testable and add some // round the current rectangle to make this API testable and add some
// padding so that the accessibility rects do not overlap with the text. // padding so that the accessibility rects do not overlap with the text.
// TODO(jonahwilliams): implement this for all text accessibility rects.
currentRect = Rect.fromLTRB( currentRect = Rect.fromLTRB(
rect.left.floorToDouble() - 4.0, rect.left.floorToDouble() - 4.0,
rect.top.floorToDouble() - 4.0, rect.top.floorToDouble() - 4.0,
rect.right.ceilToDouble() + 4.0, rect.right.ceilToDouble() + 4.0,
rect.bottom.ceilToDouble() + 4.0, rect.bottom.ceilToDouble() + 4.0,
); );
order += 1;
final SemanticsConfiguration configuration = SemanticsConfiguration()
..sortKey = OrdinalSortKey(order)
..textDirection = initialDirection
..label = rawLabel.substring(start, end);
return configuration;
}
int childIndex = 0; if (info.isPlaceholder) {
RenderBox child = firstChild; final SemanticsNode childNode = children.elementAt(placeholderIndex++);
for (int i = 0, j = 0; i < _inlineSemanticsOffsets.length; i += 2, j++) {
final int start = _inlineSemanticsOffsets[i];
final int end = _inlineSemanticsOffsets[i + 1];
// Add semantics for any text between the previous recognizer/widget and this one.
if (current != start) {
final SemanticsNode node = SemanticsNode();
final SemanticsConfiguration configuration = buildSemanticsConfig(current, start);
if (configuration == null) {
continue;
}
node.updateWith(config: configuration);
node.rect = currentRect;
newChildren.add(node);
}
final dynamic inlineElement = _inlineSemanticsElements[j];
final SemanticsConfiguration configuration = buildSemanticsConfig(start, end);
if (configuration == null) {
continue;
}
if (inlineElement != null) {
// Add semantics for this recognizer.
final SemanticsNode node = SemanticsNode();
if (inlineElement is TapGestureRecognizer) {
final TapGestureRecognizer recognizer = inlineElement;
configuration.onTap = recognizer.onTap;
} else if (inlineElement is LongPressGestureRecognizer) {
final LongPressGestureRecognizer recognizer = inlineElement;
configuration.onLongPress = recognizer.onLongPress;
} else {
assert(false);
}
node.updateWith(config: configuration);
node.rect = currentRect;
newChildren.add(node);
} else if (childIndex < children.length) {
// Add semantics for this placeholder. Semantics are precomputed in the children
// argument.
// Placeholders should not get a label, which would come through as an
// object replacement character.
configuration.label = '';
final SemanticsNode childNode = children.elementAt(childIndex);
final TextParentData parentData = child.parentData; final TextParentData parentData = child.parentData;
childNode.rect = Rect.fromLTWH( childNode.rect = Rect.fromLTWH(
childNode.rect.left, childNode.rect.left,
...@@ -856,20 +841,31 @@ class RenderParagraph extends RenderBox ...@@ -856,20 +841,31 @@ class RenderParagraph extends RenderBox
childNode.rect.width * parentData.scale, childNode.rect.width * parentData.scale,
childNode.rect.height * parentData.scale, childNode.rect.height * parentData.scale,
); );
newChildren.add(children.elementAt(childIndex)); newChildren.add(childNode);
childIndex += 1;
child = childAfter(child); child = childAfter(child);
} else {
final SemanticsConfiguration configuration = SemanticsConfiguration()
..sortKey = OrdinalSortKey(ordinal++)
..textDirection = initialDirection
..label = info.semanticsLabel ?? info.text;
if (info.recognizer != null) {
if (info.recognizer is TapGestureRecognizer) {
final TapGestureRecognizer recognizer = info.recognizer;
configuration.onTap = recognizer.onTap;
} else if (info.recognizer is LongPressGestureRecognizer) {
final LongPressGestureRecognizer recognizer = info.recognizer;
configuration.onLongPress = recognizer.onLongPress;
} else {
assert(false);
}
}
newChildren.add(
SemanticsNode()
..updateWith(config: configuration)
..rect = currentRect,
);
} }
current = end; start += info.text.length;
}
if (current < rawLabel.length) {
final SemanticsNode node = SemanticsNode();
final SemanticsConfiguration configuration = buildSemanticsConfig(current, rawLabel.length);
if (configuration != null) {
node.updateWith(config: configuration);
node.rect = currentRect;
newChildren.add(node);
}
} }
node.updateWith(config: config, childrenInInversePaintOrder: newChildren); node.updateWith(config: config, childrenInInversePaintOrder: newChildren);
} }
......
...@@ -141,6 +141,44 @@ void main() { ...@@ -141,6 +141,44 @@ void main() {
semantics.dispose(); semantics.dispose();
}); });
testWidgets('semanticsLabel can be shorter than text', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: RichText(
text: TextSpan(
children: <InlineSpan>[
const TextSpan(
text: 'Some Text',
semanticsLabel: '',
),
TextSpan(
text: 'Clickable',
recognizer: TapGestureRecognizer()..onTap = () { },
),
]),
),
));
final TestSemantics expectedSemantics = TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
children: <TestSemantics>[
TestSemantics(
textDirection: TextDirection.ltr,
),
TestSemantics(
label: 'Clickable',
actions: <SemanticsAction>[SemanticsAction.tap],
textDirection: TextDirection.ltr,
),
],
),
],
);
expect(semantics, hasSemantics(expectedSemantics, ignoreTransform: true, ignoreId: true, ignoreRect: true));
semantics.dispose();
});
testWidgets('recognizers split semantic node', (WidgetTester tester) async { testWidgets('recognizers split semantic node', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester); final SemanticsTester semantics = SemanticsTester(tester);
const TextStyle textStyle = TextStyle(fontFamily: 'Ahem'); const TextStyle textStyle = TextStyle(fontFamily: 'Ahem');
......
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