semantics_debugger.dart 11.8 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
Hixie's avatar
Hixie committed
2 3 4 5 6
// 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;

7
import 'package:flutter/foundation.dart';
Hixie's avatar
Hixie committed
8
import 'package:flutter/rendering.dart';
9
import 'package:flutter/scheduler.dart';
Hixie's avatar
Hixie committed
10 11

import 'basic.dart';
12
import 'binding.dart';
Hixie's avatar
Hixie committed
13 14 15
import 'framework.dart';
import 'gesture_detector.dart';

16
/// A widget that visualizes the semantics for the child.
17 18 19
///
/// This widget is useful for understand how an app presents itself to
/// accessibility technology.
20
class SemanticsDebugger extends StatefulWidget {
21 22 23
  /// Creates a widget that visualizes the semantics for the child.
  ///
  /// The [child] argument must not be null.
24 25 26
  ///
  /// [labelStyle] dictates the [TextStyle] used for the semantics labels.
  const SemanticsDebugger({
27
    super.key,
28
    required this.child,
29 30 31 32 33 34
    this.labelStyle = const TextStyle(
      color: Color(0xFF000000),
      fontSize: 10.0,
      height: 0.8,
    ),
  }) : assert(child != null),
35
       assert(labelStyle != null);
36

37
  /// The widget below this widget in the tree.
38
  ///
39
  /// {@macro flutter.widgets.ProxyWidget.child}
Hixie's avatar
Hixie committed
40
  final Widget child;
41

42 43 44
  /// The [TextStyle] to use when rendering semantics labels.
  final TextStyle labelStyle;

45
  @override
46
  State<SemanticsDebugger> createState() => _SemanticsDebuggerState();
Hixie's avatar
Hixie committed
47 48
}

49
class _SemanticsDebuggerState extends State<SemanticsDebugger> with WidgetsBindingObserver {
50
  late _SemanticsClient _client;
51

52
  @override
Hixie's avatar
Hixie committed
53 54
  void initState() {
    super.initState();
55 56 57 58
    // 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.
59
    _client = _SemanticsClient(WidgetsBinding.instance.pipelineOwner)
60
      ..addListener(_update);
61
    WidgetsBinding.instance.addObserver(this);
Hixie's avatar
Hixie committed
62
  }
63 64

  @override
Hixie's avatar
Hixie committed
65
  void dispose() {
66 67 68
    _client
      ..removeListener(_update)
      ..dispose();
69
    WidgetsBinding.instance.removeObserver(this);
Hixie's avatar
Hixie committed
70 71
    super.dispose();
  }
72

73 74 75 76 77 78 79
  @override
  void didChangeMetrics() {
    setState(() {
      // The root transform may have changed, we have to repaint.
    });
  }

Hixie's avatar
Hixie committed
80
  void _update() {
81
    SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
82 83
      // 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
84
      // this, we call setState() in a post-frame callback.
85 86 87 88 89 90 91 92
      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.
        });
      }
Hixie's avatar
Hixie committed
93 94
    });
  }
95

96
  Offset? _lastPointerDownLocation;
Hixie's avatar
Hixie committed
97 98
  void _handlePointerDown(PointerDownEvent event) {
    setState(() {
99
      _lastPointerDownLocation = event.position * WidgetsBinding.instance.window.devicePixelRatio;
Hixie's avatar
Hixie committed
100
    });
101 102
    // TODO(ianh): Use a gesture recognizer so that we can reset the
    // _lastPointerDownLocation when none of the other gesture recognizers win.
Hixie's avatar
Hixie committed
103
  }
104

Hixie's avatar
Hixie committed
105 106
  void _handleTap() {
    assert(_lastPointerDownLocation != null);
107
    _performAction(_lastPointerDownLocation!, SemanticsAction.tap);
Hixie's avatar
Hixie committed
108 109 110 111
    setState(() {
      _lastPointerDownLocation = null;
    });
  }
112

Hixie's avatar
Hixie committed
113 114
  void _handleLongPress() {
    assert(_lastPointerDownLocation != null);
115
    _performAction(_lastPointerDownLocation!, SemanticsAction.longPress);
Hixie's avatar
Hixie committed
116 117 118 119
    setState(() {
      _lastPointerDownLocation = null;
    });
  }
