// Copyright 2014 The Flutter 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 'dart:ui' show SemanticsFlag; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'basic.dart'; import 'binding.dart'; import 'framework.dart'; import 'gesture_detector.dart'; /// A widget that visualizes the semantics for the child. /// /// This widget is useful for understand how an app presents itself to /// accessibility technology. class SemanticsDebugger extends StatefulWidget { /// Creates a widget that visualizes the semantics for the child. /// /// The [child] argument must not be null. /// /// [labelStyle] dictates the [TextStyle] used for the semantics labels. const SemanticsDebugger({ super.key, required this.child, this.labelStyle = const TextStyle( color: Color(0xFF000000), fontSize: 10.0, height: 0.8, ), }) : assert(child != null), assert(labelStyle != null); /// The widget below this widget in the tree. /// /// {@macro flutter.widgets.ProxyWidget.child} final Widget child; /// The [TextStyle] to use when rendering semantics labels. final TextStyle labelStyle; @override State<SemanticsDebugger> createState() => _SemanticsDebuggerState(); } class _SemanticsDebuggerState extends State<SemanticsDebugger> with WidgetsBindingObserver { late _SemanticsClient _client; @override void initState() { super.initState(); // TODO(abarth): We shouldn't reach out to the WidgetsBinding.instance // static here because we might not be in a tree that's attached to that // binding. Instead, we should find a way to get to the PipelineOwner from // the BuildContext. _client = _SemanticsClient(WidgetsBinding.instance.pipelineOwner) ..addListener(_update); WidgetsBinding.instance.addObserver(this); } @override void dispose() { _client ..removeListener(_update) ..dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeMetrics() { setState(() { // The root transform may have changed, we have to repaint. }); } void _update() { SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { // Semantic information are only available at the end of a frame and our // only chance to paint them on the screen is the next frame. To achieve // this, we call setState() in a post-frame callback. if (mounted) { // If we got disposed this frame, we will still get an update, // because the inactive list is flushed after the semantics updates // are transmitted to the semantics clients. setState(() { // The generation of the _SemanticsDebuggerListener has changed. }); } }); } Offset? _lastPointerDownLocation; void _handlePointerDown(PointerDownEvent event) { setState(() { _lastPointerDownLocation = event.position * WidgetsBinding.instance.window.devicePixelRatio; }); // TODO(ianh): Use a gesture recognizer so that we can reset the // _lastPointerDownLocation when none of the other gesture recognizers win. } void _handleTap() { assert(_lastPointerDownLocation != null); _performAction(_lastPointerDownLocation!, SemanticsAction.tap); setState(() { _lastPointerDownLocation = null; }); } void _handleLongPress() { assert(_lastPointerDownLocation != null); _performAction(_lastPointerDownLocation!, SemanticsAction.longPress); setState(() { _lastPointerDownLocation = null; }); } void _handlePanEnd(DragEndDetails details) { final double vx = details.velocity.pixelsPerSecond.dx; final double vy = details.velocity.pixelsPerSecond.dy; if (vx.abs() == vy.abs()) return; if (vx.abs() > vy.abs()) { if (vx.sign < 0) { _performAction(_lastPointerDownLocation!, SemanticsAction.decrease); _performAction(_lastPointerDownLocation!, SemanticsAction.scrollLeft); } else { _performAction(_lastPointerDownLocation!, SemanticsAction.increase); _performAction(_lastPointerDownLocation!, SemanticsAction.scrollRight); } } else { if (vy.sign < 0) _performAction(_lastPointerDownLocation!, SemanticsAction.scrollUp); else _performAction(_lastPointerDownLocation!, SemanticsAction.scrollDown); } setState(() { _lastPointerDownLocation = null; }); } void _performAction(Offset position, SemanticsAction action) { _pipelineOwner.semanticsOwner?.performActionAt(position, action); } // TODO(abarth): This shouldn't be a static. We should get the pipeline owner // from [context] somehow. PipelineOwner get _pipelineOwner => WidgetsBinding.instance.pipelineOwner; @override Widget build(BuildContext context) { return CustomPaint( foregroundPainter: _SemanticsDebuggerPainter( _pipelineOwner, _client.generation, _lastPointerDownLocation, // in physical pixels WidgetsBinding.instance.window.devicePixelRatio, widget.labelStyle, ), child: 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: Listener( onPointerDown: _handlePointerDown, behavior: HitTestBehavior.opaque, child: IgnorePointer( ignoringSemantics: false, child: widget.child, ), ), ), ); } } class _SemanticsClient extends ChangeNotifier { _SemanticsClient(PipelineOwner pipelineOwner) { _semanticsHandle = pipelineOwner.ensureSemantics( listener: _didUpdateSemantics, ); } SemanticsHandle? _semanticsHandle; @override void dispose() { _semanticsHandle!.dispose(); _semanticsHandle = null; super.dispose(); } int generation = 0; void _didUpdateSemantics() { generation += 1; notifyListeners(); } } class _SemanticsDebuggerPainter extends CustomPainter { const _SemanticsDebuggerPainter(this.owner, this.generation, this.pointerPosition, this.devicePixelRatio, this.labelStyle); final PipelineOwner owner; final int generation; final Offset? pointerPosition; // in physical pixels final double devicePixelRatio; final TextStyle labelStyle; SemanticsNode? get _rootSemanticsNode { return owner.semanticsOwner?.rootSemanticsNode; } @override void paint(Canvas canvas, Size size) { final SemanticsNode? rootNode = _rootSemanticsNode; canvas.save(); canvas.scale(1.0 / devicePixelRatio, 1.0 / devicePixelRatio); if (rootNode != null) _paint(canvas, rootNode, _findDepth(rootNode)); if (pointerPosition != null) { final Paint paint = Paint(); paint.color = const Color(0x7F0090FF); canvas.drawCircle(pointerPosition!, 10.0 * devicePixelRatio, paint); } canvas.restore(); } @override bool shouldRepaint(_SemanticsDebuggerPainter oldDelegate) { return owner != oldDelegate.owner || generation != oldDelegate.generation || pointerPosition != oldDelegate.pointerPosition; } @visibleForTesting String getMessage(SemanticsNode node) { final SemanticsData data = node.getSemanticsData(); final List<String> annotations = <String>[]; bool wantsTap = false; if (data.hasFlag(SemanticsFlag.hasCheckedState)) { annotations.add(data.hasFlag(SemanticsFlag.isChecked) ? 'checked' : 'unchecked'); wantsTap = true; } if (data.hasFlag(SemanticsFlag.isTextField)) { annotations.add('textfield'); wantsTap = true; } if (data.hasAction(SemanticsAction.tap)) { if (!wantsTap) annotations.add('button'); } else { if (wantsTap) annotations.add('disabled'); } if (data.hasAction(SemanticsAction.longPress)) annotations.add('long-pressable'); final bool isScrollable = data.hasAction(SemanticsAction.scrollLeft) || data.hasAction(SemanticsAction.scrollRight) || data.hasAction(SemanticsAction.scrollUp) || data.hasAction(SemanticsAction.scrollDown); final bool isAdjustable = data.hasAction(SemanticsAction.increase) || data.hasAction(SemanticsAction.decrease); if (isScrollable) annotations.add('scrollable'); if (isAdjustable) annotations.add('adjustable'); assert(data.attributedLabel != null); final String message; final String tooltipAndLabel = <String>[ if (data.tooltip.isNotEmpty) data.tooltip, if (data.attributedLabel.string.isNotEmpty) data.attributedLabel.string, ].join('\n'); if (tooltipAndLabel.isEmpty) { message = annotations.join('; '); } else { final String effectivelabel; if (data.textDirection == null) { effectivelabel = '${Unicode.FSI}$tooltipAndLabel${Unicode.PDI}'; annotations.insert(0, 'MISSING TEXT DIRECTION'); } else { switch (data.textDirection!) { case TextDirection.rtl: effectivelabel = '${Unicode.RLI}$tooltipAndLabel${Unicode.PDF}'; break; case TextDirection.ltr: effectivelabel = tooltipAndLabel; break; } } if (annotations.isEmpty) { message = effectivelabel; } else { message = '$effectivelabel (${annotations.join('; ')})'; } } return message.trim(); } void _paintMessage(Canvas canvas, SemanticsNode node) { final String message = getMessage(node); if (message.isEmpty) return; final Rect rect = node.rect; canvas.save(); canvas.clipRect(rect); final TextPainter textPainter = TextPainter() ..text = TextSpan( style: labelStyle, text: message, ) ..textDirection = TextDirection.ltr // _getMessage always returns LTR text, even if node.label is RTL ..textAlign = TextAlign.center ..layout(maxWidth: rect.width); textPainter.paint(canvas, Alignment.center.inscribe(textPainter.size, rect).topLeft); canvas.restore(); } int _findDepth(SemanticsNode node) { if (!node.hasChildren || node.mergeAllDescendantsIntoThisNode) return 1; int childrenDepth = 0; node.visitChildren((SemanticsNode child) { childrenDepth = math.max(childrenDepth, _findDepth(child)); return true; }); return childrenDepth + 1; } void _paint(Canvas canvas, SemanticsNode node, int rank) { canvas.save(); if (node.transform != null) canvas.transform(node.transform!.storage); final Rect rect = node.rect; if (!rect.isEmpty) { final Color lineColor = Color(0xFF000000 + math.Random(node.id).nextInt(0xFFFFFF)); final Rect innerRect = rect.deflate(rank * 1.0); if (innerRect.isEmpty) { final Paint fill = Paint() ..color = lineColor ..style = PaintingStyle.fill; canvas.drawRect(rect, fill); } else { final Paint fill = Paint() ..color = const Color(0xFFFFFFFF) ..style = PaintingStyle.fill; canvas.drawRect(rect, fill); final Paint line = Paint() ..strokeWidth = rank * 2.0 ..color = lineColor ..style = PaintingStyle.stroke; canvas.drawRect(innerRect, line); } _paintMessage(canvas, node); } if (!node.mergeAllDescendantsIntoThisNode) { final int childRank = rank - 1; node.visitChildren((SemanticsNode child) { _paint(canvas, child, childRank); return true; }); } canvas.restore(); } }