diff --git a/engine/src/flutter/lib/web_ui/dev/test_platform.dart b/engine/src/flutter/lib/web_ui/dev/test_platform.dart index 79a348c06a9..2a6f403dd6b 100644 --- a/engine/src/flutter/lib/web_ui/dev/test_platform.dart +++ b/engine/src/flutter/lib/web_ui/dev/test_platform.dart @@ -49,7 +49,7 @@ import 'goldens.dart'; const int _kChromeDevtoolsPort = 12345; const int _kMaxScreenshotWidth = 1024; const int _kMaxScreenshotHeight = 1024; -const double _kMaxDiffRateFailure = 1.0/100000; // 0.001% +const double _kMaxDiffRateFailure = 0.211/100; // 0.211% class BrowserPlatform extends PlatformPlugin { /// Starts the server. diff --git a/engine/src/flutter/lib/web_ui/test/golden_tests/engine/canvas_rrect_scuba_test.dart b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/canvas_rrect_scuba_test.dart new file mode 100644 index 00000000000..8d51d677599 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/canvas_rrect_scuba_test.dart @@ -0,0 +1,86 @@ +// Copyright 2013 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:html' as html; + +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart'; +import 'package:test/test.dart'; + +import 'package:web_engine_tester/golden_tester.dart'; + +void main() async { + final Rect region = Rect.fromLTWH(8, 8, 500, 100); // Compensate for old scuba tester padding + + BitmapCanvas canvas; + + final PaintData niceRRectPaint = PaintData() + ..color = const Color.fromRGBO(250, 186, 218, 1.0) // #fabada + ..style = PaintingStyle.fill; + + // Some values to see how the algo behaves as radius get absurdly large + const List rRectRadii = [0, 10, 20, 80, 8000]; + + const Radius someFixedRadius = Radius.circular(10); + + setUp(() { + canvas = BitmapCanvas(const Rect.fromLTWH(0, 0, 500, 100)); + canvas.translate(10, 10); // Center + }); + + tearDown(() { + canvas.rootElement.remove(); + }); + + test('round square with big (equal) radius ends up as a circle', () async { + for (int i = 0; i < 5; i++) { + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(100 * i.toDouble(), 0, 80, 80), + Radius.circular(rRectRadii[i])), + niceRRectPaint); + } + + html.document.body.append(canvas.rootElement); + await matchGoldenFile('engine/canvas_rrect_round_square.png', region: region); + }, timeout: const Timeout(Duration(seconds: 10))); + + test('round rect with big radius scale down smaller radius', () async { + for (int i = 0; i < 5; i++) { + final Radius growingRadius = Radius.circular(rRectRadii[i]); + final RRect rrect = RRect.fromRectAndCorners( + Rect.fromLTWH(100 * i.toDouble(), 0, 80, 80), + bottomRight: someFixedRadius, + topRight: growingRadius, + bottomLeft: growingRadius); + + canvas.drawRRect(rrect, niceRRectPaint); + } + + html.document.body.append(canvas.rootElement); + await matchGoldenFile('engine/canvas_rrect_overlapping_radius.png', region: region); + }, timeout: const Timeout(Duration(seconds: 10))); + + test('diff round rect with big radius scale down smaller radius', () async { + for (int i = 0; i < 5; i++) { + final Radius growingRadius = Radius.circular(rRectRadii[i]); + final RRect outerRRect = RRect.fromRectAndCorners( + Rect.fromLTWH(100 * i.toDouble(), 0, 80, 80), + bottomRight: someFixedRadius, + topRight: growingRadius, + bottomLeft: growingRadius); + + // Inner is half of outer, but offset a little so it looks nicer + final RRect innerRRect = RRect.fromRectAndCorners( + Rect.fromLTWH(100 * i.toDouble() + 5, 5, 40, 40), + bottomRight: someFixedRadius / 2, + topRight: growingRadius / 2, + bottomLeft: growingRadius / 2); + + canvas.drawDRRect(outerRRect, innerRRect, niceRRectPaint); + } + + html.document.body.append(canvas.rootElement); + await matchGoldenFile('engine/canvas_drrect_overlapping_radius.png', region: region); + }, timeout: const Timeout(Duration(seconds: 10))); +} diff --git a/engine/src/flutter/lib/web_ui/test/golden_tests/engine/canvas_scuba_test.dart b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/canvas_scuba_test.dart new file mode 100644 index 00000000000..8fbddf0553a --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/canvas_scuba_test.dart @@ -0,0 +1,112 @@ +// Copyright 2013 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:html' as html; + +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart'; +import 'package:test/test.dart'; + +import 'package:web_engine_tester/golden_tester.dart'; + +void main() async { + final Rect region = Rect.fromLTWH(8, 8, 500, 100); // Compensate for old scuba tester padding + + BitmapCanvas canvas; + + setUp(() { + html.document.body.style.transform = 'translate(10px, 10px)'; + }); + + tearDown(() { + html.document.body.style.transform = 'none'; + canvas.rootElement.remove(); + }); + + /// Draws several lines, some aligned precisely with the pixel grid, and some + /// that are offset by 0.5 vertically or horizontally. + /// + /// The produced picture stresses the antialiasing generated by the browser + /// when positioning and rasterizing `` tags. Aliasing artifacts can + /// be seen depending on pixel alignment and whether antialiasing happens + /// before or after rasterization. + void drawMisalignedLines(BitmapCanvas canvas) { + final PaintData linePaint = (Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1) + .webOnlyPaintData; + + final PaintData fillPaint = + (Paint()..style = PaintingStyle.fill).webOnlyPaintData; + + canvas.drawRect( + const Rect.fromLTWH(0, 0, 40, 40), + linePaint, + ); + + canvas.drawLine( + const Offset(10, 0), + const Offset(10, 40), + linePaint, + ); + + canvas.drawLine( + const Offset(20.5, 0), + const Offset(20, 40), + linePaint, + ); + + canvas.drawCircle(const Offset(30, 10), 3, fillPaint); + canvas.drawCircle(const Offset(30.5, 30), 3, fillPaint); + } + + test('renders pixels that are not aligned inside the canvas', () async { + canvas = BitmapCanvas(const Rect.fromLTWH(0, 0, 60, 60)); + + drawMisalignedLines(canvas); + + html.document.body.append(canvas.rootElement); + + await matchGoldenFile('engine/misaligned_pixels_in_canvas_test.png', region: region); + }, timeout: const Timeout(Duration(seconds: 10))); + + test('compensates for misalignment of the canvas', () async { + // Notice the 0.5 offset in the bounds rectangle. It's what causes the + // misalignment of canvas relative to the pixel grid. BitmapCanvas will + // shift its position back to 0.0 and at the same time it will it will + // compensate by shifting the contents of the canvas in the opposite + // direction. + canvas = BitmapCanvas(const Rect.fromLTWH(0.5, 0.5, 60, 60)); + + drawMisalignedLines(canvas); + + html.document.body.append(canvas.rootElement); + + await matchGoldenFile('engine/misaligned_canvas_test.png', region: region); + }, timeout: const Timeout(Duration(seconds: 10))); + + test('fill the whole canvas with color even when transformed', () async { + canvas = BitmapCanvas(const Rect.fromLTWH(0, 0, 50, 50)); + + canvas.translate(25, 25); + canvas.drawColor(const Color.fromRGBO(0, 255, 0, 1.0), BlendMode.src); + + html.document.body.append(canvas.rootElement); + + await matchGoldenFile('engine/bitmap_canvas_fills_color_when_transformed.png', region: region); + }, timeout: const Timeout(Duration(seconds: 10))); + + test('fill the whole canvas with paint even when transformed', () async { + canvas = BitmapCanvas(const Rect.fromLTWH(0, 0, 50, 50)); + + canvas.translate(25, 25); + canvas.drawPaint(PaintData() + ..color = const Color.fromRGBO(0, 255, 0, 1.0) + ..style = PaintingStyle.fill); + + html.document.body.append(canvas.rootElement); + + await matchGoldenFile('engine/bitmap_canvas_fills_paint_when_transformed.png', region: region); + }, timeout: const Timeout(Duration(seconds: 10))); +} diff --git a/engine/src/flutter/lib/web_ui/test/golden_tests/engine/compositing_scuba_test.dart b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/compositing_scuba_test.dart new file mode 100644 index 00000000000..148660ff78e --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/compositing_scuba_test.dart @@ -0,0 +1,542 @@ +// Copyright 2013 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:html' as html; +import 'dart:math' as math; + +import 'package:ui/ui.dart'; +import 'package:ui/src/engine.dart'; +import 'package:test/test.dart'; + +import '../../matchers.dart'; +import 'package:web_engine_tester/golden_tester.dart'; + +final Rect region = Rect.fromLTWH(0, 0, 500, 100); + +void main() async { + debugShowClipLayers = true; + + setUp(() { + SceneBuilder.debugForgetFrameScene(); + for (html.Node scene in html.document.querySelectorAll('flt-scene')) { + scene.remove(); + } + }); + + test('pushClipRect', () async { + final SceneBuilder builder = SceneBuilder(); + builder.pushClipRect( + const Rect.fromLTRB(10, 10, 60, 60), + ); + _drawTestPicture(builder); + builder.pop(); + + html.document.body.append(builder.build().webOnlyRootElement); + + await matchGoldenFile('engine/compositing_shifted_clip_rect.png', region: region); + }, timeout: const Timeout(Duration(seconds: 10))); + + test('pushClipRect with offset and transform', () async { + final SceneBuilder builder = SceneBuilder(); + + builder.pushOffset(0, 60); + builder.pushTransform( + Matrix4.diagonal3Values(1, -1, 1).storage, + ); + builder.pushClipRect( + const Rect.fromLTRB(10, 10, 60, 60), + ); + _drawTestPicture(builder); + builder.pop(); + builder.pop(); + builder.pop(); + + html.document.body.append(builder.build().webOnlyRootElement); + + await matchGoldenFile('engine/compositing_clip_rect_with_offset_and_transform.png', region: region); + }, timeout: const Timeout(Duration(seconds: 10))); + + test('pushClipRRect', () async { + final SceneBuilder builder = SceneBuilder(); + builder.pushClipRRect( + RRect.fromLTRBR(10, 10, 60, 60, const Radius.circular(5)), + ); + _drawTestPicture(builder); + builder.pop(); + + html.document.body.append(builder.build().webOnlyRootElement); + + await matchGoldenFile('engine/compositing_shifted_clip_rrect.png', region: region); + }, timeout: const Timeout(Duration(seconds: 10))); + + test('pushPhysicalShape', () async { + final SceneBuilder builder = SceneBuilder(); + builder.pushPhysicalShape( + path: Path()..addRect(const Rect.fromLTRB(10, 10, 60, 60)), + clipBehavior: Clip.hardEdge, + color: const Color.fromRGBO(0, 0, 0, 0.3), + elevation: 0, + ); + _drawTestPicture(builder); + builder.pop(); + + builder.pushOffset(70, 0); + builder.pushPhysicalShape( + path: Path() + ..addRRect(RRect.fromLTRBR(10, 10, 60, 60, const Radius.circular(5))), + clipBehavior: Clip.hardEdge, + color: const Color.fromRGBO(0, 0, 0, 0.3), + elevation: 0, + ); + _drawTestPicture(builder); + builder.pop(); + builder.pop(); + + html.document.body.append(builder.build().webOnlyRootElement); + + await matchGoldenFile('engine/compositing_shifted_physical_shape_clip.png', region: region); + }, timeout: const Timeout(Duration(seconds: 10))); + + group('Cull rect computation', () { + _testCullRectComputation(); + }); +} + +void _testCullRectComputation() { + // Draw a picture larger that screen. Verify that cull rect is equal to screen + // bounds. + test('fills screen bounds', () async { + final SceneBuilder builder = SceneBuilder(); + drawWithBitmapCanvas(builder, (RecordingCanvas canvas) { + canvas.drawCircle( + Offset.zero, 10000, Paint()..style = PaintingStyle.fill); + }); + builder.build(); + + final PersistedStandardPicture picture = enumeratePictures().single; + expect(picture.optimalLocalCullRect, const Rect.fromLTRB(0, 0, 500, 100)); + }, skip: '''TODO(https://github.com/flutter/flutter/issues/40395) + Needs ability to set iframe to 500,100 size. Current screen seems to be 500,500'''); + + // Draw a picture that overflows the screen. Verify that cull rect is the + // intersection of screen bounds and paint bounds. + test('intersects with screen bounds', () async { + final SceneBuilder builder = SceneBuilder(); + drawWithBitmapCanvas(builder, (RecordingCanvas canvas) { + canvas.drawCircle(Offset.zero, 20, Paint()..style = PaintingStyle.fill); + }); + builder.build(); + + final PersistedStandardPicture picture = enumeratePictures().single; + expect(picture.optimalLocalCullRect, const Rect.fromLTRB(0, 0, 20, 20)); + }); + + // Draw a picture that's fully outside the screen bounds. Verify the cull rect + // is zero. + test('fully outside screen bounds', () async { + final SceneBuilder builder = SceneBuilder(); + drawWithBitmapCanvas(builder, (RecordingCanvas canvas) { + canvas.drawCircle( + const Offset(-100, -100), 20, Paint()..style = PaintingStyle.fill); + }); + builder.build(); + + final PersistedStandardPicture picture = enumeratePictures().single; + expect(picture.optimalLocalCullRect, Rect.zero); + expect(picture.debugExactGlobalCullRect, Rect.zero); + }); + + // Draw a picture that's fully inside the screen. Verify that cull rect is + // equal to the paint bounds. + test('limits to paint bounds if no clip layers', () async { + final SceneBuilder builder = SceneBuilder(); + drawWithBitmapCanvas(builder, (RecordingCanvas canvas) { + canvas.drawCircle( + const Offset(50, 50), 10, Paint()..style = PaintingStyle.fill); + }); + builder.build(); + + final PersistedStandardPicture picture = enumeratePictures().single; + expect(picture.optimalLocalCullRect, const Rect.fromLTRB(40, 40, 60, 60)); + }); + + // Draw a picture smaller than the screen. Offset it such that it remains + // fully inside the screen bounds. Verify that cull rect is still just the + // paint bounds. + test('offset does not affect paint bounds', () async { + final SceneBuilder builder = SceneBuilder(); + + builder.pushOffset(10, 10); + drawWithBitmapCanvas(builder, (RecordingCanvas canvas) { + canvas.drawCircle( + const Offset(50, 50), 10, Paint()..style = PaintingStyle.fill); + }); + builder.pop(); + + builder.build(); + + final PersistedStandardPicture picture = enumeratePictures().single; + expect(picture.optimalLocalCullRect, const Rect.fromLTRB(40, 40, 60, 60)); + }); + + // Draw a picture smaller than the screen. Offset it such that the picture + // overflows screen bounds. Verify that the cull rect is the intersection + // between screen bounds and paint bounds. + test('offset overflows paint bounds', () async { + final SceneBuilder builder = SceneBuilder(); + + builder.pushOffset(0, 90); + drawWithBitmapCanvas(builder, (RecordingCanvas canvas) { + canvas.drawCircle(Offset.zero, 20, Paint()..style = PaintingStyle.fill); + }); + builder.pop(); + + builder.build(); + + final PersistedStandardPicture picture = enumeratePictures().single; + expect( + picture.debugExactGlobalCullRect, const Rect.fromLTRB(0, 70, 20, 100)); + expect(picture.optimalLocalCullRect, const Rect.fromLTRB(0, -20, 20, 10)); + }, skip: '''TODO(https://github.com/flutter/flutter/issues/40395) + Needs ability to set iframe to 500,100 size. Current screen seems to be 500,500'''); + + // Draw a picture inside a layer clip but fill all available space inside it. + // Verify that the cull rect is equal to the layer clip. + test('fills layer clip rect', () async { + final SceneBuilder builder = SceneBuilder(); + builder.pushClipRect( + const Rect.fromLTWH(10, 10, 60, 60), + ); + + builder.pushClipRect( + const Rect.fromLTWH(40, 40, 60, 60), + ); + + drawWithBitmapCanvas(builder, (RecordingCanvas canvas) { + canvas.drawCircle( + Offset.zero, 10000, Paint()..style = PaintingStyle.fill); + }); + + builder.pop(); // pushClipRect + builder.pop(); // pushClipRect + html.document.body.append(builder.build().webOnlyRootElement); + + await matchGoldenFile('engine/compositing_cull_rect_fills_layer_clip.png', region: region); + + final PersistedStandardPicture picture = enumeratePictures().single; + expect(picture.optimalLocalCullRect, const Rect.fromLTRB(40, 40, 70, 70)); + }, timeout: const Timeout(Duration(seconds: 10))); + + // Draw a picture inside a layer clip but position the picture such that its + // paint bounds overflow the layer clip. Verify that the cull rect is the + // intersection between the layer clip and paint bounds. + test('intersects layer clip rect and paint bounds', () async { + final SceneBuilder builder = SceneBuilder(); + builder.pushClipRect( + const Rect.fromLTWH(10, 10, 60, 60), + ); + + builder.pushClipRect( + const Rect.fromLTWH(40, 40, 60, 60), + ); + + drawWithBitmapCanvas(builder, (RecordingCanvas canvas) { + canvas.drawCircle( + const Offset(80, 55), 30, Paint()..style = PaintingStyle.fill); + }); + + builder.pop(); // pushClipRect + builder.pop(); // pushClipRect + html.document.body.append(builder.build().webOnlyRootElement); + + await matchGoldenFile('engine/compositing_cull_rect_intersects_clip_and_paint_bounds.png', region: region); + + final PersistedStandardPicture picture = enumeratePictures().single; + expect(picture.optimalLocalCullRect, const Rect.fromLTRB(50, 40, 70, 70)); + }, timeout: const Timeout(Duration(seconds: 10))); + + // Draw a picture inside a layer clip that's positioned inside the clip using + // an offset layer. Verify that the cull rect is the intersection between the + // layer clip and the offset paint bounds. + test('offsets picture inside layer clip rect', () async { + final SceneBuilder builder = SceneBuilder(); + builder.pushClipRect( + const Rect.fromLTWH(10, 10, 60, 60), + ); + + builder.pushClipRect( + const Rect.fromLTWH(40, 40, 60, 60), + ); + + builder.pushOffset(55, 70); + + drawWithBitmapCanvas(builder, (RecordingCanvas canvas) { + canvas.drawCircle(Offset.zero, 20, Paint()..style = PaintingStyle.fill); + }); + + builder.pop(); // pushOffset + builder.pop(); // pushClipRect + builder.pop(); // pushClipRect + html.document.body.append(builder.build().webOnlyRootElement); + + await matchGoldenFile('engine/compositing_cull_rect_offset_inside_layer_clip.png', region: region); + + final PersistedStandardPicture picture = enumeratePictures().single; + expect(picture.optimalLocalCullRect, + const Rect.fromLTRB(-15.0, -20.0, 15.0, 0.0)); + }, timeout: const Timeout(Duration(seconds: 10))); + + // Draw a picture inside a layer clip that's positioned an offset layer such + // that the picture is push completely outside the clip area. Verify that the + // cull rect is zero. + test('zero intersection with clip', () async { + final SceneBuilder builder = SceneBuilder(); + builder.pushClipRect( + const Rect.fromLTWH(10, 10, 60, 60), + ); + + builder.pushClipRect( + const Rect.fromLTWH(40, 40, 60, 60), + ); + + builder.pushOffset(100, 50); + + drawWithBitmapCanvas(builder, (RecordingCanvas canvas) { + canvas.drawCircle(Offset.zero, 20, Paint()..style = PaintingStyle.fill); + }); + + builder.pop(); // pushOffset + builder.pop(); // pushClipRect + builder.pop(); // pushClipRect + + builder.build(); + + final PersistedStandardPicture picture = enumeratePictures().single; + expect(picture.optimalLocalCullRect, Rect.zero); + expect(picture.debugExactGlobalCullRect, Rect.zero); + }); + + // Draw a picture inside a rotated clip. Verify that the cull rect is big + // enough to fit the rotated clip. + test('rotates clip and the picture', () async { + final SceneBuilder builder = SceneBuilder(); + builder.pushOffset(80, 50); + + builder.pushTransform( + Matrix4.rotationZ(-math.pi / 4).storage, + ); + + builder.pushClipRect( + const Rect.fromLTRB(-10, -10, 10, 10), + ); + + builder.pushTransform( + Matrix4.rotationZ(math.pi / 4).storage, + ); + + drawWithBitmapCanvas(builder, (RecordingCanvas canvas) { + canvas.drawPaint(Paint() + ..color = const Color.fromRGBO(0, 0, 255, 0.6) + ..style = PaintingStyle.fill); + canvas.drawRect( + const Rect.fromLTRB(-5, -5, 5, 5), + Paint() + ..color = const Color.fromRGBO(0, 255, 0, 1.0) + ..style = PaintingStyle.fill, + ); + }); + + builder.pop(); // pushTransform + builder.pop(); // pushClipRect + builder.pop(); // pushTransform + builder.pop(); // pushOffset + html.document.body.append(builder.build().webOnlyRootElement); + + await matchGoldenFile('engine/compositing_cull_rect_rotated.png', region: region); + + final PersistedStandardPicture picture = enumeratePictures().single; + expect( + picture.optimalLocalCullRect, + within( + distance: 0.05, from: const Rect.fromLTRB(-14.1, -14.1, 14.1, 14.1)), + ); + }, timeout: const Timeout(Duration(seconds: 10))); + + test('pushClipPath', () async { + final SceneBuilder builder = SceneBuilder(); + final Path path = Path(); + path..addRect(const Rect.fromLTRB(10, 10, 60, 60)); + builder.pushClipPath( + path, + ); + _drawTestPicture(builder); + builder.pop(); + + html.document.body.append(builder.build().webOnlyRootElement); + + await matchGoldenFile('engine/compositing_clip_path.png', region: region); + }, timeout: const Timeout(Duration(seconds: 10))); + + // Draw a picture inside a rotated clip. Verify that the cull rect is big + // enough to fit the rotated clip. + test('clips correctly when using 3d transforms', () async { + final SceneBuilder builder = SceneBuilder(); + final double screenWidth = html.window.innerWidth.toDouble(); + final double screenHeight = html.window.innerHeight.toDouble(); + + final Matrix4 scaleTransform = Matrix4.identity().scaled(0.5, 0.2); + builder.pushTransform( + scaleTransform.storage, + ); + + builder.pushOffset(400, 200); + + builder.pushClipRect( + const Rect.fromLTRB(-200, -200, 200, 200), + ); + + builder.pushTransform( + Matrix4.rotationY(45.0 * math.pi / 180.0).storage, + ); + + builder.pushClipRect( + const Rect.fromLTRB(-140, -140, 140, 140), + ); + + builder.pushTransform(Matrix4.translationValues(0, 0, -50).storage); + + drawWithBitmapCanvas(builder, (RecordingCanvas canvas) { + canvas.drawPaint(Paint() + ..color = const Color.fromRGBO(0, 0, 255, 0.6) + ..style = PaintingStyle.fill); + // Rect will be clipped. + canvas.drawRect( + const Rect.fromLTRB(-150, -150, 150, 150), + Paint() + ..color = const Color.fromRGBO(0, 255, 0, 1.0) + ..style = PaintingStyle.fill, + ); + // Should be outside the clip range. + canvas.drawRect( + const Rect.fromLTRB(-150, -150, -140, -140), + Paint() + ..color = const Color.fromARGB(0xE0, 255, 0, 0) + ..style = PaintingStyle.fill, + ); + canvas.drawRect( + const Rect.fromLTRB(140, -150, 150, -140), + Paint() + ..color = const Color.fromARGB(0xE0, 255, 0, 0) + ..style = PaintingStyle.fill, + ); + canvas.drawRect( + const Rect.fromLTRB(-150, 140, -140, 150), + Paint() + ..color = const Color.fromARGB(0xE0, 255, 0, 0) + ..style = PaintingStyle.fill, + ); + canvas.drawRect( + const Rect.fromLTRB(140, 140, 150, 150), + Paint() + ..color = const Color.fromARGB(0xE0, 255, 0, 0) + ..style = PaintingStyle.fill, + ); + // Should be inside clip range + canvas.drawRect( + const Rect.fromLTRB(-100, -100, -90, -90), + Paint() + ..color = const Color.fromARGB(0xE0, 0, 0, 0x80) + ..style = PaintingStyle.fill, + ); + canvas.drawRect( + const Rect.fromLTRB(90, -100, 100, -90), + Paint() + ..color = const Color.fromARGB(0xE0, 0, 0, 0x80) + ..style = PaintingStyle.fill, + ); + canvas.drawRect( + const Rect.fromLTRB(-100, 90, -90, 100), + Paint() + ..color = const Color.fromARGB(0xE0, 0, 0, 0x80) + ..style = PaintingStyle.fill, + ); + canvas.drawRect( + const Rect.fromLTRB(90, 90, 100, 100), + Paint() + ..color = const Color.fromARGB(0xE0, 0, 0, 0x80) + ..style = PaintingStyle.fill, + ); + }); + + builder.pop(); // pushTransform Z-50 + builder.pop(); // pushClipRect + builder.pop(); // pushTransform 3D rotate + builder.pop(); // pushClipRect + builder.pop(); // pushOffset + builder.pop(); // pushTransform scale + html.document.body.append(builder.build().webOnlyRootElement); + + await matchGoldenFile('engine/compositing_3d_rotate1.png', region: region); + + final PersistedStandardPicture picture = enumeratePictures().single; + // TODO(https://github.com/flutter/flutter/issues/40395): + // Needs ability to set iframe to 500,100 size. Current screen seems to be 500,500. + // expect( + // picture.optimalLocalCullRect, + // within( + // distance: 0.05, + // from: Rect.fromLTRB( + // -140, -140, screenWidth - 360.0, screenHeight + 40.0)), + // ); + }, timeout: const Timeout(Duration(seconds: 10))); +} + +void _drawTestPicture(SceneBuilder builder) { + final PictureRecorder recorder = PictureRecorder(); + final RecordingCanvas canvas = + recorder.beginRecording(const Rect.fromLTRB(0, 0, 100, 100)); + canvas.drawCircle( + const Offset(10, 10), 10, Paint()..style = PaintingStyle.fill); + canvas.drawCircle( + const Offset(60, 10), + 10, + Paint() + ..style = PaintingStyle.fill + ..color = const Color.fromRGBO(255, 0, 0, 1)); + canvas.drawCircle( + const Offset(10, 60), + 10, + Paint() + ..style = PaintingStyle.fill + ..color = const Color.fromRGBO(0, 255, 0, 1)); + canvas.drawCircle( + const Offset(60, 60), + 10, + Paint() + ..style = PaintingStyle.fill + ..color = const Color.fromRGBO(0, 0, 255, 1)); + final Picture picture = recorder.endRecording(); + + builder.addPicture( + Offset.zero, + picture, + ); +} + +typedef PaintCallback = void Function(RecordingCanvas canvas); + +void drawWithBitmapCanvas(SceneBuilder builder, PaintCallback callback, + {Rect bounds = Rect.largest}) { + final PictureRecorder recorder = PictureRecorder(); + final RecordingCanvas canvas = recorder.beginRecording(bounds); + + canvas.debugEnforceArbitraryPaint(); + callback(canvas); + final Picture picture = recorder.endRecording(); + + builder.addPicture( + Offset.zero, + picture, + ); +} diff --git a/engine/src/flutter/lib/web_ui/test/golden_tests/engine/conic_scuba_test.dart b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/conic_scuba_test.dart new file mode 100644 index 00000000000..3a27a3d0dbe --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/conic_scuba_test.dart @@ -0,0 +1,93 @@ +// Copyright 2013 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:html' as html; + +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart'; +import 'package:test/test.dart'; + +import 'package:web_engine_tester/golden_tester.dart'; + +void main() async { + final Rect region = Rect.fromLTWH(8, 8, 600, 800); // Compensate for old scuba tester padding + + Future testPath(Path path, String scubaFileName) async { + const Rect canvasBounds = Rect.fromLTWH(0, 0, 600, 800); + final BitmapCanvas bitmapCanvas = BitmapCanvas(canvasBounds); + final RecordingCanvas canvas = RecordingCanvas(canvasBounds); + + Paint paint = Paint() + ..color = const Color(0x7F7F7F7F) + ..style = PaintingStyle.fill; + + canvas.drawPath(path, paint); + + paint = Paint() + ..strokeWidth = 2.0 + ..color = const Color(0xFF7F007F) + ..style = PaintingStyle.stroke; + + canvas.drawPath(path, paint); + + html.document.body.append(bitmapCanvas.rootElement); + canvas.apply(bitmapCanvas); + await matchGoldenFile('engine/$scubaFileName.png', region: region); + bitmapCanvas.rootElement.remove(); + } + + test('render conic with control point horizontal center', () async { + const double yStart = 20; + + const Offset p0 = Offset(25, yStart + 25); + const Offset pc = Offset(60, yStart + 150); + const Offset p2 = Offset(100, yStart + 50); + + final Path path = Path(); + path.moveTo(p0.dx, p0.dy); + path.conicTo(pc.dx, pc.dy, p2.dx, p2.dy, 0.5); + path.close(); + path.moveTo(p0.dx, p0.dy + 200); + path.conicTo(pc.dx, pc.dy + 200, p2.dx, p2.dy + 200, 10); + path.close(); + + await testPath(path, 'render_conic_1_w10'); + }, timeout: const Timeout(Duration(seconds: 10))); + + test('render conic with control point left of start point', () async { + const double yStart = 20; + + const Offset p0 = Offset(60, yStart + 25); + const Offset pc = Offset(25, yStart + 150); + const Offset p2 = Offset(100, yStart + 50); + + final Path path = Path(); + path.moveTo(p0.dx, p0.dy); + path.conicTo(pc.dx, pc.dy, p2.dx, p2.dy, 0.5); + path.close(); + path.moveTo(p0.dx, p0.dy + 200); + path.conicTo(pc.dx, pc.dy + 200, p2.dx, p2.dy + 200, 10); + path.close(); + + await testPath(path, 'render_conic_2_w10'); + }, timeout: const Timeout(Duration(seconds: 10))); + + test('render conic with control point above start point', () async { + const double yStart = 20; + + const Offset p0 = Offset(25, yStart + 125); + const Offset pc = Offset(60, yStart + 50); + const Offset p2 = Offset(100, yStart + 150); + + final Path path = Path(); + path.moveTo(p0.dx, p0.dy); + path.conicTo(pc.dx, pc.dy, p2.dx, p2.dy, 0.5); + path.close(); + path.moveTo(p0.dx, p0.dy + 200); + path.conicTo(pc.dx, pc.dy + 200, p2.dx, p2.dy + 200, 10); + path.close(); + + await testPath(path, 'render_conic_2'); + }, timeout: const Timeout(Duration(seconds: 10))); +} diff --git a/engine/src/flutter/lib/web_ui/test/golden_tests/engine/multiline_text_clipping_scuba_test.dart b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/multiline_text_clipping_scuba_test.dart new file mode 100644 index 00000000000..c14ca52550c --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/multiline_text_clipping_scuba_test.dart @@ -0,0 +1,194 @@ +// Copyright 2013 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 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart'; + +import 'scuba.dart'; + +typedef PaintTest = void Function(RecordingCanvas recordingCanvas); + +void main() async { + // Scuba doesn't give us viewport smaller than 472px wide. + final EngineScubaTester scuba = await EngineScubaTester.initialize( + viewportSize: const Size(600, 600), + ); + + void paintTest(EngineCanvas canvas, PaintTest painter) { + final RecordingCanvas recordingCanvas = + RecordingCanvas(const Rect.fromLTWH(0, 0, 600, 600)); + painter(recordingCanvas); + recordingCanvas.apply(canvas); + } + + testEachCanvas( + 'clips multiline text against rectangle', + (EngineCanvas canvas) { + // [DomCanvas] doesn't support clip commands. + if (canvas is! DomCanvas) { + paintTest(canvas, paintTextWithClipRect); + return scuba.diffCanvasScreenshot( + canvas, 'multiline_text_clipping_rect'); + } + return null; + }, + ); + + testEachCanvas( + 'clips multiline text against rectangle with transform', + (EngineCanvas canvas) { + // [DomCanvas] doesn't support clip commands. + if (canvas is! DomCanvas) { + paintTest(canvas, paintTextWithClipRectTranslated); + return scuba.diffCanvasScreenshot( + canvas, 'multiline_text_clipping_rect_translate'); + } + return null; + }, + ); + + testEachCanvas( + 'clips multiline text against round rectangle', + (EngineCanvas canvas) { + // [DomCanvas] doesn't support clip commands. + if (canvas is! DomCanvas) { + paintTest(canvas, paintTextWithClipRoundRect); + return scuba.diffCanvasScreenshot( + canvas, 'multiline_text_clipping_roundrect'); + } + return null; + }, + ); + + testEachCanvas( + 'clips multiline text against path', + (EngineCanvas canvas) { + // [DomCanvas] doesn't support clip commands. + if (canvas is! DomCanvas) { + paintTest(canvas, paintTextWithClipPath); + return scuba.diffCanvasScreenshot( + canvas, 'multiline_text_clipping_path'); + } + return null; + }, + ); + + testEachCanvas( + 'clips multiline text against stack of rects', + (EngineCanvas canvas) { + // [DomCanvas] doesn't support clip commands. + if (canvas is! DomCanvas) { + // TODO(flutter_web): https://github.com/flutter/flutter/issues/35086 + // This produces the wrong result when using [BitmapCanvas] but without + // the new experimental canvas mode. + paintTest(canvas, paintTextWithClipStack); + return scuba.diffCanvasScreenshot( + canvas, 'multiline_text_clipping_stack1'); + } + return null; + }, + ); +} + +const Rect testBounds = Rect.fromLTRB(50, 50, 230, 220); + +void drawBackground(RecordingCanvas canvas) { + canvas.drawRect( + testBounds, + Paint() + ..style = PaintingStyle.fill + ..color = const Color(0xFF9E9E9E)); + canvas.drawRect( + testBounds.inflate(-40), + Paint() + ..strokeWidth = 1 + ..style = PaintingStyle.stroke + ..color = const Color(0xFF009688)); +} + +void drawQuickBrownFox(RecordingCanvas canvas) { + canvas.drawParagraph( + paragraph( + 'The quick brown fox jumps over the lazy dog', + textStyle: TextStyle( + color: const Color(0xFF000000), + decoration: TextDecoration.none, + fontFamily: 'Arial', + fontSize: 30, + background: Paint()..color = const Color.fromRGBO(50, 255, 50, 1.0), + ), + maxWidth: 180, + ), + Offset(testBounds.left, testBounds.top)); +} + +void paintTextWithClipRect(RecordingCanvas canvas) { + drawBackground(canvas); + canvas.clipRect(testBounds.inflate(-40)); + drawQuickBrownFox(canvas); +} + +void paintTextWithClipRectTranslated(RecordingCanvas canvas) { + drawBackground(canvas); + canvas.clipRect(testBounds.inflate(-40)); + canvas.translate(30, 10); + drawQuickBrownFox(canvas); +} + +const Color deepOrange = Color(0xFFFF5722); + +void paintTextWithClipRoundRect(RecordingCanvas canvas) { + final RRect roundRect = RRect.fromRectAndCorners(testBounds.inflate(-40), + topLeft: Radius.zero, + topRight: const Radius.elliptical(45, 40), + bottomLeft: const Radius.elliptical(50, 40), + bottomRight: const Radius.circular(30)); + drawBackground(canvas); + canvas.drawRRect( + roundRect, + Paint() + ..color = deepOrange + ..style = PaintingStyle.fill); + canvas.clipRRect(roundRect); + drawQuickBrownFox(canvas); +} + +void paintTextWithClipPath(RecordingCanvas canvas) { + drawBackground(canvas); + final Path path = Path(); + const double delta = 40.0; + final Rect clipBounds = testBounds.inflate(-delta); + final double midX = (clipBounds.left + clipBounds.right) / 2.0; + final double midY = (clipBounds.top + clipBounds.bottom) / 2.0; + path.moveTo(clipBounds.left - delta, midY); + path.quadraticBezierTo(midX, midY, midX, clipBounds.top - delta); + path.quadraticBezierTo(midX, midY, clipBounds.right + delta, midY); + path.quadraticBezierTo(midX, midY, midX, clipBounds.bottom + delta); + path.quadraticBezierTo(midX, midY, clipBounds.left - delta, midY); + path.close(); + canvas.drawPath( + path, + Paint() + ..color = deepOrange + ..style = PaintingStyle.fill); + canvas.clipPath(path); + drawQuickBrownFox(canvas); +} + +void paintTextWithClipStack(RecordingCanvas canvas) { + drawBackground(canvas); + final Rect inflatedRect = testBounds.inflate(-40); + canvas.clipRect(inflatedRect); + canvas.rotate(math.pi / 8.0); + canvas.translate(40, -40); + canvas.clipRect(inflatedRect); + canvas.drawRect( + inflatedRect, + Paint() + ..color = deepOrange + ..style = PaintingStyle.fill); + drawQuickBrownFox(canvas); +} diff --git a/engine/src/flutter/lib/web_ui/test/golden_tests/engine/path_to_svg_scuba_test.dart b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/path_to_svg_scuba_test.dart new file mode 100644 index 00000000000..7abe5ca0c36 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/path_to_svg_scuba_test.dart @@ -0,0 +1,187 @@ +// Copyright 2013 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:html' as html; + +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart'; +import 'package:test/test.dart'; + +import 'package:web_engine_tester/golden_tester.dart'; + +void main() async { + final Rect region = Rect.fromLTWH(8, 8, 600, 800); // Compensate for old scuba tester padding + + Future testPath(Path path, String scubaFileName, {Paint paint}) async { + const Rect canvasBounds = Rect.fromLTWH(0, 0, 600, 800); + final BitmapCanvas bitmapCanvas = BitmapCanvas(canvasBounds); + final RecordingCanvas canvas = RecordingCanvas(canvasBounds); + + paint ??= Paint() + ..color = const Color(0x807F7F7F) + ..style = PaintingStyle.fill; + + canvas.drawPath(path, paint); + + paint = Paint() + ..strokeWidth = 2 + ..color = const Color(0xFFFF0000) + ..style = PaintingStyle.stroke; + + canvas.drawPath(path, paint); + + final html.Element svgElement = pathToSvgElement(path, paint); + + html.document.body.append(bitmapCanvas.rootElement); + html.document.body.append(svgElement); + + canvas.apply(bitmapCanvas); + + await matchGoldenFile('engine/$scubaFileName.png', region: region); + + bitmapCanvas.rootElement.remove(); + svgElement.remove(); + } + + tearDown(() { + html.document.body.children.clear(); + }); + + test('render line strokes', () async { + final Path path = Path(); + path.moveTo(50, 60); + path.lineTo(200, 300); + await testPath(path, 'svg_stroke_line', + paint: Paint() + ..color = const Color(0xFFFF0000) + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke); + }, timeout: const Timeout(Duration(seconds: 10))); + + test('render quad bezier curve', () async { + final Path path = Path(); + path.moveTo(50, 60); + path.quadraticBezierTo(200, 60, 50, 200); + await testPath(path, 'svg_quad_bezier'); + }, timeout: const Timeout(Duration(seconds: 10))); + + test('render cubic curve', () async { + final Path path = Path(); + path.moveTo(50, 60); + path.cubicTo(200, 60, -100, -50, 150, 200); + await testPath(path, 'svg_cubic_bezier'); + }, timeout: const Timeout(Duration(seconds: 10))); + + test('render arcs', () async { + final List arcs = [ + ArcSample(const Offset(0, 0), + largeArc: false, clockwise: false, distance: 20), + ArcSample(const Offset(200, 0), + largeArc: true, clockwise: false, distance: 20), + ArcSample(const Offset(0, 150), + largeArc: false, clockwise: true, distance: 20), + ArcSample(const Offset(200, 150), + largeArc: true, clockwise: true, distance: 20), + ArcSample(const Offset(0, 300), + largeArc: false, clockwise: false, distance: -20), + ArcSample(const Offset(200, 300), + largeArc: true, clockwise: false, distance: -20), + ArcSample(const Offset(0, 450), + largeArc: false, clockwise: true, distance: -20), + ArcSample(const Offset(200, 450), + largeArc: true, clockwise: true, distance: -20) + ]; + int sampleIndex = 0; + for (ArcSample sample in arcs) { + ++sampleIndex; + final Path path = sample.createPath(); + await testPath(path, 'svg_arc_$sampleIndex'); + } + }, timeout: const Timeout(Duration(seconds: 10))); + + test('render rect', () async { + final Path path = Path(); + path.addRect(const Rect.fromLTRB(15, 15, 60, 20)); + path.addRect(const Rect.fromLTRB(35, 160, 15, 100)); + await testPath(path, 'svg_rect'); + }, timeout: const Timeout(Duration(seconds: 10))); + + test('render notch', () async { + final Path path = Path(); + path.moveTo(0, 0); + path.lineTo(83, 0); + path.quadraticBezierTo(98, 0, 99.97, 7.8); + path.arcToPoint(const Offset(162, 7.8), + radius: const Radius.circular(32), + largeArc: false, + clockwise: false, + rotation: 0); + path.lineTo(200, 7.8); + path.lineTo(200, 80); + path.lineTo(0, 80); + path.lineTo(0, 10); + await testPath(path, 'svg_notch'); + }, timeout: const Timeout(Duration(seconds: 10))); +} + +html.Element pathToSvgElement(Path path, Paint paint) { + final Rect bounds = path.getBounds(); + final StringBuffer sb = StringBuffer(); + sb.write( + ''); + sb.write(''); + sb.write(''); + final html.Element svgElement = + html.Element.html(sb.toString(), treeSanitizer: _NullTreeSanitizer()); + svgElement.style.transform = 'translate(200px, 0px)'; + return svgElement; +} + +class _NullTreeSanitizer implements html.NodeTreeSanitizer { + @override + void sanitizeTree(html.Node node) {} +} + +class ArcSample { + final Offset offset; + final bool largeArc; + final bool clockwise; + final double distance; + ArcSample(this.offset, + {this.largeArc = false, this.clockwise = false, this.distance = 0}); + + Path createPath() { + final Offset startP = + Offset(75 - distance + offset.dx, 75 - distance + offset.dy); + final Offset endP = + Offset(75.0 + distance + offset.dx, 75.0 + distance + offset.dy); + final Path path = Path(); + path.moveTo(startP.dx, startP.dy); + path.arcToPoint(endP, + rotation: 60, + radius: const Radius.elliptical(40, 60), + largeArc: largeArc, + clockwise: clockwise); + return path; + } + + // Returns bounds of start/end point of arc. + Rect getBounds() { + final Offset startP = + Offset(75 - distance + offset.dx, 75 - distance + offset.dy); + final Offset endP = + Offset(75.0 + distance + offset.dx, 75.0 + distance + offset.dy); + return Rect.fromLTRB(startP.dx, startP.dy, endP.dx, endP.dy); + } +} diff --git a/engine/src/flutter/lib/web_ui/test/golden_tests/engine/recording_canvas_scuba_test.dart b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/recording_canvas_scuba_test.dart new file mode 100644 index 00000000000..d439ed70ee2 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/recording_canvas_scuba_test.dart @@ -0,0 +1,470 @@ +// Copyright 2013 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:html' as html; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:ui/ui.dart' hide TextStyle; +import 'package:ui/src/engine.dart'; +import 'package:test/test.dart'; + +import '../../matchers.dart'; +import 'package:web_engine_tester/golden_tester.dart'; + +void main() async { + const double screenWidth = 600.0; + const double screenHeight = 800.0; + const Rect screenRect = Rect.fromLTWH(0, 0, screenWidth, screenHeight); + final Paint testPaint = Paint()..color = const Color(0xFFFF0000); + + // Commit a recording canvas to a bitmap, and compare with the expected + Future _checkScreenshot(RecordingCanvas rc, String fileName, + { Rect region = const Rect.fromLTWH(0, 0, 500, 500) }) async { + + final EngineCanvas engineCanvas = BitmapCanvas(screenRect); + + // Draws the estimated bounds so we can spot the bug in Scuba. + engineCanvas + ..save() + ..drawRect( + rc.computePaintBounds(), + PaintData() + ..color = const Color.fromRGBO(0, 0, 255, 1.0) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0, + ) + ..restore(); + + rc.apply(engineCanvas); + + // Wrap in so that our CSS selectors kick in. + final html.Element sceneElement = html.Element.tag('flt-scene'); + try { + sceneElement.append(engineCanvas.rootElement); + html.document.body.append(sceneElement); + await matchGoldenFile('engine/paint_bounds_for_$fileName.png', region: region); + } finally { + // The page is reused across tests, so remove the element after taking the + // Scuba screenshot. + sceneElement.remove(); + } + } + + setUp(() async { + debugEmulateFlutterTesterEnvironment = true; + await webOnlyInitializePlatform(); + webOnlyFontCollection.debugRegisterTestFonts(); + await webOnlyFontCollection.ensureFontsLoaded(); + }); + + test('Empty canvas reports correct paint bounds', () async { + final RecordingCanvas rc = + RecordingCanvas(const Rect.fromLTWH(1, 2, 300, 400)); + expect(rc.computePaintBounds(), Rect.zero); + await _checkScreenshot(rc, 'empty_canvas'); + }); + + test('Computes paint bounds for draw line', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.drawLine(const Offset(50, 100), const Offset(120, 140), testPaint); + // The off by one is due to the minimum stroke width of 1. + expect(rc.computePaintBounds(), const Rect.fromLTRB(49, 99, 121, 141)); + await _checkScreenshot(rc, 'draw_line'); + }); + + test('Computes paint bounds for draw line when line exceeds limits', + () async { + // Uses max bounds when computing paint bounds + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.drawLine(const Offset(50, 100), const Offset(screenWidth + 100.0, 140), + testPaint); + // The off by one is due to the minimum stroke width of 1. + expect(rc.computePaintBounds(), + const Rect.fromLTRB(49.0, 99.0, screenWidth, 141.0)); + await _checkScreenshot(rc, 'draw_line_exceeding_limits'); + }); + + test('Computes paint bounds for draw rect', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.drawRect(const Rect.fromLTRB(10, 20, 30, 40), testPaint); + expect(rc.computePaintBounds(), const Rect.fromLTRB(10, 20, 30, 40)); + await _checkScreenshot(rc, 'draw_rect'); + }); + + test('Computes paint bounds for draw rect when exceeds limits', () async { + // Uses max bounds when computing paint bounds + RecordingCanvas rc = RecordingCanvas(screenRect); + rc.drawRect( + const Rect.fromLTRB(10, 20, 30 + screenWidth, 40 + screenHeight), + testPaint); + expect(rc.computePaintBounds(), + const Rect.fromLTRB(10, 20, screenWidth, screenHeight)); + + rc = RecordingCanvas(screenRect); + rc.drawRect(const Rect.fromLTRB(-200, -100, 30, 40), testPaint); + expect(rc.computePaintBounds(), const Rect.fromLTRB(0, 0, 30, 40)); + await _checkScreenshot(rc, 'draw_rect_exceeding_limits'); + }); + + test('Computes paint bounds for translate', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.translate(5, 7); + rc.drawRect(const Rect.fromLTRB(10, 20, 30, 40), testPaint); + expect(rc.computePaintBounds(), const Rect.fromLTRB(15, 27, 35, 47)); + await _checkScreenshot(rc, 'translate'); + }); + + test('Computes paint bounds for scale', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.scale(2, 2); + rc.drawRect(const Rect.fromLTRB(10, 20, 30, 40), testPaint); + expect(rc.computePaintBounds(), const Rect.fromLTRB(20, 40, 60, 80)); + await _checkScreenshot(rc, 'scale'); + }); + + test('Computes paint bounds for rotate', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.rotate(math.pi / 4.0); + rc.drawLine( + const Offset(1, 0), Offset(50 * math.sqrt(2) - 1, 0), testPaint); + // The extra 0.7 is due to stroke width of 1 rotated by 45 degrees. + expect(rc.computePaintBounds(), + within(distance: 0.1, from: const Rect.fromLTRB(0, 0, 50.7, 50.7))); + await _checkScreenshot(rc, 'rotate'); + }); + + test('Computes paint bounds for horizontal skew', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.skew(1.0, 0.0); + rc.drawRect(const Rect.fromLTRB(20, 20, 40, 40), testPaint); + expect( + rc.computePaintBounds(), + within( + distance: 0.1, from: const Rect.fromLTRB(40.0, 20.0, 80.0, 40.0))); + await _checkScreenshot(rc, 'skew_horizontally'); + }); + + test('Computes paint bounds for vertical skew', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.skew(0.0, 1.0); + rc.drawRect(const Rect.fromLTRB(20, 20, 40, 40), testPaint); + expect( + rc.computePaintBounds(), + within( + distance: 0.1, from: const Rect.fromLTRB(20.0, 40.0, 40.0, 80.0))); + await _checkScreenshot(rc, 'skew_vertically'); + }); + + test('Computes paint bounds for a complex transform', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + final Float64List matrix = Float64List(16); + // translate(210, 220) , scale(2, 3), rotate(math.pi / 4.0) + matrix[0] = 1.4; + matrix[1] = 2.12; + matrix[2] = 0.0; + matrix[3] = 0.0; + matrix[4] = -1.4; + matrix[5] = 2.12; + matrix[6] = 0.0; + matrix[7] = 0.0; + matrix[8] = 0.0; + matrix[9] = 0.0; + matrix[10] = 2.0; + matrix[11] = 0.0; + matrix[12] = 210.0; + matrix[13] = 220.0; + matrix[14] = 0.0; + matrix[15] = 1.0; + rc.transform(matrix); + rc.drawRect(const Rect.fromLTRB(10, 20, 30, 40), testPaint); + expect(rc.computePaintBounds(), + const Rect.fromLTRB(168.0, 283.6, 224.0, 368.4)); + await _checkScreenshot(rc, 'complex_transform'); + }); + + test('drawPaint should cover full size', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.drawPaint(testPaint); + rc.drawRect(const Rect.fromLTRB(10, 20, 30, 40), testPaint); + expect(rc.computePaintBounds(), screenRect); + await _checkScreenshot(rc, 'draw_paint'); + }); + + test('drawColor should cover full size', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.drawColor(const Color(0xFFFF0000), BlendMode.multiply); + rc.drawRect(const Rect.fromLTRB(10, 20, 30, 40), testPaint); + expect(rc.computePaintBounds(), screenRect); + await _checkScreenshot(rc, 'draw_color'); + }); + + test('Computes paint bounds for draw oval', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.drawOval(const Rect.fromLTRB(10, 20, 30, 40), testPaint); + expect(rc.computePaintBounds(), const Rect.fromLTRB(10, 20, 30, 40)); + await _checkScreenshot(rc, 'draw_oval'); + }); + + test('Computes paint bounds for draw round rect', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.drawRRect( + RRect.fromRectAndRadius( + const Rect.fromLTRB(10, 20, 30, 40), const Radius.circular(5.0)), + testPaint); + expect(rc.computePaintBounds(), const Rect.fromLTRB(10, 20, 30, 40)); + await _checkScreenshot(rc, 'draw_round_rect'); + }); + + test( + 'Computes empty paint bounds when inner rect outside of outer rect for ' + 'drawDRRect', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.drawDRRect(RRect.fromRectAndCorners(const Rect.fromLTRB(10, 20, 30, 40)), + RRect.fromRectAndCorners(const Rect.fromLTRB(1, 2, 3, 4)), testPaint); + expect(rc.computePaintBounds(), const Rect.fromLTRB(0, 0, 0, 0)); + await _checkScreenshot(rc, 'draw_drrect_empty'); + }); + + test('Computes paint bounds using outer rect for drawDRRect', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.drawDRRect( + RRect.fromRectAndCorners(const Rect.fromLTRB(10, 20, 30, 40)), + RRect.fromRectAndCorners(const Rect.fromLTRB(12, 22, 28, 38)), + testPaint); + expect(rc.computePaintBounds(), const Rect.fromLTRB(10, 20, 30, 40)); + await _checkScreenshot(rc, 'draw_drrect'); + }); + + test('Computes paint bounds for draw circle', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.drawCircle(const Offset(20, 20), 10.0, testPaint); + expect( + rc.computePaintBounds(), const Rect.fromLTRB(10.0, 10.0, 30.0, 30.0)); + rc.drawCircle(const Offset(200, 300), 100.0, testPaint); + expect( + rc.computePaintBounds(), const Rect.fromLTRB(10.0, 10.0, 300.0, 400.0)); + await _checkScreenshot(rc, 'draw_circle'); + }); + + test('Computes paint bounds for draw image', () { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.drawImage(TestImage(), const Offset(50, 100), Paint()); + expect( + rc.computePaintBounds(), const Rect.fromLTRB(50.0, 100.0, 70.0, 110.0)); + }); + + test('Computes paint bounds for draw image rect', () { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.drawImageRect(TestImage(), const Rect.fromLTRB(1, 1, 20, 10), + const Rect.fromLTRB(5, 6, 400, 500), Paint()); + expect( + rc.computePaintBounds(), const Rect.fromLTRB(5.0, 6.0, 400.0, 500.0)); + }); + + test('Computes paint bounds for single-line draw paragraph', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + final Paragraph paragraph = createTestParagraph(); + const double textLeft = 5.0; + const double textTop = 7.0; + const double widthConstraint = 300.0; + paragraph.layout(const ParagraphConstraints(width: widthConstraint)); + rc.drawParagraph(paragraph, const Offset(textLeft, textTop)); + expect( + rc.computePaintBounds(), + const Rect.fromLTRB(textLeft, textTop, textLeft + widthConstraint, 21.0), + ); + await _checkScreenshot(rc, 'draw_paragraph'); + }); + + test('Computes paint bounds for multi-line draw paragraph', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + final Paragraph paragraph = createTestParagraph(); + const double textLeft = 5.0; + const double textTop = 7.0; + const double widthConstraint = + 130.0; // do not go lower than the shortest word. + paragraph.layout(const ParagraphConstraints(width: widthConstraint)); + rc.drawParagraph(paragraph, const Offset(textLeft, textTop)); + expect( + rc.computePaintBounds(), + const Rect.fromLTRB(textLeft, textTop, textLeft + widthConstraint, 35.0), + ); + await _checkScreenshot(rc, 'draw_paragraph_multi_line'); + }); + + test('Should exclude painting outside simple clipRect', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.clipRect(const Rect.fromLTRB(50, 50, 100, 100)); + rc.drawLine(const Offset(10, 11), const Offset(20, 21), testPaint); + + expect(rc.computePaintBounds(), Rect.zero); + rc.drawLine(const Offset(52, 53), const Offset(55, 56), testPaint); + + // Extra pixel due to default line length + expect(rc.computePaintBounds(), const Rect.fromLTRB(51, 52, 56, 57)); + await _checkScreenshot(rc, 'clip_rect_simple'); + }); + + test('Should include intersection of clipRect and painting', () async { + RecordingCanvas rc = RecordingCanvas(screenRect); + rc.clipRect(const Rect.fromLTRB(50, 50, 100, 100)); + rc.drawRect(const Rect.fromLTRB(20, 60, 120, 70), testPaint); + expect(rc.computePaintBounds(), const Rect.fromLTRB(50, 60, 100, 70)); + await _checkScreenshot(rc, 'clip_rect_intersects_paint_left_to_right'); + + rc = RecordingCanvas(screenRect); + rc.clipRect(const Rect.fromLTRB(50, 50, 100, 100)); + rc.drawRect(const Rect.fromLTRB(60, 20, 70, 200), testPaint); + expect(rc.computePaintBounds(), const Rect.fromLTRB(60, 50, 70, 100)); + await _checkScreenshot(rc, 'clip_rect_intersects_paint_top_to_bottom'); + }); + + test('Should intersect rects for multiple clipRect calls', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + rc.clipRect(const Rect.fromLTRB(50, 50, 100, 100)); + rc.scale(2.0, 2.0); + rc.clipRect(const Rect.fromLTRB(30, 30, 45, 45)); + rc.drawRect(const Rect.fromLTRB(10, 30, 60, 35), testPaint); + expect(rc.computePaintBounds(), const Rect.fromLTRB(60, 60, 90, 70)); + await _checkScreenshot(rc, 'clip_rects_intersect'); + }); + + // drawShadow + test('Computes paint bounds for drawShadow', () async { + final RecordingCanvas rc = RecordingCanvas(screenRect); + final Path path = Path(); + path.addRect(const Rect.fromLTRB(20, 30, 100, 110)); + rc.drawShadow(path, const Color(0xFFFF0000), 2.0, true); + expect( + rc.computePaintBounds(), const Rect.fromLTRB(15.0, 27.0, 106.0, 117.0)); + await _checkScreenshot(rc, 'path_with_shadow'); + }); + + test('Clip with negative scale reports correct paint bounds', () async { + // The following draws a filled rectangle that occupies the bottom half of + // the canvas. Notice that both the clip and the rectangle are drawn + // forward. What makes them appear at the bottom is the translation and a + // vertical flip via a negative scale. This replicates the Material + // overscroll glow effect at the bottom of a list, where it is drawn upside + // down. + final RecordingCanvas rc = + RecordingCanvas(const Rect.fromLTRB(0, 0, 100, 100)); + rc + ..translate(0, 100) + ..scale(1, -1) + ..clipRect(const Rect.fromLTRB(0, 0, 100, 50)) + ..drawRect(const Rect.fromLTRB(0, 0, 100, 100), Paint()); + expect( + rc.computePaintBounds(), const Rect.fromLTRB(0.0, 50.0, 100.0, 100.0)); + await _checkScreenshot(rc, 'scale_negative'); + }); + + test('Clip with a rotation reports correct paint bounds', () async { + final RecordingCanvas rc = + RecordingCanvas(const Rect.fromLTRB(0, 0, 100, 100)); + rc + ..translate(50, 50) + ..rotate(math.pi / 4.0) + ..clipRect(const Rect.fromLTWH(-20, -20, 40, 40)) + ..drawRect(const Rect.fromLTWH(-80, -80, 160, 160), Paint()); + expect( + rc.computePaintBounds(), + Rect.fromCircle(center: const Offset(50, 50), radius: 20 * math.sqrt(2)), + ); + await _checkScreenshot(rc, 'clip_rect_rotated'); + }); + + test('Rotated line reports correct paint bounds', () async { + final RecordingCanvas rc = + RecordingCanvas(const Rect.fromLTRB(0, 0, 100, 100)); + rc + ..translate(50, 50) + ..rotate(math.pi / 4.0) + ..drawLine(const Offset(0, 0), const Offset(20, 20), Paint()); + expect( + rc.computePaintBounds(), + within(distance: 0.1, from: const Rect.fromLTRB(34.4, 48.6, 65.6, 79.7)), + ); + await _checkScreenshot(rc, 'line_rotated'); + }); + + test('Should support reusing path and reset when drawing into canvas.', + () async { + final RecordingCanvas rc = + RecordingCanvas(const Rect.fromLTRB(0, 0, 100, 100)); + + final Path path = Path(); + path.moveTo(3, 0); + path.lineTo(100, 97); + rc.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0 + ..color = const Color(0xFFFF0000)); + path.reset(); + path.moveTo(0, 3); + path.lineTo(97, 100); + rc.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0 + ..color = const Color(0xFF00FF00)); + await _checkScreenshot(rc, 'reuse_path'); + }); + + test('Should draw RRect after line when beginning new path.', () async { + final RecordingCanvas rc = + RecordingCanvas(const Rect.fromLTRB(0, 0, 200, 400)); + rc.save(); + rc.translate(50.0, 100.0); + final Path path = Path(); + // Draw a vertical small line (caret). + path.moveTo(8, 4); + path.lineTo(8, 24); + // Draw round rect below caret. + path.addRRect( + RRect.fromLTRBR(0.5, 100.5, 80.7, 150.7, const Radius.circular(10))); + rc.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0 + ..color = const Color(0xFF404000)); + await _checkScreenshot(rc, 'path_with_line_and_roundrect'); + }); +} + +class TestImage implements Image { + @override + int get width => 20; + + @override + int get height => 10; + + @override + Future toByteData( + {ImageByteFormat format = ImageByteFormat.rawRgba}) async { + throw UnsupportedError('Cannot encode test image'); + } + + @override + String toString() => '[$width\u00D7$height]'; + + @override + void dispose() {} +} + +Paragraph createTestParagraph() { + final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle( + fontFamily: 'Ahem', + fontStyle: FontStyle.normal, + fontWeight: FontWeight.normal, + fontSize: 14.0, + )); + builder.addText('A short sentence.'); + return builder.build(); +} diff --git a/engine/src/flutter/lib/web_ui/test/golden_tests/engine/scuba.dart b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/scuba.dart new file mode 100644 index 00000000000..d54d0925a77 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/scuba.dart @@ -0,0 +1,131 @@ +// Copyright 2013 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:async'; +import 'dart:html' as html; + +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart' as ui; +import 'package:test/test.dart'; + +import 'package:web_engine_tester/golden_tester.dart'; + +/// Class that controls some details of how screenshotting is made. +/// +/// (For Googlers: Not really related with internal Scuba anymore) +class EngineScubaTester { + /// The size of the browser window used in this scuba test. + final ui.Size viewportSize; + + EngineScubaTester(this.viewportSize); + + static Future initialize( + {ui.Size viewportSize = const ui.Size(2400, 1800)}) async { + assert(viewportSize != null); + + assert(() { + if (viewportSize.width.ceil() != viewportSize.width || + viewportSize.height.ceil() != viewportSize.height) { + throw Exception( + 'Scuba only supports integer screen sizes, but found: $viewportSize'); + } + if (viewportSize.width < 472) { + throw Exception('Scuba does not support screen width smaller than 472'); + } + return true; + }()); + + return EngineScubaTester(viewportSize); + } + + Future diffScreenshot(String fileName) async { + await matchGoldenFile('engine/$fileName.png', region: ui.Rect.fromLTWH(0, 0, viewportSize.width, viewportSize.height)); + } + + /// Prepares the DOM and inserts all the necessary nodes, then invokes scuba's + /// screenshot diffing. + /// + /// It also cleans up the DOM after itself. + Future diffCanvasScreenshot( + EngineCanvas canvas, + String fileName, + ) async { + // Wrap in so that our CSS selectors kick in. + final html.Element sceneElement = html.Element.tag('flt-scene'); + try { + sceneElement.append(canvas.rootElement); + html.document.body.append(sceneElement); + String screenshotName = '${fileName}_${canvas.runtimeType}'; + if (TextMeasurementService.enableExperimentalCanvasImplementation) { + screenshotName += '+canvas_measurement'; + } + await diffScreenshot(screenshotName); + } finally { + // The page is reused across tests, so remove the element after taking the + // Scuba screenshot. + sceneElement.remove(); + } + } +} + +typedef CanvasTest = FutureOr Function(EngineCanvas canvas); + +/// Runs the given test [body] with each type of canvas. +void testEachCanvas(String description, CanvasTest body) { + const ui.Rect bounds = ui.Rect.fromLTWH(0, 0, 600, 800); + test('$description (bitmap)', () { + try { + TextMeasurementService.initialize(rulerCacheCapacity: 2); + return body(BitmapCanvas(bounds)); + } finally { + TextMeasurementService.clearCache(); + } + }); + test('$description (bitmap + canvas measurement)', () async { + try { + TextMeasurementService.initialize(rulerCacheCapacity: 2); + TextMeasurementService.enableExperimentalCanvasImplementation = true; + await body(BitmapCanvas(bounds)); + } finally { + TextMeasurementService.enableExperimentalCanvasImplementation = false; + TextMeasurementService.clearCache(); + } + }); + test('$description (dom)', () { + try { + TextMeasurementService.initialize(rulerCacheCapacity: 2); + return body(DomCanvas()); + } finally { + TextMeasurementService.clearCache(); + } + }); + test('$description (houdini)', () { + try { + TextMeasurementService.initialize(rulerCacheCapacity: 2); + return body(HoudiniCanvas(bounds)); + } finally { + TextMeasurementService.clearCache(); + } + }); +} + +final ui.TextStyle _defaultTextStyle = ui.TextStyle( + color: const ui.Color(0xFF000000), + fontFamily: 'Arial', + fontSize: 14, +); + +ui.Paragraph paragraph( + String text, { + ui.ParagraphStyle paragraphStyle, + ui.TextStyle textStyle, + double maxWidth = double.infinity, +}) { + final ui.ParagraphBuilder builder = + ui.ParagraphBuilder(paragraphStyle ?? ui.ParagraphStyle()); + builder.pushStyle(textStyle ?? _defaultTextStyle); + builder.addText(text); + builder.pop(); + return builder.build()..layout(ui.ParagraphConstraints(width: maxWidth)); +} diff --git a/engine/src/flutter/lib/web_ui/test/golden_tests/engine/text_overflow_scuba_test.dart b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/text_overflow_scuba_test.dart new file mode 100644 index 00000000000..c155354ede4 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/text_overflow_scuba_test.dart @@ -0,0 +1,154 @@ +// Copyright 2013 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:async'; + +import 'package:ui/ui.dart'; +import 'package:ui/src/engine.dart'; + +import 'scuba.dart'; + +typedef CanvasTest = FutureOr Function(EngineCanvas canvas); + +const String threeLines = 'First\nSecond\nThird'; +const String veryLongWithShortPrefix = + 'Lorem ipsum dolor\nsit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; +const String veryLongWithShortSuffix = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et\ndolore magna aliqua.'; +const String veryLong = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; +const String longUnbreakable = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +void main() async { + final EngineScubaTester scuba = await EngineScubaTester.initialize( + viewportSize: const Size(800, 800), + ); + + final TextStyle warningStyle = TextStyle( + color: const Color(0xFFFF0000), + fontFamily: 'Arial', + fontSize: 10, + ); + + Paragraph warning(String text) { + return paragraph(text, textStyle: warningStyle); + } + + testEachCanvas('maxLines clipping', (EngineCanvas canvas) { + Offset offset = Offset.zero; + Paragraph p; + + // All three lines are rendered. + p = paragraph(threeLines); + canvas.drawParagraph(p, offset); + offset = offset.translate(0, p.height + 10); + + // Only the first two lines are rendered. + p = paragraph(threeLines, paragraphStyle: ParagraphStyle(maxLines: 2)); + canvas.drawParagraph(p, offset); + offset = offset.translate(0, p.height + 10); + + // The whole text is rendered. + p = paragraph(veryLong, maxWidth: 200); + canvas.drawParagraph(p, offset); + offset = offset.translate(0, p.height + 10); + + // Only the first two lines are rendered. + p = paragraph(veryLong, + paragraphStyle: ParagraphStyle(maxLines: 2), maxWidth: 200); + canvas.drawParagraph(p, offset); + offset = offset.translate(0, p.height + 10); + + return scuba.diffCanvasScreenshot(canvas, 'text_max_lines'); + }); + + testEachCanvas('maxLines with overflow', (EngineCanvas canvas) { + Offset offset = Offset.zero; + Paragraph p; + + // Only the first line is rendered with no ellipsis because the first line + // doesn't overflow. + p = paragraph( + threeLines, + paragraphStyle: ParagraphStyle(ellipsis: '...'), + ); + canvas.drawParagraph(p, offset); + offset = offset.translate(0, p.height + 10); + + // The first two lines are rendered with an ellipsis on the 2nd line. + p = paragraph( + veryLongWithShortPrefix, + paragraphStyle: ParagraphStyle(ellipsis: '...'), + maxWidth: 200, + ); + canvas.drawParagraph(p, offset); + offset = offset.translate(0, p.height + 10); + + // Only the first line is rendered with an ellipsis. + if (!TextMeasurementService.enableExperimentalCanvasImplementation) { + // This is now correct with the canvas-based measurement, so we shouldn't + // print the "(wrong)" warning. + p = warning('(wrong)'); + canvas.drawParagraph(p, offset); + offset = offset.translate(0, p.height); + } + p = paragraph( + veryLongWithShortSuffix, + paragraphStyle: ParagraphStyle(ellipsis: '...'), + maxWidth: 200, + ); + canvas.drawParagraph(p, offset); + offset = offset.translate(0, p.height + 10); + + // Only the first two lines are rendered and the ellipsis appears on the 2nd + // line. + if (!TextMeasurementService.enableExperimentalCanvasImplementation) { + // This is now correct with the canvas-based measurement, so we shouldn't + // print the "(wrong)" warning. + p = warning('(wrong)'); + canvas.drawParagraph(p, offset); + offset = offset.translate(0, p.height); + } + p = paragraph( + veryLong, + paragraphStyle: ParagraphStyle(maxLines: 2, ellipsis: '...'), + maxWidth: 200, + ); + canvas.drawParagraph(p, offset); + offset = offset.translate(0, p.height + 10); + + return scuba.diffCanvasScreenshot(canvas, 'text_max_lines_with_ellipsis'); + }); + + testEachCanvas('long unbreakable text', (EngineCanvas canvas) { + Offset offset = Offset.zero; + Paragraph p; + + // The whole line is rendered unbroken when there are no constraints. + p = paragraph(longUnbreakable); + canvas.drawParagraph(p, offset); + offset = offset.translate(0, p.height + 10); + + // The whole line is rendered with an ellipsis. + p = paragraph( + longUnbreakable, + paragraphStyle: ParagraphStyle(ellipsis: '...'), + maxWidth: 200, + ); + canvas.drawParagraph(p, offset); + offset = offset.translate(0, p.height + 10); + + // The text is broken into multiple lines. + p = paragraph(longUnbreakable, maxWidth: 200); + canvas.drawParagraph(p, offset); + offset = offset.translate(0, p.height + 10); + + // Very narrow constraint (less than one character's width). + p = paragraph('AA', maxWidth: 7); + canvas.drawParagraph(p, offset); + offset = offset.translate(0, p.height + 10); + + return scuba.diffCanvasScreenshot(canvas, 'text_long_unbreakable'); + }); +} diff --git a/engine/src/flutter/lib/web_ui/test/golden_tests/engine/text_style_scuba_test.dart b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/text_style_scuba_test.dart new file mode 100644 index 00000000000..b0c10abf83f --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/text_style_scuba_test.dart @@ -0,0 +1,165 @@ +// Copyright 2013 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 'package:ui/ui.dart'; +import 'package:ui/src/engine.dart'; + +import 'scuba.dart'; + +void main() async { + final EngineScubaTester scuba = await EngineScubaTester.initialize( + viewportSize: const Size(800, 800), + ); + + void drawLetterAndWordSpacing(EngineCanvas canvas) { + Offset offset = Offset.zero; + + for (double spacing = 0; spacing < 15; spacing += 5) { + canvas.drawParagraph( + paragraph('HelloWorld', + textStyle: TextStyle( + color: const Color(0xFF000000), + decoration: TextDecoration.none, + fontFamily: 'Arial', + fontSize: 30, + letterSpacing: spacing)), + offset, + ); + offset = offset.translate(0, 40); + } + for (double spacing = 0; spacing < 30; spacing += 10) { + final TextStyle textStyle = TextStyle( + color: const Color(0xFF00FF00), + decoration: TextDecoration.none, + fontFamily: 'Arial', + fontSize: 30, + wordSpacing: spacing); + canvas.drawParagraph( + paragraph('Hello World', textStyle: textStyle, maxWidth: 600), + offset, + ); + offset = offset.translate(0, 40); + } + } + + testEachCanvas('draws text with letter/word spacing', (EngineCanvas canvas) { + drawLetterAndWordSpacing(canvas); + return scuba.diffCanvasScreenshot( + canvas, 'paint_bounds_for_text_style_letter_spacing'); + }); + + void drawTextDecorationStyle(EngineCanvas canvas) { + final List decorationStyles = [ + TextDecorationStyle.solid, + TextDecorationStyle.double, + TextDecorationStyle.dotted, + TextDecorationStyle.dashed, + TextDecorationStyle.wavy, + ]; + + Offset offset = Offset.zero; + + for (TextDecorationStyle decorationStyle in decorationStyles) { + final TextStyle textStyle = TextStyle( + color: const Color.fromRGBO(50, 50, 255, 1.0), + decoration: TextDecoration.underline, + decorationStyle: decorationStyle, + decorationColor: const Color.fromRGBO(50, 50, 50, 1.0), + fontFamily: 'Arial', + fontSize: 30, + ); + canvas.drawParagraph( + paragraph('Hello World', textStyle: textStyle, maxWidth: 600), + offset, + ); + offset = offset.translate(0, 40); + } + } + + testEachCanvas('draws text decoration style', (EngineCanvas canvas) { + drawTextDecorationStyle(canvas); + return scuba.diffCanvasScreenshot( + canvas, 'paint_bounds_for_text_decorationStyle'); + }); + + void drawTextDecoration(EngineCanvas canvas) { + final List decorations = [ + TextDecoration.overline, + TextDecoration.underline, + TextDecoration.combine([ + TextDecoration.underline, + TextDecoration.lineThrough + ]), + TextDecoration.combine( + [TextDecoration.underline, TextDecoration.overline]), + TextDecoration.combine( + [TextDecoration.overline, TextDecoration.lineThrough]) + ]; + + Offset offset = Offset.zero; + + for (TextDecoration decoration in decorations) { + final TextStyle textStyle = TextStyle( + color: const Color.fromRGBO(50, 50, 255, 1.0), + decoration: decoration, + decorationStyle: TextDecorationStyle.solid, + decorationColor: const Color.fromRGBO(255, 160, 0, 1.0), + fontFamily: 'Arial', + fontSize: 20, + ); + canvas.drawParagraph( + paragraph( + 'Hello World $decoration', + textStyle: textStyle, + maxWidth: 600, + ), + offset, + ); + offset = offset.translate(0, 40); + } + } + + testEachCanvas('draws text decoration', (EngineCanvas canvas) { + drawTextDecoration(canvas); + return scuba.diffCanvasScreenshot( + canvas, 'paint_bounds_for_text_decoration'); + }); + + void drawTextWithBackground(EngineCanvas canvas) { + // Single-line text. + canvas.drawParagraph( + paragraph( + 'Hello World', + maxWidth: 600, + textStyle: TextStyle( + color: const Color.fromRGBO(0, 0, 0, 1.0), + background: Paint()..color = const Color.fromRGBO(255, 50, 50, 1.0), + fontFamily: 'Arial', + fontSize: 30, + ), + ), + Offset.zero, + ); + + // Multi-line text. + canvas.drawParagraph( + paragraph( + 'Multi line Hello World paragraph', + maxWidth: 200, + textStyle: TextStyle( + color: const Color.fromRGBO(0, 0, 0, 1.0), + background: Paint()..color = const Color.fromRGBO(50, 50, 255, 1.0), + fontFamily: 'Arial', + fontSize: 30, + ), + ), + const Offset(0, 40), + ); + } + + testEachCanvas('draws text with a background', (EngineCanvas canvas) { + drawTextWithBackground(canvas); + return scuba.diffCanvasScreenshot(canvas, 'text_background'); + }); +} diff --git a/engine/src/flutter/web_sdk/web_engine_tester/lib/static/host.dart b/engine/src/flutter/web_sdk/web_engine_tester/lib/static/host.dart index 57511418516..5f339abc29b 100644 --- a/engine/src/flutter/web_sdk/web_engine_tester/lib/static/host.dart +++ b/engine/src/flutter/web_sdk/web_engine_tester/lib/static/host.dart @@ -184,8 +184,8 @@ StreamChannel _connectToIframe(String url, int id) { _iframes[id] = iframe; iframe ..src = url - ..width = '500' - ..height = '500'; + ..width = '1000' + ..height = '1000'; document.body.children.add(iframe); // Use this to communicate securely with the iframe.