120

121
  void _handlePanEnd(DragEndDetails details) {
122 123
    final double vx = details.velocity.pixelsPerSecond.dx;
    final double vy = details.velocity.pixelsPerSecond.dy;
124
    if (vx.abs() == vy.abs()) {
125
      return;
126
    }
127 128
    if (vx.abs() > vy.abs()) {
      if (vx.sign < 0) {
129 130
        _performAction(_lastPointerDownLocation!, SemanticsAction.decrease);
        _performAction(_lastPointerDownLocation!, SemanticsAction.scrollLeft);
131
      } else {
132 133
        _performAction(_lastPointerDownLocation!, SemanticsAction.increase);
        _performAction(_lastPointerDownLocation!, SemanticsAction.scrollRight);
134 135
      }
    } else {
136
      if (vy.sign < 0) {
137
        _performAction(_lastPointerDownLocation!, SemanticsAction.scrollUp);
138
      } else {
139
        _performAction(_lastPointerDownLocation!, SemanticsAction.scrollDown);
140
      }
141
    }
Hixie's avatar
Hixie committed
142 143 144 145
    setState(() {
      _lastPointerDownLocation = null;
    });
  }
146

147
  void _performAction(Offset position, SemanticsAction action) {
148 149 150 151 152
    _pipelineOwner.semanticsOwner?.performActionAt(position, action);
  }

  // TODO(abarth): This shouldn't be a static. We should get the pipeline owner
  // from [context] somehow.
153
  PipelineOwner get _pipelineOwner => WidgetsBinding.instance.pipelineOwner;
154

155
  @override
Hixie's avatar
Hixie committed
156
  Widget build(BuildContext context) {
157 158
    return CustomPaint(
      foregroundPainter: _SemanticsDebuggerPainter(
159 160
        _pipelineOwner,
        _client.generation,
161
        _lastPointerDownLocation, // in physical pixels
162
        WidgetsBinding.instance.window.devicePixelRatio,
163
        widget.labelStyle,
164
      ),
165
      child: GestureDetector(
Hixie's avatar
Hixie committed
166 167 168 169 170
        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...
171
        child: Listener(
Hixie's avatar
Hixie committed
172 173
          onPointerDown: _handlePointerDown,
          behavior: HitTestBehavior.opaque,
174
          child: IgnorePointer(
Hixie's avatar
Hixie committed
175
            ignoringSemantics: false,
176 177 178 179
            child: widget.child,
          ),
        ),
      ),
Hixie's avatar
Hixie committed
180 181 182 183
    );
  }
}

184 185
class _SemanticsClient extends ChangeNotifier {
  _SemanticsClient(PipelineOwner pipelineOwner) {
186
    _semanticsHandle = pipelineOwner.ensureSemantics(
187
      listener: _didUpdateSemantics,
188
    );
Hixie's avatar
Hixie committed
189 190
  }

191
  SemanticsHandle? _semanticsHandle;
192 193 194

  @override
  void dispose() {
195
    _semanticsHandle!.dispose();
196
    _semanticsHandle = null;
197 198
    super.dispose();
  }
Hixie's avatar
Hixie committed
199 200 201

  int generation = 0;

202
  void _didUpdateSemantics() {
Hixie's avatar
Hixie committed
203
    generation += 1;
204
    notifyListeners();
Hixie's avatar
Hixie committed
205
  }
206 207
}

