// Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:math' as math; import 'package:flutter/rendering.dart'; import 'package:sky_services/semantics/semantics.mojom.dart' as mojom; import 'basic.dart'; import 'framework.dart'; import 'gesture_detector.dart'; /// Visualizes the semantics for the child. /// /// This widget is useful for understand how an app presents itself to /// accessibility technology. class SemanticsDebugger extends StatefulComponent { const SemanticsDebugger({ Key key, this.child }) : super(key: key); final Widget child; _SemanticsDebuggerState createState() => new _SemanticsDebuggerState(); } class _SemanticsDebuggerState extends State<SemanticsDebugger> { void initState() { super.initState(); _SemanticsDebuggerListener.ensureInstantiated(); _SemanticsDebuggerListener.instance.addListener(_update); } void dispose() { _SemanticsDebuggerListener.instance.removeListener(_update); super.dispose(); } void _update() { setState(() { // the generation of the _SemanticsDebuggerListener has changed }); } Point _lastPointerDownLocation; void _handlePointerDown(PointerDownEvent event) { setState(() { _lastPointerDownLocation = event.position; }); } void _handleTap() { assert(_lastPointerDownLocation != null); _SemanticsDebuggerListener.instance.handleTap(_lastPointerDownLocation); setState(() { _lastPointerDownLocation = null; }); } void _handleLongPress() { assert(_lastPointerDownLocation != null); _SemanticsDebuggerListener.instance.handleLongPress(_lastPointerDownLocation); setState(() { _lastPointerDownLocation = null; }); } void _handlePanEnd(Velocity velocity) { assert(_lastPointerDownLocation != null); _SemanticsDebuggerListener.instance.handlePanEnd(_lastPointerDownLocation, velocity); setState(() { _lastPointerDownLocation = null; }); } Widget build(BuildContext context) { return new CustomPaint( foregroundPainter: new _SemanticsDebuggerPainter(_SemanticsDebuggerListener.instance.generation, _lastPointerDownLocation), child: new GestureDetector( behavior: HitTestBehavior.opaque, onTap: _handleTap, onLongPress: _handleLongPress, onPanEnd: _handlePanEnd, excludeFromSemantics: true, // otherwise if you don't hit anything, we end up receiving it, which causes an infinite loop... child: new Listener( onPointerDown: _handlePointerDown, behavior: HitTestBehavior.opaque, child: new IgnorePointer( ignoringSemantics: false, child: config.child ) ) ) ); } } typedef bool _SemanticsDebuggerEntryFilter(_SemanticsDebuggerEntry entry); class _SemanticsDebuggerEntry { _SemanticsDebuggerEntry(this.id); final int id; bool canBeTapped = false; bool canBeLongPressed = false; bool canBeScrolledHorizontally = false; bool canBeScrolledVertically = false; bool hasCheckedState = false; bool isChecked = false; String label; Matrix4 transform; Rect rect; List<_SemanticsDebuggerEntry> children; String toString() { return '_SemanticsDebuggerEntry($id; $rect; "$label"' '${canBeTapped ? "; canBeTapped" : ""}' '${canBeLongPressed ? "; canBeLongPressed" : ""}' '${canBeScrolledHorizontally ? "; canBeScrolledHorizontally" : ""}' '${canBeScrolledVertically ? "; canBeScrolledVertically" : ""}' '${hasCheckedState ? isChecked ? "; checked" : "; unchecked" : ""}' ')'; } String toStringDeep([ String prefix = '']) { if (prefix.length > 20) return '$prefix<ABORTED>\n'; String result = '$prefix$this\n'; for (_SemanticsDebuggerEntry child in children.reversed) { prefix += ' '; result += '${child.toStringDeep(prefix)}'; } return result; } int findDepth() { if (children == null || children.isEmpty) return 1; return children.map((_SemanticsDebuggerEntry e) => e.findDepth()).reduce((int runningDepth, int nextDepth) { return math.max(runningDepth, nextDepth); }) + 1; } static const TextStyle textStyles = const TextStyle( color: const Color(0xFF000000), fontSize: 10.0, height: 0.8, textAlign: TextAlign.center ); TextPainter textPainter; void updateMessage() { List<String> annotations = <String>[]; bool wantsTap = false; if (hasCheckedState) { annotations.add(isChecked ? 'checked' : 'unchecked'); wantsTap = true; } if (canBeTapped) { if (!wantsTap) annotations.add('button'); } else { if (wantsTap) annotations.add('disabled'); } if (canBeLongPressed) annotations.add('long-pressable'); if (canBeScrolledHorizontally || canBeScrolledVertically) annotations.add('scrollable'); String message; if (annotations.isEmpty) { assert(label != null); message = label; } else { if (label == '') { message = annotations.join('; '); } else { message = '$label (${annotations.join('; ')})'; } } message = message.trim(); if (message != '') { textPainter ??= new TextPainter(); textPainter.text = new TextSpan(style: textStyles, text: message); textPainter.maxWidth = rect.width; textPainter.maxHeight = rect.height; textPainter.layout(); } else { textPainter = null; } } void paint(Canvas canvas, int rank) { canvas.save(); if (transform != null) canvas.transform(transform.storage); if (!rect.isEmpty) { Color lineColor = new Color(0xFF000000 + new math.Random(id).nextInt(0xFFFFFF)); Rect innerRect = rect.deflate(rank * 1.0); if (innerRect.isEmpty) { Paint fill = new Paint() ..color = lineColor ..style = PaintingStyle.fill; canvas.drawRect(rect, fill); } else { Paint fill = new Paint() ..color = const Color(0xFFFFFFFF) ..style = PaintingStyle.fill; canvas.drawRect(rect, fill); Paint line = new Paint() ..strokeWidth = rank * 2.0 ..color = lineColor ..style = PaintingStyle.stroke; canvas.drawRect(innerRect, line); } if (textPainter != null) { canvas.save(); canvas.clipRect(rect); textPainter.paint(canvas, rect.topLeft.toOffset()); canvas.restore(); } } for (_SemanticsDebuggerEntry child in children) child.paint(canvas, rank - 1); canvas.restore(); } _SemanticsDebuggerEntry hitTest(Point position, _SemanticsDebuggerEntryFilter filter) { if (transform != null) { Matrix4 invertedTransform = new Matrix4.identity(); double determinant = invertedTransform.copyInverse(transform); if (determinant == 0.0) return null; position = MatrixUtils.transformPoint(invertedTransform, position); } if (!rect.contains(position)) return null; _SemanticsDebuggerEntry result; for (_SemanticsDebuggerEntry child in children.reversed) { result = child.hitTest(position, filter); if (result != null) break; } if (result == null || !filter(result)) result = this; return result; } } class _SemanticsDebuggerListener implements mojom.SemanticsListener { _SemanticsDebuggerListener._() { SemanticsNode.addListener(this); } static _SemanticsDebuggerListener instance; static final SemanticsServer _server = new SemanticsServer(); static void ensureInstantiated() { instance ??= new _SemanticsDebuggerListener._(); } Set<VoidCallback> _listeners = new Set<VoidCallback>(); void addListener(VoidCallback callback) { assert(!_listeners.contains(callback)); _listeners.add(callback); } void removeListener(VoidCallback callback) { _listeners.remove(callback); } Map<int, _SemanticsDebuggerEntry> nodes = <int, _SemanticsDebuggerEntry>{}; _SemanticsDebuggerEntry _updateNode(mojom.SemanticsNode node) { _SemanticsDebuggerEntry entry = nodes.putIfAbsent(node.id, () => new _SemanticsDebuggerEntry(node.id)); if (node.flags != null) { entry.canBeTapped = node.flags.canBeTapped; entry.canBeLongPressed = node.flags.canBeLongPressed; entry.canBeScrolledHorizontally = node.flags.canBeScrolledHorizontally; entry.canBeScrolledVertically = node.flags.canBeScrolledVertically; entry.hasCheckedState = node.flags.hasCheckedState; entry.isChecked = node.flags.isChecked; } if (node.strings != null) { assert(node.strings.label != null); entry.label = node.strings.label; } else { assert(entry.label != null); } if (node.geometry != null) { if (node.geometry.transform != null) { assert(node.geometry.transform.length == 16); // TODO(ianh): Replace this with a cleaner call once // https://github.com/google/vector_math.dart/issues/159 // is fixed. List<double> array = node.geometry.transform; entry.transform = new Matrix4( array[0], array[1], array[2], array[3], array[4], array[5], array[6], array[7], array[8], array[9], array[10], array[11], array[12], array[13], array[14], array[15] ); } else { entry.transform = null; } entry.rect = new Rect.fromLTWH(node.geometry.left, node.geometry.top, node.geometry.width, node.geometry.height); } entry.updateMessage(); if (node.children != null) { Set oldChildren = new Set<_SemanticsDebuggerEntry>.from(entry.children ?? const <_SemanticsDebuggerEntry>[]); entry.children?.clear(); entry.children ??= new List<_SemanticsDebuggerEntry>(); for (mojom.SemanticsNode child in node.children) entry.children.add(_updateNode(child)); Set newChildren = new Set<_SemanticsDebuggerEntry>.from(entry.children); Set<_SemanticsDebuggerEntry> removedChildren = oldChildren.difference(newChildren); for (_SemanticsDebuggerEntry oldChild in removedChildren) nodes.remove(oldChild.id); } return entry; } int generation = 0; updateSemanticsTree(List<mojom.SemanticsNode> nodes) { generation += 1; for (mojom.SemanticsNode node in nodes) _updateNode(node); for (VoidCallback listener in _listeners) listener(); } _SemanticsDebuggerEntry _hitTest(Point position, _SemanticsDebuggerEntryFilter filter) { return nodes[0]?.hitTest(position, filter); } void handleTap(Point position) { _server.tap(_hitTest(position, (_SemanticsDebuggerEntry entry) => entry.canBeTapped)?.id ?? 0); } void handleLongPress(Point position) { _server.longPress(_hitTest(position, (_SemanticsDebuggerEntry entry) => entry.canBeLongPressed)?.id ?? 0); } void handlePanEnd(Point position, Velocity velocity) { double vx = velocity.pixelsPerSecond.dx; double vy = velocity.pixelsPerSecond.dy; if (vx.abs() == vy.abs()) return; if (vx.abs() > vy.abs()) { if (vx.sign < 0) _server.scrollLeft(_hitTest(position, (_SemanticsDebuggerEntry entry) => entry.canBeScrolledHorizontally)?.id ?? 0); else _server.scrollRight(_hitTest(position, (_SemanticsDebuggerEntry entry) => entry.canBeScrolledHorizontally)?.id ?? 0); } else { if (vy.sign < 0) _server.scrollUp(_hitTest(position, (_SemanticsDebuggerEntry entry) => entry.canBeScrolledVertically)?.id ?? 0); else _server.scrollDown(_hitTest(position, (_SemanticsDebuggerEntry entry) => entry.canBeScrolledVertically)?.id ?? 0); } } } class _SemanticsDebuggerPainter extends CustomPainter { const _SemanticsDebuggerPainter(this.generation, this.pointerPosition); final int generation; final Point pointerPosition; void paint(Canvas canvas, Size size) { _SemanticsDebuggerListener.instance.nodes[0]?.paint( canvas, _SemanticsDebuggerListener.instance.nodes[0].findDepth() ); if (pointerPosition != null) { Paint paint = new Paint(); paint.color = const Color(0x7F0090FF); canvas.drawCircle(pointerPosition, 10.0, paint); } } bool shouldRepaint(_SemanticsDebuggerPainter oldDelegate) { return generation != oldDelegate.generation || pointerPosition != oldDelegate.pointerPosition; } }