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 {
/// [InlineSpan]s.
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.
///
/// * The subclass [TextSpan] specifies text and may contain child [InlineSpan]s.
......@@ -175,6 +237,28 @@ abstract class InlineSpan extends DiagnosticableTree {
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`.
///
/// This method should not be directly called. Use [toPlainText] instead.
......@@ -229,6 +313,7 @@ abstract class InlineSpan extends DiagnosticableTree {
///
/// Any [GestureRecognizer]s are added to `semanticsElements`. Null is added to
/// `semanticsElements` for [PlaceholderSpan]s.
@Deprecated('Implement computeSemanticsInformation instead.')
void describeSemantics(Accumulator offset, List<int> semanticsOffsets, List<dynamic> semanticsElements);
/// In checked mode, throws an exception if the object is not in a
......
......@@ -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.
/// The [visitTextSpan] method is invalid on [PlaceholderSpan]s
@override
......
......@@ -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
int codeUnitAtVisitor(int index, Accumulator offset) {
if (text == null) {
......
......@@ -731,49 +731,83 @@ class RenderParagraph extends RenderBox
return _textPainter.size;
}
// The offsets for each span that requires custom semantics.
final List<int> _inlineSemanticsOffsets = <int>[];
// Holds either [GestureRecognizer] or null (for placeholders) to generate
// proper semnatics configurations.
final List<dynamic> _inlineSemanticsElements = <dynamic>[];
/// Collected during [describeSemanticsConfiguration], used by
/// [assembleSemanticsNode] and [_combineSemanticsInfo].
List<InlineSpanSemanticsInformation> _semanticsInfo;
/// 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
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
_inlineSemanticsOffsets.clear();
_inlineSemanticsElements.clear();
final Accumulator offset = Accumulator();
text.visitChildren((InlineSpan span) {
span.describeSemantics(offset, _inlineSemanticsOffsets, _inlineSemanticsElements);
return true;
});
if (_inlineSemanticsOffsets.isNotEmpty) {
_semanticsInfo = text.getSemanticsInformation();
if (_semanticsInfo.any((InlineSpanSemanticsInformation info) => info.recognizer != null)) {
config.explicitChildNodes = true;
config.isSemanticBoundary = true;
} 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;
}
}
@override
void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) {
assert(_inlineSemanticsOffsets.isNotEmpty);
assert(_inlineSemanticsOffsets.length.isEven);
assert(_inlineSemanticsElements.isNotEmpty);
assert(_semanticsInfo != null && _semanticsInfo.isNotEmpty);
final List<SemanticsNode> newChildren = <SemanticsNode>[];
final String rawLabel = text.toPlainText();
int current = 0;
double order = -1.0;
TextDirection currentDirection = textDirection;
Rect currentRect;
SemanticsConfiguration buildSemanticsConfig(int start, int end) {
double ordinal = 0.0;
int start = 0;
int placeholderIndex = 0;
RenderBox child = firstChild;
for (InlineSpanSemanticsInformation info in _combineSemanticsInfo()) {
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);
if (rects.isEmpty) {
return null;
continue;
}
Rect rect = rects.first.toRect();
currentDirection = rects.first.direction;
......@@ -791,64 +825,15 @@ class RenderParagraph extends RenderBox
);
// round the current rectangle to make this API testable and add some
// padding so that the accessibility rects do not overlap with the text.
// TODO(jonahwilliams): implement this for all text accessibility rects.
currentRect = Rect.fromLTRB(
rect.left.floorToDouble() - 4.0,
rect.top.floorToDouble() - 4.0,
rect.right.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;
RenderBox child = firstChild;
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);
if (info.isPlaceholder) {
final SemanticsNode childNode = children.elementAt(placeholderIndex++);
final TextParentData parentData = child.parentData;
childNode.rect = Rect.fromLTWH(
childNode.rect.left,
......@@ -856,20 +841,31 @@ class RenderParagraph extends RenderBox
childNode.rect.width * parentData.scale,
childNode.rect.height * parentData.scale,
);
newChildren.add(children.elementAt(childIndex));
childIndex += 1;
newChildren.add(childNode);
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;
}
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);
}
start += info.text.length;
}
node.updateWith(config: config, childrenInInversePaintOrder: newChildren);
}
......
......@@ -141,6 +141,44 @@ void main() {
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 {
final SemanticsTester semantics = SemanticsTester(tester);
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