Hixie's avatar
Hixie committed
208
class _SemanticsDebuggerPainter extends CustomPainter {
209
  const _SemanticsDebuggerPainter(this.owner, this.generation, this.pointerPosition, this.devicePixelRatio, this.labelStyle);
210

211
  final PipelineOwner owner;
Hixie's avatar
Hixie committed
212
  final int generation;
213
  final Offset? pointerPosition; // in physical pixels
214
  final double devicePixelRatio;
215
  final TextStyle labelStyle;
216

217
  SemanticsNode? get _rootSemanticsNode {
218 219 220
    return owner.semanticsOwner?.rootSemanticsNode;
  }

221
  @override
Hixie's avatar
Hixie committed
222
  void paint(Canvas canvas, Size size) {
223
    final SemanticsNode? rootNode = _rootSemanticsNode;
224 225
    canvas.save();
    canvas.scale(1.0 / devicePixelRatio, 1.0 / devicePixelRatio);
226
    if (rootNode != null) {
227
      _paint(canvas, rootNode, _findDepth(rootNode));
228
    }
Hixie's avatar
Hixie committed
229
    if (pointerPosition != null) {
230
      final Paint paint = Paint();
Hixie's avatar
Hixie committed
231
      paint.color = const Color(0x7F0090FF);
232
      canvas.drawCircle(pointerPosition!, 10.0 * devicePixelRatio, paint);
Hixie's avatar
Hixie committed
233
    }
234
    canvas.restore();
Hixie's avatar
Hixie committed
235
  }
236 237

  @override
Hixie's avatar
Hixie committed
238
  bool shouldRepaint(_SemanticsDebuggerPainter oldDelegate) {
239 240
    return owner != oldDelegate.owner
        || generation != oldDelegate.generation
Hixie's avatar
Hixie committed
241 242
        || pointerPosition != oldDelegate.pointerPosition;
  }
243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259

  @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)) {
260
      if (!wantsTap) {
261
        annotations.add('button');
262
      }
263
    } else {
264
      if (wantsTap) {
265
        annotations.add('disabled');
266
      }
267 268
    }

269
    if (data.hasAction(SemanticsAction.longPress)) {
270
      annotations.add('long-pressable');
271
    }
272 273 274 275 276 277 278 279 280

    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);

281
    if (isScrollable) {
282
      annotations.add('scrollable');
283
    }
284

285
    if (isAdjustable) {
286
      annotations.add('adjustable');
287
    }
288

289
    assert(data.attributedLabel != null);
290
    final String message;
291 292 293 294 295 296 297
    final String tooltipAndLabel = <String>[
      if (data.tooltip.isNotEmpty)
        data.tooltip,
      if (data.attributedLabel.string.isNotEmpty)
        data.attributedLabel.string,
    ].join('\n');
    if (tooltipAndLabel.isEmpty) {
298 299
      message = annotations.join('; ');
    } else {
300
      final String effectivelabel;
301
      if (data.textDirection == null) {
302
        effectivelabel = '${Unicode.FSI}$tooltipAndLabel${Unicode.PDI}';
303 304
        annotations.insert(0, 'MISSING TEXT DIRECTION');
      } else {
305
        switch (data.textDirection!) {
306
          case TextDirection.rtl:
307
            effectivelabel = '${Unicode.RLI}$tooltipAndLabel${Unicode.PDF}';
308 309
            break;
          case TextDirection.ltr:
310
            effectivelabel = tooltipAndLabel;
311 312 313 314
            break;
        }
      }
      if (annotations.isEmpty) {
315
        message = effectivelabel;
316
      } else {
317
        message = '$effectivelabel (${annotations.join('; ')})';
318 319 320 321 322 323 324 325
      }
    }

    return message.trim();
  }

  void _paintMessage(Canvas canvas, SemanticsNode node) {
    final String message = getMessage(node);
326
    if (message.isEmpty) {
327
      return;
328
    }
329 330 331 332 333
    final Rect rect = node.rect;
    canvas.save();
    canvas.clipRect(rect);
    final TextPainter textPainter = TextPainter()
      ..text = TextSpan(
334
        style: labelStyle,
335 336 337 338 339 340 341
        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);
342
    textPainter.dispose();
343 344 345 346
    canvas.restore();
  }

  int _findDepth(SemanticsNode node) {
347
    if (!node.hasChildren || node.mergeAllDescendantsIntoThisNode) {
348
      return 1;
349
    }
350 351 352 353 354 355 356 357 358 359
    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();
360
    if (node.transform != null) {
361
      canvas.transform(node.transform!.storage);
362
    }
363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393
    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();
  }
Hixie's avatar
Hixie committed
394
}