recording_canvas.dart 7.16 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
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 'package:flutter/rendering.dart';

7 8 9 10 11
/// An [Invocation] and the [stack] trace that led to it.
///
/// Used by [TestRecordingCanvas] to trace canvas calls.
class RecordedInvocation {
  /// Create a record for an invocation list.
12
  const RecordedInvocation(this.invocation, { required this.stack });
13 14

  /// The method that was called and its arguments.
15 16 17 18 19 20
  ///
  /// The arguments preserve identity, but not value. Thus, if two invocations
  /// were made with the same [Paint] object, but with that object configured
  /// differently each time, then they will both have the same object as their
  /// argument, and inspecting that object will return the object's current
  /// values (mostly likely those passed to the second call).
21 22 23 24 25 26 27 28 29
  final Invocation invocation;

  /// The stack trace at the time of the method call.
  final StackTrace stack;

  @override
  String toString() => _describeInvocation(invocation);

  /// Converts [stack] to a string using the [FlutterError.defaultStackFilter] logic.
30
  String stackToString({ String indent = '' }) {
31
    return indent + FlutterError.defaultStackFilter(
32
      stack.toString().trimRight().split('\n'),
33 34 35 36
    ).join('\n$indent');
  }
}

37 38
/// A [Canvas] for tests that records its method calls.
///
39
/// This class can be used in conjunction with [TestRecordingPaintingContext]
40 41 42 43
/// to record the [Canvas] method calls made by a renderer. For example:
///
/// ```dart
/// RenderBox box = tester.renderObject(find.text('ABC'));
44 45
/// TestRecordingCanvas canvas = TestRecordingCanvas();
/// TestRecordingPaintingContext context = TestRecordingPaintingContext(canvas);
46 47 48 49 50
/// box.paint(context, Offset.zero);
/// // Now test the expected canvas.invocations.
/// ```
///
/// In some cases it may be useful to define a subclass that overrides the
51
/// [Canvas] methods the test is checking and squirrels away the parameters
52
/// that the test requires.
53 54 55
///
/// For simple tests, consider using the [paints] matcher, which overlays a
/// pattern matching API over [TestRecordingCanvas].
56 57
class TestRecordingCanvas implements Canvas {
  /// All of the method calls on this canvas.
58
  final List<RecordedInvocation> invocations = <RecordedInvocation>[];
59 60 61 62 63 64 65 66 67

  int _saveCount = 0;

  @override
  int getSaveCount() => _saveCount;

  @override
  void save() {
    _saveCount += 1;
68
    invocations.add(RecordedInvocation(_MethodCall(#save), stack: StackTrace.current));
69 70
  }

71
  @override
72
  void saveLayer(Rect? bounds, Paint paint) {
73
    _saveCount += 1;
74
    invocations.add(RecordedInvocation(_MethodCall(#saveLayer, <dynamic>[bounds, paint]), stack: StackTrace.current));
75 76
  }

77 78 79 80
  @override
  void restore() {
    _saveCount -= 1;
    assert(_saveCount >= 0);
81
    invocations.add(RecordedInvocation(_MethodCall(#restore), stack: StackTrace.current));
82 83 84 85
  }

  @override
  void noSuchMethod(Invocation invocation) {
86
    invocations.add(RecordedInvocation(invocation, stack: StackTrace.current));
87 88 89 90
  }
}

/// A [PaintingContext] for tests that use [TestRecordingCanvas].
91
class TestRecordingPaintingContext extends ClipContext implements PaintingContext {
92 93 94 95 96 97 98 99 100 101 102 103
  /// Creates a [PaintingContext] for tests that use [TestRecordingCanvas].
  TestRecordingPaintingContext(this.canvas);

  @override
  final Canvas canvas;

  @override
  void paintChild(RenderObject child, Offset offset) {
    child.paint(this, offset);
  }

  @override
104
  ClipRectLayer? pushClipRect(
105 106 107 108 109
    bool needsCompositing,
    Offset offset,
    Rect clipRect,
    PaintingContextCallback painter, {
    Clip clipBehavior = Clip.hardEdge,
110
    ClipRectLayer? oldLayer,
111
  }) {
112
    clipRectAndPaint(clipRect.shift(offset), clipBehavior, clipRect.shift(offset), () => painter(this, offset));
113
    return null;
114 115
  }

116
  @override
117
  ClipRRectLayer? pushClipRRect(
118 119 120 121 122 123
    bool needsCompositing,
    Offset offset,
    Rect bounds,
    RRect clipRRect,
    PaintingContextCallback painter, {
    Clip clipBehavior = Clip.antiAlias,
124
    ClipRRectLayer? oldLayer,
125
  }) {
126
    clipRRectAndPaint(clipRRect.shift(offset), clipBehavior, bounds.shift(offset), () => painter(this, offset));
127
    return null;
128 129
  }

130
  @override
131
  ClipPathLayer? pushClipPath(
132 133 134 135 136 137
    bool needsCompositing,
    Offset offset,
    Rect bounds,
    Path clipPath,
    PaintingContextCallback painter, {
    Clip clipBehavior = Clip.antiAlias,
138
    ClipPathLayer? oldLayer,
139
  }) {
140
    clipPathAndPaint(clipPath.shift(offset), clipBehavior, bounds.shift(offset), () => painter(this, offset));
141
    return null;
142 143
  }

144
  @override
145
  TransformLayer? pushTransform(
146 147 148 149
    bool needsCompositing,
    Offset offset,
    Matrix4 transform,
    PaintingContextCallback painter, {
150
    TransformLayer? oldLayer,
151
  }) {
152 153 154 155
    canvas.save();
    canvas.transform(transform.storage);
    painter(this, offset);
    canvas.restore();
156
    return null;
157
  }
158 159

  @override
160 161 162 163 164 165 166
  OpacityLayer pushOpacity(
    Offset offset,
    int alpha,
    PaintingContextCallback painter, {
    OpacityLayer? oldLayer,
  }) {
    canvas.saveLayer(null, Paint()); // TODO(ianh): Expose the alpha somewhere.
167 168
    painter(this, offset);
    canvas.restore();
169
    return OpacityLayer();
170
  }
171

172
  @override
173 174 175 176 177 178
  void pushLayer(
    Layer childLayer,
    PaintingContextCallback painter,
    Offset offset, {
    Rect? childPaintBounds,
  }) {
179 180 181
    painter(this, offset);
  }

182 183 184
  @override
  VoidCallback addCompositionCallback(CompositionCallback callback) => () {};

185
  @override
186
  void noSuchMethod(Invocation invocation) { }
187 188 189
}

class _MethodCall implements Invocation {
190
  _MethodCall(this._name, [ this._arguments = const <dynamic>[]]);
191
  final Symbol _name;
192
  final List<dynamic> _arguments;
193 194 195 196 197 198 199 200 201 202 203 204 205
  @override
  bool get isAccessor => false;
  @override
  bool get isGetter => false;
  @override
  bool get isMethod => true;
  @override
  bool get isSetter => false;
  @override
  Symbol get memberName => _name;
  @override
  Map<Symbol, dynamic> get namedArguments => <Symbol, dynamic>{};
  @override
206
  List<dynamic> get positionalArguments => _arguments;
207
  @override
208
  List<Type> get typeArguments => const <Type> [];
209
}
210

211
String _valueName(Object? value) {
212
  if (value is double) {
213
    return value.toStringAsFixed(1);
214
  }
215 216 217 218 219 220 221 222 223 224 225 226 227
  return value.toString();
}

// Workaround for https://github.com/dart-lang/sdk/issues/28372
String _symbolName(Symbol symbol) {
  // WARNING: Assumes a fixed format for Symbol.toString which is *not*
  // guaranteed anywhere.
  final String s = '$symbol';
  return s.substring(8, s.length - 2);
}

// Workaround for https://github.com/dart-lang/sdk/issues/28373
String _describeInvocation(Invocation call) {
228
  final StringBuffer buffer = StringBuffer();
229 230 231 232 233 234 235
  buffer.write(_symbolName(call.memberName));
  if (call.isSetter) {
    buffer.write(call.positionalArguments[0].toString());
  } else if (call.isMethod) {
    buffer.write('(');
    buffer.writeAll(call.positionalArguments.map<String>(_valueName), ', ');
    String separator = call.positionalArguments.isEmpty ? '' : ', ';
236
    call.namedArguments.forEach((Symbol name, Object? value) {
237 238 239 240 241 242 243 244 245 246
      buffer.write(separator);
      buffer.write(_symbolName(name));
      buffer.write(': ');
      buffer.write(_valueName(value));
      separator = ', ';
    });
    buffer.write(')');
  }
  return buffer.toString();
}